2026-10-08 01:24:24 -04:00
import { createHash } from "node:crypto" ;
import { mkdir , writeFile } from "node:fs/promises" ;
import { join } from "node:path" ;
2026-10-08 12:15:24 -04:00
import {
defaultTreeAdapter ,
parse ,
type DefaultTreeAdapterTypes ,
} from "parse5" ;
2026-10-08 01:24:24 -04:00
import { expect , it } from "vitest" ;
2026-10-11 04:41:49 +08:00
import { reconstructJavaScriptArtifact } from "../../support/javascriptApplicationScope.js" ;
2026-10-08 01:24:24 -04:00
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js" ;
const cases = [
[ '<script data-src="decoy.js"></script>' , []],
[ '<script x:src="decoy.js"></script>' , []],
[ `<script title='src="decoy.js"'></script>` , []],
[ "<script src=actual.js></script>" , [ "actual.js" ]],
[ '<script title=">" src="actual.js"></script>' , [ "actual.js" ]],
[ '<script src="actual.js"></script>' , [ "actual.js" ]],
[ "<script src='actual.js'></script>" , [ "actual.js" ]],
[ '<script data-src="decoy.js" src="actual.js"></script>' , [ "actual.js" ]],
[ '<!-- <script src="decoy.js"></script> -->' , []],
[ '<textarea><script src="decoy.js"></script></textarea>' , []],
[ '<template><script src="decoy.js"></script></template>' , []],
[ '<script src="actual.js" src="decoy.js"></script>' , [ "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 = '<script title=">" src="actual.js">' ;
const prefix = '<base data-href="decoy/"><base href=assets/>\n' ;
const html = ` ${ prefix }${ opening } </script>` ;
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 },
},
},
},
});
});
2026-10-08 12:15:24 -04:00
const coordinateCases = [
{ name : "LF" , prefix : "before\n" , opening : '<script src="actual.js">' },
{ name : "CRLF" , prefix : "before\r\n" , opening : '<script src="actual.js">' },
{ name : "bare CR" , prefix : "before\r" , opening : '<script src="actual.js">' },
{
name : "mixed CRLF and CR" ,
prefix : "before\r\nafter\r" ,
opening : '<script src="actual.js">' ,
},
{
name : "CR inside opening tag" ,
prefix : "before\n" ,
opening : '<script\r src="actual.js">' ,
},
{
name : "CRLF inside opening tag" ,
prefix : "before\n" ,
opening : '<script\r\n src="actual.js">' ,
},
{
name : "astral UTF-16 columns" ,
prefix : "😀" ,
opening : '<script src="actual.js">' ,
},
{
name : "U+2028 literal text" ,
prefix : "before\u2028" ,
opening : '<script src="actual.js">' ,
},
] as const ;
it . each ( coordinateCases )(
"retains parse5 source coordinates for resolved and unresolved scripts with $name" ,
async ({ prefix , opening }) => {
const root = await createTestTempDirectory ( "rea-html-coordinate-evidence-" );
const html = ` ${ prefix }${ opening } </script><script src="missing.js"></script>` ;
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 ;
};
2026-10-14 08:46:50 +08:00
it ( "reports unanalyzed inline HTML scripts instead of claiming complete coverage" , async () => {
const root = await createTestTempDirectory ( "rea-html-inline-scripts-" );
const html = [
"<!doctype html><html><head>" ,
'<script src="cordova.js"></script>' ,
'<script type="application/ld+json">{"@type":"Thing"}</script>' ,
"</head><body>" ,
"<script>" ,
'function startGame(){ fetch("https://api.example.test/score"); }' ,
"</script>" ,
'<script type="module">import { x } from "./mod.js"; console.log(x);</script>' ,
"</body></html>" ,
]. join ( "\n" );
await mkdir ( join ( root , "www" ));
await Promise . all ([
writeFile (
join ( root , "package.json" ),
JSON . stringify ({ name : "game" , version : "1.0.0" }),
),
writeFile ( join ( root , "www" , "index.html" ), html ),
writeFile ( join ( root , "www" , "cordova.js" ), "globalThis.cordova = {};" ),
]);
const result = await reconstructJavaScriptArtifact ({ input_path : root });
const gaps = result . graph . nodes . filter (
({ kind , observations }) =>
kind === "unknown" &&
observations . some (
({ properties }) =>
properties . operation === "parse-html-inline-scripts" ,
),
);
expect ( result . graph . coverage . status ). toBe ( "partial" );
expect ( gaps ). toHaveLength ( 1 );
expect ( gaps [ 0 ] ? . observations [ 0 ]). toMatchObject ({
properties : {
path : "www/index.html" ,
inline_script_count : 2 ,
inline_scripts : [
{
script_type : "classic" ,
location : {
start : { line : 5 , column : 8 },
end : { line : 7 , column : 0 },
},
},
{
script_type : "module" ,
location : {
start : { line : 8 , column : 22 },
end : { line : 8 , column : 67 },
},
},
],
},
evidence : {
state : "unavailable" ,
artifact : {
sha256 : createHash ( "sha256" ). update ( html ). digest ( "hex" ),
},
coverage : { status : "partial" },
},
});
expect ( result . graph . limitations ). toContain (
"www/index.html has 2 inline JavaScript bodies starting at lines 5, 8 that were not analyzed; endpoints, storage, imports and other facts in them are absent from the evidence." ,
);
// The external script is still projected as before.
expect (
result . graph . edges . some (
({ relation , properties }) =>
relation === "loads" && properties . resolved_path === "www/cordova.js" ,
),
). toBe ( true );
});
it ( "keeps complete coverage when HTML has only external scripts and data blocks" , async () => {
const root = await createTestTempDirectory ( "rea-html-no-inline-scripts-" );
await Promise . all ([
writeFile (
join ( root , "index.html" ),
'<script src="actual.js"></script><script type="importmap">{"imports":{}}</script><script> </script>' ,
),
writeFile ( join ( root , "actual.js" ), "globalThis.ownedActual = true;" ),
]);
const result = await reconstructJavaScriptArtifact ({ input_path : root });
expect ( result . graph . coverage . status ). toBe ( "complete" );
expect (
result . graph . limitations . some (( limitation ) =>
limitation . includes ( "inline JavaScript" ),
),
). toBe ( false );
});
it . each ( coordinateCases )(
"retains parse5 source coordinates for inline script bodies with $name" ,
async ({ prefix }) => {
const root = await createTestTempDirectory ( "rea-html-inline-coordinates-" );
const html = ` ${ prefix } <script> \ r \ nrun(); \ rnext(); \ n</script>` ;
await writeFile ( join ( root , "index.html" ), html );
const result = await reconstructJavaScriptArtifact ({ input_path : root });
const gap = result . graph . nodes
. flatMap (({ observations }) => observations )
. find (
({ properties }) =>
properties . operation === "parse-html-inline-scripts" ,
);
expect ( gap ? . properties . inline_scripts ). toEqual ([
{ script_type : "classic" , location : inlineBodyRange ( html ) },
]);
},
);
const inlineBodyRange = ( html : string ) => {
const pending : DefaultTreeAdapterTypes.Node [] = [
parse ( html , { sourceCodeLocationInfo : true }),
];
while ( pending . length > 0 ) {
const node = pending . pop ();
if ( node === undefined ) continue ;
if ( defaultTreeAdapter . isElementNode ( node ) && node . tagName === "script" ) {
const opening = node . sourceCodeLocation ? . startTag ;
const body = node . childNodes [ 0 ] ? . sourceCodeLocation ;
if ( opening === undefined || body === undefined || body === null )
throw new Error ( "Missing parse5 inline script range" );
return {
start : { line : opening.endLine , column : opening.endCol - 1 },
end : { line : body.endLine , column : body.endCol - 1 },
};
}
if ( "childNodes" in node ) {
for ( const child of node . childNodes . toReversed ()) pending . push ( child );
}
}
throw new Error ( "Missing parse5 inline script" );
};
it ( "bounds retained inline script locations and names the omitted remainder" , async () => {
const root = await createTestTempDirectory ( "rea-html-many-inline-scripts-" );
const count = 5 _000 ;
await writeFile (
join ( root , "index.html" ),
"<script>run();</script>\n" . repeat ( count ),
);
const result = await reconstructJavaScriptArtifact ({ input_path : root });
const gap = result . graph . nodes
. flatMap (({ observations }) => observations )
. find (
({ properties }) => properties . operation === "parse-html-inline-scripts" ,
);
const limit = {
name : "html_inline_script_locations_per_file" ,
value : 100 ,
unit : "items" ,
};
expect ( gap ? . properties . inline_script_count ). toBe ( count );
expect ( gap ? . properties . inline_scripts ). toHaveLength ( 100 );
// The gap record knows exactly which locations the bound dropped.
expect ( gap ? . evidence . coverage ). toEqual ({
status : "partial" ,
truncated : true ,
omitted_count : count - 100 ,
limits : [ limit ],
});
// The graph is truncated too, but unanalyzed bodies hide an unknown total.
expect ( result . graph . coverage ). toMatchObject ({
status : "partial" ,
truncated : true ,
omitted_count : null ,
limits : expect.arrayContaining ([ limit ]),
});
expect ( result . graph . limitations ). toContain (
`index.html has ${ String ( count ) } inline JavaScript bodies starting at lines 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 and ${ String ( count - 10 ) } more that were not analyzed; endpoints, storage, imports and other facts in them are absent from the evidence.` ,
);
});