fix: streamline tests and preserve Darwin process ownership (#8275)
Release / Release reviewed main merge (push) Skipped
Release / Release Please (push) Skipped
Release / Qualify exact release revision (push) Skipped
Release / Rehearse qualified release on a clean runner (push) Skipped
Release / Publish to npm (push) Skipped
Release / Verify published npm package (push) Skipped
Release / Publish to MCP Registry (push) Skipped
Release / Update release proposal (push) Failing after 2s
Release / Release reviewed main merge (push) Skipped
Release / Release Please (push) Skipped
Release / Qualify exact release revision (push) Skipped
Release / Rehearse qualified release on a clean runner (push) Skipped
Release / Publish to npm (push) Skipped
Release / Verify published npm package (push) Skipped
Release / Publish to MCP Registry (push) Skipped
Release / Update release proposal (push) Failing after 2s
* test: strengthen suite guards and regression oracles * test: share JSON client registration contract * test: reuse fixtures and reduce repeated setup * fix(process): preserve ambiguous Darwin token ownership * test(process): bound child readiness waits
This commit is contained in:
1 parent
b43c329df7
commit
23de10bb28
33 files changed
+1297
-771
No files matched your search
@@ -161,8 +161,20 @@ private func unavailableIdentity(pid: Int32, reason: String) -> ProcessIdentityO
|
||||
)
|
||||
}
|
||||
|
||||
enum ProcargsMalformedStage: String, CaseIterable {
|
||||
case header
|
||||
case argumentCount = "argument_count"
|
||||
case executablePath = "executable_path"
|
||||
case pointerSize = "pointer_size"
|
||||
case pathPadding = "path_padding"
|
||||
case arguments
|
||||
case terminator
|
||||
case trailingEntries = "trailing_entries"
|
||||
}
|
||||
|
||||
enum RunTokenReadError: Error {
|
||||
case invalidBuffer
|
||||
case invalidBufferAt(ProcargsMalformedStage)
|
||||
case duplicateToken
|
||||
case invalidTokenEncoding
|
||||
case environmentUnavailable
|
||||
@@ -244,6 +256,8 @@ func processRunTokenObservation(
|
||||
return unavailable(pid: pid, reason: "apple_vector_unavailable")
|
||||
case .invalidBuffer:
|
||||
return unavailable(pid: pid, reason: "malformed_procargs")
|
||||
case .invalidBufferAt(let stage):
|
||||
return unavailable(pid: pid, reason: "malformed_procargs_\(stage.rawValue)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,27 +316,30 @@ func parseProcessEnvironment(
|
||||
in bytes: [UInt8],
|
||||
pointerSize: Int = MemoryLayout<UnsafeRawPointer>.size
|
||||
) throws -> ParsedRunToken {
|
||||
guard bytes.count >= MemoryLayout<Int32>.size else { throw RunTokenReadError.invalidBuffer }
|
||||
guard bytes.count >= MemoryLayout<Int32>.size else { throw RunTokenReadError.invalidBufferAt(.header) }
|
||||
let argc = bytes.withUnsafeBytes { $0.loadUnaligned(as: Int32.self).littleEndian }
|
||||
guard argc >= 0, Int(argc) <= bytes.count else { throw RunTokenReadError.invalidBuffer }
|
||||
guard argc >= 0, Int(argc) <= bytes.count else { throw RunTokenReadError.invalidBufferAt(.argumentCount) }
|
||||
var offset = MemoryLayout<Int32>.size
|
||||
guard let executablePath = readString(in: bytes, offset: &offset) else {
|
||||
throw RunTokenReadError.invalidBuffer
|
||||
throw RunTokenReadError.invalidBufferAt(.executablePath)
|
||||
}
|
||||
guard pointerSize == 4 || pointerSize == 8 else { throw RunTokenReadError.invalidBuffer }
|
||||
guard pointerSize == 4 || pointerSize == 8 else { throw RunTokenReadError.invalidBufferAt(.pointerSize) }
|
||||
let pathRecordSize = Array("executable_path=".utf8).count + executablePath.count + 1
|
||||
let padding = (pointerSize - pathRecordSize % pointerSize) % pointerSize
|
||||
guard offset + padding <= bytes.count,
|
||||
bytes[offset..<(offset + padding)].allSatisfy({ $0 == 0 }) else {
|
||||
throw RunTokenReadError.invalidBuffer
|
||||
throw RunTokenReadError.invalidBufferAt(.pathPadding)
|
||||
}
|
||||
offset += padding
|
||||
let tokenPrefix = Array("REA_PROCESS_RUN_ID=".utf8)
|
||||
var tokenInArgument = false
|
||||
for _ in 0..<argc {
|
||||
guard readString(in: bytes, offset: &offset, allowEmpty: true) != nil else {
|
||||
throw RunTokenReadError.invalidBuffer
|
||||
guard let argument = readString(in: bytes, offset: &offset, allowEmpty: true) else {
|
||||
throw RunTokenReadError.invalidBufferAt(.arguments)
|
||||
}
|
||||
tokenInArgument = tokenInArgument || argument.starts(with: tokenPrefix)
|
||||
}
|
||||
guard bytes.last == 0 else { throw RunTokenReadError.invalidBuffer }
|
||||
guard bytes.last == 0 else { throw RunTokenReadError.invalidBufferAt(.terminator) }
|
||||
if offset == bytes.count {
|
||||
throw RunTokenReadError.environmentUnavailable
|
||||
}
|
||||
@@ -340,7 +357,7 @@ func parseProcessEnvironment(
|
||||
continue
|
||||
}
|
||||
guard let entry = readString(in: bytes, offset: &offset) else {
|
||||
throw RunTokenReadError.invalidBuffer
|
||||
throw RunTokenReadError.invalidBufferAt(.trailingEntries)
|
||||
}
|
||||
trailingEntries.append(entry)
|
||||
}
|
||||
@@ -361,22 +378,28 @@ func parseProcessEnvironment(
|
||||
throw RunTokenReadError.appleVectorUnavailable
|
||||
}
|
||||
if trailingEntries[appleVectorStart...].contains(where: {
|
||||
$0.starts(with: Array("REA_PROCESS_RUN_ID=".utf8))
|
||||
$0.starts(with: tokenPrefix)
|
||||
}) {
|
||||
throw RunTokenReadError.ambiguousEnvironmentBoundary
|
||||
}
|
||||
let environmentEntries = trailingEntries[..<appleVectorStart]
|
||||
var found: String?
|
||||
for entry in environmentEntries {
|
||||
if entry.starts(with: Array("REA_PROCESS_RUN_ID=".utf8)) {
|
||||
if entry.starts(with: tokenPrefix) {
|
||||
guard found == nil else { throw RunTokenReadError.duplicateToken }
|
||||
let value = entry.dropFirst("REA_PROCESS_RUN_ID=".utf8.count)
|
||||
let value = entry.dropFirst(tokenPrefix.count)
|
||||
guard let decoded = String(bytes: value, encoding: .utf8) else {
|
||||
throw RunTokenReadError.invalidTokenEncoding
|
||||
}
|
||||
found = decoded
|
||||
}
|
||||
}
|
||||
// A changed process title can overwrite the original argument boundaries,
|
||||
// causing a real environment entry to be consumed as an argument. A real
|
||||
// argument can have this form too, so it proves neither ownership nor absence.
|
||||
if tokenInArgument {
|
||||
throw RunTokenReadError.ambiguousEnvironmentBoundary
|
||||
}
|
||||
return ParsedRunToken(runId: found, environmentEntryCount: environmentEntries.count)
|
||||
}
|
||||
|
||||
|
||||
+27
-12
@@ -15,8 +15,8 @@ def fail(code, **details):
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
class BridgeResult(unittest.TextTestResult):
|
||||
"""Record non-skipped test and subtest outcomes for required families."""
|
||||
class ExecutedResult(unittest.TextTestResult):
|
||||
"""Record tests with a non-skipped test or subtest outcome."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -25,10 +25,7 @@ class BridgeResult(unittest.TextTestResult):
|
||||
self.has_subtests = False
|
||||
|
||||
def record(self, test):
|
||||
parts = test.id().split(".")
|
||||
if len(parts) > 2 and parts[0] == "bridge":
|
||||
if parts[1] in REQUIRED_BRIDGE_FAMILIES:
|
||||
self.completed.add(parts[1])
|
||||
self.completed.add(test.id())
|
||||
|
||||
def startTest(self, test):
|
||||
self.active_test = test
|
||||
@@ -65,11 +62,16 @@ def run_bridge(modules):
|
||||
# The script directory is sys.path[0]; imports must resolve from the checkout.
|
||||
sys.path.insert(0, str(root))
|
||||
suite = unittest.defaultTestLoader.loadTestsFromNames(modules)
|
||||
result = unittest.TextTestRunner(verbosity=2, resultclass=BridgeResult).run(suite)
|
||||
result = unittest.TextTestRunner(verbosity=2, resultclass=ExecutedResult).run(suite)
|
||||
completed_families = {
|
||||
parts[1]
|
||||
for name in result.completed
|
||||
if len(parts := name.split(".")) > 2 and parts[0] == "bridge"
|
||||
}
|
||||
missing = [
|
||||
family
|
||||
for family in REQUIRED_BRIDGE_FAMILIES
|
||||
if family not in result.completed
|
||||
if family not in completed_families
|
||||
]
|
||||
if missing:
|
||||
fail("no_executed_bridge_tests", families=missing)
|
||||
@@ -77,6 +79,19 @@ def run_bridge(modules):
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def run_analysis_efficiency():
|
||||
# Match the checkout-root import path of `python -m unittest discover`.
|
||||
sys.path.insert(0, str(root))
|
||||
suite = unittest.defaultTestLoader.discover(
|
||||
str(root / "tests/support/analysis-efficiency"), pattern="test_*.py"
|
||||
)
|
||||
result = unittest.TextTestRunner(verbosity=2, resultclass=ExecutedResult).run(suite)
|
||||
if not result.wasSuccessful():
|
||||
raise SystemExit(1)
|
||||
if not result.completed:
|
||||
fail("no_executed_analysis_efficiency_tests")
|
||||
|
||||
|
||||
bridge_root = root / "bridge"
|
||||
bridge_tests = sorted(
|
||||
path for path in bridge_root.rglob("test_*.py") if path.is_file()
|
||||
@@ -95,6 +110,9 @@ modules = [
|
||||
if sys.argv[1:] == ["--bridge"]:
|
||||
run_bridge(modules)
|
||||
raise SystemExit(0)
|
||||
if sys.argv[1:] == ["--analysis-efficiency"]:
|
||||
run_analysis_efficiency()
|
||||
raise SystemExit(0)
|
||||
if sys.argv[1:]:
|
||||
fail("invalid_python_test_arguments")
|
||||
bridge_result = subprocess.run(
|
||||
@@ -103,10 +121,7 @@ bridge_result = subprocess.run(
|
||||
if bridge_result.returncode:
|
||||
sys.exit(bridge_result.returncode)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable, "-m", "unittest", "discover",
|
||||
"-s", "tests/support/analysis-efficiency", "-p", "test_*.py", "-v",
|
||||
],
|
||||
[sys.executable, __file__, "--analysis-efficiency"],
|
||||
cwd=root,
|
||||
check=False,
|
||||
)
|
||||
|
||||
@@ -167,49 +167,83 @@ it("finishes the public cleanup sweep when the application timer is unavailable"
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps an unreadable live group member fail-closed when the application timer is unavailable", async () => {
|
||||
const fixture = makeHost(() => undefined);
|
||||
let unreadableReads = 0;
|
||||
it.each([
|
||||
"EACCES: fixture permission denied",
|
||||
"ambiguous_environment_boundary",
|
||||
])(
|
||||
"keeps an unreadable live group member fail-closed when the application timer is unavailable: %s",
|
||||
async (diagnostic) => {
|
||||
const fixture = makeHost(() => undefined);
|
||||
let unreadableReads = 0;
|
||||
const host: ProcessOwnershipHost = {
|
||||
...fixture.host,
|
||||
runTokens: async (entries) =>
|
||||
new Map<number, ProcessRunTokenObservation>(
|
||||
entries.map(({ pid }) => {
|
||||
if (pid === 101) {
|
||||
unreadableReads += 1;
|
||||
return [
|
||||
pid,
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: diagnostic,
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
return [pid, { state: "readable", runId: "sweep-run" }] as const;
|
||||
}),
|
||||
),
|
||||
};
|
||||
const applicationTimer = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("application timer is unavailable");
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(ownership(), host),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
reason: expect.stringContaining(
|
||||
"process ownership token could not be read",
|
||||
),
|
||||
});
|
||||
expect(unreadableReads).toBeGreaterThan(1);
|
||||
expect(fixture.signalGroups).toEqual([]);
|
||||
expect(applicationTimer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
applicationTimer.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retries an ambiguous owned member before signaling its group", async () => {
|
||||
const fixture = makeHost(() => fixture.replaceRows([]));
|
||||
let memberReads = 0;
|
||||
const host: ProcessOwnershipHost = {
|
||||
...fixture.host,
|
||||
runTokens: async (entries) =>
|
||||
new Map<number, ProcessRunTokenObservation>(
|
||||
entries.map(({ pid }) => {
|
||||
if (pid === 101) {
|
||||
unreadableReads += 1;
|
||||
if (pid === 101 && memberReads++ === 0)
|
||||
return [
|
||||
pid,
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "EACCES: fixture permission denied",
|
||||
reason: "ambiguous_environment_boundary",
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
return [pid, { state: "readable", runId: "sweep-run" }] as const;
|
||||
}),
|
||||
),
|
||||
};
|
||||
const applicationTimer = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation(() => {
|
||||
throw new Error("application timer is unavailable");
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
cleanupOwnedProcessGroup(ownership(), host),
|
||||
).resolves.toMatchObject({
|
||||
cleaned: false,
|
||||
reason: expect.stringContaining(
|
||||
"process ownership token could not be read",
|
||||
),
|
||||
});
|
||||
expect(unreadableReads).toBeGreaterThan(1);
|
||||
expect(fixture.signalGroups).toEqual([]);
|
||||
expect(applicationTimer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
applicationTimer.mockRestore();
|
||||
}
|
||||
await expect(cleanupOwnedProcessGroup(ownership(), host)).resolves.toEqual({
|
||||
cleaned: true,
|
||||
signaled: true,
|
||||
});
|
||||
expect(memberReads).toBeGreaterThan(1);
|
||||
expect(fixture.signalGroups).toEqual([100]);
|
||||
});
|
||||
|
||||
it("preserves a moved same-generation helper as unresolved through the final deadline", async () => {
|
||||
|
||||
@@ -1278,6 +1278,18 @@ const sanitizedTokenReadFailure = (diagnostic: string | undefined): string => {
|
||||
if (diagnostic === "apple_vector_unavailable") return diagnostic;
|
||||
if (diagnostic === "ambiguous_environment_boundary") return diagnostic;
|
||||
if (diagnostic === "malformed_procargs") return diagnostic;
|
||||
// The native parser reports only fixed stage names, never process data.
|
||||
if (
|
||||
diagnostic === "malformed_procargs_header" ||
|
||||
diagnostic === "malformed_procargs_argument_count" ||
|
||||
diagnostic === "malformed_procargs_executable_path" ||
|
||||
diagnostic === "malformed_procargs_pointer_size" ||
|
||||
diagnostic === "malformed_procargs_path_padding" ||
|
||||
diagnostic === "malformed_procargs_arguments" ||
|
||||
diagnostic === "malformed_procargs_terminator" ||
|
||||
diagnostic === "malformed_procargs_trailing_entries"
|
||||
)
|
||||
return diagnostic;
|
||||
if (diagnostic === "process_identity_changed_during_token_read")
|
||||
return diagnostic;
|
||||
if (diagnostic === "process_not_in_snapshot") return diagnostic;
|
||||
|
||||
@@ -160,6 +160,42 @@ describe("owned process-group cleanup validation: ownership and lineage", () =>
|
||||
});
|
||||
|
||||
describe("owned process-group cleanup liveness rechecks", () => {
|
||||
it("reports fixed malformed procargs stages and sanitizes the cleanup summary", async () => {
|
||||
const reasons = [
|
||||
"malformed_procargs_header",
|
||||
"malformed_procargs_argument_count",
|
||||
"malformed_procargs_executable_path",
|
||||
"malformed_procargs_pointer_size",
|
||||
"malformed_procargs_path_padding",
|
||||
"malformed_procargs_arguments",
|
||||
"malformed_procargs_terminator",
|
||||
"malformed_procargs_trailing_entries",
|
||||
"malformed_procargs_private-data",
|
||||
];
|
||||
const members = reasons.map((_, index) => ({
|
||||
pid: 100 + index,
|
||||
parentPid: 100,
|
||||
processGroupId: 100,
|
||||
state: "S",
|
||||
command: "fixture",
|
||||
}));
|
||||
const signalGroup = vi.fn();
|
||||
const adapter: ProcessOwnershipHost = {
|
||||
listProcesses: async () => members,
|
||||
environment: async (pid) => {
|
||||
throw new Error(reasons[pid - 100]);
|
||||
},
|
||||
signalGroup,
|
||||
};
|
||||
const result = await cleanupOwnedProcessGroup(ownership, adapter);
|
||||
expect(result.cleaned).toBe(false);
|
||||
if (result.cleaned) throw new Error("expected cleanup to retain ownership");
|
||||
for (const reason of reasons.slice(0, -1))
|
||||
expect(result.reason).toContain(reason);
|
||||
expect(result.reason).toContain("108=other_unavailable");
|
||||
expect(result.reason).not.toContain("private-data");
|
||||
expect(signalGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rechecks all unreadable members after the token phase in one fresh snapshot", async () => {
|
||||
const members = [100, 101, 102, 103].map((pid) => ({
|
||||
pid,
|
||||
|
||||
@@ -1115,11 +1115,15 @@ const expectRuntimeExports = async (
|
||||
expected: (count: number) => unknown,
|
||||
): Promise<void> => {
|
||||
// Execute only source-owned fixtures as an independent runtime oracle.
|
||||
for (const [path, exportName, count] of [
|
||||
const entries = [
|
||||
[fixture.leftPath, fixture.input.left_export_name, 1],
|
||||
[fixture.rightPath, fixture.input.right_export_name, 2],
|
||||
] as const)
|
||||
expect(await runtimeExport(path, exportName)).toEqual(expected(count));
|
||||
] as const;
|
||||
const values = await runtimeExports(
|
||||
entries.map(([path, exportName]) => [path, exportName]),
|
||||
);
|
||||
for (const [index, [, , count]] of entries.entries())
|
||||
expect(values[index]).toEqual(expected(count));
|
||||
};
|
||||
|
||||
const expectExportedCallableBinding = (
|
||||
@@ -1333,20 +1337,30 @@ const getMcpClient = async (): Promise<Client> => {
|
||||
return mcpConnection.client;
|
||||
};
|
||||
|
||||
const runtimeExport = async (
|
||||
path: string,
|
||||
exportName: string,
|
||||
): Promise<unknown> => {
|
||||
const runtimeExports = async (
|
||||
entries: readonly (readonly [path: string, exportName: string])[],
|
||||
): Promise<unknown[]> => {
|
||||
const { stdout } = await execute(process.execPath, [
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import { pathToFileURL } from 'node:url';
|
||||
const module = await import(pathToFileURL(process.argv[1]).href);
|
||||
const value = module[process.argv[2]];
|
||||
console.log(JSON.stringify({ value: typeof value === 'function' ? value() : value }));`,
|
||||
path,
|
||||
exportName,
|
||||
const results = [];
|
||||
for (const [path, exportName] of JSON.parse(process.argv[1])) {
|
||||
const module = await import(pathToFileURL(path).href);
|
||||
const value = module[exportName];
|
||||
results.push({ value: typeof value === 'function' ? value() : value });
|
||||
}
|
||||
console.log(JSON.stringify(results));`,
|
||||
JSON.stringify(entries),
|
||||
]);
|
||||
return z.object({ value: z.unknown().optional() }).parse(JSON.parse(stdout))
|
||||
.value;
|
||||
return z
|
||||
.array(z.object({ value: z.unknown().optional() }))
|
||||
.length(entries.length)
|
||||
.parse(JSON.parse(stdout))
|
||||
.map(({ value }) => value);
|
||||
};
|
||||
|
||||
const runtimeExport = async (
|
||||
path: string,
|
||||
exportName: string,
|
||||
): Promise<unknown> => (await runtimeExports([[path, exportName]]))[0];
|
||||
@@ -2,6 +2,7 @@ import { expect } from "vitest";
|
||||
|
||||
import {
|
||||
verifyJavaScriptReturnShapes,
|
||||
verifyJavaScriptReturnShapesThroughCli,
|
||||
type JavaScriptReturnFields,
|
||||
} from "../../fixtures/javascriptReturnShapes.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
@@ -60,6 +61,22 @@ const writes = {
|
||||
(_, index) => `m${index}() { return shared; }`,
|
||||
).join(", ")} }; box.m0().x = 2; box.m63().other = 3;`,
|
||||
};
|
||||
const wideReceiverSource = (width: number): string => {
|
||||
const object = Array.from(
|
||||
{ length: width },
|
||||
(_, index) => `p${index}:0`,
|
||||
).join(",");
|
||||
const array = "0,".repeat(width);
|
||||
return [
|
||||
["wideObject", `{${object},last:shared}`],
|
||||
["wideArray", `[${array}shared]`],
|
||||
]
|
||||
.map(
|
||||
([name, value]) =>
|
||||
`export function ${name}(){const shared={x:1};const box={wide(){return ${value}},noop(){}};box.noop();return {x:shared.x,keep:7};}`,
|
||||
)
|
||||
.join("\n");
|
||||
};
|
||||
const source = [
|
||||
...Object.entries(writes).map(
|
||||
([name, body]) =>
|
||||
@@ -93,8 +110,20 @@ const source = [
|
||||
"export function unrelated(){ const shared = {x: 1}; const other = {x: 9}; const get = () => shared; get().x = 2; return other.x; }",
|
||||
"export function receiverDistinctScopes(){ const shared = {x: 1}; const box = {outer(){ return shared; }, inner(){ const shared = {x: 9}; return shared; }, noop(){}}; box.noop(); return {outer: shared.x, inner: box.inner().x}; }",
|
||||
"export function receiverDistinctPaths(){ const parent = {left: {x: 1}, right: {x: 9}, keep: 7}; const box = {left(){ return parent.left; }, right(){ return parent.right; }, noop(){}}; box.noop(); return {left: parent.left.x, right: parent.right.x, keep: parent.keep}; }",
|
||||
wideReceiverSource(64),
|
||||
].join("\n");
|
||||
|
||||
const assertWideReturns = (fields: JavaScriptReturnFields): void => {
|
||||
for (const name of ["wideObject", "wideArray"]) {
|
||||
expect(fields(name)).toContainEqual(
|
||||
expect.objectContaining({ path: "/x", state: "unknown" }),
|
||||
);
|
||||
expect(fields(name)).toContainEqual(
|
||||
expect.objectContaining({ path: "/keep", state: "literal", value: 7 }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const assertReturns = (fields: JavaScriptReturnFields): void => {
|
||||
for (const name of Object.keys(writes)) {
|
||||
const field = fields(name).find(({ path }) => path === "");
|
||||
@@ -176,7 +205,11 @@ const assertReturns = (fields: JavaScriptReturnFields): void => {
|
||||
|
||||
cliTest(
|
||||
"preserves mutation uncertainty for objects returned by local calls",
|
||||
({ cli }) => verifyJavaScriptReturnShapes(cli, source, assertReturns),
|
||||
({ cli }) =>
|
||||
verifyJavaScriptReturnShapes(cli, source, (fields) => {
|
||||
assertReturns(fields);
|
||||
assertWideReturns(fields);
|
||||
}),
|
||||
120_000,
|
||||
);
|
||||
|
||||
@@ -185,35 +218,11 @@ cliTest(
|
||||
({ cli }) => {
|
||||
// Exceed the argument limit with the worker's 2048 KiB stack as well as
|
||||
// Node's default stack, so a spread into push still fails this regression.
|
||||
const width = 300_000;
|
||||
const object = Array.from(
|
||||
{ length: width },
|
||||
(_, index) => `p${index}:0`,
|
||||
).join(",");
|
||||
const array = "0,".repeat(width);
|
||||
const wideSource = [
|
||||
["wideObject", `{${object},last:shared}`],
|
||||
["wideArray", `[${array}shared]`],
|
||||
]
|
||||
.map(
|
||||
([name, value]) =>
|
||||
`export function ${name}(){const shared={x:1};const box={wide(){return ${value}},noop(){}};box.noop();return {x:shared.x,keep:7};}`,
|
||||
)
|
||||
.join("\n");
|
||||
return verifyJavaScriptReturnShapes(cli, wideSource, (fields) => {
|
||||
for (const name of ["wideObject", "wideArray"]) {
|
||||
expect(fields(name)).toContainEqual(
|
||||
expect.objectContaining({ path: "/x", state: "unknown" }),
|
||||
);
|
||||
expect(fields(name)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
path: "/keep",
|
||||
state: "literal",
|
||||
value: 7,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
return verifyJavaScriptReturnShapesThroughCli(
|
||||
cli,
|
||||
wideReceiverSource(300_000),
|
||||
assertWideReturns,
|
||||
);
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
@@ -38,7 +38,7 @@ cliTest(
|
||||
] as const) {
|
||||
const path = join(root, name);
|
||||
await writeFile(path, bytes);
|
||||
const response = await cli.run({
|
||||
const response = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-gpu-code-objects",
|
||||
path,
|
||||
@@ -817,7 +817,7 @@ cliTest(
|
||||
"attributes malformed selector JSON to its actual CLI option",
|
||||
async ({ cli }) => {
|
||||
for (const name of ["object_ids", "targets", "view"]) {
|
||||
const response = await cli.run({
|
||||
const response = await runFileCli(cli, {
|
||||
arguments: [
|
||||
"inspect-gpu-code-objects",
|
||||
"/not-opened-for-invalid-selectors",
|
||||
|
||||
@@ -278,7 +278,7 @@ cliTest(
|
||||
header[2] = 0x0d;
|
||||
header[3] = 0x0a;
|
||||
await writeFile(path, Buffer.concat([header, marshal]));
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-python-pyc", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
@@ -295,7 +295,7 @@ cliTest(
|
||||
for (const magic of [3392, 3394]) {
|
||||
const path = join(root, `${magic}.pyc`);
|
||||
await writeFile(path, Buffer.concat([pythonPycHeader(magic), marshal]));
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-python-pyc", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
@@ -354,7 +354,7 @@ cliTest(
|
||||
path,
|
||||
Buffer.concat([pythonPycHeader(magic), marshalCode(layout, fields)]),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-python-pyc", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
@@ -413,7 +413,7 @@ cliTest(
|
||||
path,
|
||||
Buffer.concat([pythonPycHeader(magic), marshalCode(layout, fields)]),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-python-pyc", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
@@ -502,7 +502,7 @@ cliTest(
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-python-pyc", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
@@ -552,7 +552,7 @@ cliTest(
|
||||
marshalCode(testCase.layout, { consts: [frozenSet, dict] }),
|
||||
]),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-python-pyc", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
@@ -633,7 +633,7 @@ cliTest(
|
||||
marshalCode(testCase.layout, { consts: [frozenSet, dict] }),
|
||||
]),
|
||||
);
|
||||
const result = await cli.run({
|
||||
const result = await runFileCli(cli, {
|
||||
arguments: ["inspect-python-pyc", path, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
@@ -664,7 +664,7 @@ cliTest(
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const rawResult = await cli.run({
|
||||
const rawResult = await runFileCli(cli, {
|
||||
arguments: ["inspect-python-pyc", rawPath, "--json"],
|
||||
});
|
||||
expect(rawResult.exitCode, rawResult.stderr).toBe(0);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
@@ -238,16 +239,15 @@ cliTest(
|
||||
},
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const setup = await run([
|
||||
"setup",
|
||||
"--client",
|
||||
"hermes",
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(setup.exitCode).toBe(0);
|
||||
const entry = parse(await readFile(config, "utf8")).mcp_servers.rea;
|
||||
const [command, ...args] = currentClientRegistrationCommand({
|
||||
entrypointPath: resolve("scripts/rea.mjs"),
|
||||
});
|
||||
const entry = {
|
||||
command,
|
||||
args,
|
||||
env: { HOPPER_LAUNCHER_PATH: process.execPath },
|
||||
enabled: true,
|
||||
};
|
||||
const defaults = { rea: entry, other: { command: "other-server" } };
|
||||
const original =
|
||||
"server_defaults: &servers\n" +
|
||||
@@ -258,9 +258,23 @@ cliTest(
|
||||
.join("") +
|
||||
"mcp_servers: *servers\nobserver: *servers\n";
|
||||
await writeFile(config, original);
|
||||
expect(
|
||||
(await run(["doctor", "--client", "hermes", "--json"])).exitCode,
|
||||
).toBe(0);
|
||||
await expect(access(`${config}.rea.backup`)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
const doctor = await run(["doctor", "--client", "hermes", "--json"]);
|
||||
expect(doctor.exitCode, doctor.stdout).toBe(0);
|
||||
expect(doctor.json).toMatchObject({
|
||||
healthy: true,
|
||||
identity: {
|
||||
registrations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
client: "hermes",
|
||||
config_path: config,
|
||||
state: "aligned",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const removed = await run(["uninstall", "--json"]);
|
||||
expect(removed.exitCode).toBe(0);
|
||||
const remaining = parse(await readFile(config, "utf8"));
|
||||
|
||||
@@ -1,10 +1,160 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import { expect } from "vitest";
|
||||
import { expect, onTestFinished } from "vitest";
|
||||
|
||||
import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
|
||||
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
|
||||
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const cliLifecycle = (cli: TestCli, home: string, profile: string) => {
|
||||
const client = supportedClients(home, process.platform, {
|
||||
HERMES_HOME: profile,
|
||||
}).find(({ name }) => name === "hermes");
|
||||
if (client?.configPath !== join(profile, "config.yaml"))
|
||||
throw new Error("Hermes profile did not resolve to the test config");
|
||||
const run = (arguments_: readonly string[]) =>
|
||||
cli.run({
|
||||
arguments: arguments_,
|
||||
cwd: home,
|
||||
environment: { HOME: home, USERPROFILE: home, HERMES_HOME: profile },
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const setup = [
|
||||
"setup",
|
||||
"--client",
|
||||
"hermes",
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
"--json",
|
||||
];
|
||||
const remove = async () => {
|
||||
const result = await run(["uninstall", "--json"]);
|
||||
expect(result.exitCode, JSON.stringify(result)).toBe(0);
|
||||
};
|
||||
return {
|
||||
configure: async () => {
|
||||
const result = await run(setup);
|
||||
expect(result.exitCode, JSON.stringify(result)).toBe(0);
|
||||
expect(result.json).toMatchObject({ status: "ready" });
|
||||
},
|
||||
verifyAndRepeat: async () => {
|
||||
const doctor = await run(["doctor", "--client", "hermes", "--json"]);
|
||||
expect(doctor.exitCode, JSON.stringify(doctor)).toBe(0);
|
||||
expect(doctor.json).toMatchObject({
|
||||
healthy: true,
|
||||
identity: { registrations: [{ client: "hermes", state: "aligned" }] },
|
||||
});
|
||||
const repeated = await run(setup);
|
||||
expect(repeated.exitCode, JSON.stringify(repeated)).toBe(0);
|
||||
expect(repeated.json).toMatchObject({
|
||||
plannedActions: [],
|
||||
appliedActions: [],
|
||||
});
|
||||
},
|
||||
remove,
|
||||
removeAgain: remove,
|
||||
};
|
||||
};
|
||||
|
||||
const configurationLifecycle = (
|
||||
home: string,
|
||||
profile: string,
|
||||
config: string,
|
||||
command: readonly string[],
|
||||
) => {
|
||||
const environment = { HERMES_HOME: profile };
|
||||
const client = supportedClients(home, process.platform, environment).find(
|
||||
({ name }) => name === "hermes",
|
||||
);
|
||||
if (client === undefined || client.configPath !== config)
|
||||
throw new Error("Hermes profile did not resolve to the test config");
|
||||
const uninstallHost = systemUninstallHost(
|
||||
home,
|
||||
undefined,
|
||||
environment,
|
||||
process.platform,
|
||||
);
|
||||
onTestFinished(async () => {
|
||||
expect(await uninstallHost.close?.()).toBeUndefined();
|
||||
});
|
||||
return {
|
||||
configure: async () => {
|
||||
expect(
|
||||
await configureClientConfiguration(client, {}, command),
|
||||
).toMatchObject({ status: "configured" });
|
||||
},
|
||||
verifyAndRepeat: async () => {
|
||||
expect(
|
||||
(
|
||||
await readClientRegistrationStatuses(home, undefined, {
|
||||
platform: process.platform,
|
||||
environment,
|
||||
selectedClientIds: ["hermes"],
|
||||
})
|
||||
).find(({ client: name }) => name === "hermes"),
|
||||
).toMatchObject({ state: "aligned", config_path: config });
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
},
|
||||
remove: async () => {
|
||||
expect((await uninstallHost.removeClient(client)).status).toBe("removed");
|
||||
},
|
||||
removeAgain: async () => {
|
||||
expect((await uninstallHost.removeClient(client)).status).toBe("skipped");
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type AnchorStyle = "entry" | "child" | "shadowed" | "key" | "external";
|
||||
|
||||
const anchoredConfiguration = (
|
||||
version: "1.1" | "1.2",
|
||||
style: AnchorStyle,
|
||||
server: {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: { KEEP: string };
|
||||
enabled: boolean;
|
||||
},
|
||||
) => {
|
||||
const directive = version === "1.2" ? "%YAML 1.2\n---\n" : "";
|
||||
const hasDefaults = style === "shadowed" || style === "external";
|
||||
const entry =
|
||||
` command: ${JSON.stringify(server.command)}\n` +
|
||||
` args: ${JSON.stringify(server.args)}\n` +
|
||||
" enabled: false\n" +
|
||||
(hasDefaults
|
||||
? " env: *settings # Keep nested reference comment\n"
|
||||
: ` env: ${style === "child" ? "&settings " : ""}{KEEP: 'off'}\n`);
|
||||
const original =
|
||||
directive +
|
||||
"# Keep this user setting\nmodel: custom\n" +
|
||||
(hasDefaults ? "defaults: &settings {KEEP: 'off'}\n" : "") +
|
||||
`mcp_servers:\n ${style === "key" ? "&server_name " : ""}rea:${style === "child" || style === "key" ? "" : " &server"}${style === "entry" ? " # Keep anchor definition comment" : ""}\n` +
|
||||
entry +
|
||||
(style === "child"
|
||||
? " other:\n command: other-server\n env: *settings # Shared environment\n"
|
||||
: style === "key"
|
||||
? " other: {command: other-server}\nobserver: *server_name # Registration name\n"
|
||||
: " other: *server # Independent server\n") +
|
||||
(style === "shadowed"
|
||||
? "later: &settings {KEEP: later}\nobserver: *server\n"
|
||||
: "");
|
||||
const other =
|
||||
style === "child"
|
||||
? { command: "other-server", env: { KEEP: "off" } }
|
||||
: style === "key"
|
||||
? { command: "other-server" }
|
||||
: server;
|
||||
return { original, other, hasDefaults };
|
||||
};
|
||||
|
||||
for (const version of ["1.1", "1.2"] as const) {
|
||||
for (const style of [
|
||||
@@ -14,73 +164,39 @@ for (const version of ["1.1", "1.2"] as const) {
|
||||
"key",
|
||||
"external",
|
||||
] as const) {
|
||||
const publicCli = style === "entry" || style === "shadowed";
|
||||
cliTest(
|
||||
`Hermes ${version} preserves ${style} anchor consumers through setup and uninstall`,
|
||||
`Hermes ${version} preserves ${style} anchor consumers through ${publicCli ? "CLI" : "configuration boundary"} setup and uninstall`,
|
||||
async ({ cli }) => {
|
||||
// Exercise both YAML versions through the CLI, including the shadowed
|
||||
// anchor case; the other styles retain the full registration lifecycle
|
||||
// at the production configuration and uninstall boundary.
|
||||
const home = await createTestTempDirectory(
|
||||
"rea-hermes-anchored-entry-",
|
||||
);
|
||||
const profile = join(home, "hermes");
|
||||
const config = join(profile, "config.yaml");
|
||||
const command = publicCli
|
||||
? [process.execPath, resolve("scripts/rea.mjs"), "mcp"]
|
||||
: currentClientRegistrationCommand({ platform: process.platform });
|
||||
if (command[0] === undefined) throw new Error("Missing REA entrypoint");
|
||||
const server = {
|
||||
command: process.execPath,
|
||||
args: [resolve("scripts/rea.mjs"), "mcp"],
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
env: { KEEP: "off" },
|
||||
enabled: false,
|
||||
};
|
||||
const directive = version === "1.2" ? "%YAML 1.2\n---\n" : "";
|
||||
const hasDefaults = style === "shadowed" || style === "external";
|
||||
const entry =
|
||||
` command: ${JSON.stringify(server.command)}\n` +
|
||||
` args: ${JSON.stringify(server.args)}\n` +
|
||||
" enabled: false\n" +
|
||||
(hasDefaults
|
||||
? " env: *settings # Keep nested reference comment\n"
|
||||
: ` env: ${style === "child" ? "&settings " : ""}{KEEP: 'off'}\n`);
|
||||
const original =
|
||||
directive +
|
||||
"# Keep this user setting\nmodel: custom\n" +
|
||||
(hasDefaults ? "defaults: &settings {KEEP: 'off'}\n" : "") +
|
||||
`mcp_servers:\n ${style === "key" ? "&server_name " : ""}rea:${style === "child" || style === "key" ? "" : " &server"}${style === "entry" ? " # Keep anchor definition comment" : ""}\n` +
|
||||
entry +
|
||||
(style === "child"
|
||||
? " other:\n command: other-server\n env: *settings # Shared environment\n"
|
||||
: style === "key"
|
||||
? " other: {command: other-server}\nobserver: *server_name # Registration name\n"
|
||||
: " other: *server # Independent server\n") +
|
||||
(style === "shadowed"
|
||||
? "later: &settings {KEEP: later}\nobserver: *server\n"
|
||||
: "");
|
||||
const other =
|
||||
style === "child"
|
||||
? { command: "other-server", env: { KEEP: "off" } }
|
||||
: style === "key"
|
||||
? { command: "other-server" }
|
||||
: server;
|
||||
const { original, other, hasDefaults } = anchoredConfiguration(
|
||||
version,
|
||||
style,
|
||||
server,
|
||||
);
|
||||
await mkdir(profile);
|
||||
await writeFile(config, original);
|
||||
const run = (arguments_: readonly string[]) =>
|
||||
cli.run({
|
||||
arguments: arguments_,
|
||||
cwd: home,
|
||||
environment: {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
HERMES_HOME: profile,
|
||||
},
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const setup = [
|
||||
"setup",
|
||||
"--client",
|
||||
"hermes",
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
"--json",
|
||||
];
|
||||
const applied = await run(setup);
|
||||
expect(applied.exitCode, JSON.stringify(applied)).toBe(0);
|
||||
expect(applied.json).toMatchObject({ status: "ready" });
|
||||
const lifecycle = publicCli
|
||||
? cliLifecycle(cli, home, profile)
|
||||
: configurationLifecycle(home, profile, config, command);
|
||||
await lifecycle.configure();
|
||||
const configured = await readFile(config, "utf8");
|
||||
const configuredValues = parse(configured, { version });
|
||||
expect(configuredValues).toMatchObject({
|
||||
@@ -108,21 +224,9 @@ for (const version of ["1.1", "1.2"] as const) {
|
||||
if (hasDefaults)
|
||||
expect(configured).toContain("# Keep nested reference comment");
|
||||
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
|
||||
const doctor = await run(["doctor", "--client", "hermes", "--json"]);
|
||||
expect(doctor.exitCode, JSON.stringify(doctor)).toBe(0);
|
||||
expect(doctor.json).toMatchObject({
|
||||
healthy: true,
|
||||
identity: { registrations: [{ client: "hermes", state: "aligned" }] },
|
||||
});
|
||||
const repeated = await run(setup);
|
||||
expect(repeated.exitCode, JSON.stringify(repeated)).toBe(0);
|
||||
expect(repeated.json).toMatchObject({
|
||||
plannedActions: [],
|
||||
appliedActions: [],
|
||||
});
|
||||
await lifecycle.verifyAndRepeat();
|
||||
expect(await readFile(config, "utf8")).toBe(configured);
|
||||
const removed = await run(["uninstall", "--json"]);
|
||||
expect(removed.exitCode, JSON.stringify(removed)).toBe(0);
|
||||
await lifecycle.remove();
|
||||
const removedValues = parse(await readFile(config, "utf8"), {
|
||||
version,
|
||||
});
|
||||
@@ -148,7 +252,7 @@ for (const version of ["1.1", "1.2"] as const) {
|
||||
expect(remaining).toContain("# Keep anchor definition comment");
|
||||
if (hasDefaults)
|
||||
expect(remaining).toContain("# Keep nested reference comment");
|
||||
expect((await run(["uninstall", "--json"])).exitCode).toBe(0);
|
||||
await lifecycle.removeAgain();
|
||||
expect(await readFile(config, "utf8")).toBe(remaining);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { expect, onTestFinished } from "vitest";
|
||||
import { afterAll, expect, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
expandProviderCapabilities,
|
||||
@@ -30,11 +30,24 @@ import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
||||
import { expectCliMcpEvidenceParity } from "../fixtures/cliMcpEvidenceParity.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest, runFileCli } from "../support/cli/cliFixture.js";
|
||||
import {
|
||||
cliTest,
|
||||
createSharedMcpSession,
|
||||
runFileCli,
|
||||
} from "../support/cli/cliFixture.js";
|
||||
|
||||
const digest = (bytes: Buffer): string =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
// These stateless parity cases use the same default admission settings. Keep
|
||||
// their server file-owned, while lifecycle and configuration cases use fresh
|
||||
// connections with test-owned cleanup.
|
||||
const parityMcp = createSharedMcpSession(async (registerCleanup, isClosed) => {
|
||||
if (isClosed()) throw new Error("SQLite parity MCP session is closed");
|
||||
return (await connectLocalToolsMcp({}, registerCleanup)).call;
|
||||
});
|
||||
afterAll(() => parityMcp.close());
|
||||
|
||||
cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)(
|
||||
"unions SQLite cell slices with numeric cell paths",
|
||||
async ({ cli }) => {
|
||||
@@ -719,7 +732,7 @@ cliTest
|
||||
},
|
||||
},
|
||||
});
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const call = await parityMcp.session.call();
|
||||
const response = await call("inspect_sqlite_database", {
|
||||
path,
|
||||
table: 'odd " table',
|
||||
@@ -1197,7 +1210,7 @@ cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)(
|
||||
writer.close();
|
||||
}
|
||||
const original = await readFile(path);
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const call = await parityMcp.session.call();
|
||||
for (const schema_scope of [
|
||||
"inventory",
|
||||
"selected_table",
|
||||
@@ -1413,7 +1426,7 @@ cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)(
|
||||
"folds ASCII trigger spelling without merging non-ASCII UTF-16 table names",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-sqlite-utf16-identity-");
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const call = await parityMcp.session.call();
|
||||
for (const encoding of ["UTF-16le", "UTF-16be"]) {
|
||||
const path = join(root, `${encoding}.db`);
|
||||
const producer = new DatabaseSync(path);
|
||||
|
||||
@@ -7,7 +7,13 @@ import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it as runtimeTest } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
expect,
|
||||
it as runtimeTest,
|
||||
onTestFinished,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { z } from "zod";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
@@ -15,6 +21,7 @@ import {
|
||||
webNetworkCaptureSchema,
|
||||
} from "../../../src/domain/webNetworkCapture.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { protobufFrame } from "../../fixtures/protobufCapture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
@@ -29,6 +36,12 @@ const hasMemoryBound =
|
||||
|
||||
const it = runtimeTest.skipIf(!hasMemoryBound);
|
||||
|
||||
// Share schema compilation, not sessions or their retained Evidence.
|
||||
const schemaValidation = createMcpSchemaValidation();
|
||||
afterAll(() => schemaValidation.clear());
|
||||
const connectSazMcp = () =>
|
||||
connectLocalToolsMcp({}, undefined, {}, schemaValidation);
|
||||
|
||||
// Synthetic member fixtures based on Telerik's SAZ layout; not a real-producer golden.
|
||||
const compressedBody = gzipSync(Buffer.from("stored compressed body"));
|
||||
const members = (): [string, Buffer][] => [
|
||||
@@ -164,7 +177,7 @@ it("uses HTTP optional whitespace for SAZ identity codings without changing raw
|
||||
]);
|
||||
else expect(capture.records[index]?.protobuf).toBeUndefined();
|
||||
}
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const { call } = await connectSazMcp();
|
||||
const response = await call("inspect_web_network_capture", {
|
||||
capture_path: path,
|
||||
format: "saz",
|
||||
@@ -241,7 +254,7 @@ it("checks trailer credentials before hidden metadata blocks analysis and withho
|
||||
]);
|
||||
const path = await fixture(bytes);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const { call } = await connectSazMcp();
|
||||
const base = {
|
||||
capture_path: path,
|
||||
format: "saz",
|
||||
@@ -499,7 +512,9 @@ it("inspects native SAZ through the public CLI and MCP with equal evidence, byte
|
||||
);
|
||||
expect(Buffer.compare(await readFile(path), bytes)).toBe(0);
|
||||
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const getValidator = vi.spyOn(schemaValidation, "getValidator");
|
||||
onTestFinished(() => getValidator.mockRestore());
|
||||
const { client, call } = await connectSazMcp();
|
||||
const tool = (await client.listTools()).tools.find(
|
||||
(item) => item.name === "inspect_web_network_capture",
|
||||
);
|
||||
@@ -510,6 +525,13 @@ it("inspects native SAZ through the public CLI and MCP with equal evidence, byte
|
||||
expect(ajv.validate(tool.inputSchema, args)).toBe(true);
|
||||
const response = await call("inspect_web_network_capture", args);
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
expect(getValidator).toHaveBeenCalled();
|
||||
const expectedSchema = JSON.stringify(tool.outputSchema);
|
||||
expect(
|
||||
getValidator.mock.calls.some(
|
||||
([schema]) => JSON.stringify(schema) === expectedSchema,
|
||||
),
|
||||
).toBe(true);
|
||||
const fromMcp = parseEvidence(response.structuredContent);
|
||||
expect(fromMcp.normalized_result).toEqual(evidence.normalized_result);
|
||||
const bundle = await call("get_evidence_bundle", { detail: "complete" });
|
||||
@@ -524,7 +546,7 @@ it("inspects native SAZ through the public CLI and MCP with equal evidence, byte
|
||||
|
||||
it("pages retained SAZ Evidence through public MCP and portable CLI after source deletion", async () => {
|
||||
const path = await fixture(await archive());
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const { call } = await connectSazMcp();
|
||||
const args = { capture_path: path, format: "saz", record_ordinals: [1, 0] };
|
||||
const full = await call("inspect_web_network_capture", args);
|
||||
const fromMcp = parseEvidence(full.structuredContent);
|
||||
@@ -895,7 +917,7 @@ it.each([
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const { call } = await connectSazMcp();
|
||||
const response = await call("inspect_web_network_capture", {
|
||||
capture_path: path,
|
||||
format: "saz",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { expect } from "vitest";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
import { cliTest, runFileCli } from "../../support/cli/cliFixture.js";
|
||||
|
||||
import {
|
||||
analysisViewLayoutEvidence,
|
||||
@@ -97,7 +97,7 @@ cliTest(
|
||||
"evidence_integrity_mismatch",
|
||||
],
|
||||
] as const) {
|
||||
const rejected = await cli.run({
|
||||
const invocation = {
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify({ source, view }),
|
||||
@@ -105,14 +105,18 @@ cliTest(
|
||||
path,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
};
|
||||
const rejected =
|
||||
source.kind === "inline"
|
||||
? await cli.run(invocation)
|
||||
: await runFileCli(cli, invocation);
|
||||
expect(rejected.exitCode).toBe(1);
|
||||
expect(rejected.json).toMatchObject({ code: expectedCode });
|
||||
expect(rejected.json).not.toHaveProperty("evidence_id");
|
||||
}
|
||||
for (const [contents, expectedCode] of [
|
||||
[JSON.stringify(parent), "invalid_request"],
|
||||
["{invalid JSON", "evidence_integrity_mismatch"],
|
||||
for (const [contents, expectedCode, freshProcess] of [
|
||||
[JSON.stringify(parent), "invalid_request", false],
|
||||
["{invalid JSON", "evidence_integrity_mismatch", false],
|
||||
[
|
||||
JSON.stringify({
|
||||
...bundle,
|
||||
@@ -122,10 +126,11 @@ cliTest(
|
||||
})),
|
||||
}),
|
||||
"evidence_integrity_mismatch",
|
||||
true,
|
||||
],
|
||||
] as const) {
|
||||
await writeFile(path, contents);
|
||||
const rejected = await cli.run({
|
||||
const invocation = {
|
||||
arguments: [
|
||||
"inspect-analysis-view",
|
||||
JSON.stringify(request),
|
||||
@@ -133,7 +138,12 @@ cliTest(
|
||||
path,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
};
|
||||
// Keep a fresh-process integrity check for the altered bundle while the
|
||||
// worker must reread the same path for the other invalid inputs.
|
||||
const rejected = freshProcess
|
||||
? await cli.run(invocation)
|
||||
: await runFileCli(cli, invocation);
|
||||
expect(rejected.exitCode).toBe(1);
|
||||
expect(rejected.json).toMatchObject({ code: expectedCode });
|
||||
expect(rejected.json).not.toHaveProperty("evidence_id");
|
||||
@@ -416,9 +426,15 @@ cliTest(
|
||||
};
|
||||
const application = await inspectAnalysisView(request);
|
||||
await writeFile(path, JSON.stringify(request));
|
||||
const reply = await cli.run({
|
||||
const invocation = {
|
||||
arguments: ["inspect-analysis-view", path, "--json"],
|
||||
});
|
||||
};
|
||||
// Exercise a valid and a malformed parent in fresh processes as well
|
||||
// as the shared worker's repeated reads of the rewritten input path.
|
||||
const reply =
|
||||
group === "manifest" && variant !== "missing"
|
||||
? await cli.run(invocation)
|
||||
: await runFileCli(cli, invocation);
|
||||
expect(reply.stderr).toBe("");
|
||||
if (variant === "valid") {
|
||||
expect(application.ok).toBe(true);
|
||||
|
||||
@@ -418,13 +418,12 @@ for (const group of groups) {
|
||||
});
|
||||
if (client === "grok_build") {
|
||||
const original = await readFile(config, "utf8");
|
||||
await writeFile(
|
||||
config,
|
||||
original.replace(
|
||||
"[mcp_servers.rea]",
|
||||
"[mcp_servers.rea]\ndisabled = true",
|
||||
),
|
||||
const disabled = original.replace(
|
||||
"[mcp_servers.rea]",
|
||||
"[mcp_servers.rea]\ndisabled = true",
|
||||
);
|
||||
expect(disabled).not.toBe(original);
|
||||
await writeFile(config, disabled);
|
||||
expect(
|
||||
(await run(["doctor", "--client", "grok_build", "--json"]))
|
||||
.exitCode,
|
||||
|
||||
@@ -391,7 +391,6 @@ cliTest(
|
||||
const home = await createTestTempDirectory("rea-gemini-case-");
|
||||
const system = join(home, "system.json");
|
||||
const defaults = join(home, "defaults.json");
|
||||
await writeFile(system, JSON.stringify({ mcp: { allowed: ["REA"] } }));
|
||||
const environment = {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
@@ -404,20 +403,20 @@ cliTest(
|
||||
cwd: home,
|
||||
environment,
|
||||
});
|
||||
// The intersection compares names case-insensitively, so the spelling that
|
||||
// survives must not decide the outcome: the same pair of policies in
|
||||
// either arrangement has to consolidate to the same decision.
|
||||
// Keep another server in both policies. Without normalized matching the
|
||||
// intersection would then exclude REA rather than become an unrestricted
|
||||
// empty allowlist, so each ordering independently detects that regression.
|
||||
for (const [systemSpelling, defaultSpelling] of [
|
||||
["REA", "rea"],
|
||||
["rea", "REA"],
|
||||
]) {
|
||||
await writeFile(
|
||||
system,
|
||||
JSON.stringify({ mcp: { allowed: [systemSpelling] } }),
|
||||
JSON.stringify({ mcp: { allowed: [systemSpelling, "other"] } }),
|
||||
);
|
||||
await writeFile(
|
||||
defaults,
|
||||
JSON.stringify({ mcp: { allowed: [defaultSpelling] } }),
|
||||
JSON.stringify({ mcp: { allowed: [defaultSpelling, "other"] } }),
|
||||
);
|
||||
const result = await run();
|
||||
expect(result.exitCode, `${systemSpelling}+${defaultSpelling}`).toBe(0);
|
||||
|
||||
@@ -733,6 +733,65 @@ it("records failed omissions from full reports without treating partial or unhan
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("requires executed Python analysis-efficiency cases while allowing optional skips", async () => {
|
||||
const root = await createTestTempDirectory("rea-python-analysis-inventory-");
|
||||
await mkdir(join(root, "scripts"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "scripts", "run-python-tests.py"),
|
||||
await readFile(
|
||||
new URL("../../../scripts/run-python-tests.py", import.meta.url),
|
||||
),
|
||||
);
|
||||
const passing =
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self): pass\n";
|
||||
for (const family of ["mitmproxy", "native", "pwndbg"]) {
|
||||
await mkdir(join(root, "bridge", family), { recursive: true });
|
||||
await writeFile(join(root, "bridge", family, "test_case.py"), passing);
|
||||
}
|
||||
const directory = join(root, "tests", "support", "analysis-efficiency");
|
||||
await mkdir(directory, { recursive: true });
|
||||
const testFile = join(directory, "test_example.py");
|
||||
const run = () =>
|
||||
execute("python3", ["scripts/run-python-tests.py"], { cwd: root });
|
||||
|
||||
await writeFile(join(root, "checkout_support.py"), "VALUE = 42\n");
|
||||
await writeFile(
|
||||
testFile,
|
||||
"import unittest\nfrom checkout_support import VALUE\nclass Example(unittest.TestCase):\n def test_case(self): self.assertEqual(VALUE, 42)\n",
|
||||
);
|
||||
expect((await run()).stderr).toContain("OK");
|
||||
|
||||
for (const content of [
|
||||
"# A present file can contain no tests.\n",
|
||||
"import unittest\nclass Example(unittest.TestCase):\n @unittest.skip('unavailable')\n def test_case(self): pass\n",
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self):\n for x in range(2):\n with self.subTest(x=x): self.skipTest('unavailable')\n",
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self):\n with self.subTest(outer=1):\n with self.subTest(inner=2): self.skipTest('unavailable')\n",
|
||||
]) {
|
||||
await writeFile(testFile, content);
|
||||
await expect(run()).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining(
|
||||
'"code": "no_executed_analysis_efficiency_tests"',
|
||||
),
|
||||
});
|
||||
}
|
||||
for (const content of [
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self): pass\n @unittest.skip('optional')\n def test_skipped(self): pass\n",
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self):\n for x in range(2):\n with self.subTest(x=x):\n if x == 0: self.skipTest('optional')\n self.assertEqual(x, 1)\n",
|
||||
]) {
|
||||
await writeFile(testFile, content);
|
||||
expect((await run()).stderr).toContain("skipped 'optional'");
|
||||
}
|
||||
await writeFile(
|
||||
testFile,
|
||||
"import unittest\nclass Example(unittest.TestCase):\n def test_case(self):\n with self.subTest(value=1): self.fail('subtest failed')\n",
|
||||
);
|
||||
await expect(run()).rejects.toMatchObject({
|
||||
code: 1,
|
||||
stderr: expect.stringContaining("subtest failed"),
|
||||
});
|
||||
});
|
||||
|
||||
it("requires executed Python cases in every bridge family", async () => {
|
||||
const root = await createTestTempDirectory("rea-python-inventory-");
|
||||
await mkdir(join(root, "scripts"), { recursive: true });
|
||||
|
||||
+116
-109
@@ -18,7 +18,10 @@ import {
|
||||
readJavaScriptArtifactFiles,
|
||||
} from "../../../support/javascriptApplicationScope.js";
|
||||
import { analyzeAndAdoptJavaScriptArtifactFiles } from "../../../../src/application/javascript/JavaScriptArtifactAnalysis.js";
|
||||
import { analyzeArtifactFiles } from "../../../fixtures/javascriptArtifactAnalysis.js";
|
||||
import {
|
||||
analyzeArtifactFiles,
|
||||
withArtifactFileAnalyzer,
|
||||
} from "../../../fixtures/javascriptArtifactAnalysis.js";
|
||||
import type { JavaScriptAnalyzedSource } from "../../../../src/domain/javascript/javascriptSourceAnalysis.js";
|
||||
import { immutableArtifactGraph } from "../../../fixtures/javascriptArtifactGraph.js";
|
||||
import { createJavaScriptSemanticGraphProjection } from "../../../../src/application/javascript/JavaScriptSemanticGraphBuilder.js";
|
||||
@@ -404,93 +407,95 @@ it("keeps known IPC pairings when a different Electron value is dynamic", async
|
||||
const snapshot = await scanCanonicalArtifactInventory(root, {});
|
||||
const reader = createJavaScriptArtifactReader(root, "directory");
|
||||
try {
|
||||
const files = await readJavaScriptArtifactFiles(reader, snapshot);
|
||||
const { analysis } = await analyzeArtifactFiles(files);
|
||||
expect(
|
||||
analysis.files.find(({ file }) => file.path === "main.js")?.javascript,
|
||||
).toMatchObject({ parse_status: "partial", parse_error_count: 0 });
|
||||
const { graph, electronSummary } = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(snapshot, files, analysis),
|
||||
);
|
||||
expect(graph.coverage.status).toBe("partial");
|
||||
expect(electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 1,
|
||||
ambiguous_renderer_transmissions: 0,
|
||||
});
|
||||
await withArtifactFileAnalyzer(async (analyze) => {
|
||||
const files = await readJavaScriptArtifactFiles(reader, snapshot);
|
||||
const { analysis } = await analyze(files);
|
||||
expect(
|
||||
analysis.files.find(({ file }) => file.path === "main.js")?.javascript,
|
||||
).toMatchObject({ parse_status: "partial", parse_error_count: 0 });
|
||||
const { graph, electronSummary } = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(snapshot, files, analysis),
|
||||
);
|
||||
expect(graph.coverage.status).toBe("partial");
|
||||
expect(electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 1,
|
||||
ambiguous_renderer_transmissions: 0,
|
||||
});
|
||||
|
||||
// An unselected source map cannot hide an IPC handler.
|
||||
const withoutMap = await readJavaScriptArtifactFiles(
|
||||
reader,
|
||||
snapshot,
|
||||
undefined,
|
||||
["package.json", "main.js", "preload.js", "hidden.js"],
|
||||
);
|
||||
const withoutMapAnalysis = (await analyzeArtifactFiles(withoutMap))
|
||||
.analysis;
|
||||
const nonJsResult = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(
|
||||
// An unselected source map cannot hide an IPC handler.
|
||||
const withoutMap = await readJavaScriptArtifactFiles(
|
||||
reader,
|
||||
snapshot,
|
||||
withoutMap,
|
||||
withoutMapAnalysis,
|
||||
),
|
||||
);
|
||||
expect(nonJsResult.graph.coverage.status).toBe("partial");
|
||||
expect(nonJsResult.electronSummary.ipc.paired_renderer_transmissions).toBe(
|
||||
1,
|
||||
);
|
||||
undefined,
|
||||
["package.json", "main.js", "preload.js", "hidden.js"],
|
||||
);
|
||||
const withoutMapAnalysis = (await analyze(withoutMap)).analysis;
|
||||
const nonJsResult = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(
|
||||
snapshot,
|
||||
withoutMap,
|
||||
withoutMapAnalysis,
|
||||
),
|
||||
);
|
||||
expect(nonJsResult.graph.coverage.status).toBe("partial");
|
||||
expect(
|
||||
nonJsResult.electronSummary.ipc.paired_renderer_transmissions,
|
||||
).toBe(1);
|
||||
|
||||
// A source outside the requested scope can hide a competing IPC handler,
|
||||
// even when the observed sender and handler have a known package owner.
|
||||
const selected = await readJavaScriptArtifactFiles(
|
||||
reader,
|
||||
snapshot,
|
||||
undefined,
|
||||
["package.json", "main.js", "preload.js"],
|
||||
);
|
||||
const selectedAnalysis = (await analyzeArtifactFiles(selected)).analysis;
|
||||
const selectionResult = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(
|
||||
// A source outside the requested scope can hide a competing IPC handler,
|
||||
// even when the observed sender and handler have a known package owner.
|
||||
const selected = await readJavaScriptArtifactFiles(
|
||||
reader,
|
||||
snapshot,
|
||||
selected,
|
||||
selectedAnalysis,
|
||||
),
|
||||
);
|
||||
expect(selectionResult.electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 0,
|
||||
ambiguous_renderer_transmissions: 1,
|
||||
});
|
||||
undefined,
|
||||
["package.json", "main.js", "preload.js"],
|
||||
);
|
||||
const selectedAnalysis = (await analyze(selected)).analysis;
|
||||
const selectionResult = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(
|
||||
snapshot,
|
||||
selected,
|
||||
selectedAnalysis,
|
||||
),
|
||||
);
|
||||
expect(selectionResult.electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 0,
|
||||
ambiguous_renderer_transmissions: 1,
|
||||
});
|
||||
|
||||
const hidden = files.files.find(({ path }) => path === "hidden.js");
|
||||
if (hidden === undefined) throw new Error("Missing hidden source fixture");
|
||||
const unavailable = {
|
||||
...files,
|
||||
files: files.files.filter(({ path }) => path !== hidden.path),
|
||||
unavailable_relevant_files: [
|
||||
...files.unavailable_relevant_files,
|
||||
{
|
||||
path: hidden.path,
|
||||
container_sha256: hidden.container_sha256,
|
||||
sha256: hidden.sha256,
|
||||
bytes: hidden.bytes,
|
||||
inventory_artifact_id: hidden.inventory_artifact_id,
|
||||
kind: hidden.kind,
|
||||
},
|
||||
],
|
||||
};
|
||||
const unavailableAnalysis = {
|
||||
...analysis,
|
||||
files: analysis.files.filter(({ file }) => file.path !== hidden.path),
|
||||
};
|
||||
const unavailableResult = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(
|
||||
snapshot,
|
||||
unavailable,
|
||||
unavailableAnalysis,
|
||||
),
|
||||
);
|
||||
expect(unavailableResult.electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 0,
|
||||
ambiguous_renderer_transmissions: 1,
|
||||
const hidden = files.files.find(({ path }) => path === "hidden.js");
|
||||
if (hidden === undefined)
|
||||
throw new Error("Missing hidden source fixture");
|
||||
const unavailable = {
|
||||
...files,
|
||||
files: files.files.filter(({ path }) => path !== hidden.path),
|
||||
unavailable_relevant_files: [
|
||||
...files.unavailable_relevant_files,
|
||||
{
|
||||
path: hidden.path,
|
||||
container_sha256: hidden.container_sha256,
|
||||
sha256: hidden.sha256,
|
||||
bytes: hidden.bytes,
|
||||
inventory_artifact_id: hidden.inventory_artifact_id,
|
||||
kind: hidden.kind,
|
||||
},
|
||||
],
|
||||
};
|
||||
const unavailableAnalysis = {
|
||||
...analysis,
|
||||
files: analysis.files.filter(({ file }) => file.path !== hidden.path),
|
||||
};
|
||||
const unavailableResult = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(
|
||||
snapshot,
|
||||
unavailable,
|
||||
unavailableAnalysis,
|
||||
),
|
||||
);
|
||||
expect(unavailableResult.electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 0,
|
||||
ambiguous_renderer_transmissions: 1,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await reader.close();
|
||||
@@ -511,34 +516,36 @@ it("keeps IPC pairings uncertain when inline HTML scripts are not examined", asy
|
||||
const snapshot = await scanCanonicalArtifactInventory(root, {});
|
||||
const reader = createJavaScriptArtifactReader(root, "directory");
|
||||
try {
|
||||
const files = await readJavaScriptArtifactFiles(reader, snapshot);
|
||||
const { analysis } = await analyzeArtifactFiles(files);
|
||||
expect(analysis.html_inline_scripts.length).toBeGreaterThan(0);
|
||||
const result = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(snapshot, files, analysis),
|
||||
);
|
||||
expect(result.electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 0,
|
||||
ambiguous_renderer_transmissions: 1,
|
||||
});
|
||||
await withArtifactFileAnalyzer(async (analyze) => {
|
||||
const files = await readJavaScriptArtifactFiles(reader, snapshot);
|
||||
const { analysis } = await analyze(files);
|
||||
expect(analysis.html_inline_scripts.length).toBeGreaterThan(0);
|
||||
const result = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(snapshot, files, analysis),
|
||||
);
|
||||
expect(result.electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 0,
|
||||
ambiguous_renderer_transmissions: 1,
|
||||
});
|
||||
|
||||
const selected = await readJavaScriptArtifactFiles(
|
||||
reader,
|
||||
snapshot,
|
||||
undefined,
|
||||
["package.json", "main.js", "preload.js"],
|
||||
);
|
||||
const selectedAnalysis = (await analyzeArtifactFiles(selected)).analysis;
|
||||
const selectedResult = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(
|
||||
const selected = await readJavaScriptArtifactFiles(
|
||||
reader,
|
||||
snapshot,
|
||||
selected,
|
||||
selectedAnalysis,
|
||||
),
|
||||
);
|
||||
expect(selectedResult.electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 0,
|
||||
ambiguous_renderer_transmissions: 1,
|
||||
undefined,
|
||||
["package.json", "main.js", "preload.js"],
|
||||
);
|
||||
const selectedAnalysis = (await analyze(selected)).analysis;
|
||||
const selectedResult = await completeJavaScriptAnalysisSteps(
|
||||
buildImmutableJavaScriptArtifactGraphSteps(
|
||||
snapshot,
|
||||
selected,
|
||||
selectedAnalysis,
|
||||
),
|
||||
);
|
||||
expect(selectedResult.electronSummary.ipc).toMatchObject({
|
||||
paired_renderer_transmissions: 0,
|
||||
ambiguous_renderer_transmissions: 1,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await reader.close();
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
|
||||
|
||||
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { skillDestinations } from "../../../src/application/SetupSkill.js";
|
||||
import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
|
||||
|
||||
/**
|
||||
* The registration command setup writes on the host running the tests. Windows
|
||||
* wraps it in `cmd.exe /d /c`, so the expected entry follows the host too.
|
||||
*/
|
||||
const command = currentClientRegistrationCommand({
|
||||
platform: process.platform,
|
||||
});
|
||||
import { jsonClientRegistrationContract } from "../../support/jsonClientRegistrationContract.js";
|
||||
|
||||
beforeEach(clearClientLocationEnvironment);
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
@@ -71,99 +58,11 @@ describe("Qoder configuration paths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Qoder JSON registration", () => {
|
||||
const qoderClient = async () => {
|
||||
const home = await createTestTempDirectory("rea-qoder-");
|
||||
const client = qoderPath(home, process.platform);
|
||||
if (client?.markerPath === undefined)
|
||||
throw new Error("missing Qoder client");
|
||||
await mkdir(client.markerPath, { recursive: true });
|
||||
return { home, client };
|
||||
};
|
||||
|
||||
it("upserts rea into mcpServers while preserving unrelated settings", async () => {
|
||||
const { home, client } = await qoderClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
`{
|
||||
"mcpServers": {
|
||||
"other": { "command": "other-server" }
|
||||
},
|
||||
"model": { "name": "qoder-model" }
|
||||
}`,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
|
||||
expect(parsed.model).toEqual({ name: "qoder-model" });
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await readClientRegistrationStatuses(home, undefined, {
|
||||
platform: process.platform,
|
||||
environment: {},
|
||||
})
|
||||
).find(({ client }) => client === "qoder"),
|
||||
).toMatchObject({ state: "aligned", command });
|
||||
});
|
||||
|
||||
it("creates the mcpServers table in an existing settings file without servers", async () => {
|
||||
const { client } = await qoderClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify({ general: { vimMode: false } }, null, 2),
|
||||
);
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.general).toEqual({ vimMode: false });
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
});
|
||||
|
||||
it("registers into an empty settings file", async () => {
|
||||
const { client } = await qoderClient();
|
||||
await writeFile(client.configPath, "");
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
});
|
||||
|
||||
it("removes only the rea entry on uninstall", async () => {
|
||||
const { home, client } = await qoderClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify(
|
||||
{ mcpServers: { other: { command: "other-server" } } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await configureClientConfiguration(client, {}, command);
|
||||
|
||||
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
|
||||
"removed",
|
||||
);
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
|
||||
expect(parsed.mcpServers.rea).toBeUndefined();
|
||||
});
|
||||
jsonClientRegistrationContract({
|
||||
clientName: "qoder",
|
||||
displayName: "Qoder",
|
||||
directory: ".qoder",
|
||||
filename: "settings.json",
|
||||
initialSettings: { model: { name: "qoder-model" } },
|
||||
settingsWithoutServers: { general: { vimMode: false } },
|
||||
});
|
||||
@@ -1,25 +1,12 @@
|
||||
import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join, win32 } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
|
||||
|
||||
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
|
||||
import { skillDestinations } from "../../../src/application/SetupSkill.js";
|
||||
import { supportedClients } from "../../../src/application/SupportedClients.js";
|
||||
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js";
|
||||
|
||||
/**
|
||||
* The registration command setup writes on the host running the tests. Windows
|
||||
* wraps it in `cmd.exe /d /c`, so the expected entry follows the host too.
|
||||
*/
|
||||
const command = currentClientRegistrationCommand({
|
||||
platform: process.platform,
|
||||
});
|
||||
import { jsonClientRegistrationContract } from "../../support/jsonClientRegistrationContract.js";
|
||||
|
||||
beforeEach(clearClientLocationEnvironment);
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
@@ -71,97 +58,11 @@ describe("WorkBuddy configuration paths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkBuddy JSON registration", () => {
|
||||
const workbuddyClient = async () => {
|
||||
const home = await createTestTempDirectory("rea-workbuddy-");
|
||||
const client = workbuddyPath(home, process.platform);
|
||||
if (client?.markerPath === undefined)
|
||||
throw new Error("missing WorkBuddy client");
|
||||
await mkdir(client.markerPath, { recursive: true });
|
||||
return { home, client };
|
||||
};
|
||||
|
||||
it("upserts rea into mcpServers while preserving unrelated settings", async () => {
|
||||
const { home, client } = await workbuddyClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
`{
|
||||
"mcpServers": {
|
||||
"other": { "command": "other-server" }
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await readClientRegistrationStatuses(home, undefined, {
|
||||
platform: process.platform,
|
||||
environment: {},
|
||||
})
|
||||
).find(({ client }) => client === "workbuddy"),
|
||||
).toMatchObject({ state: "aligned", command });
|
||||
});
|
||||
|
||||
it("creates the mcpServers table in an existing mcp.json without servers", async () => {
|
||||
const { client } = await workbuddyClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify({ unrelated: { setting: true } }, null, 2),
|
||||
);
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.unrelated).toEqual({ setting: true });
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
});
|
||||
|
||||
it("registers into an empty mcp.json", async () => {
|
||||
const { client } = await workbuddyClient();
|
||||
await writeFile(client.configPath, "");
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
});
|
||||
|
||||
it("removes only the rea entry on uninstall", async () => {
|
||||
const { home, client } = await workbuddyClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify(
|
||||
{ mcpServers: { other: { command: "other-server" } } },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await configureClientConfiguration(client, {}, command);
|
||||
|
||||
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
|
||||
"removed",
|
||||
);
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
|
||||
expect(parsed.mcpServers.rea).toBeUndefined();
|
||||
});
|
||||
jsonClientRegistrationContract({
|
||||
clientName: "workbuddy",
|
||||
displayName: "WorkBuddy",
|
||||
directory: ".workbuddy",
|
||||
filename: "mcp.json",
|
||||
initialSettings: {},
|
||||
settingsWithoutServers: { unrelated: { setting: true } },
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { resolve, join } from "node:path";
|
||||
import { resolve, join, dirname } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../../fixtures/mcpToolCatalog.js";
|
||||
@@ -651,6 +651,33 @@ const copyCompiledPackage = async (prefix: string): Promise<string> => {
|
||||
return directory;
|
||||
};
|
||||
|
||||
// Recorder cleanup tests replace every runtime import through their loader
|
||||
// hook. Keep only resolvable sentinels so an unmodeled import fails closed.
|
||||
const createRecorderCleanupFixture = async (): Promise<string> => {
|
||||
const directory = await createTestTempDirectory("rea-catalog-cleanup-");
|
||||
await mkdir(join(directory, "scripts"), { recursive: true });
|
||||
await cp(
|
||||
resolve("scripts/record-mcp-catalog.mjs"),
|
||||
join(directory, "scripts/record-mcp-catalog.mjs"),
|
||||
);
|
||||
await cp(resolve("package.json"), join(directory, "package.json"));
|
||||
for (const path of [
|
||||
"server/createServer.js",
|
||||
"composition/binary.js",
|
||||
"config/parseConfig.js",
|
||||
"logger.js",
|
||||
"contracts/promptContracts.js",
|
||||
]) {
|
||||
const file = join(directory, "dist", path);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(
|
||||
file,
|
||||
`throw new Error(${JSON.stringify(`Fixture import not replaced: ${path}`)});\n`,
|
||||
);
|
||||
}
|
||||
return directory;
|
||||
};
|
||||
|
||||
it("records the canonical catalog without a host account or ambient configuration", async () => {
|
||||
const directory = await copyCompiledPackage("rea-catalog-without-account-");
|
||||
await cp(
|
||||
@@ -705,11 +732,7 @@ it.each([
|
||||
])(
|
||||
"preserves producer failure=$producerFails and settles every recorder owner with cleanup failures=$failures",
|
||||
async ({ failures, producerFails }) => {
|
||||
const directory = await copyCompiledPackage("rea-catalog-cleanup-");
|
||||
await cp(
|
||||
resolve("scripts/record-mcp-catalog.mjs"),
|
||||
join(directory, "scripts/record-mcp-catalog.mjs"),
|
||||
);
|
||||
const directory = await createRecorderCleanupFixture();
|
||||
const log = join(directory, "cleanup.jsonl");
|
||||
const temporary = join(directory, "temporary");
|
||||
await mkdir(temporary);
|
||||
|
||||
@@ -24,7 +24,11 @@ import { silentLogger } from "../../../src/logger.js";
|
||||
import { registerOfficialTools } from "../../../src/server/registerOfficialTools.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { mcpResponseOverflowProjection } from "../../../src/server/ToolResultDelivery.js";
|
||||
import { MINIMUM_RESPONSE_BYTES } from "../../../src/config/mcpResponseBudget.js";
|
||||
import {
|
||||
MINIMUM_RESPONSE_BYTES,
|
||||
nodeStringCeilingRecovery,
|
||||
stdioResponseBudgetRecovery,
|
||||
} from "../../../src/config/mcpResponseBudget.js";
|
||||
import {
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
@@ -110,29 +114,16 @@ describe("MCP response budget recovery", () => {
|
||||
effectiveResponseBytes: MINIMUM_RESPONSE_BYTES,
|
||||
nextStep: "Use complete CLI JSON output to consume the analysis.",
|
||||
});
|
||||
expect(projected.reason).toContain(
|
||||
`The effective REA_MCP_MAX_RESPONSE_BYTES value is ${String(MINIMUM_RESPONSE_BYTES)} bytes`,
|
||||
);
|
||||
expect(projected.reason).toContain("floor 10485760 bytes, 10 MiB");
|
||||
expect(projected.reason).toContain("restarting the REA process");
|
||||
expect(projected.reason).toContain("does not raise");
|
||||
expect(projected.reason).toContain("no runtime override");
|
||||
expect(NODE_MAX_STRING_LENGTH - MCP_RESULT_STRING_LIMIT).toBe(1024);
|
||||
const guidance = stdioResponseBudgetRecovery(
|
||||
MINIMUM_RESPONSE_BYTES,
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
);
|
||||
expect(projected.reason).toContain(guidance);
|
||||
expect(projected.remediationAction).toContain(guidance);
|
||||
expect(projected.reason).toContain(
|
||||
`REA's reserved single-string limit of ${String(MCP_RESULT_STRING_LIMIT)} UTF-16 code units`,
|
||||
);
|
||||
expect(projected.reason).toContain(
|
||||
`Node's single-string ceiling of ${String(NODE_MAX_STRING_LENGTH)} UTF-16 code units`,
|
||||
);
|
||||
expect(projected.reason).not.toContain(
|
||||
`Node's single-string ceiling of ${String(MCP_RESULT_STRING_LIMIT)}`,
|
||||
);
|
||||
expect(projected.reason).not.toContain(
|
||||
`Node's single-string ceiling is ${String(MCP_RESULT_STRING_LIMIT)}`,
|
||||
);
|
||||
expect(projected.remediationAction).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
expect(projected.remediationAction).not.toContain(
|
||||
"set REA_MCP_MAX_RESPONSE_BYTES above Node",
|
||||
"Use complete CLI JSON output to consume the analysis.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -143,26 +134,14 @@ describe("MCP response budget recovery", () => {
|
||||
effectiveResponseBytes: MINIMUM_RESPONSE_BYTES * 2,
|
||||
nextStep: "Use complete CLI JSON output when the analysis completed.",
|
||||
});
|
||||
expect(projected.reason).toContain("REA's reserved single-string limit");
|
||||
const guidance = nodeStringCeilingRecovery(
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
);
|
||||
expect(projected.reason).toContain(guidance);
|
||||
expect(projected.remediationAction).toContain(guidance);
|
||||
expect(projected.reason).toContain(
|
||||
`REA's reserved single-string limit is ${String(MCP_RESULT_STRING_LIMIT)} UTF-16 code units`,
|
||||
);
|
||||
expect(projected.reason).toContain(
|
||||
`below Node's single-string ceiling of ${String(NODE_MAX_STRING_LENGTH)} UTF-16 code units`,
|
||||
);
|
||||
expect(projected.reason).not.toContain(
|
||||
`Node's single-string ceiling is ${String(MCP_RESULT_STRING_LIMIT)}`,
|
||||
);
|
||||
expect(projected.reason).not.toContain(
|
||||
`Node's single-string ceiling of ${String(MCP_RESULT_STRING_LIMIT)}`,
|
||||
);
|
||||
expect(projected.reason).toContain(
|
||||
"REA_MCP_MAX_RESPONSE_BYTES does not raise them",
|
||||
);
|
||||
expect(projected.reason).toContain("no runtime override");
|
||||
expect(projected.reason).not.toContain("floor 10485760");
|
||||
expect(projected.remediationAction).toContain(
|
||||
"REA_MCP_MAX_RESPONSE_BYTES does not raise them",
|
||||
"Use complete CLI JSON output when the analysis completed.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -179,9 +158,12 @@ describe("MCP response budget recovery", () => {
|
||||
})
|
||||
.parse(delivered.structuredContent).error.remediation.action;
|
||||
expect(action).toContain("get_evidence_bundle and detail: summary");
|
||||
expect(action).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
expect(action).toContain(
|
||||
`Node's single-string ceiling of ${String(NODE_MAX_STRING_LENGTH)}`,
|
||||
stdioResponseBudgetRecovery(
|
||||
2048,
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -343,12 +325,14 @@ describe("large complete Evidence MCP delivery", () => {
|
||||
}),
|
||||
})
|
||||
.parse(detached.structuredContent).error;
|
||||
expect(limits.message).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
expect(limits.message).toContain("10485760");
|
||||
expect(limits.message).toContain("restarting the REA process");
|
||||
expect(limits.message).toContain("does not raise");
|
||||
const guidance = stdioResponseBudgetRecovery(
|
||||
STDIO_DEFAULT_MAX_BUFFER_SIZE,
|
||||
MCP_RESULT_STRING_LIMIT,
|
||||
NODE_MAX_STRING_LENGTH,
|
||||
);
|
||||
expect(limits.message).toContain(guidance);
|
||||
expect(limits.message).not.toContain("Export retained evidence");
|
||||
expect(limits.remediation.action).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
expect(limits.remediation.action).toContain(guidance);
|
||||
expect(limits.details.reported_limits).not.toHaveProperty(
|
||||
"evidence_reference",
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
|
||||
import {
|
||||
MCP_DOCTOR_SESSION_SCOPE_DETAIL,
|
||||
@@ -22,6 +24,9 @@ import {
|
||||
createCatalogIdentity,
|
||||
} from "../../../src/catalogIdentity.js";
|
||||
import { compactPresentationForMcpInputSchemaProfile } from "../../../src/config/mcpInputSchemaProfile.js";
|
||||
import { parseConfig } from "../../../src/config/parseConfig.js";
|
||||
import { runSessionStatus } from "../../../src/application/DirectAnalysisStatus.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import {
|
||||
resolveMcpStartupBudgets,
|
||||
MCP_PHASE_BUDGET_FLOOR_MS,
|
||||
@@ -66,9 +71,31 @@ const assertCliCatalogIdentity = async (
|
||||
}
|
||||
};
|
||||
|
||||
// The CLI commands share this workflow. Exercise each profile here, while
|
||||
// retaining representative compiled CLI checks for each public command.
|
||||
const assertStatusCatalogIdentity = async (
|
||||
environment: NodeJS.ProcessEnv,
|
||||
identity: ReturnType<typeof createCatalogIdentity>,
|
||||
) => {
|
||||
const session = createTestBinarySession(() => ({
|
||||
close: async () => ok(null),
|
||||
execute: async () => observed(null),
|
||||
}));
|
||||
const status = await runSessionStatus({
|
||||
readConfiguration: () => parseConfig(environment),
|
||||
createBinarySession: () => session,
|
||||
});
|
||||
expect(status).toMatchObject({
|
||||
server_identity: {
|
||||
catalog: { counts: identity.counts, digests: identity.digests },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const catalogProfiles = [
|
||||
{
|
||||
name: "default compact input",
|
||||
cliParity: false,
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: undefined,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
|
||||
@@ -80,6 +107,7 @@ const catalogProfiles = [
|
||||
},
|
||||
{
|
||||
name: "full",
|
||||
cliParity: true,
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
|
||||
@@ -88,6 +116,7 @@ const catalogProfiles = [
|
||||
},
|
||||
{
|
||||
name: "compact",
|
||||
cliParity: false,
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "full",
|
||||
@@ -99,6 +128,7 @@ const catalogProfiles = [
|
||||
},
|
||||
{
|
||||
name: "output schemas off",
|
||||
cliParity: false,
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
|
||||
@@ -107,6 +137,7 @@ const catalogProfiles = [
|
||||
},
|
||||
{
|
||||
name: "output schemas compact",
|
||||
cliParity: false,
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "full",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "compact",
|
||||
@@ -115,6 +146,7 @@ const catalogProfiles = [
|
||||
},
|
||||
{
|
||||
name: "compact input and output schemas off",
|
||||
cliParity: true,
|
||||
environment: {
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: "off",
|
||||
@@ -189,8 +221,8 @@ describe("production MCP doctor", () => {
|
||||
});
|
||||
|
||||
it.each(catalogProfiles)(
|
||||
"verifies the $name catalog through production stdio and both CLI inventories",
|
||||
async ({ environment, identity }) => {
|
||||
"verifies the $name catalog through production stdio and status",
|
||||
async ({ environment, identity, cliParity }) => {
|
||||
const result = await runProductionMcpDoctor({
|
||||
command: process.execPath,
|
||||
args: [resolve("scripts/rea.mjs"), "mcp"],
|
||||
@@ -223,10 +255,10 @@ describe("production MCP doctor", () => {
|
||||
expect(result.identity?.observed_catalog_digest).toBe(
|
||||
identity.digests.combined_sha256,
|
||||
);
|
||||
await assertCliCatalogIdentity(
|
||||
{ ...process.env, ...environment },
|
||||
identity,
|
||||
);
|
||||
const selectedEnvironment = { ...process.env, ...environment };
|
||||
await assertStatusCatalogIdentity(selectedEnvironment, identity);
|
||||
if (cliParity)
|
||||
await assertCliCatalogIdentity(selectedEnvironment, identity);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
@@ -234,20 +266,22 @@ describe("production MCP doctor", () => {
|
||||
|
||||
describe("production MCP doctor family scopes", () => {
|
||||
it.each([
|
||||
{ families: "session", output: "full" },
|
||||
{ families: "session,application", output: "compact" },
|
||||
{ families: "session", output: "full", cliParity: false },
|
||||
{ families: "session,application", output: "compact", cliParity: true },
|
||||
] as const)(
|
||||
"reports a selected family scope healthy with its own inventory and digest ($output)",
|
||||
async ({ families, output }) => {
|
||||
async ({ families, output, cliParity }) => {
|
||||
const environment = {
|
||||
...process.env,
|
||||
REA_MCP_TOOL_FAMILIES: families,
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: "compact",
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: output,
|
||||
};
|
||||
const result = await runProductionMcpDoctor({
|
||||
command: process.execPath,
|
||||
args: [resolve("scripts/rea.mjs"), "mcp"],
|
||||
cwd: process.cwd(),
|
||||
environment: {
|
||||
...process.env,
|
||||
REA_MCP_TOOL_FAMILIES: families,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: output,
|
||||
},
|
||||
environment,
|
||||
});
|
||||
|
||||
const expected = contractsForToolFamilies(new Set(families.split(",")));
|
||||
@@ -264,22 +298,19 @@ describe("production MCP doctor family scopes", () => {
|
||||
},
|
||||
request_flow: { tool: "binary_session", ok: true },
|
||||
});
|
||||
await assertCliCatalogIdentity(
|
||||
{
|
||||
...process.env,
|
||||
REA_MCP_TOOL_FAMILIES: families,
|
||||
REA_MCP_OUTPUT_SCHEMA_PROFILE: output,
|
||||
},
|
||||
catalogIdentityForToolFamilies(
|
||||
compactPresentationForMcpInputSchemaProfile(
|
||||
process.env.REA_MCP_INPUT_SCHEMA_PROFILE === "full"
|
||||
? "full"
|
||||
: "compact",
|
||||
),
|
||||
output,
|
||||
new Set(families.split(",")),
|
||||
),
|
||||
const identity = catalogIdentityForToolFamilies(
|
||||
compactPresentationForMcpInputSchemaProfile("compact"),
|
||||
output,
|
||||
new Set(families.split(",")),
|
||||
);
|
||||
expect(result.identity?.expected_catalog_digest).toBe(
|
||||
identity.digests.combined_sha256,
|
||||
);
|
||||
expect(result.identity?.observed_catalog_digest).toBe(
|
||||
identity.digests.combined_sha256,
|
||||
);
|
||||
await assertStatusCatalogIdentity(environment, identity);
|
||||
if (cliParity) await assertCliCatalogIdentity(environment, identity);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import { afterEach, expect } from "vitest";
|
||||
import { afterAll, afterEach, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { BinarySessionPort } from "../../../src/application/binary/BinarySessionPort.js";
|
||||
@@ -20,6 +20,10 @@ import { createServer } from "../../../src/server/createServer.js";
|
||||
import { optionalObservationFactories } from "../../fixtures/optionalObservationProviders.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { mcpTest } from "../../support/mcp/mcpFixture.js";
|
||||
import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js";
|
||||
|
||||
const schemaValidation = createMcpSchemaValidation();
|
||||
afterAll(() => schemaValidation.clear());
|
||||
|
||||
const failedAdapters = [
|
||||
["browserObservation", "list_browser_targets", "rea-cdp-browser"],
|
||||
@@ -128,7 +132,11 @@ mcpTest.for(failedAdapters)(
|
||||
});
|
||||
for (const [peer] of failedAdapters)
|
||||
expect(connected.loaded[peer] !== undefined).toBe(peer !== port);
|
||||
const client = await mcp.connect(connected.server);
|
||||
const client = await mcp.connect(
|
||||
connected.server,
|
||||
undefined,
|
||||
schemaValidation,
|
||||
);
|
||||
const names = (await client.listTools()).tools.map(({ name }) => name);
|
||||
expect(new Set(names)).toEqual(
|
||||
new Set(TOOL_CONTRACTS.map(({ name }) => name)),
|
||||
@@ -252,7 +260,7 @@ mcpTest(
|
||||
electronActiveObservation: failed,
|
||||
javascriptRuntimeObservation: failed,
|
||||
});
|
||||
const client = await mcp.connect(first.server);
|
||||
const client = await mcp.connect(first.server, undefined, schemaValidation);
|
||||
expect((await client.listTools()).tools).toHaveLength(
|
||||
TOOL_CONTRACTS.length,
|
||||
);
|
||||
|
||||
@@ -91,37 +91,6 @@ it("inspects both tools without reading a capture", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a selected TShark whose bytes change during its version probe", async () => {
|
||||
const root = await createTestTempDirectory("rea-pcap-tool-identity-");
|
||||
const { tshark, capinfos } = await writeStubTools(root);
|
||||
await writeFile(
|
||||
tshark,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "--version" ]; then
|
||||
printf 'TShark (Wireshark) 4.6.6-stub\\n'
|
||||
printf '\\n# changed during version probe\\n' >> "$0"
|
||||
exit 0
|
||||
fi
|
||||
exit 2
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const provider = new PcapProvider({
|
||||
environment: { REA_TSHARK_COMMAND: tshark, REA_CAPINFOS_COMMAND: capinfos },
|
||||
});
|
||||
onTestFinished(() => provider.close());
|
||||
const result = await provider.execute({
|
||||
operation: "inspect_pcap_client",
|
||||
input: {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "artifact_changed",
|
||||
details: { path: tshark },
|
||||
});
|
||||
});
|
||||
|
||||
it("inspects a capture with endpoints, DNS, TLS SNI, and HTTP", async () => {
|
||||
const { provider, root } = await providerWith();
|
||||
const path = await captureFixture(root);
|
||||
@@ -441,16 +410,6 @@ it("reports a non-capture file as unsupported", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a missing tshark binary as a capability gap", async () => {
|
||||
const provider = new PcapProvider({
|
||||
environment: { REA_TSHARK_COMMAND: "/nonexistent/tshark" },
|
||||
});
|
||||
expect(await provider.inspectAvailability()).toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "executable_missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a capture that is not a readable file", async () => {
|
||||
const { provider } = await providerWith();
|
||||
const result = await provider.execute({
|
||||
@@ -464,25 +423,6 @@ it("rejects a capture that is not a readable file", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels before spawning anything", async () => {
|
||||
const { provider, root } = await providerWith();
|
||||
await writeFile(join(root, "spawned"), "");
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await provider.execute(
|
||||
{
|
||||
operation: "inspect_pcap_capture",
|
||||
input: { path: "/some/capture.pcap" },
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports relative configuration and missing capinfos as capability gaps", async () => {
|
||||
const root = await createTestTempDirectory("rea-pcap-config-");
|
||||
const { tshark } = await writeStubTools(root);
|
||||
|
||||
@@ -48,12 +48,13 @@ const capinfosStub = isWindows
|
||||
? join(stubDir, "capinfos-stub.cmd")
|
||||
: join(stubDir, "capinfos-stub.sh");
|
||||
|
||||
const providerOver = (): PcapProvider => {
|
||||
const providerOver = (launcher?: PcapCommandLauncher): PcapProvider => {
|
||||
const provider = new PcapProvider({
|
||||
environment: {
|
||||
REA_TSHARK_COMMAND: tsharkStub,
|
||||
REA_CAPINFOS_COMMAND: capinfosStub,
|
||||
},
|
||||
...(launcher === undefined ? {} : { launcher }),
|
||||
});
|
||||
onTestFinished(() => provider.close());
|
||||
return provider;
|
||||
@@ -315,6 +316,7 @@ exit 2
|
||||
if (!result.ok)
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "artifact_changed",
|
||||
details: { path: tshark },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -471,7 +473,10 @@ it("reports a missing binary as a capability gap", async () => {
|
||||
it("cancels before spawning anything", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const result = await providerOver().execute(
|
||||
const launcher = vi.fn<NonNullable<PcapCommandLauncher>>(async () => {
|
||||
throw new Error("Cancelled analysis must not launch a tool");
|
||||
});
|
||||
const result = await providerOver(launcher).execute(
|
||||
{ operation: "inspect_pcap_capture", input: { path: "/any/capture.pcap" } },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
@@ -480,6 +485,7 @@ it("cancels before spawning anything", async () => {
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
});
|
||||
expect(launcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.runIf(!hasNativeBound)(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
@@ -17,6 +18,7 @@ import { promisify } from "node:util";
|
||||
import { _electron as electron } from "playwright-core";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import {
|
||||
@@ -1269,7 +1271,7 @@ it.skipIf(!onDarwin)(
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
argvDecoyIgnored: true,
|
||||
argvDecoyFailsClosed: true,
|
||||
readableEnvironmentWithoutToken: {
|
||||
runId: null,
|
||||
environmentEntryCount: 1,
|
||||
@@ -1310,16 +1312,35 @@ it.skipIf(!onDarwin)(
|
||||
emptyArgv0Read: true,
|
||||
emptyEnvironmentRecordsBeforeTokenFailClosed: true,
|
||||
duplicateTokenFailsClosed: true,
|
||||
invalidTokenEncodingFailsClosed: true,
|
||||
finalAssignmentArgumentRead: true,
|
||||
missingAppleBoundaryFailsClosed: true,
|
||||
malformedBuffersRejected: true,
|
||||
invalidPointerSizeFailsClosed: true,
|
||||
malformedStages: [
|
||||
{ state: "unavailable", reason: "malformed_procargs_header" },
|
||||
{ state: "unavailable", reason: "malformed_procargs_argument_count" },
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "malformed_procargs_executable_path",
|
||||
},
|
||||
{ state: "unavailable", reason: "malformed_procargs_pointer_size" },
|
||||
{ state: "unavailable", reason: "malformed_procargs_path_padding" },
|
||||
{ state: "unavailable", reason: "malformed_procargs_arguments" },
|
||||
{ state: "unavailable", reason: "malformed_procargs_terminator" },
|
||||
{
|
||||
state: "unavailable",
|
||||
reason: "malformed_procargs_trailing_entries",
|
||||
},
|
||||
],
|
||||
malformedObservation: {
|
||||
state: "unavailable",
|
||||
reason: "malformed_procargs",
|
||||
},
|
||||
nonAsciiPathRead: true,
|
||||
realTokenRead: true,
|
||||
reservedArgumentIgnored: true,
|
||||
reservedArgumentWithRealTokenFailsClosed: true,
|
||||
titleOverwriteFailsClosed: true,
|
||||
truncatedArgvFailsClosed: true,
|
||||
zeroArgumentsRead: true,
|
||||
});
|
||||
@@ -1439,6 +1460,79 @@ it.skipIf(!onDarwin)(
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!onDarwin)(
|
||||
"keeps a long title overwrite unknown when it hides the child's token",
|
||||
async () => {
|
||||
const host = createSystemProcessOwnershipHost("darwin");
|
||||
const children: ChildProcess[] = [];
|
||||
try {
|
||||
for (const length of [256, 384, 512, 1024]) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
`if(process.env.PROBE_TITLE!=='-') process.title=process.env.PROBE_TITLE;process.send({ready:true,stillOwned:process.env.REA_PROCESS_RUN_ID==='probe-owned'});setInterval(()=>{},1000)`,
|
||||
"arg1",
|
||||
"arg2",
|
||||
"arg3",
|
||||
],
|
||||
{
|
||||
env: {
|
||||
REA_PROCESS_RUN_ID: "probe-owned",
|
||||
PROBE_TITLE: "n".repeat(length),
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
},
|
||||
);
|
||||
children.push(child);
|
||||
const message = await waitForChildReadiness(child);
|
||||
expect(message).toEqual({ ready: true, stillOwned: true });
|
||||
if (child.pid === undefined)
|
||||
throw new Error("test child did not receive a PID");
|
||||
const entry = (await host.listProcesses()).find(
|
||||
({ pid }) => pid === child.pid,
|
||||
);
|
||||
if (
|
||||
entry === undefined ||
|
||||
host.processIdentities === undefined ||
|
||||
host.runTokens === undefined
|
||||
)
|
||||
throw new Error("native child inspection unavailable");
|
||||
const before = (await host.processIdentities([entry])).get(child.pid);
|
||||
const token = (await host.runTokens([entry])).get(child.pid);
|
||||
const after = (await host.processIdentities([entry])).get(child.pid);
|
||||
expect(before).toMatchObject({
|
||||
state: "readable",
|
||||
lifecycle: { state: "observed", lifecycle: "non-zombie" },
|
||||
});
|
||||
if (before?.state !== "readable")
|
||||
throw new Error("child identity unavailable");
|
||||
expect(after).toMatchObject({
|
||||
state: "readable",
|
||||
identity: before.identity,
|
||||
lifecycle: { state: "observed", lifecycle: "non-zombie" },
|
||||
});
|
||||
if (token?.state === "readable") {
|
||||
expect(token.runId).toBe("probe-owned");
|
||||
} else {
|
||||
expect(token).toEqual({
|
||||
state: "unavailable",
|
||||
reason: "ambiguous_environment_boundary",
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await Promise.all(children.map(stopNodeChild));
|
||||
} finally {
|
||||
await host.close?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
|
||||
it.skipIf(!onDarwin)(
|
||||
"reads owned tokens across executable padding and distinguishes a real empty environment",
|
||||
async () => {
|
||||
@@ -1517,6 +1611,36 @@ const stopNodeChild = async (child: ChildProcess) => {
|
||||
await exited;
|
||||
};
|
||||
|
||||
const waitForChildReadiness = async (child: ChildProcess): Promise<unknown> => {
|
||||
const listeners = new AbortController();
|
||||
const message = once(child, "message", { signal: listeners.signal });
|
||||
const exit = once(child, "exit", { signal: listeners.signal });
|
||||
try {
|
||||
await awaitAcknowledgement(
|
||||
message.then(() => undefined),
|
||||
exit,
|
||||
"title-mutating child readiness",
|
||||
);
|
||||
return (await message)[0];
|
||||
} finally {
|
||||
listeners.abort();
|
||||
}
|
||||
};
|
||||
|
||||
it("stops a child that never acknowledges readiness", async () => {
|
||||
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
});
|
||||
try {
|
||||
await expect(waitForChildReadiness(child)).rejects.toThrow(
|
||||
"title-mutating child readiness was not acknowledged before the gate deadline",
|
||||
);
|
||||
} finally {
|
||||
await stopNodeChild(child);
|
||||
}
|
||||
expect(child.exitCode !== null || child.signalCode !== null).toBe(true);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32").each([
|
||||
{
|
||||
name: "invalid UTF-8",
|
||||
|
||||
@@ -27,26 +27,28 @@ describe("MCP response budget configuration", () => {
|
||||
reserved,
|
||||
nodeCeiling,
|
||||
);
|
||||
expect(recovery).toContain(
|
||||
"The effective REA_MCP_MAX_RESPONSE_BYTES value is 20971520 bytes",
|
||||
);
|
||||
expect(recovery).toContain("floor 10485760 bytes, 10 MiB");
|
||||
expect(recovery).toContain("restarting the REA process");
|
||||
expect(recovery).toContain(
|
||||
`REA's reserved single-string limit of ${String(reserved)} UTF-16 code units`,
|
||||
);
|
||||
expect(recovery).toContain(
|
||||
`Node's single-string ceiling of ${String(nodeCeiling)} UTF-16 code units`,
|
||||
);
|
||||
expect(recovery).not.toContain(
|
||||
`Node's single-string ceiling of ${String(reserved)}`,
|
||||
);
|
||||
expect(recovery).not.toContain(
|
||||
`Node's single-string ceiling is ${String(reserved)}`,
|
||||
);
|
||||
expect(nodeStringCeilingRecovery(reserved, nodeCeiling)).toBe(
|
||||
`REA's reserved single-string limit is ${String(reserved)} UTF-16 code units, below Node's single-string ceiling of ${String(nodeCeiling)} UTF-16 code units. This release has no runtime override for either limit, and REA_MCP_MAX_RESPONSE_BYTES does not raise them.`,
|
||||
);
|
||||
expect(recovery).toMatch(/REA_MCP_MAX_RESPONSE_BYTES[^.]*20971520 bytes/u);
|
||||
expect(recovery).toMatch(/floor 10485760 bytes, 10 MiB/u);
|
||||
expect(recovery).toMatch(/restart\w* the REA process/u);
|
||||
const nodeRecovery = nodeStringCeilingRecovery(reserved, nodeCeiling);
|
||||
expect(nodeRecovery).toMatch(/below Node's single-string ceiling/u);
|
||||
for (const guidance of [recovery, nodeRecovery]) {
|
||||
expect(guidance).toMatch(
|
||||
new RegExp(
|
||||
`REA's reserved single-string limit [^.]*${String(reserved)} UTF-16 code units`,
|
||||
"u",
|
||||
),
|
||||
);
|
||||
expect(guidance).toMatch(
|
||||
new RegExp(
|
||||
`Node's single-string ceiling [^.]*${String(nodeCeiling)} UTF-16 code units`,
|
||||
"u",
|
||||
),
|
||||
);
|
||||
expect(guidance).toContain("REA_MCP_MAX_RESPONSE_BYTES");
|
||||
expect(guidance).toMatch(/does not raise/u);
|
||||
expect(guidance).toMatch(/no runtime override/u);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["", "1.5", "1e8", " 10485760", "9007199254740992"])(
|
||||
|
||||
+45
-25
@@ -5,36 +5,56 @@ import {
|
||||
type JavaScriptSemanticGraphProjection,
|
||||
} from "../../src/application/javascript/JavaScriptSemanticGraphBuilder.js";
|
||||
import { createJavaScriptSourceAnalysis } from "../../src/artifacts/javascript/JavaScriptSourceAnalysis.js";
|
||||
import type { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||
import type { JavaScriptArtifactFileSet } from "../../src/domain/javascript/javascriptArtifactFiles.js";
|
||||
import { withArtifactResourceScope } from "./artifactInventory.js";
|
||||
|
||||
/** Analyze inert fixture sources through the real worker and join its owning scope. */
|
||||
export const analyzeArtifactFiles = (
|
||||
const analyzeArtifactFilesInScope = async (
|
||||
fileSet: JavaScriptArtifactFileSet,
|
||||
scope: ArtifactResourceScope,
|
||||
): Promise<{
|
||||
readonly analysis: JavaScriptArtifactAnalysis;
|
||||
readonly projection: JavaScriptSemanticGraphProjection;
|
||||
}> =>
|
||||
withArtifactResourceScope(async (scope) => {
|
||||
const worker = await createJavaScriptSourceAnalysis(
|
||||
{ heapMb: 512, timeoutMs: 30_000 },
|
||||
fileSet.files.filter(
|
||||
(file) => file.kind === "javascript" && file.text.included,
|
||||
).length,
|
||||
{ scope },
|
||||
);
|
||||
scope.retain({
|
||||
kind: "javascript-source-analysis",
|
||||
resource: "fixture JavaScript source analyzer",
|
||||
analysis: worker,
|
||||
});
|
||||
const projection = createJavaScriptSemanticGraphProjection();
|
||||
const outcome = await analyzeAndAdoptJavaScriptArtifactFiles(
|
||||
fileSet,
|
||||
worker,
|
||||
projection,
|
||||
async () => {},
|
||||
);
|
||||
if (outcome.interruption !== null) throw outcome.interruption;
|
||||
return { analysis: outcome.analysis, projection };
|
||||
});
|
||||
}> => {
|
||||
const worker = await createJavaScriptSourceAnalysis(
|
||||
{ heapMb: 512, timeoutMs: 30_000 },
|
||||
fileSet.files.filter(
|
||||
(file) => file.kind === "javascript" && file.text.included,
|
||||
).length,
|
||||
{ scope },
|
||||
);
|
||||
const owner = {
|
||||
kind: "javascript-source-analysis",
|
||||
resource: "fixture JavaScript source analyzer",
|
||||
analysis: worker,
|
||||
} as const;
|
||||
scope.retain(owner);
|
||||
const projection = createJavaScriptSemanticGraphProjection();
|
||||
const outcome = await analyzeAndAdoptJavaScriptArtifactFiles(
|
||||
fileSet,
|
||||
worker,
|
||||
projection,
|
||||
async () => {},
|
||||
);
|
||||
if (outcome.interruption !== null) throw outcome.interruption;
|
||||
const released = await scope.release(owner);
|
||||
if (released.kind === "failed")
|
||||
throw scope.cleanupFailure({ kind: "completed" }, [released]);
|
||||
return { analysis: outcome.analysis, projection };
|
||||
};
|
||||
|
||||
/** Reuse a healthy worker across analyses owned by the same test. */
|
||||
export const withArtifactFileAnalyzer = <T>(
|
||||
operation: (
|
||||
analyze: (
|
||||
fileSet: JavaScriptArtifactFileSet,
|
||||
) => ReturnType<typeof analyzeArtifactFilesInScope>,
|
||||
) => Promise<T>,
|
||||
): Promise<T> =>
|
||||
withArtifactResourceScope((scope) =>
|
||||
operation((fileSet) => analyzeArtifactFilesInScope(fileSet, scope)),
|
||||
);
|
||||
|
||||
export const analyzeArtifactFiles = (fileSet: JavaScriptArtifactFileSet) =>
|
||||
withArtifactFileAnalyzer((analyze) => analyze(fileSet));
|
||||
+28
-4
@@ -36,13 +36,12 @@ const exportedReturnFields = (value: unknown) => {
|
||||
/** Select complete return fields from a real JavaScript application result. */
|
||||
export type JavaScriptReturnFields = ReturnType<typeof exportedReturnFields>;
|
||||
|
||||
/** Exercise identical return assertions through the compiled CLI and stdio MCP. */
|
||||
export const verifyJavaScriptReturnShapes = async (
|
||||
const analyzeReturnShapesThroughCli = async (
|
||||
cli: TestCli,
|
||||
source: string,
|
||||
check: (fields: JavaScriptReturnFields) => void,
|
||||
fileName = "app.js",
|
||||
): Promise<void> => {
|
||||
fileName: string,
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-return-shapes-");
|
||||
await writeFile(join(root, fileName), source);
|
||||
const cliResponse = await cli.run({
|
||||
@@ -58,6 +57,31 @@ export const verifyJavaScriptReturnShapes = async (
|
||||
const contract = toolContract("analyze_javascript_application");
|
||||
const cliEvidence = contract.outputSchema.parse(cliResponse.json);
|
||||
check(exportedReturnFields(cliEvidence.normalized_result));
|
||||
return { root, contract, cliEvidence };
|
||||
};
|
||||
|
||||
/** Exercise return assertions through the compiled CLI and its real worker. */
|
||||
export const verifyJavaScriptReturnShapesThroughCli = async (
|
||||
cli: TestCli,
|
||||
source: string,
|
||||
check: (fields: JavaScriptReturnFields) => void,
|
||||
): Promise<void> => {
|
||||
await analyzeReturnShapesThroughCli(cli, source, check, "app.js");
|
||||
};
|
||||
|
||||
/** Exercise identical return assertions through the compiled CLI and stdio MCP. */
|
||||
export const verifyJavaScriptReturnShapes = async (
|
||||
cli: TestCli,
|
||||
source: string,
|
||||
check: (fields: JavaScriptReturnFields) => void,
|
||||
fileName = "app.js",
|
||||
): Promise<void> => {
|
||||
const { root, contract, cliEvidence } = await analyzeReturnShapesThroughCli(
|
||||
cli,
|
||||
source,
|
||||
check,
|
||||
fileName,
|
||||
);
|
||||
const client = new Client({ name: "return-shapes-e2e", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
|
||||
Vendored
+6
-1
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Client,
|
||||
type CallToolRequestOptions,
|
||||
type ClientOptions,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import {
|
||||
StdioClientTransport,
|
||||
@@ -41,6 +42,7 @@ export async function connectLocalToolsMcp(
|
||||
environment: Readonly<Record<string, string>> = {},
|
||||
registerCleanup: (cleanup: () => Promise<void>) => void = onTestFinished,
|
||||
transportOptions: Readonly<Pick<StdioServerParameters, "maxBufferSize">> = {},
|
||||
jsonSchemaValidator?: NonNullable<ClientOptions["jsonSchemaValidator"]>,
|
||||
) {
|
||||
const transport = new StdioClientTransport({
|
||||
...transportOptions,
|
||||
@@ -57,7 +59,10 @@ export async function connectLocalToolsMcp(
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "local-tools-e2e", version: "1" });
|
||||
const client = new Client(
|
||||
{ name: "local-tools-e2e", version: "1" },
|
||||
jsonSchemaValidator === undefined ? undefined : { jsonSchemaValidator },
|
||||
);
|
||||
registerCleanup(async () => {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
|
||||
+79
-22
@@ -26,10 +26,29 @@ func fixture(
|
||||
@main
|
||||
struct ProcessRunTokenParserProbe {
|
||||
static func main() throws {
|
||||
let argvDecoy = try parseProcessEnvironment(in: fixture(
|
||||
arguments: ["/fixture/executable", "REA_PROCESS_RUN_ID=argv-decoy", "trailing"],
|
||||
environment: ["DECOY=words REA_PROCESS_RUN_ID=synthetic-decoy"]
|
||||
))
|
||||
let argvDecoyFailsClosed: Bool
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: fixture(
|
||||
arguments: ["/fixture/executable", "REA_PROCESS_RUN_ID=argv-decoy", "trailing"],
|
||||
environment: ["DECOY=words REA_PROCESS_RUN_ID=synthetic-decoy"]
|
||||
))
|
||||
argvDecoyFailsClosed = false
|
||||
} catch RunTokenReadError.ambiguousEnvironmentBoundary {
|
||||
argvDecoyFailsClosed = true
|
||||
}
|
||||
// This layout matches the observed long-title overwrite: a former
|
||||
// environment entry is consumed as an argv slot before the cleared pfz.
|
||||
let titleOverwriteFailsClosed: Bool
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: fixture(
|
||||
arguments: [String(repeating: "n", count: 248), "REA_PROCESS_RUN_ID=owned-token", "a", "b", "c", "d"],
|
||||
environment: ["PATH=/usr/bin"],
|
||||
zeroedPfz: true
|
||||
))
|
||||
titleOverwriteFailsClosed = false
|
||||
} catch RunTokenReadError.ambiguousEnvironmentBoundary {
|
||||
titleOverwriteFailsClosed = true
|
||||
}
|
||||
let realToken = try parseProcessEnvironment(in: fixture(
|
||||
arguments: ["/fixture/executable"],
|
||||
environment: ["DECOY=words REA_PROCESS_RUN_ID=synthetic-decoy", "REA_PROCESS_RUN_ID=owned-token"]
|
||||
@@ -57,10 +76,16 @@ struct ProcessRunTokenParserProbe {
|
||||
arguments: ["/fixture/executable", "FOO=bar"],
|
||||
environment: ["REA_PROCESS_RUN_ID=final-assignment-token"]
|
||||
))
|
||||
let reservedArgumentIgnored = try parseProcessEnvironment(in: fixture(
|
||||
arguments: ["/fixture/executable", "REA_PROCESS_RUN_ID=argv-decoy"],
|
||||
environment: ["DECOY=words REA_PROCESS_RUN_ID=synthetic-decoy"]
|
||||
))
|
||||
let reservedArgumentWithRealTokenFailsClosed: Bool
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: fixture(
|
||||
arguments: ["/fixture/executable", "REA_PROCESS_RUN_ID=argv-decoy"],
|
||||
environment: ["REA_PROCESS_RUN_ID=actual-token"]
|
||||
))
|
||||
reservedArgumentWithRealTokenFailsClosed = false
|
||||
} catch RunTokenReadError.ambiguousEnvironmentBoundary {
|
||||
reservedArgumentWithRealTokenFailsClosed = true
|
||||
}
|
||||
let nonAsciiPathRead = try parseProcessEnvironment(in: fixture(
|
||||
arguments: ["/fixture/é", "FOO=bar"],
|
||||
environment: ["REA_PROCESS_RUN_ID=utf8-path-token"],
|
||||
@@ -123,30 +148,46 @@ struct ProcessRunTokenParserProbe {
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: truncatedArgv)
|
||||
truncatedArgvFailsClosed = false
|
||||
} catch RunTokenReadError.invalidBuffer {
|
||||
} catch RunTokenReadError.invalidBufferAt(.arguments) {
|
||||
truncatedArgvFailsClosed = true
|
||||
}
|
||||
var invalidPadding = fixture(arguments: ["x"], environment: [], executablePath: "x")
|
||||
invalidPadding[6] = 1 // argc (4 bytes), path and its NUL precede the padding.
|
||||
var unterminated = fixture(arguments: ["x"], environment: [], executablePath: "x")
|
||||
var unterminated = fixture(arguments: ["REA_PROCESS_RUN_ID=argv-decoy"], environment: [], executablePath: "x")
|
||||
unterminated.removeLast()
|
||||
let malformedBuffers: [[UInt8]] = [
|
||||
[0, 0, 0], // Short argc.
|
||||
[255, 255, 255, 255], // Negative argc.
|
||||
[0, 0, 0, 0, 120], // Unterminated executable path.
|
||||
invalidPadding,
|
||||
unterminated,
|
||||
let malformedBuffers: [([UInt8], ProcargsMalformedStage)] = [
|
||||
([0, 0, 0], .header),
|
||||
([255, 255, 255, 255], .argumentCount),
|
||||
([0, 0, 0, 0, 120], .executablePath),
|
||||
(invalidPadding, .pathPadding),
|
||||
(unterminated, .terminator),
|
||||
(truncatedArgv, .arguments),
|
||||
]
|
||||
let malformedBuffersRejected = malformedBuffers.allSatisfy { bytes in
|
||||
let malformedBuffersRejected = malformedBuffers.allSatisfy { bytes, expectedStage in
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: bytes)
|
||||
return false
|
||||
} catch RunTokenReadError.invalidBuffer {
|
||||
return true
|
||||
} catch RunTokenReadError.invalidBufferAt(let stage) {
|
||||
return stage == expectedStage
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
let invalidPointerSizeFailsClosed: Bool
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: fixture(arguments: [], environment: []), pointerSize: 3)
|
||||
invalidPointerSizeFailsClosed = false
|
||||
} catch RunTokenReadError.invalidBufferAt(.pointerSize) {
|
||||
invalidPointerSizeFailsClosed = true
|
||||
}
|
||||
let malformedStages = ProcargsMalformedStage.allCases.map { stage in
|
||||
let observation = processRunTokenObservation(
|
||||
pid: 1,
|
||||
parse: .failure(.invalidBufferAt(stage)),
|
||||
codeSigningFlags: { 0 }
|
||||
)
|
||||
return ["state": observation.state, "reason": observation.reason ?? ""]
|
||||
}
|
||||
let malformedObservation = processRunTokenObservation(
|
||||
pid: 1,
|
||||
parse: .failure(.invalidBuffer),
|
||||
@@ -155,13 +196,25 @@ struct ProcessRunTokenParserProbe {
|
||||
let duplicateTokenFailsClosed: Bool
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: fixture(
|
||||
arguments: ["/fixture/executable"],
|
||||
arguments: ["/fixture/executable", "REA_PROCESS_RUN_ID=argv-decoy"],
|
||||
environment: ["REA_PROCESS_RUN_ID=first", "REA_PROCESS_RUN_ID=second"]
|
||||
))
|
||||
duplicateTokenFailsClosed = false
|
||||
} catch RunTokenReadError.duplicateToken {
|
||||
duplicateTokenFailsClosed = true
|
||||
}
|
||||
var invalidTokenEncoding = fixture(
|
||||
arguments: ["/fixture/executable", "REA_PROCESS_RUN_ID=argv-decoy"],
|
||||
environment: ["REA_PROCESS_RUN_ID=é"]
|
||||
)
|
||||
invalidTokenEncoding[invalidTokenEncoding.firstIndex(of: 0xc3)!] = 0xff
|
||||
let invalidTokenEncodingFailsClosed: Bool
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: invalidTokenEncoding)
|
||||
invalidTokenEncodingFailsClosed = false
|
||||
} catch RunTokenReadError.invalidTokenEncoding {
|
||||
invalidTokenEncodingFailsClosed = true
|
||||
}
|
||||
let missingAppleBoundaryFailsClosed: Bool
|
||||
do {
|
||||
_ = try parseProcessEnvironment(in: fixture(
|
||||
@@ -217,7 +270,8 @@ struct ProcessRunTokenParserProbe {
|
||||
)
|
||||
|
||||
let result: [String: Any] = [
|
||||
"argvDecoyIgnored": argvDecoy.runId == nil && argvDecoy.environmentEntryCount == 1,
|
||||
"argvDecoyFailsClosed": argvDecoyFailsClosed,
|
||||
"titleOverwriteFailsClosed": titleOverwriteFailsClosed,
|
||||
"realTokenRead": realToken.runId == "owned-token",
|
||||
"readableEnvironmentWithoutToken": [
|
||||
"runId": readableNoToken.runId as Any? ?? NSNull(),
|
||||
@@ -255,15 +309,18 @@ struct ProcessRunTokenParserProbe {
|
||||
"alternatePaddingRead": alternatePadding.runId == "alternate-padding-token",
|
||||
"truncatedArgvFailsClosed": truncatedArgvFailsClosed,
|
||||
"malformedBuffersRejected": malformedBuffersRejected,
|
||||
"invalidPointerSizeFailsClosed": invalidPointerSizeFailsClosed,
|
||||
"malformedStages": malformedStages,
|
||||
"malformedObservation": [
|
||||
"state": malformedObservation.state,
|
||||
"reason": malformedObservation.reason as Any? ?? NSNull(),
|
||||
],
|
||||
"duplicateTokenFailsClosed": duplicateTokenFailsClosed,
|
||||
"invalidTokenEncodingFailsClosed": invalidTokenEncodingFailsClosed,
|
||||
"missingAppleBoundaryFailsClosed": missingAppleBoundaryFailsClosed,
|
||||
"emptyArgv0Read": emptyArgv0Read.runId == "empty-argv0-token",
|
||||
"finalAssignmentArgumentRead": finalAssignmentArgumentRead.runId == "final-assignment-token",
|
||||
"reservedArgumentIgnored": reservedArgumentIgnored.runId == nil && reservedArgumentIgnored.environmentEntryCount == 1,
|
||||
"reservedArgumentWithRealTokenFailsClosed": reservedArgumentWithRealTokenFailsClosed,
|
||||
"nonAsciiPathRead": nonAsciiPathRead.runId == "utf8-path-token",
|
||||
"appleVectorTokenFailsClosed": appleVectorTokenFailsClosed,
|
||||
"clearedAppleVectorTokenFailsClosed": clearedAppleVectorTokenFailsClosed,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { currentClientRegistrationCommand } from "../../src/application/ClientRegistrationIdentity.js";
|
||||
import { readClientRegistrationStatuses } from "../../src/application/ClientRegistrationStatus.js";
|
||||
import { configureClientConfiguration } from "../../src/application/SetupClientConfiguration.js";
|
||||
import { supportedClients } from "../../src/application/SupportedClients.js";
|
||||
import { systemUninstallHost } from "../../src/application/Uninstall.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
|
||||
/** Run the JSON registration contract against each client's independently supplied location. */
|
||||
export const jsonClientRegistrationContract = ({
|
||||
clientName,
|
||||
displayName,
|
||||
directory,
|
||||
filename,
|
||||
initialSettings,
|
||||
settingsWithoutServers,
|
||||
}: {
|
||||
clientName: "qoder" | "workbuddy";
|
||||
displayName: string;
|
||||
directory: string;
|
||||
filename: string;
|
||||
initialSettings: Record<string, unknown>;
|
||||
settingsWithoutServers: Record<string, unknown>;
|
||||
}): void => {
|
||||
const command = currentClientRegistrationCommand({
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
describe(`${displayName} JSON registration`, () => {
|
||||
const createClient = async () => {
|
||||
const home = await createTestTempDirectory(`rea-${clientName}-`);
|
||||
const client = supportedClients(home, process.platform, {}).find(
|
||||
({ name }) => name === clientName,
|
||||
);
|
||||
if (client?.markerPath === undefined)
|
||||
throw new Error(`missing ${displayName} client`);
|
||||
expect(client.markerPath).toBe(join(home, directory));
|
||||
expect(client.configPath).toBe(join(home, directory, filename));
|
||||
await mkdir(client.markerPath, { recursive: true });
|
||||
return { home, client };
|
||||
};
|
||||
|
||||
it("upserts rea into mcpServers while preserving unrelated settings", async () => {
|
||||
const { home, client } = await createClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify({
|
||||
...initialSettings,
|
||||
mcpServers: { other: { command: "other-server" } },
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
for (const [key, value] of Object.entries(initialSettings))
|
||||
expect(parsed[key]).toEqual(value);
|
||||
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await readClientRegistrationStatuses(home, undefined, {
|
||||
platform: process.platform,
|
||||
environment: {},
|
||||
})
|
||||
).find(({ client }) => client === clientName),
|
||||
).toMatchObject({ state: "aligned", command });
|
||||
});
|
||||
|
||||
it("creates the mcpServers table in existing settings without servers", async () => {
|
||||
const { client } = await createClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify(settingsWithoutServers),
|
||||
);
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
for (const [key, value] of Object.entries(settingsWithoutServers))
|
||||
expect(parsed[key]).toEqual(value);
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
});
|
||||
|
||||
it("registers into an empty settings file", async () => {
|
||||
const { client } = await createClient();
|
||||
await writeFile(client.configPath, "");
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.rea).toEqual({
|
||||
command: command[0],
|
||||
args: command.slice(1),
|
||||
});
|
||||
});
|
||||
|
||||
it("removes only the rea entry on uninstall", async () => {
|
||||
const { home, client } = await createClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
JSON.stringify({ mcpServers: { other: { command: "other-server" } } }),
|
||||
);
|
||||
await configureClientConfiguration(client, {}, command);
|
||||
expect(
|
||||
(await systemUninstallHost(home).removeClient(client)).status,
|
||||
).toBe("removed");
|
||||
const parsed = JSON.parse(await readFile(client.configPath, "utf8"));
|
||||
expect(parsed.mcpServers.other).toEqual({ command: "other-server" });
|
||||
expect(parsed.mcpServers.rea).toBeUndefined();
|
||||
});
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user