@@ -5,6 +5,7 @@ import { join } from "node:path";
55import { parseHTML } from "linkedom" ;
66import { describe , it , expect } from "vitest" ;
77import { bundleToSingleHtml } from "./htmlBundler" ;
8+ import { getHyperframeRuntimeScript } from "../generated/runtime-inline" ;
89
910function makeTempProject ( files : Record < string , string > ) : string {
1011 const dir = mkdtempSync ( join ( tmpdir ( ) , "hf-bundler-test-" ) ) ;
@@ -82,6 +83,55 @@ describe("bundleToSingleHtml", () => {
8283 expect ( innerLength ) . toBeGreaterThan ( 1000 ) ;
8384 } ) ;
8485
86+ it ( "preserves `$&` replace-pattern characters in the inlined runtime body" , async ( ) => {
87+ // Regression guard: `injectInterceptor` used to insert the runtime via
88+ // `sanitized.replace("</head>", `${tag}\n</head>`)`. `String.prototype.replace`'s
89+ // second argument is a substitution template — `$&` expands to the matched
90+ // substring (here, `</head>`). The minified runtime IIFE contains legitimate
91+ // `$&` sequences (e.g. `if(te&&$&!y.hasAttribute(...))`), so the bundler
92+ // silently injected stray `</head>` tags inside the runtime, producing a JS
93+ // SyntaxError that broke every timeline in the bundle. Switching to the
94+ // function-replacer form passes the runtime body through verbatim.
95+ // Use a document with an explicit `<head>` so the bundler takes the
96+ // `sanitized.replace("</head>", …)` injection path — the only branch that
97+ // exercises the substitution-template behavior. Authoring without a
98+ // `<head>` falls back to slice+concat (safe but doesn't catch this bug).
99+ const dir = makeTempProject ( {
100+ "index.html" : `<!doctype html>
101+ <html><head></head><body>
102+ <div data-composition-id="root" data-width="320" data-height="180"></div>
103+ </body></html>` ,
104+ } ) ;
105+
106+ const previousUrl = process . env . HYPERFRAME_RUNTIME_URL ;
107+ delete process . env . HYPERFRAME_RUNTIME_URL ;
108+ let bundled : string ;
109+ try {
110+ bundled = await bundleToSingleHtml ( dir ) ;
111+ } finally {
112+ if ( previousUrl !== undefined ) process . env . HYPERFRAME_RUNTIME_URL = previousUrl ;
113+ }
114+
115+ const original = getHyperframeRuntimeScript ( ) ;
116+ // Sanity: the built runtime exercises this regression (no `$&` means the
117+ // test would tautologically pass even with the broken implementation).
118+ expect ( original ) . toContain ( "$&" ) ;
119+
120+ const runtimeBlock = bundled . match (
121+ / < s c r i p t \b [ ^ > ] * d a t a - h y p e r f r a m e s - p r e v i e w - r u n t i m e [ ^ > ] * > ( [ \s \S ] * ?) < \/ s c r i p t > / i,
122+ ) ;
123+ expect ( runtimeBlock ) . not . toBeNull ( ) ;
124+ const runtimeBody = runtimeBlock ?. [ 1 ] ?? "" ;
125+ expect ( runtimeBody ) . toBe ( original ) ;
126+
127+ // Defense in depth: the entire bundled document should contain exactly one
128+ // `</head>` — the real closing tag. Before the fix, every `$&` in the
129+ // runtime expanded to an extra `</head>` inside the inlined IIFE,
130+ // producing a `Unexpected token '<'` SyntaxError at parse time.
131+ const headCloses = bundled . match ( / < \/ h e a d > / g) ?? [ ] ;
132+ expect ( headCloses . length ) . toBe ( 1 ) ;
133+ } ) ;
134+
85135 it ( "preserves chunk integrity when a chunk ends with a line comment (ASI hazard guard)" , async ( ) => {
86136 // Regression guard for the joinJsChunks helper. If a chunk ends with `// ...`
87137 // and we naively appended `;` on the same line, the appended semicolon would
0 commit comments