import { createHash } from "node:crypto"; import { access, readFile, readdir, symlink, writeFile, } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js"; import { exportWebScripts } from "../../../src/application/WebScriptExportService.js"; import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js"; import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascriptApplicationAnalysis.js"; import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { scriptCaptureEvidenceFixture, scriptScenarioFixture, } from "../../fixtures/webScriptCapture.js"; const setup = async (capture: unknown = scriptCaptureEvidenceFixture()) => { const root = await createTestTempDirectory("rea-web-script-export-"); const input = { capture_path: join(root, "capture.json"), output_directory: join(root, "export"), }; await writeFile(input.capture_path, JSON.stringify(capture)); return { root, input }; }; describe("captured script publication boundary", () => { it("verifies durable bytes, manifest, capture identity, and existing module analysis", async () => { const capture = scriptScenarioFixture([ { url: "https://fixture.test/app/main.js", bytes: Buffer.from( "import { marker } from './lib/dep.js'; export const result = marker;\n", ), }, { url: "https://fixture.test/app/lib/dep.js", bytes: Buffer.from("export const marker = 'source-owned';\n"), }, ]); const { input } = await setup(capture); const exported = await exportWebScripts(input); if (!exported.ok) throw exported.error; const result = webScriptExportResultSchema.parse( exported.value.normalized_result, ); expect(result.capture_sha256).toBe( createHash("sha256") .update(await readFile(input.capture_path)) .digest("hex"), ); const manifest = await readFile(result.manifest.path); expect(result.manifest.sha256).toBe( createHash("sha256").update(manifest).digest("hex"), ); const { manifest: descriptor, ...inline } = result; expect(descriptor.bytes).toBe(manifest.length); expect(JSON.parse(manifest.toString())).toEqual(inline); if (result.analysis_input === null) throw new Error("Missing analysis input"); for (const [index, script] of result.scripts.entries()) { if (script.content.state !== "exported") throw new Error("Expected exported script"); const bytes = await readFile( join(result.analysis_input.input_path, script.content.relative_path), ); expect(bytes).toEqual( capture.events.items .filter(({ kind }) => kind === "network-content") .map((event) => event.kind === "network-content" && event.body.state === "captured" ? Buffer.from(event.body.content, "base64") : Buffer.alloc(0), )[index], ); } const analyzed = await analyzeJavaScriptApplication(result.analysis_input); if (!analyzed.ok) throw analyzed.error; const analysis = javascriptApplicationAnalysisResultSchema.parse( analyzed.value.normalized_result, ); expect(analysis.statistics.parsed_javascript_files).toBe(2); expect(analysis.graph.edges).toContainEqual( expect.objectContaining({ relation: "imports", properties: expect.objectContaining({ specifier: "./lib/dep.js", resolution_status: "resolved", }), }), ); }); it("links authenticated input Evidence and preserves binary and empty source bytes", async () => { const { input } = await setup(); const result = await exportWebScripts(input); if (!result.ok) throw result.error; expect(result.value.evidence_links).toEqual([ scriptCaptureEvidenceFixture().evidence_id, ]); const binary = Buffer.from([255, 0, 254, 1]); const { input: binaryInput } = await setup( scriptScenarioFixture([ { url: "https://fixture.test/binary.js", bytes: binary }, { url: "https://fixture.test/empty.js", bytes: Buffer.alloc(0) }, ]), ); const binaryExport = await exportWebScripts(binaryInput); if (!binaryExport.ok) throw binaryExport.error; const parsed = webScriptExportResultSchema.parse( binaryExport.value.normalized_result, ); const [first, second] = parsed.scripts; if ( first?.content.state !== "exported" || second?.content.state !== "exported" || parsed.analysis_input === null ) throw new Error("Missing exported bytes"); expect( await readFile( join(parsed.analysis_input.input_path, first.content.relative_path), ), ).toEqual(binary); expect( await readFile( join(parsed.analysis_input.input_path, second.content.relative_path), ), ).toHaveLength(0); }); it("retains missing sources in a manifest without inventing an analysis directory", async () => { const capture = scriptScenarioFixture(); capture.events.items = capture.events.items.filter( ({ kind }) => kind !== "network-content", ); capture.events.retained = capture.events.items.length; const { input } = await setup(capture); const result = await exportWebScripts(input); if (!result.ok) throw result.error; const parsed = webScriptExportResultSchema.parse( result.value.normalized_result, ); expect(parsed.analysis_input).toBeNull(); expect(parsed.scripts[0]?.content.state).toBe("unavailable"); expect(await readdir(input.output_directory)).toEqual(["manifest.json"]); }); }); describe("captured script publication failures and cleanup", () => { it("preserves adapter-reported limitations without strengthening their string contract", async () => { const { input } = await setup(); const capture = { ...selectScriptCapture(scriptScenarioFixture()), limitations: ["", "producer-reported limitation"], }; const result = await publishWebScripts(input, capture, "a".repeat(64)); expect(result.limitations.slice(0, 2)).toEqual(capture.limitations); }); it.each([ Buffer.from("{"), Buffer.from([255, 254]), Buffer.from(JSON.stringify({ unsupported: true })), ])( "rejects malformed capture bytes before creating output", async (bytes) => { const { input } = await setup(); await writeFile(input.capture_path, bytes); const result = await exportWebScripts(input); expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected invalid input"); expect(result.error._tag).toBe("AnalysisInputError"); await expect(access(input.output_directory)).rejects.toMatchObject({ code: "ENOENT", }); }, ); it("preserves an existing destination and refuses output through a symlink", async () => { const { root, input } = await setup(); const marker = join(root, "marker.js"); await writeFile(marker, "keep"); await symlink( root, input.output_directory, process.platform === "win32" ? "junction" : "dir", ); const result = await exportWebScripts(input); expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected exclusive output failure"); expect(result.error.userMessage).toContain("already exists"); expect(await readFile(marker, "utf8")).toBe("keep"); }); it("rolls back only the new owned output when durable byte verification fails", async () => { const { root, input } = await setup(); const capture = selectScriptCapture( scriptScenarioFixture([ { url: "https://fixture.test/a.js", bytes: Buffer.from("first") }, { url: "https://fixture.test/b.js", bytes: Buffer.from("second") }, ]), ); const second = capture.scripts[1]; if (second?.content.state !== "captured") throw new Error("Missing source"); const broken = { ...capture, scripts: [ capture.scripts[0], { ...second, content: { ...second.content, sha256: "0".repeat(64) } }, ].filter((value) => value !== undefined), }; await expect( publishWebScripts(input, broken, "a".repeat(64)), ).rejects.toMatchObject({ reason: "integrity" }); expect(await readdir(root)).toEqual(["capture.json"]); }); it("cancels before work and rolls back cancellation after output creation", async () => { const { root, input } = await setup(); const controller = new AbortController(); controller.abort(); const result = await exportWebScripts(input, { signal: controller.signal }); expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected cancellation"); expect(result.error._tag).toBe("AnalysisCancelledError"); await expect( publishWebScripts( input, selectScriptCapture(scriptScenarioFixture()), "a".repeat(64), controller.signal, ), ).rejects.toBeDefined(); expect(await readdir(root)).toEqual(["capture.json"]); }); it("identifies a missing capture file and rejected host path syntax", async () => { const { input } = await setup(); const missing = await exportWebScripts({ ...input, capture_path: `${input.capture_path}.missing`, }); expect(missing.ok).toBe(false); if (missing.ok) throw new Error("Expected unavailable input"); expect(missing.error.userMessage).toContain( `${input.capture_path}.missing`, ); const relative = await exportWebScripts({ ...input, capture_path: "capture.json", }); expect(relative.ok).toBe(false); if (relative.ok) throw new Error("Expected host path error"); expect(relative.error._tag).toBe("AnalysisInputError"); }); });