import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { defaultTreeAdapter, parse, type DefaultTreeAdapterTypes, } from "parse5"; import { expect, it } from "vitest"; import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; const cases = [ ['', []], ['', []], [``, []], ["", ["actual.js"]], ['', ["actual.js"]], ['', ["actual.js"]], ["", ["actual.js"]], ['', ["actual.js"]], ['', []], ['', []], ['', []], ['', ["actual.js"]], ] as const; it.each(cases)( "projects local loads from actual HTML attributes in %s", async (html, expected) => { const root = await createTestTempDirectory("rea-html-entrypoints-"); await Promise.all([ writeFile(join(root, "index.html"), html), writeFile(join(root, "actual.js"), "globalThis.ownedActual = true;"), writeFile(join(root, "decoy.js"), "globalThis.ownedDecoy = true;"), ]); const result = await reconstructJavaScriptArtifact({ input_path: root }); const loads = result.graph.edges.filter( ({ relation, properties }) => relation === "loads" && properties.script_path !== undefined, ); expect(loads.map(({ properties }) => properties.resolved_path)).toEqual( expected, ); }, ); it("preserves source digest, opening-tag range and decoded local base resolution", async () => { const root = await createTestTempDirectory("rea-html-entrypoint-evidence-"); const opening = '`; await mkdir(join(root, "assets")); await Promise.all([ writeFile(join(root, "index.html"), html), writeFile( join(root, "assets", "actual.js"), "globalThis.ownedActual = true;", ), ]); const result = await reconstructJavaScriptArtifact({ input_path: root }); const loads = result.graph.edges.filter( ({ relation, properties }) => relation === "loads" && properties.script_path !== undefined, ); expect(loads).toHaveLength(1); expect(loads[0]).toMatchObject({ properties: { script_path: "actual.js", base_href: "assets/", resolved_path: "assets/actual.js", }, evidence: { authority: "static-relationship-inference", artifact: { available: true, sha256: createHash("sha256").update(html).digest("hex"), }, location: { available: true, value: { kind: "source-range", source: "index.html", start: { line: 2, column: 0 }, end: { line: 2, column: opening.length }, }, }, }, }); }); const coordinateCases = [ { name: "LF", prefix: "before\n", opening: '`; const expected = htmlScriptRanges(html); expect(expected).toHaveLength(2); await Promise.all([ writeFile(join(root, "index.html"), html), writeFile(join(root, "actual.js"), "export const actual = true;"), ]); const result = await reconstructJavaScriptArtifact({ input_path: root }); const resolved = result.graph.edges.filter( ({ relation, properties }) => relation === "loads" && properties.script_path === "actual.js", ); const unresolved = result.graph.nodes .flatMap(({ observations }) => observations) .filter( ({ properties }) => properties.mechanism === "html-script-reference" && properties.script_path === "missing.js", ); expect(resolved).toHaveLength(1); expect(unresolved).toHaveLength(1); for (const [index, observation] of [resolved[0], unresolved[0]].entries()) { expect(observation).toMatchObject({ evidence: { artifact: { available: true, sha256: createHash("sha256").update(html).digest("hex"), }, location: { available: true, value: { kind: "source-range", source: "index.html", ...expected[index], }, }, }, }); } }, ); const htmlScriptRanges = (html: string) => { const pending: DefaultTreeAdapterTypes.Node[] = [ parse(html, { sourceCodeLocationInfo: true }), ]; const ranges = []; while (pending.length > 0) { const node = pending.pop(); if (node === undefined) continue; if (defaultTreeAdapter.isElementNode(node) && node.tagName === "script") { const location = node.sourceCodeLocation?.startTag; if (location === undefined) throw new Error("Missing parse5 script range"); ranges.push({ start: { line: location.startLine, column: location.startCol - 1 }, end: { line: location.endLine, column: location.endCol - 1 }, }); } if ("childNodes" in node) { for (const child of node.childNodes.toReversed()) pending.push(child); } } return ranges; };