diff --git a/src/application/javascript/JavaScriptApplicationTemplateLiterals.test.ts b/src/application/javascript/JavaScriptApplicationTemplateLiterals.test.ts index 56e2c3a1eb..2028147bfc 100644 --- a/src/application/javascript/JavaScriptApplicationTemplateLiterals.test.ts +++ b/src/application/javascript/JavaScriptApplicationTemplateLiterals.test.ts @@ -2,7 +2,6 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { z } from "zod"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascript/javascriptApplicationAnalysis.js"; @@ -32,15 +31,16 @@ const graphIdentities = async (source: string) => { ...(identity.strategy === "artifact-local-key" ? { namespace: identity.namespace, + // Module-export identity is a JSON tuple whose first entry + // binds the whole container; only that digest changes here. key: identity.namespace === "module-export" ? JSON.stringify( - z - .array(z.string()) - .parse(JSON.parse(identity.key)) - .slice(1), + (JSON.parse(identity.key) as unknown[]).slice(1), ) - : identity.key.replace(/^[0-9a-f]{64}:/u, ""), + : identity.namespace === "source-module" + ? identity.key.replace(/^[0-9a-f]{64}:/u, "") + : identity.key, } : {}), })) diff --git a/src/browser/CdpTargetSession.ts b/src/browser/CdpTargetSession.ts index 588f83e94a..4b140e2eda 100644 --- a/src/browser/CdpTargetSession.ts +++ b/src/browser/CdpTargetSession.ts @@ -69,6 +69,8 @@ export const closeCdpTargetSession = async ( } } } + // Cancellation skips optional domain shutdown but still releases the + // attached session using its independent, bounded cleanup signal. if (!cleanup.signal.aborted && sessionId !== undefined) try { await connection.send( diff --git a/src/browser/PlaywrightBrowserScenarioProvider.test.ts b/src/browser/PlaywrightBrowserScenarioProvider.test.ts index eb4187292d..af94b7f90f 100644 --- a/src/browser/PlaywrightBrowserScenarioProvider.test.ts +++ b/src/browser/PlaywrightBrowserScenarioProvider.test.ts @@ -553,7 +553,7 @@ it("shares concurrent close and retries browser cleanup after failure", async () expect(cleanupSettlements).toEqual([false, true]); }); -it("reports event finalization failure before allowing settled cleanup retries", async () => { +it("keeps failed event finalization failed across cleanup retries", async () => { const finishEvents = vi .fn<() => Promise>() .mockRejectedValue(new Error("event finalization failed")); @@ -567,9 +567,10 @@ it("reports event finalization failure before allowing settled cleanup retries", await expect(cleanup.close(finishEvents)).rejects.toMatchObject({ reason: "cleanup_failed", - cleanup: { reason: "event finalization failed" }, }); - await expect(cleanup.close(finishEvents)).resolves.toBeUndefined(); + await expect(cleanup.close(finishEvents)).rejects.toMatchObject({ + reason: "cleanup_failed", + }); expect(finishEvents).toHaveBeenCalledTimes(1); expect(closeBrowser).toHaveBeenCalledTimes(1); }); diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts index f65fada69c..36089a788d 100644 --- a/src/browser/PlaywrightScenarioBrowser.ts +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -42,7 +42,6 @@ export class PlaywrightScenarioBrowserCleanupOwner { #profileRemoved: boolean; #closePromise: Promise | undefined; #eventFinalizationPromise: Promise | undefined; - #eventFinalizationFailureReported = false; /** Keep the narrow provider actions needed to release one opened browser. */ constructor(private readonly resources: PlaywrightScenarioCleanupResources) { @@ -98,10 +97,10 @@ export class PlaywrightScenarioBrowserCleanupOwner { try { await eventFinalization; } catch (cause: unknown) { - if (!this.#eventFinalizationFailureReported) { - failures.push(cause); - this.#eventFinalizationFailureReported = true; - } + // A failed finalization stays failed: the memoized attempt rejects on + // every retry so cleanup can never report success for unfinalized + // events. + failures.push(cause); } if (!this.#browserClosed) { try { diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts index 4ee60cae70..9d62f0967d 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts @@ -22,9 +22,13 @@ it("preserves a 12,000-member native import through both analyzers", () => { const staticAnalysis = analyzeParsedJavaScriptStaticSource("", parsed); const ir = analyzeParsedJavaScriptSemantics(parsed); expect(staticAnalysis.parse_status).toBe("complete"); - // The addon exports `next`; `last` belongs to an object reached from it. expect(staticAnalysis.electron.native_addon_bindings).toEqual([ - expect.objectContaining({ specifier: "./addon.node", members: ["next"] }), + expect.objectContaining({ + specifier: "./addon.node", + members: ["next"], + namespace_access: false, + dynamic_member_access: false, + }), ]); expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 }); const path = origin(onlyBinding(ir, "value")); diff --git a/src/process/capture/FilesystemCoverage.test.ts b/src/process/capture/FilesystemCoverage.test.ts index 22cfa4450b..baa5f5c942 100644 --- a/src/process/capture/FilesystemCoverage.test.ts +++ b/src/process/capture/FilesystemCoverage.test.ts @@ -16,35 +16,41 @@ import { classifyFilesystemEffects } from "./ProcessFilesystemEffects.js"; it.each(["after", "before"] as const)( "keeps absence unknown when the %s snapshot omits an existing file", async (partialSide) => { + // Enumeration order is filesystem-dependent. Choose a genuinely omitted + // entry by comparing a complete snapshot with a capacity-limited one. const root = await createTestTempDirectory("rea-fs-partial-absence-"); - const names = ["a.txt", "z.txt"]; - for (const name of names) await writeFile(join(root, name), "unchanged"); + await writeFile(join(root, "a1.txt"), "first"); + await writeFile(join(root, "a2.txt"), "second"); + await writeFile(join(root, "z.txt"), "target"); const scenario = parseProcessScenario({ executable: process.execPath, working_directory: root, filesystem_observation_paths: [root], limits: { files: 2 }, }); + const complete = await snapshotRoots({ + ...scenario, + limits: { ...scenario.limits, files: 4 }, + }); const partial = await snapshotRoots(scenario); - const omitted = names.find( - (name) => !partial.files.some(({ path }) => path === `root_0:${name}`), + expect(complete.completeRoots).toEqual(["root_0"]); + expect(partial.truncated).toBe(true); + const omitted = complete.files.find( + (file) => !partial.files.some(({ path }) => path === file.path), ); - if (omitted === undefined) - throw new Error("Expected an omitted fixture file"); - for (const name of names) if (name !== omitted) await rm(join(root, name)); - const complete = await snapshotRoots(scenario); + if (omitted === undefined) throw new Error("Expected an omitted entry"); const before = partialSide === "before" ? partial : complete; const after = partialSide === "after" ? partial : complete; - expect(await readFile(join(root, omitted), "utf8")).toBe("unchanged"); const effect = classifyFilesystemEffects(before, after).find( - ({ path }) => path === `root_0:${omitted}`, + ({ path }) => path === omitted.path, ); expect(effect?.status).toBe("unknown"); - expect(effect).toMatchObject( - partialSide === "after" - ? { before: { path: `root_0:${omitted}` }, after: null } - : { before: null, after: { path: `root_0:${omitted}` } }, - ); + expect(effect).toMatchObject({ + ...(partialSide === "after" + ? { before: omitted, after: null } + : { before: null, after: omitted }), + reason: expect.stringContaining("did not exhaust path enumeration"), + }); }, ); diff --git a/src/process/capture/ProcessHarness.test.ts b/src/process/capture/ProcessHarness.test.ts index be74741c29..1a898ae943 100644 --- a/src/process/capture/ProcessHarness.test.ts +++ b/src/process/capture/ProcessHarness.test.ts @@ -18,6 +18,7 @@ import { } from "./ProcessCaptureError.js"; import { releaseProcessResources, + ProcessCaptureResourceScope, createProcessCaptureObservationBuffer, prepareProcessCapture, resolveProcessResult, @@ -291,26 +292,31 @@ it("keeps empty cleanup exception messages actionable in the report", async () = }); it.each(["termination", "verification"] as const)( - "retains observations and the temporary root when process %s throws", + "retains observations and retries the temporary root when process %s throws", async (stage) => { const cleanupFailure = new Error(`${stage} inspection failed`); const removeTemporaryRoot = vi.fn(async () => undefined); + let failing = true; + const scope = new ProcessCaptureResourceScope(); const host: ProcessCaptureCleanupHost = { platform: "linux", cleanupProcessGroup: async () => { - if (stage === "termination") throw cleanupFailure; + if (failing && stage === "termination") throw cleanupFailure; return { cleaned: true, signaled: true, - unverified: [{ pid: 900, diagnostic: "environment unavailable" }], + unverified: failing + ? [{ pid: 900, diagnostic: "environment unavailable" }] + : [], }; }, verifyTokenOwnedProcesses: async () => { - throw cleanupFailure; + if (failing) throw cleanupFailure; + return { cleaned: true, signaled: false }; }, removeTemporaryRoot, }; - const cleanup = await releaseProcessResources({ + const cleanup = await scope.release({ timers: new Set(), terminal: { pid: 321 }, renderer: undefined, @@ -360,6 +366,13 @@ it.each(["termination", "verification"] as const)( }, }); } + failing = false; + await scope.retryCleanup(); + expect(removeTemporaryRoot).toHaveBeenCalledExactlyOnceWith( + "/fixture/root", + ); + await scope.close(); + expect(removeTemporaryRoot).toHaveBeenCalledTimes(1); }, ); diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 38f054d124..437f36480b 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -17,6 +17,7 @@ import { parse as parseJsonc, type ParseError } from "jsonc-parser"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; @@ -31,8 +32,7 @@ import { } from "../../../src/application/Uninstall.js"; const roots: string[] = []; -const managedSkillManifest = - "---\nname: reverse-engineer-anything\n---\n# REA\n"; +const ownedSkillManifest = `---\nname: ${PRODUCT_IDENTITY.skillName}\n---\n# REA\n`; beforeEach(clearClientLocationEnvironment); afterEach(async () => { vi.unstubAllEnvs(); @@ -1355,7 +1355,7 @@ describe("client configuration filesystem removal", () => { }, }), ); - await writeFile(skill, managedSkillManifest); + await writeFile(skill, ownedSkillManifest); await mkdir(join(home, ".rea"), { recursive: true }); await import("node:fs/promises").then(({ symlink }) => symlink(home, join(home, ".rea/cache")), @@ -1424,7 +1424,9 @@ describe("client configuration filesystem removal", () => { readFile(`${configPath}.rea.backup`, "utf8"), ).rejects.toThrow(); }); +}); +describe("uninstall configuration preflight", () => { it("fails closed on malformed client configuration", async () => { const home = await createTestTempDirectory("rea-uninstall-bad-"); roots.push(home); @@ -1443,16 +1445,18 @@ describe("client configuration filesystem removal", () => { ); expect(await readFile(config, "utf8")).toBe("not-json"); }); -}); -describe("client configuration uninstall preflight", () => { it("stops before any removal when one client configuration is malformed", async () => { const home = await createTestTempDirectory("rea-uninstall-stop-"); roots.push(home); const codex = join(home, ".codex/config.toml"); const cursor = join(home, ".cursor/mcp.json"); + const skillMarker = join( + home, + ".agents/skills/reverse-engineer-anything/marker.txt", + ); const managed = [ - join(home, ".agents/skills/reverse-engineer-anything/SKILL.md"), + skillMarker, join(home, ".rea/cache/marker.txt"), join(home, ".rea/state/marker.txt"), ]; @@ -1466,11 +1470,9 @@ describe("client configuration uninstall preflight", () => { await mkdir(dirname(path), { recursive: true }); await writeFile(codex, "[mcp_servers.rea]\ncommand = [broken\n"); await writeFile(cursor, cursorConfig); - for (const path of managed) - await writeFile( - path, - path === managed[0] ? managedSkillManifest : "marker", - ); + for (const path of managed) await writeFile(path, "marker"); + const manifest = join(dirname(skillMarker), "SKILL.md"); + await writeFile(manifest, ownedSkillManifest); const stopped = await runUninstall(true, systemUninstallHost(home)); @@ -1485,9 +1487,8 @@ describe("client configuration uninstall preflight", () => { ); expect(await readFile(cursor, "utf8")).toBe(cursorConfig); for (const path of managed) - expect(await readFile(path, "utf8")).toBe( - path === managed[0] ? managedSkillManifest : "marker", - ); + expect(await readFile(path, "utf8")).toBe("marker"); + expect(await readFile(manifest, "utf8")).toBe(ownedSkillManifest); await writeFile(codex, ""); const repaired = await runUninstall(true, systemUninstallHost(home)); @@ -1495,7 +1496,12 @@ describe("client configuration uninstall preflight", () => { expect(JSON.parse(await readFile(cursor, "utf8"))).toEqual({ mcpServers: { other: { command: "echo", args: ["hello"] } }, }); - for (const path of managed) + // Uninstall removes only the managed bundle, never arbitrary skill files. + expect(await readFile(skillMarker, "utf8")).toBe("marker"); + await expect(readFile(manifest, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + for (const path of managed.slice(1)) await expect(readFile(path, "utf8")).rejects.toThrow(); }); }); @@ -1635,8 +1641,7 @@ describe("client configuration filesystem failure reporting", () => { roots.push(home); const skillRoot = join(home, ".agents/skills/reverse-engineer-anything"); await mkdir(skillRoot, { recursive: true }); - const manifest = join(skillRoot, "SKILL.md"); - await writeFile(manifest, managedSkillManifest); + await writeFile(join(skillRoot, "SKILL.md"), ownedSkillManifest); const result = await runUninstall( false, systemUninstallHost(home, { @@ -1649,14 +1654,17 @@ describe("client configuration filesystem failure reporting", () => { name: "skill", status: "failed", detail: expect.stringContaining( - `Managed file could not be removed: ${manifest}.`, + `Managed file could not be removed: ${join(skillRoot, "SKILL.md")}`, ), }), ); expect(JSON.stringify(result)).not.toContain("SECRET"); + expect(await readFile(join(skillRoot, "SKILL.md"), "utf8")).toBe( + ownedSkillManifest, + ); }); - it("uninstalls owned skill files across roots while preserving other files", async () => { + it("uninstalls all managed skill roots, including a custom Claude Code directory", async () => { const home = await createTestTempDirectory("rea-uninstall-skills-"); roots.push(home); const customClaudeDirectory = join(home, "claude-config"); @@ -1665,12 +1673,12 @@ describe("client configuration filesystem failure reporting", () => { join(customClaudeDirectory, "skills/reverse-engineer-anything"), ]; await Promise.all( - rootsToRemove.map((path) => mkdir(path, { recursive: true })), + rootsToRemove.map(async (path) => { + await mkdir(path, { recursive: true }); + await writeFile(join(path, "SKILL.md"), ownedSkillManifest); + await writeFile(join(path, "user-notes.txt"), "user-authored"); + }), ); - for (const path of rootsToRemove) { - await writeFile(join(path, "SKILL.md"), managedSkillManifest); - await writeFile(join(path, "user-notes.txt"), "user-authored notes"); - } const result = await systemUninstallHost(home, testFileSystem, { CLAUDE_CONFIG_DIR: customClaudeDirectory, @@ -1679,11 +1687,11 @@ describe("client configuration filesystem failure reporting", () => { expect(result).toMatchObject({ status: "removed" }); await Promise.all( rootsToRemove.map(async (path) => { - await expect(readFile(join(path, "SKILL.md"))).rejects.toMatchObject({ - code: "ENOENT", - }); + await expect( + readFile(join(path, "SKILL.md"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); expect(await readFile(join(path, "user-notes.txt"), "utf8")).toBe( - "user-authored notes", + "user-authored", ); }), ); diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index ce626ece7c..a767af442e 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -59,7 +59,10 @@ const expectUnverifiedHostCleanup = (error: ProcessCaptureError): void => { /^(?:[1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM)))(?:, [1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM)))*$/u, ); expect(report?.terminal_renderer.state).toBe("cleaned"); - expect(report?.temporary_root.state).toBe("cleaned"); + expect(report?.temporary_root).toEqual({ + state: "unverified", + reason: `temporary root retained because owned process cleanup is unverified: ${report?.owned_process_group.reason}`, + }); }; const captureObservations = ( diff --git a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts index 3ab27d2f56..68c2fceac1 100644 --- a/tests/boundary/process/processFilesystemCoverageAdapters.test.ts +++ b/tests/boundary/process/processFilesystemCoverageAdapters.test.ts @@ -169,14 +169,15 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)( await writeFile(join(root, "z.txt"), "unchanged"); const scenario = { executable: process.execPath, + // Delete the known file and overflow the remaining directory capacity. + // Incomplete enumeration still cannot prove its absence, regardless of + // which new entry the filesystem returns first. arguments: [ "-e", - "const fs=require('node:fs');fs.mkdirSync('added');fs.writeFileSync('added/a.txt','added')", + "const fs=require('node:fs');fs.unlinkSync('z.txt');fs.writeFileSync('a.txt','added');fs.writeFileSync('b.txt','added');", ], working_directory: root, - // Before capture the first root is absent. After capture it consumes - // both entries, so the unchanged second root is certainly unexamined. - filesystem_observation_paths: [join(root, "added"), join(root, "z.txt")], + filesystem_observation_paths: [root], limits: { files: 2 }, }; const evidence = await (adapter === "cli" ? captureViaCli : captureViaMcp)( @@ -184,15 +185,17 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)( ); const capture = parseProcessCapture(evidence.normalized_result); expect(capture.truncated).toBe(true); - expect(await readFile(join(root, "z.txt"), "utf8")).toBe("unchanged"); + await expect(readFile(join(root, "z.txt"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); expect( - capture.filesystem_effects.find(({ path }) => path === "root_1:.") + capture.filesystem_effects.find(({ path }) => path === "root_0:z.txt") ?.status, ).toBe("unknown"); expect( capture.filesystem_checkpoints .find(({ name }) => name === "after_settlement") - ?.effects.find(({ path }) => path === "root_1:.")?.status, + ?.effects.find(({ path }) => path === "root_0:z.txt")?.status, ).toBe("unknown"); }, 20_000, diff --git a/tests/boundary/process/processTruncationAdapters.test.ts b/tests/boundary/process/processTruncationAdapters.test.ts index fe19fb4711..a346a377e9 100644 --- a/tests/boundary/process/processTruncationAdapters.test.ts +++ b/tests/boundary/process/processTruncationAdapters.test.ts @@ -194,13 +194,14 @@ itWithCaptureCapability.each(["cli", "mcp"] as const)( expect(details.filesystem_after.enumeration_reasons).toContain( "files_limit", ); - expect( - result.files_before.find(({ path }) => path === "root_0:b.txt")?.sha256, - ).toBeNull(); - const after = result.files_after.find( + // The truncated subset follows filesystem enumeration order. The large + // file may be retained without a digest, or not enumerated at all. + const largeFile = result.files_after.find( ({ path }) => path === "root_0:b.txt", ); - if (after !== undefined) expect(after.sha256).toBeNull(); + if (largeFile !== undefined) expect(largeFile.sha256).toBeNull(); + expect(details.filesystem_after.enumeration_truncated).toBe(true); + expect(result.files_after.length).toBeLessThanOrEqual(3); expect(processSourceTruncated(result, "terminal_rendered")).toBe(true); expect(compareProcessCaptures(result, result)).toMatchObject({ terminal: "unknown", diff --git a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts index dd7bd8809e..0ec76e2c5a 100644 --- a/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts +++ b/tests/composition/javascript-applications/electronApplicationAnalysis.test.ts @@ -72,13 +72,8 @@ describe("static Electron application analysis", () => { const result = await reconstructJavaScriptArtifact({ input_path: root }); const graph = parseJavaScriptApplicationGraph(result.graph); - const requestedMembers = graph.nodes.flatMap((node) => - node.kind === "native-export" - ? node.observations.flatMap(({ properties }) => { - const members = properties.requested_members; - return Array.isArray(members) ? members : []; - }) - : [], + const nativeBindings = graph.nodes.filter( + (node) => node.kind === "native-export", ); expect(result.electron_summary).toMatchObject({ @@ -86,7 +81,20 @@ describe("static Electron application analysis", () => { native_addon_bindings: 1, resolved_native_addon_bindings: 1, }); - expect(requestedMembers).toEqual(["*"]); + expect(nativeBindings).toMatchObject([ + { + observations: [ + { + properties: { + requested_members: [], + namespace_access: false, + dynamic_member_access: true, + native_export_verification: "not-performed", + }, + }, + ], + }, + ]); }); it("maps Electron boundaries through bundler-renamed bindings", async () => {