From 7546da47b3d3dde6326a37fb00e37a83e632a752 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 27 Jul 2026 13:57:53 +0200 Subject: [PATCH 1/4] Preserve hydration compatibility with legacy router assets --- .../dev-client-renderer.ts | 4 +- .../prod-scripts.test.ts | 58 ++++++ .../hydration-script-builder/prod-scripts.ts | 4 +- .../templates/router.ts | 12 +- ...2026-07-27-legacy-router-hydration.test.ts | 165 ++++++++++++++++++ 5 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts diff --git a/src/html/hydration-script-builder/dev-client-renderer.ts b/src/html/hydration-script-builder/dev-client-renderer.ts index 1584a44f16..befc46d83e 100644 --- a/src/html/hydration-script-builder/dev-client-renderer.ts +++ b/src/html/hydration-script-builder/dev-client-renderer.ts @@ -8,8 +8,10 @@ export function generateDevClientRendererScript(nonce?: string): string { + + +
Existing release content
+ + + +`; + +const REACT_MODULE = ` +export function createElement(type, props, ...children) { + return { type, props: props || {}, children }; +} +`; + +const REACT_DOM_CLIENT_MODULE = ` +function markHydrated() { + document.documentElement.dataset.hydrated = "yes"; + return { render: markHydrated }; +} +export function createRoot() { + return { render: markHydrated }; +} +export function hydrateRoot() { + markHydrated(); + return { render: markHydrated }; +} +`; + +const LEGACY_ROUTER_MODULE = ` +export function RouterProvider({ children }) { + return children; +} +export function useRouter() { + return {}; +} +`; + +const PAGE_CONTEXT_MODULE = ` +export function PageContextProvider({ children }) { + return children; +} +`; + +const PAGE_MODULE = ` +export default function ExistingReleasePage() { + return null; +} +`; + +function javascript(source: string): Response { + return new Response(source, { + headers: { "content-type": "application/javascript; charset=utf-8" }, + }); +} + +describe( + "Regression: shared hydration runtime supports legacy router release assets", + { sanitizeOps: false, sanitizeResources: false }, + () => { + it("hydrates without requiring getNavigationStore", async () => { + const hydrationModule = generateProdHydrationModule(); + const server = Deno.serve({ + hostname: "127.0.0.1", + port: 0, + onListen() {}, + }, (request) => { + const pathname = new URL(request.url).pathname; + + if (pathname === "/") { + return new Response(HTML, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + if (pathname === "/hydration-runtime.js") return javascript(hydrationModule); + if (pathname === "/react.js") return javascript(REACT_MODULE); + if (pathname === "/react-dom-client.js") return javascript(REACT_DOM_CLIENT_MODULE); + if (pathname === "/legacy-router.js") return javascript(LEGACY_ROUTER_MODULE); + if (pathname === "/context.js") return javascript(PAGE_CONTEXT_MODULE); + if (pathname.startsWith("/_vf_modules/")) return javascript(PAGE_MODULE); + + return new Response("Not found", { status: 404 }); + }); + const browser = await launchChromium(); + + try { + if (!browser) return; + + const page = await browser.newPage(); + const diagnostics = captureBrowserDiagnostics(page); + const { port } = server.addr as Deno.NetAddr; + const response = await page.goto(`http://127.0.0.1:${port}/`); + + assertEquals(response?.status(), 200); + await page.waitForFunction(() => document.documentElement.dataset.hydrated === "yes"); + + const messages = getBrowserDiagnosticMessages(diagnostics); + assertEquals( + messages.some((message) => + message.includes( + "does not provide an export named 'getNavigationStore'", + ) + ), + false, + messages.join("\n"), + ); + assertEquals( + await page.locator("#root").textContent(), + "Existing release content", + ); + } finally { + await browser?.close(); + await server.shutdown(); + await server.finished; + } + }); + }, +); From c7a6e4fb8385413e64849ce1624bdbcc8ca1082f Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 27 Jul 2026 15:44:44 +0200 Subject: [PATCH 2/4] Address hydration compatibility review feedback --- deno.json | 4 +- .../dev-client-renderer.ts | 9 ++- .../prod-scripts.test.ts | 18 ++++- .../hydration-script-builder/prod-scripts.ts | 9 ++- .../templates/index.ts | 1 + .../templates/navigation-store.ts | 53 ++++++++++++ .../templates/router.ts | 6 ++ src/utils/version-constant.ts | 2 +- tests/_helpers/playwright.ts | 80 ++++++++++++------- ...2026-07-27-legacy-router-hydration.test.ts | 62 +++++++++++++- 10 files changed, 204 insertions(+), 40 deletions(-) create mode 100644 src/html/hydration-script-builder/templates/navigation-store.ts diff --git a/deno.json b/deno.json index c6798ddef4..7b24b7cc00 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "veryfront", - "version": "0.1.1156", + "version": "0.1.1157", "license": "Apache-2.0", "nodeModulesDir": "auto", "minimumDependencyAge": { @@ -472,7 +472,7 @@ "test:all-runtimes": "deno task test:unit && deno task test:node && deno task test:bun", "test:e2e": "deno task test:e2e:playwright", "test:e2e:playwright": "PW_DISABLE_TS_ESM=1 npx playwright test --config=tests/e2e/playwright.config.cjs", - "test:e2e:rsc-browser": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --allow-all tests/e2e/regressions/rsc-proxy-hydration.test.ts --unstable-worker-options --unstable-net", + "test:e2e:rsc-browser": "deno task generate && VF_DISABLE_LRU_INTERVAL=1 SSR_TRANSFORM_PER_PROJECT_LIMIT=0 REVALIDATION_PER_PROJECT_LIMIT=0 NODE_ENV=production LOG_FORMAT=text deno test --no-check --allow-all tests/e2e/regressions/rsc-proxy-hydration.test.ts tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts --unstable-worker-options --unstable-net", "test:e2e:binary": "deno task generate && deno test --allow-all tests/integration/compiled-binary-e2e.test.ts", "test:e2e:binary:fresh": "deno task generate && VERYFRONT_BINARY_FRESH=1 deno test --allow-all tests/integration/compiled-binary-e2e.test.ts", "test:e2e:templates": "deno run --allow-all scripts/test/template-runtime-e2e.ts", diff --git a/src/html/hydration-script-builder/dev-client-renderer.ts b/src/html/hydration-script-builder/dev-client-renderer.ts index befc46d83e..58aa59810f 100644 --- a/src/html/hydration-script-builder/dev-client-renderer.ts +++ b/src/html/hydration-script-builder/dev-client-renderer.ts @@ -1,4 +1,9 @@ -import { getLoaderScript, getRendererScript, getRouterScript } from "./templates/index.ts"; +import { + getLoaderScript, + getNavigationStoreCompatibilityScript, + getRendererScript, + getRouterScript, +} from "./templates/index.ts"; import { buildNonceAttribute } from "../html-escape.ts"; export function generateDevClientRendererScript(nonce?: string): string { @@ -11,7 +16,7 @@ export function generateDevClientRendererScript(nonce?: string): string { import { RouterProvider, useRouter as useRouterFromModule } from 'veryfront/router'; import * as RouterRuntime from 'veryfront/router'; import { PageContextProvider } from 'veryfront/context'; - const getNavigationStore = RouterRuntime.getNavigationStore; + ${getNavigationStoreCompatibilityScript()} ${getRouterScript()} ${getLoaderScript()} diff --git a/src/html/hydration-script-builder/prod-scripts.test.ts b/src/html/hydration-script-builder/prod-scripts.test.ts index a0884d0ab9..5308186637 100644 --- a/src/html/hydration-script-builder/prod-scripts.test.ts +++ b/src/html/hydration-script-builder/prod-scripts.test.ts @@ -2,6 +2,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import * as esbuild from "veryfront/extensions/bundler"; +import { generateDevClientRendererScript } from "./dev-client-renderer.ts"; import { generateProdHydrationModule, getProdHydrationModulePath, @@ -36,6 +37,9 @@ async function bundleHydrationModuleAgainstLegacyRouter(hydrationModule: string) write: false, format: "esm", platform: "browser", + // A missing namespace property is the compatibility path under test, so the + // expected esbuild warning is intentionally quiet. Any static named import + // still fails the build. logLevel: "silent", plugins: [{ name: "legacy-router-fixture", @@ -53,6 +57,13 @@ async function bundleHydrationModuleAgainstLegacyRouter(hydrationModule: string) }); } +function extractModuleScript(scriptTag: string): string { + const start = scriptTag.indexOf(">") + 1; + const end = scriptTag.lastIndexOf(""); + if (start === 0 || end < start) throw new Error("Expected a module script tag"); + return scriptTag.slice(start, end); +} + describe("hydration-script-builder/prod-scripts", () => { describe("getProdScripts", () => { it("should return an external module script tag with the versioned runtime path", () => { @@ -128,12 +139,13 @@ describe("hydration-script-builder/prod-scripts", () => { }); Deno.test({ - name: "prod hydration module links against router assets from existing releases", - sanitizeOps: false, - sanitizeResources: false, + name: "generated hydration modules link against router assets from existing releases", async fn() { try { await bundleHydrationModuleAgainstLegacyRouter(generateProdHydrationModule()); + await bundleHydrationModuleAgainstLegacyRouter( + extractModuleScript(generateDevClientRendererScript()), + ); } finally { await esbuild.stop(); } diff --git a/src/html/hydration-script-builder/prod-scripts.ts b/src/html/hydration-script-builder/prod-scripts.ts index aeac02b562..4c2f353b75 100644 --- a/src/html/hydration-script-builder/prod-scripts.ts +++ b/src/html/hydration-script-builder/prod-scripts.ts @@ -1,4 +1,9 @@ -import { getLoaderScript, getRendererScript, getRouterScript } from "./templates/index.ts"; +import { + getLoaderScript, + getNavigationStoreCompatibilityScript, + getRendererScript, + getRouterScript, +} from "./templates/index.ts"; import { buildNonceAttribute } from "../html-escape.ts"; import { fnv1aHash } from "#veryfront/utils/hash-utils.ts"; @@ -15,7 +20,7 @@ export function generateProdHydrationModule(): string { `import { RouterProvider, useRouter as useRouterFromModule } from 'veryfront/router';`, `import * as RouterRuntime from 'veryfront/router';`, `import { PageContextProvider } from 'veryfront/context';`, - `const getNavigationStore = RouterRuntime.getNavigationStore;`, + getNavigationStoreCompatibilityScript().trim(), getRouterScript().trim(), getLoaderScript().trim(), getRendererScript().trim(), diff --git a/src/html/hydration-script-builder/templates/index.ts b/src/html/hydration-script-builder/templates/index.ts index 46eafc2e5a..2c28566c1e 100644 --- a/src/html/hydration-script-builder/templates/index.ts +++ b/src/html/hydration-script-builder/templates/index.ts @@ -5,6 +5,7 @@ */ export { getRouterScript } from "./router.ts"; +export { getNavigationStoreCompatibilityScript } from "./navigation-store.ts"; export { getLoaderScript } from "./loader.ts"; export { getRendererScript } from "./renderer.ts"; export { getSpaLoaderScript, getSpaRendererScript } from "./spa-renderer.ts"; diff --git a/src/html/hydration-script-builder/templates/navigation-store.ts b/src/html/hydration-script-builder/templates/navigation-store.ts new file mode 100644 index 0000000000..5e6d90cec8 --- /dev/null +++ b/src/html/hydration-script-builder/templates/navigation-store.ts @@ -0,0 +1,53 @@ +/** + * Generate the compatibility bridge between the shared hydration runtime and + * release-pinned router assets. + * + * Router releases since the v1 navigation store landed share the same + * Symbol.for registry entry even when they predate the public + * getNavigationStore export. Keeping the fallback store shape in sync with the + * two v1 implementations lets those releases retain SPA navigation. + */ +export const getNavigationStoreCompatibilityScript = () => ` + const navigationStoreUsesRegistryFallback = + typeof RouterRuntime.getNavigationStore !== 'function'; + const getNavigationStore = navigationStoreUsesRegistryFallback + ? () => { + const storeKey = Symbol.for('veryfront.navigation.store.v1'); + const existing = globalThis[storeKey]; + if (existing) return existing; + + const listeners = new Set(); + let navigator = null; + const store = { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getHref() { + const loc = globalThis.location; + return loc ? loc.pathname + loc.search + loc.hash : '/'; + }, + notify() { + for (const listener of [...listeners]) { + try { + listener(); + } catch { + // One subscriber must not prevent the others from updating. + } + } + }, + navigate(href, options) { + if (navigator) return navigator(href, options); + globalThis.location?.assign(href); + return Promise.resolve(); + }, + setNavigator(next) { + navigator = next; + }, + }; + + globalThis[storeKey] = store; + return store; + } + : RouterRuntime.getNavigationStore; +`; diff --git a/src/html/hydration-script-builder/templates/router.ts b/src/html/hydration-script-builder/templates/router.ts index 05b03fea9d..b68c40db9e 100644 --- a/src/html/hydration-script-builder/templates/router.ts +++ b/src/html/hydration-script-builder/templates/router.ts @@ -1153,6 +1153,12 @@ export const getRouterScript = () => ` // same SPA navigator that intercepts clicks. Without this the shared // navigation store has no navigator registered and its navigate() falls back // to a full-page location.assign (finding #7: push() full-reloads). + if ( + typeof navigationStoreUsesRegistryFallback !== 'undefined' && + navigationStoreUsesRegistryFallback + ) { + log('Router runtime does not export getNavigationStore; using shared v1 registry fallback'); + } if (typeof getNavigationStore === 'function') { getNavigationStore().setNavigator((href, options) => { const mode = options && options.history; diff --git a/src/utils/version-constant.ts b/src/utils/version-constant.ts index 46ac3d711b..fff3c6910c 100644 --- a/src/utils/version-constant.ts +++ b/src/utils/version-constant.ts @@ -1,4 +1,4 @@ // Keep in sync with deno.json version. // scripts/release.ts updates this constant during releases. /** Shared version value. */ -export const VERSION = "0.1.1156"; +export const VERSION = "0.1.1157"; diff --git a/tests/_helpers/playwright.ts b/tests/_helpers/playwright.ts index b7be58352a..d247b68ab3 100644 --- a/tests/_helpers/playwright.ts +++ b/tests/_helpers/playwright.ts @@ -35,6 +35,8 @@ interface BrowserBridgeMessage { wsEndpoint: string; } +const browserBridgeCleanups = new WeakMap Promise>(); + export function parseBrowserBridgeMessage(message: string): BrowserBridgeMessage { let parsed: unknown; try { @@ -61,12 +63,17 @@ async function readBrowserBridgeMessage( const decoder = new TextDecoder(); let output = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) throw new Error("Playwright browser bridge exited before reporting an endpoint"); - output += decoder.decode(value, { stream: true }); - const newline = output.indexOf("\n"); - if (newline >= 0) return parseBrowserBridgeMessage(output.slice(0, newline)); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) throw new Error("Playwright browser bridge exited before reporting an endpoint"); + output += decoder.decode(value, { stream: true }); + const newline = output.indexOf("\n"); + if (newline >= 0) return parseBrowserBridgeMessage(output.slice(0, newline)); + } + } finally { + await reader.cancel(); + reader.releaseLock(); } } @@ -92,13 +99,37 @@ async function launchChromiumThroughNode(chromium: ChromiumConnector): Promise | undefined; + const cleanup = (): Promise => { + cleanupPromise ??= (async () => { + try { + const writer = child.stdin.getWriter(); + try { + await writer.close(); + } finally { + writer.releaseLock(); + } + } catch { + try { + child.kill("SIGTERM"); + } catch { + /* expected: bridge may already have exited */ + } + } + + await statusPromise; + await stderrPromise; + })(); + return cleanupPromise; + }; try { const bridge = await withTimeout( Promise.race([ readBrowserBridgeMessage(child.stdout), - child.status.then(async (status) => { + statusPromise.then(async (status) => { const stderr = (await stderrPromise).trim(); throw new Error( `Playwright browser bridge exited with code ${status.code}${ @@ -114,31 +145,14 @@ async function launchChromiumThroughNode(chromium: ChromiumConnector): Promise { - if (cleanedUp) return; - cleanedUp = true; - void (async () => { - try { - const writer = child.stdin.getWriter(); - await writer.close(); - } catch { - try { - child.kill("SIGTERM"); - } catch { - /* expected: bridge may already have exited */ - } - } - })(); + void cleanup(); }); return browser; } catch (error) { - try { - child.kill("SIGTERM"); - } catch { - /* expected: bridge may already have exited */ - } + await cleanup(); throw error; } } @@ -179,6 +193,18 @@ export async function launchChromium(): Promise { } } +/** Close a browser and await any Node bridge subprocess cleanup. */ +export async function closeChromium(browser: Browser | null): Promise { + if (!browser) return; + const cleanup = browserBridgeCleanups.get(browser); + try { + await browser.close(); + } finally { + await cleanup?.(); + browserBridgeCleanups.delete(browser); + } +} + export function captureBrowserDiagnostics(page: Page): BrowserDiagnostics { const consoleMessages: string[] = []; const pageErrors: string[] = []; diff --git a/tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts b/tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts index acf501044c..d118d48a4a 100644 --- a/tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts +++ b/tests/e2e/regressions/2026-07-27-legacy-router-hydration.test.ts @@ -26,6 +26,7 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { captureBrowserDiagnostics, + closeChromium, getBrowserDiagnosticMessages, launchChromium, } from "../../_helpers/playwright.ts"; @@ -75,6 +76,32 @@ export function hydrateRoot() { `; const LEGACY_ROUTER_MODULE = ` +const navigationStoreKey = Symbol.for("veryfront.navigation.store.v1"); +const navigationState = { navigator: null }; +const listeners = new Set(); +const navigationStore = { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getHref() { + return location.pathname + location.search + location.hash; + }, + notify() { + for (const listener of listeners) listener(); + }, + navigate(href, options) { + if (navigationState.navigator) return navigationState.navigator(href, options); + location.assign(href); + return Promise.resolve(); + }, + setNavigator(navigator) { + navigationState.navigator = navigator; + } +}; +globalThis[navigationStoreKey] = navigationStore; +globalThis.__legacyRouterNavigationState = navigationState; + export function RouterProvider({ children }) { return children; } @@ -101,9 +128,29 @@ function javascript(source: string): Response { }); } +async function waitForHydration( + page: import("npm:playwright@1.60.0").Page, + diagnostics: ReturnType, +): Promise { + try { + await page.waitForFunction( + () => document.documentElement.dataset.hydrated === "yes", + undefined, + { timeout: 5_000 }, + ); + } catch (error) { + const missingExport = getBrowserDiagnosticMessages(diagnostics).find((message) => + message.includes("does not provide an export named 'getNavigationStore'") + ); + if (missingExport) { + throw new Error(`Hydration module linking failed: ${missingExport}`, { cause: error }); + } + throw error; + } +} + describe( "Regression: shared hydration runtime supports legacy router release assets", - { sanitizeOps: false, sanitizeResources: false }, () => { it("hydrates without requiring getNavigationStore", async () => { const hydrationModule = generateProdHydrationModule(); @@ -139,7 +186,7 @@ describe( const response = await page.goto(`http://127.0.0.1:${port}/`); assertEquals(response?.status(), 200); - await page.waitForFunction(() => document.documentElement.dataset.hydrated === "yes"); + await waitForHydration(page, diagnostics); const messages = getBrowserDiagnosticMessages(diagnostics); assertEquals( @@ -155,8 +202,17 @@ describe( await page.locator("#root").textContent(), "Existing release content", ); + assertEquals( + await page.evaluate(() => { + const state = Reflect.get(globalThis, "__legacyRouterNavigationState") as + | { navigator?: unknown } + | undefined; + return typeof state?.navigator === "function"; + }), + true, + ); } finally { - await browser?.close(); + await closeChromium(browser); await server.shutdown(); await server.finished; } From a3e03e630bcecc7b4e065f434c36c0b286a25563 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 27 Jul 2026 16:57:48 +0200 Subject: [PATCH 3/4] Anonymize customer-specific references --- src/transforms/mdx/esm-module-loader/cache/index.ts | 2 +- tests/integration/compiled-binary-e2e.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/transforms/mdx/esm-module-loader/cache/index.ts b/src/transforms/mdx/esm-module-loader/cache/index.ts index 28b9d89d42..948453a5e7 100644 --- a/src/transforms/mdx/esm-module-loader/cache/index.ts +++ b/src/transforms/mdx/esm-module-loader/cache/index.ts @@ -485,7 +485,7 @@ export async function clearESMDiskCache(): Promise { try { // Remove entire cache directory and recreate it - // This handles nested project directories like codersociety/local-main/ + // This handles nested project directories such as customer/local-main/ await fs.remove(cacheDir, { recursive: true }); await fs.mkdir(cacheDir, { recursive: true }); logger.debug(`${LOG_PREFIX_MDX_LOADER} Cleared ESM disk cache`); diff --git a/tests/integration/compiled-binary-e2e.test.ts b/tests/integration/compiled-binary-e2e.test.ts index 3ce2095570..d6d9c626cc 100644 --- a/tests/integration/compiled-binary-e2e.test.ts +++ b/tests/integration/compiled-binary-e2e.test.ts @@ -2679,7 +2679,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) }); }); - // Test: Layout at components/layouts/ path via config (mimics codersociety production setup) + // Test: Layout at components/layouts/ path via config (mirrors a production setup) // Regression test: layout at components/layouts/DefaultLayout.tsx was not found due to // path normalization double-stripping in getEntityInfo (components/ prefix matched, then // layouts/ prefix matched again, corrupting the path). @@ -2925,7 +2925,7 @@ export default function Home() { }); // Test: MDX layout at components/layouts/ path via config - // Tests the exact codersociety pattern: config layout using .mdx file in components/layouts/ + // Tests the production pattern: config layout using .mdx file in components/layouts/ it("should render MDX layout from components/layouts/ via config", async () => { const projectDir = await Deno.makeTempDir({ prefix: "vf-e2e-mdx-components-layout-test-" }); From 5fd028e76f4e61a3eb3bb4856d621020852d4445 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Mon, 27 Jul 2026 17:17:04 +0200 Subject: [PATCH 4/4] Bound browser bridge cleanup and bump version --- deno.json | 2 +- src/utils/version-constant.ts | 2 +- tests/_helpers/playwright.test.ts | 26 +++++++++++ tests/_helpers/playwright.ts | 77 +++++++++++++++++++++++-------- 4 files changed, 86 insertions(+), 21 deletions(-) diff --git a/deno.json b/deno.json index 7b24b7cc00..b349b334fe 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "veryfront", - "version": "0.1.1157", + "version": "0.1.1158", "license": "Apache-2.0", "nodeModulesDir": "auto", "minimumDependencyAge": { diff --git a/src/utils/version-constant.ts b/src/utils/version-constant.ts index fff3c6910c..520c0fc892 100644 --- a/src/utils/version-constant.ts +++ b/src/utils/version-constant.ts @@ -1,4 +1,4 @@ // Keep in sync with deno.json version. // scripts/release.ts updates this constant during releases. /** Shared version value. */ -export const VERSION = "0.1.1157"; +export const VERSION = "0.1.1158"; diff --git a/tests/_helpers/playwright.test.ts b/tests/_helpers/playwright.test.ts index 3a0816b7cd..dfe04f73b0 100644 --- a/tests/_helpers/playwright.test.ts +++ b/tests/_helpers/playwright.test.ts @@ -3,6 +3,7 @@ import { describe, it } from "#veryfront/testing/bdd.ts"; import type { Browser } from "npm:playwright@1.60.0"; import { CHROMIUM_LAUNCH_TIMEOUT_MS, + cleanupBrowserBridgeProcess, launchChromiumWith, parseBrowserBridgeMessage, } from "./playwright.ts"; @@ -71,3 +72,28 @@ describe("launchChromiumWith", () => { assertEquals(browser, null); }); }); + +describe("cleanupBrowserBridgeProcess", () => { + it("force-kills a bridge that does not exit after stdin closes", async () => { + let resolveStatus: (status: Deno.CommandStatus) => void = () => {}; + const statusPromise = new Promise((resolve) => { + resolveStatus = resolve; + }); + const signals: Deno.Signal[] = []; + + await cleanupBrowserBridgeProcess( + { + stdin: new WritableStream(), + kill(signal) { + signals.push(signal); + resolveStatus({ success: false, code: 137, signal }); + }, + }, + statusPromise, + Promise.resolve(""), + 10, + ); + + assertEquals(signals, ["SIGKILL"]); + }); +}); diff --git a/tests/_helpers/playwright.ts b/tests/_helpers/playwright.ts index d247b68ab3..994a91fb6e 100644 --- a/tests/_helpers/playwright.ts +++ b/tests/_helpers/playwright.ts @@ -22,6 +22,7 @@ export function isMissingBrowserExecutable(error: unknown): boolean { } export const CHROMIUM_LAUNCH_TIMEOUT_MS = 15_000; +const BROWSER_BRIDGE_SHUTDOWN_TIMEOUT_MS = 1_000; interface ChromiumLauncher { launch(options: { headless: boolean; timeout: number }): Promise; @@ -35,6 +36,11 @@ interface BrowserBridgeMessage { wsEndpoint: string; } +interface BrowserBridgeProcess { + stdin: WritableStream; + kill(signal: Deno.Signal): void; +} + const browserBridgeCleanups = new WeakMap Promise>(); export function parseBrowserBridgeMessage(message: string): BrowserBridgeMessage { @@ -91,6 +97,57 @@ async function withTimeout(promise: Promise, timeoutMs: number, message: s } } +/** @internal Exported for deterministic bridge-cleanup regression coverage. */ +export async function cleanupBrowserBridgeProcess( + child: BrowserBridgeProcess, + statusPromise: Promise, + stderrPromise: Promise, + timeoutMs = BROWSER_BRIDGE_SHUTDOWN_TIMEOUT_MS, +): Promise { + try { + const writer = child.stdin.getWriter(); + try { + await writer.close(); + } finally { + writer.releaseLock(); + } + } catch { + try { + child.kill("SIGTERM"); + } catch { + /* expected: bridge may already have exited */ + } + } + + try { + await withTimeout( + Promise.allSettled([statusPromise, stderrPromise]), + timeoutMs, + `Playwright browser bridge did not exit within ${timeoutMs}ms`, + ); + return; + } catch { + try { + child.kill("SIGKILL"); + } catch { + /* expected: bridge may have exited as the timeout fired */ + } + } + + await Promise.allSettled([ + withTimeout( + statusPromise, + timeoutMs, + `Playwright browser bridge did not report exit after SIGKILL within ${timeoutMs}ms`, + ), + withTimeout( + stderrPromise, + timeoutMs, + `Playwright browser bridge stderr did not close after SIGKILL within ${timeoutMs}ms`, + ), + ]); +} + async function launchChromiumThroughNode(chromium: ChromiumConnector): Promise { const bridgePath = fromFileUrl(new URL("./playwright-node-bridge.mjs", import.meta.url)); const child = new Deno.Command("node", { @@ -103,25 +160,7 @@ async function launchChromiumThroughNode(chromium: ChromiumConnector): Promise | undefined; const cleanup = (): Promise => { - cleanupPromise ??= (async () => { - try { - const writer = child.stdin.getWriter(); - try { - await writer.close(); - } finally { - writer.releaseLock(); - } - } catch { - try { - child.kill("SIGTERM"); - } catch { - /* expected: bridge may already have exited */ - } - } - - await statusPromise; - await stderrPromise; - })(); + cleanupPromise ??= cleanupBrowserBridgeProcess(child, statusPromise, stderrPromise); return cleanupPromise; };