diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 88d97864a9..7ec2febebb 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -1002,7 +1002,12 @@ reason in the renderer observations. HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR line endings, preserving UTF-16 columns. Inline JavaScript in local HTML, meaning classic or module `', []], ["", []], ["", []], - ["", []], + ["", ["classic"]], ])("classifies inline JavaScript in %s", (text, expected) => { expect( htmlArtifactReferences(text).inlineScripts.map( @@ -115,6 +115,65 @@ it.each([ ).toEqual(expected); }); +it.each([ + ['src="ignored.js"', "classic"], + ['language="vbscript"', "classic"], + ['xlink:type="simple"', "classic"], + ['type=" Text/JavaScript "', "classic"], + ['type="MODULE"', "module"], + ['type=" module "', null], + ['type="application/json"', null], + ['type="text/javascript; charset=utf-8"', null], + ['href="external.js"', null], + ['href=""', null], + ['xlink:href="external.js"', null], +] as const)("classifies SVG inline bodies using %s", (attributes, expected) => { + const result = htmlArtifactReferences( + ``, + ); + expect(result.inlineScripts.map(({ scriptType }) => scriptType)).toEqual( + expected === null ? [] : [expected], + ); + expect(result.inlineScriptCount).toBe(expected === null ? 0 : 1); +}); + +it.each([ + ['href="external.js"', ["external.js"]], + ['xlink:href="external.js"', ["external.js"]], + ['href="first.js" xlink:href="second.js"', ["first.js"]], + ['xlink:href="second.js" href="first.js"', ["first.js"]], + ['href="" xlink:href="ignored.js"', []], + ['other:href="ignored.js"', []], + ['src="ignored.js"', []], +])("extracts SVG script URL from %s", (attributes, paths) => { + const result = htmlArtifactReferences( + ``, + ); + expect(result.scripts.map(({ scriptPath }) => scriptPath)).toEqual(paths); + expect(result.inlineScriptCount).toBe(0); +}); + +it("shares precise body coordinates and location limits across HTML and SVG", () => { + const prefix = '\n`).inlineScripts, + ).toEqual([ + { + scriptType: "classic", + location: { start: { line: 2, column: 30 }, end: { line: 4, column: 0 } }, + }, + ]); + const repeated = "".repeat(60); + const result = htmlArtifactReferences(repeated); + expect(result.inlineScriptCount).toBe(120); + expect(result.inlineScripts).toHaveLength(HTML_INLINE_SCRIPT_LOCATION_LIMIT); + expect( + htmlArtifactReferences("") + .inlineScriptCount, + ).toBe(0); +}); + it("retains exact inline script body coordinates, including unclosed scripts", () => { const prefix = "\n`, + '', + '', + '', + '', + "", + ].join("\n"); + await writeFile(join(root, "index.html"), html); + await writeFile( + join(root, "main.js"), + 'fetch("https://example.test/external");', + ); + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--source-path", + "index.html", + "--source-path", + "main.js", + "--format", + "json", + ], + }); + expect(response.exitCode, response.stderr).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + const observations = result.graph.nodes.flatMap( + ({ observations }) => observations, + ); + const scopes = observations.filter( + ({ properties }) => properties.operation === "parse-html-inline-scripts", + ); + expect(scopes).toHaveLength(1); + expect(scopes[0]).toMatchObject({ + properties: { + path: "index.html", + inline_script_count: 1, + inline_scripts: [ + { + script_type: "classic", + location: { + start: { line: 2, column: 45 }, + end: { line: 2, column: 86 }, + }, + }, + ], + }, + evidence: { + state: "unavailable", + confidence: "unknown", + coverage: { status: "partial" }, + }, + }); + expect( + result.graph.edges.filter( + ({ relation, properties }) => + relation === "loads" && properties.resolved_path === "main.js", + ), + ).toHaveLength(2); + const endpoints = result.graph.nodes + .filter(({ kind }) => kind === "endpoint") + .flatMap(({ observations }) => observations) + .map(({ properties }) => properties.value); + expect(endpoints).toContain("https://example.test/external"); + expect(endpoints).not.toContain(inlineUrl); + }, +); + cliTest.for([ "invalid-html", "empty-html",