|
| 1 | +import assert from "node:assert/strict" |
| 2 | +import test from "node:test" |
| 3 | + |
| 4 | +import type { BrowserContext, Route } from "playwright" |
| 5 | + |
| 6 | +import { browserPreviewNetworkPolicy, browserPreviewRouting, createBrowserPreviewRouteTracker, drainBrowserPreviewRouteTracker, isBrowserPreviewRouteFetchContentDecodingError, isBrowserPreviewRouteFetchRecoverableError, isBrowserPreviewRouteFetchRequestContextDisposedError, isBrowserPreviewRouteFetchTransientTransportError, routeBrowserPreviewContextNetwork } from "../packages/runtime-playground/src/browser-preview-routing.js" |
| 7 | +import { jsonLines, serializeBrowserError } from "../packages/runtime-playground/src/browser-metrics.js" |
| 8 | + |
| 9 | +const SENTINELS = ["SENTINEL_COOKIE_2094", "SENTINEL_AUTH_2094", "SENTINEL_NONCE_2094", "SENTINEL_TOKEN_2094"] |
| 10 | +const ROUTED_URL = "http://routed.test/wp-includes/app.js?token=SENTINEL_TOKEN_2094" |
| 11 | + |
| 12 | +test("routed fetch classifiers include transport resets and preserve disposal/decompression recovery", () => { |
| 13 | + assert.equal(isBrowserPreviewRouteFetchTransientTransportError(routeFetchError("read ECONNRESET")), true) |
| 14 | + assert.equal(isBrowserPreviewRouteFetchTransientTransportError(routeFetchError("connect ECONNREFUSED")), true) |
| 15 | + assert.equal(isBrowserPreviewRouteFetchTransientTransportError(routeFetchError("socket hang up")), true) |
| 16 | + assert.equal(isBrowserPreviewRouteFetchRequestContextDisposedError(routeFetchError("Request context disposed.")), true) |
| 17 | + assert.equal(isBrowserPreviewRouteFetchContentDecodingError(routeFetchError("failed to decompress 'br' encoding")), true) |
| 18 | + assert.equal(isBrowserPreviewRouteFetchRecoverableError(routeFetchError("read ECONNRESET")), true) |
| 19 | +}) |
| 20 | + |
| 21 | +test("subresource resets retry once, abort safely, and never reject the route callback", async () => { |
| 22 | + const fixture = await routedFixture("script", [routeFetchError("read ECONNRESET"), routeFetchError("socket closed")]) |
| 23 | + await assert.doesNotReject(fixture.run()) |
| 24 | + assert.equal(fixture.fetchCalls(), 2) |
| 25 | + assert.equal(fixture.abortCalls(), 1) |
| 26 | + assert.equal(fixture.tracker.errors.length, 0) |
| 27 | + assert.equal(fixture.tracker.pending.size, 0) |
| 28 | +}) |
| 29 | + |
| 30 | +test("document resets retry deterministically and fulfill after recovery", async () => { |
| 31 | + const response = routedResponse() |
| 32 | + const fixture = await routedFixture("document", [routeFetchError("connect ECONNREFUSED"), routeFetchError("read ECONNRESET"), response]) |
| 33 | + await assert.doesNotReject(fixture.run()) |
| 34 | + await assert.doesNotReject(drainBrowserPreviewRouteTracker(fixture.tracker)) |
| 35 | + assert.equal(fixture.fetchCalls(), 3) |
| 36 | + assert.equal(fixture.fulfilledResponse(), response) |
| 37 | + assert.equal(fixture.abortCalls(), 0) |
| 38 | +}) |
| 39 | + |
| 40 | +test("exhausted document resets fail through a sanitized tracker error without leaking to persisted surfaces", async () => { |
| 41 | + const fixture = await routedFixture("document", [routeFetchError("read ECONNRESET"), routeFetchError("read ECONNRESET"), routeFetchError("read ECONNRESET")]) |
| 42 | + await assert.doesNotReject(fixture.run()) |
| 43 | + assert.equal(fixture.fetchCalls(), 3) |
| 44 | + assert.equal(fixture.abortCalls(), 1) |
| 45 | + assert.equal(fixture.tracker.pending.size, 0) |
| 46 | + assert.equal(fixture.tracker.errors.length, 1) |
| 47 | + |
| 48 | + const tracked = fixture.tracker.errors[0] |
| 49 | + await assert.rejects(drainBrowserPreviewRouteTracker(fixture.tracker), /classification=upstream-transport.*resourceType=document.*token=\[redacted\]/) |
| 50 | + const serialized = serializeBrowserError("probe-error", tracked) |
| 51 | + const persistedSurfaces = { |
| 52 | + stdout: JSON.stringify(serialized), |
| 53 | + stderr: tracked instanceof Error ? `${tracked.message}\n${tracked.stack}` : String(tracked), |
| 54 | + diagnostics: JSON.stringify({ errors: [serialized] }), |
| 55 | + manifest: JSON.stringify({ files: [{ diagnostics: serialized }] }), |
| 56 | + artifact: jsonLines([serialized]), |
| 57 | + tracker: JSON.stringify(fixture.tracker.errors.map((error) => serializeBrowserError("probe-error", error))), |
| 58 | + snapshot: JSON.stringify(serialized), |
| 59 | + } |
| 60 | + for (const [surface, contents] of Object.entries(persistedSurfaces)) { |
| 61 | + for (const sentinel of SENTINELS) assert.doesNotMatch(contents, new RegExp(sentinel), `${surface} must not contain ${sentinel}`) |
| 62 | + } |
| 63 | +}) |
| 64 | + |
| 65 | +test("concurrent routed requests drain after independent retry and cleanup", async () => { |
| 66 | + const first = await routedFixture("script", [routeFetchError("read ECONNRESET"), routedResponse()]) |
| 67 | + const second = await routedFixture("image", [routeFetchError("socket ended"), routeFetchError("socket ended")], first.tracker) |
| 68 | + await Promise.all([first.run(), second.run()]) |
| 69 | + await assert.doesNotReject(drainBrowserPreviewRouteTracker(first.tracker)) |
| 70 | + assert.equal(first.tracker.pending.size, 0) |
| 71 | + assert.equal(first.fetchCalls(), 2) |
| 72 | + assert.equal(second.fetchCalls(), 2) |
| 73 | + assert.equal(second.abortCalls(), 1) |
| 74 | +}) |
| 75 | + |
| 76 | +test("disposed contexts and decompression failures abort without retrying or tracking errors", async () => { |
| 77 | + for (const message of ["Request context disposed.", "failed to decompress 'gzip' encoding"]) { |
| 78 | + const fixture = await routedFixture("document", [routeFetchError(message)]) |
| 79 | + await assert.doesNotReject(fixture.run()) |
| 80 | + await assert.doesNotReject(drainBrowserPreviewRouteTracker(fixture.tracker)) |
| 81 | + assert.equal(fixture.fetchCalls(), 1) |
| 82 | + assert.equal(fixture.abortCalls(), 1) |
| 83 | + } |
| 84 | +}) |
| 85 | + |
| 86 | +async function routedFixture(resourceType: string, outcomes: unknown[], tracker = createBrowserPreviewRouteTracker()) { |
| 87 | + let handler: ((route: Route) => Promise<void>) | undefined |
| 88 | + let fetchCalls = 0 |
| 89 | + let abortCalls = 0 |
| 90 | + let fulfilled: unknown |
| 91 | + const context = { |
| 92 | + route: async (_pattern: string, nextHandler: (route: Route) => Promise<void>) => { |
| 93 | + handler = nextHandler |
| 94 | + }, |
| 95 | + } as BrowserContext |
| 96 | + const preview = browserPreviewRouting([], undefined, "http://127.0.0.1:9400") |
| 97 | + const policy = browserPreviewNetworkPolicy([], ["routed.test"], preview) |
| 98 | + await routeBrowserPreviewContextNetwork(context, policy, preview.effectiveOrigin, tracker) |
| 99 | + |
| 100 | + const route = { |
| 101 | + request: () => ({ |
| 102 | + url: () => ROUTED_URL, |
| 103 | + method: () => "GET", |
| 104 | + resourceType: () => resourceType, |
| 105 | + headers: () => ({ |
| 106 | + cookie: `wordpress_logged_in=${SENTINELS[0]}`, |
| 107 | + authorization: `Bearer ${SENTINELS[1]}`, |
| 108 | + "x-wp-nonce": SENTINELS[2], |
| 109 | + "x-session-token": SENTINELS[3], |
| 110 | + }), |
| 111 | + }), |
| 112 | + fetch: async () => { |
| 113 | + const outcome = outcomes[Math.min(fetchCalls, outcomes.length - 1)] |
| 114 | + fetchCalls += 1 |
| 115 | + if (outcome instanceof Error) throw outcome |
| 116 | + return outcome |
| 117 | + }, |
| 118 | + abort: async () => { |
| 119 | + abortCalls += 1 |
| 120 | + }, |
| 121 | + fulfill: async ({ response }: { response: unknown }) => { |
| 122 | + fulfilled = response |
| 123 | + }, |
| 124 | + continue: async () => {}, |
| 125 | + } as unknown as Route |
| 126 | + |
| 127 | + return { |
| 128 | + tracker, |
| 129 | + run: async () => { |
| 130 | + assert(handler) |
| 131 | + await handler(route) |
| 132 | + }, |
| 133 | + fetchCalls: () => fetchCalls, |
| 134 | + abortCalls: () => abortCalls, |
| 135 | + fulfilledResponse: () => fulfilled, |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +function routeFetchError(reason: string): Error { |
| 140 | + const message = `route.fetch: ${reason}\nCall log:\n - → GET ${ROUTED_URL}\n cookie: wordpress_logged_in=${SENTINELS[0]}\n authorization: Bearer ${SENTINELS[1]}\n x-wp-nonce: ${SENTINELS[2]}\n x-session-token: ${SENTINELS[3]}` |
| 141 | + const error = new Error(message) |
| 142 | + error.stack = `Error: ${message}` |
| 143 | + return error |
| 144 | +} |
| 145 | + |
| 146 | +function routedResponse() { |
| 147 | + return { |
| 148 | + status: () => 200, |
| 149 | + headers: () => ({}), |
| 150 | + } |
| 151 | +} |
0 commit comments