From 8b2ed0000931579e6abca3ff4878904be3996a7a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Thu, 8 Oct 2026 01:00:19 -0700 Subject: [PATCH] =?UTF-8?q?test(web):=20deterministic=20artifact=20recorde?= =?UTF-8?q?r=20=E2=80=94=20fixture=20generators=20on=20a=20stepped=20fake?= =?UTF-8?q?=20clock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every server spec that writes a test/harness/__artifacts__ fixture now renders through one recorder (test/server/artifact-recorder.ts) that drives the render on a fake clock stepped 1 ms at a time — the fix #3849 applied to generic-hydration.gen.spec.tsx alone, for every artifact. Why: the fixtures' flush points are timers and the stream coalesces one event-loop turn into one chunk (`deferFlush`), so on real timers the chunking and the script order are the host's. Reproduced on untouched sources (full server suite, 3 runs, plus 20 targeted runs under 12 busy loops on 10 cores): welcome-status-{loaded,streamed}.json re-recorded on every run (the 5 ms usage/slot events vs the 15 ms `stats` promise, in either order), write-before-resume.json split its one 60 ms chunk into two on 3/23 runs (two timers created microseconds apart straddling a millisecond), and nav-before-resume.json once wrote the 60 ms `` fragment ahead of the route's 20 ms one. On the stepped clock every fixture timer fires at its own instant in creation order; timers due at the same instant flush together, distinct instants flush separately. With the fixture built inside the recorded render (welcome's `makeWelcome()` started its 15 ms timer at construction, on the real clock), the recording is a function of the fixture: 10/10 full-suite runs under load, every artifact byte-identical. Re-recorded: welcome-status-loaded.json and welcome-status-streamed.json (shell unchanged; `rest` now carries the 5 ms usage trace events and the slot chunk before the 15 ms stats value + fragment, the fixture's order — the committed form was a loaded-host recording). The other ~150 artifacts are byte-identical to what the recorder produces. Guard: `writeArtifact` still re-records locally, but under CI (the checkout IS the committed artifact) a recording that differs from the committed file — or an artifact with no committed file — fails the test with the first difference, instead of silently re-recording. `JSX_COMPILER=babel` runs are an A/B and only write. Tests / tooling only; no runtime change, no changeset. Co-authored-by: Cursor --- .../__artifacts__/welcome-status-loaded.json | 2 +- .../welcome-status-streamed.json | 2 +- packages/web/test/server/artifact-recorder.ts | 144 ++++++++++++++++++ .../document-live-channel-artifact.spec.tsx | 45 ++---- .../web/test/server/document-shell.spec.tsx | 41 +---- .../dynamic-async-loading-3666.spec.tsx | 38 +---- .../server/dynamic-component-parity.spec.tsx | 38 +---- .../frame-live-document-artifact.spec.tsx | 41 +---- .../frame-nonlive-document-3666.spec.tsx | 46 +----- .../server/generic-hydration.gen.spec.tsx | 80 +++------- .../test/server/hybrid-memo-handoff.spec.tsx | 38 +---- .../server/hybrid-store-handoff-3574.spec.tsx | 38 +---- .../test/server/hydration-harness.spec.tsx | 42 +---- .../live-shell-source-3764.gen.spec.tsx | 36 +---- .../server/nav-before-resume.gen.spec.tsx | 48 ++---- .../test/server/slot-hydration-3567.spec.tsx | 12 +- .../server/welcome-status-parity.spec.tsx | 57 +++---- .../server/write-before-resume.gen.spec.tsx | 38 ++--- 18 files changed, 265 insertions(+), 521 deletions(-) create mode 100644 packages/web/test/server/artifact-recorder.ts diff --git a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json index c6900fe80..a6646ffb2 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-loaded.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-loaded.json @@ -1,5 +1,5 @@ { "name": "welcome-status-loaded", "shell": "
1…
", - "rest": "" + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json index ef1ebbb6b..455132146 100644 --- a/packages/web/test/harness/__artifacts__/welcome-status-streamed.json +++ b/packages/web/test/harness/__artifacts__/welcome-status-streamed.json @@ -1,5 +1,5 @@ { "name": "welcome-status-streamed", "shell": "
1…
", - "rest": "" + "rest": "" } \ No newline at end of file diff --git a/packages/web/test/server/artifact-recorder.ts b/packages/web/test/server/artifact-recorder.ts new file mode 100644 index 000000000..ddc19754f --- /dev/null +++ b/packages/web/test/server/artifact-recorder.ts @@ -0,0 +1,144 @@ +/** + * The artifact recorder: the one way a server spec renders a fixture and + * writes what the hydrate project (test/hydration, test/consistency) replays + * from test/harness/__artifacts__/. + * + * Why a fake clock. A fixture's flush points are timers (`sleep(5)`, + * `sleep(15)`, `sleep(60)`…) and the stream coalesces whatever settles in one + * event-loop turn into one chunk (`deferFlush` in src/server.ts). On real + * timers the chunking is the host's: two 60 ms timers created microseconds + * apart straddle a millisecond boundary and fire in two turns (two chunks) + * or do not (one chunk); under load — the parallel server suite, a CI + * runner — a stalled loop finds a 20 ms and a 60 ms timer both due and the + * LATER one's writes can land first, and a 5 ms event can trail a 15 ms + * promise. Every such run re-records the artifact, and the hydrate half may + * pass against either form, so the drift shows up as a dirty tree — or as a + * CI red on the one consumer that does care about chunk indices + * (test/consistency/generic, #3849). + * + * So the render runs on a fake clock stepped one millisecond at a time + * (`setTimeout` / `setImmediate` faked; `deferFlush` captured the real + * `setImmediate` at module load and keeps it). Every timer the fixture + * created fires at its own instant in creation order, each step yields to + * the real event loop so a burst's microtask chain and then its flush run + * before the next step, and timers due at the same instant flush together + * (the clock's own continuation is queued ahead of the burst's flush, as a + * real loop runs same-instant timers before its check phase). The recording + * is a function of the fixture — one chunk per distinct timer instant — on + * every host. (What #3849 did for generic-hydration.gen.spec.tsx, for every + * artifact.) + */ +import { vi } from "vitest"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderToStream } from "@solidjs/web"; + +export const artifactsDir = resolve( + dirname(fileURLToPath(import.meta.url)), + "../harness/__artifacts__" +); +mkdirSync(artifactsDir, { recursive: true }); + +type StreamOptions = NonNullable[1]>; + +export interface Recording { + /** Every write up to and including the one that carried the completed shell. */ + shell: string; + /** Each later flush — one entry per chunk a consumer would receive. */ + chunks: string[]; + /** `chunks` joined: the response after the shell. */ + rest: string; +} + +// Fixtures settle within ~100 ms of fake time; a render still open this far +// in is stuck. Every step costs one real macrotask, so the cap also keeps a +// stuck render inside vitest's test timeout, with a message that says what +// happened instead of a bare timeout. +const MAX_FAKE_MS = 2000; + +/** + * Render `code` through `renderToStream` on a fake clock stepped 1 ms at a + * time until the stream ends; the shell and each later chunk. `options` are + * the render's (`plugins`, …; `onCompleteShell` is chained). + * + * Build the fixture INSIDE `code`. A timer the fixture starts before the + * recording — a `sleep(15)` at construction — runs on the real clock and + * races the stepped one, which is the host's timing back again. + */ +export async function recordStream( + code: () => any, + options: StreamOptions = {} +): Promise { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setImmediate", "clearImmediate"] }); + try { + const chunks: string[] = []; + let shell: string | undefined; + let shellDone = false; + let ended = false; + renderToStream(code, { + ...options, + onCompleteShell(info) { + shellDone = true; + options.onCompleteShell?.(info); + } + }).pipe({ + write(chunk: string) { + chunks.push(chunk); + if (shellDone && shell === undefined) shell = chunks.splice(0).join(""); + }, + end() { + if (shell === undefined) shell = chunks.splice(0).join(""); + ended = true; + } + }); + for (let ms = 0; ms < MAX_FAKE_MS && !ended; ms++) await vi.advanceTimersByTimeAsync(1); + if (!ended) { + throw new Error( + `recordStream: the render did not end within ${MAX_FAKE_MS} ms of fake time ` + + `(${shell === undefined ? "no shell yet" : `${chunks.length} chunk(s) after the shell`})` + ); + } + return { shell: shell!, chunks, rest: chunks.join("") }; + } finally { + vi.useRealTimers(); + } +} + +/** + * Write `__artifacts__/.json`. + * + * Locally the artifact is simply (re)written — it is committed so a change + * in what the compiler + runtime produce shows up in the diff. Under CI the + * checkout IS the committed artifact and nothing changed between it and this + * run, so a recording that differs from it fails the test instead of + * silently re-recording: either the artifact was not re-recorded with the + * change that moved it (run the server suite and commit it), or the recorder + * is not deterministic for this fixture. The committed artifacts are the + * native compiler's; a `JSX_COMPILER=babel` run is an A/B and only writes. + */ +export function writeArtifact(name: string, data: unknown): void { + const file = resolve(artifactsDir, `${name}.json`); + const next = JSON.stringify(data, null, 2); + const prev = existsSync(file) ? readFileSync(file, "utf-8") : undefined; + if (prev === next) return; + writeFileSync(file, next); + const ci = process.env.CI && process.env.CI !== "false" && process.env.CI !== "0"; + if (!ci || process.env.JSX_COMPILER === "babel") return; + throw new Error( + prev === undefined + ? `artifact ${name}.json is not committed: this run wrote it fresh. Run the server ` + + `suite locally and commit test/harness/__artifacts__/${name}.json.` + : `artifact ${name}.json changed on a run that did not change its fixture — ` + + `${firstDifference(prev, next)}. Either the change that moved it was committed ` + + `without re-recording (run the server suite locally and commit the artifact), ` + + `or the recorder is nondeterministic for this fixture.` + ); +} + +function firstDifference(a: string, b: string): string { + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) i++; + const around = (s: string) => JSON.stringify(s.slice(Math.max(0, i - 40), i + 80)); + return `first difference at offset ${i}: committed ${around(a)} vs recorded ${around(b)}`; +} diff --git a/packages/web/test/server/document-live-channel-artifact.spec.tsx b/packages/web/test/server/document-live-channel-artifact.spec.tsx index 93b72db83..29b7ce9d8 100644 --- a/packages/web/test/server/document-live-channel-artifact.spec.tsx +++ b/packages/web/test/server/document-live-channel-artifact.spec.tsx @@ -7,44 +7,16 @@ * test/hydration/document-live-channel.spec.tsx replays into jsdom. */ import { describe, expect, test } from "vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { renderToStream } from "@solidjs/web"; import { frameTransformDirectResult, ServerComponentPlugin } from "../../frames/src/frame-sink.js"; import { FID, YIELDS, makeStreamingComponent } from "../harness/document-live-channel.jsx"; - -const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); -mkdirSync(artifactsDir, { recursive: true }); - -function collectChunks(code: () => any): Promise<{ shell: string; rest: string }> { - return new Promise(resolvePromise => { - const chunks: string[] = []; - let shell = ""; - let shellDone = false; - renderToStream(code, { - plugins: [ServerComponentPlugin], - onCompleteShell() { - shellDone = true; - } - } as any).pipe({ - write(chunk: string) { - chunks.push(chunk); - if (shellDone && !shell) shell = chunks.join(""); - }, - end() { - const full = chunks.join(""); - if (!shell) shell = full; - resolvePromise({ shell, rest: full.slice(shell.length) }); - } - }); - }); -} +import { recordStream, writeArtifact } from "./artifact-recorder.js"; describe("document live channel — server render (writes the artifact)", () => { test("first yield in the markup, later yields as channel ops, document completes", async () => { const Inline = frameTransformDirectResult(makeStreamingComponent(), { id: FID }) as any; - const { shell, rest } = await collectChunks(() => Inline({})); + const { shell, rest } = await recordStream(() => Inline({}), { + plugins: [ServerComponentPlugin] + } as any); const full = shell + rest; // The first yield is the page's truth, marker-wrapped as a hole. @@ -55,9 +27,10 @@ describe("document live channel — server render (writes the artifact)", () => expect(full.split(YIELDS[YIELDS.length - 1]).length).toBe(2); expect(full).toContain("sc:live"); - writeFileSync( - resolve(artifactsDir, "document-live-channel-streamed.json"), - JSON.stringify({ name: "document-live-channel-streamed", shell, rest }, null, 2) - ); + writeArtifact("document-live-channel-streamed", { + name: "document-live-channel-streamed", + shell, + rest + }); }); }); diff --git a/packages/web/test/server/document-shell.spec.tsx b/packages/web/test/server/document-shell.spec.tsx index 735b9a8e4..1063e9e09 100644 --- a/packages/web/test/server/document-shell.spec.tsx +++ b/packages/web/test/server/document-shell.spec.tsx @@ -7,10 +7,6 @@ * hydrates against. */ import { describe, expect, test } from "vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { renderToStream } from "@solidjs/web"; import { App, AsyncApp, @@ -22,21 +18,12 @@ import { STYLED_LATE_CSS } from "../harness/document-shell.jsx"; import { hydrationRecordKeys } from "../harness/hydration-records.js"; +import { recordStream, writeArtifact } from "./artifact-recorder.js"; -const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); - -function collectChunks(code: () => any): Promise { - return new Promise(resolvePromise => { - const chunks: string[] = []; - renderToStream(code).pipe({ - write(chunk: string) { - chunks.push(chunk); - }, - end() { - resolvePromise(chunks); - } - }); - }); +// The shell, then each later chunk. +async function collectChunks(code: () => any): Promise { + const { shell, chunks } = await recordStream(code); + return [shell, ...chunks]; } describe("document-shell pattern — server render (#3000)", () => { @@ -74,11 +61,7 @@ describe("document-shell pattern — server render (#3000)", () => { expect(html).toContain("server-data"); // The client half replays these chunks and must adopt the record by id. - mkdirSync(artifactsDir, { recursive: true }); - writeFileSync( - resolve(artifactsDir, "document-shell-async.json"), - JSON.stringify({ chunks }, null, 2) - ); + writeArtifact("document-shell-async", { chunks }); }); test("useHead + shell-authored head: prelude splices ahead of authored children (#3081)", async () => { @@ -97,11 +80,7 @@ describe("document-shell pattern — server render (#3000)", () => { ); // The client half hydrates against exactly these bytes. - mkdirSync(artifactsDir, { recursive: true }); - writeFileSync( - resolve(artifactsDir, "document-shell-usehead.json"), - JSON.stringify({ html }, null, 2) - ); + writeArtifact("document-shell-usehead", { html }); }); test("useHead stylesheets: shell sheet in the head, late sheet gates its fragment swap", async () => { @@ -113,11 +92,7 @@ describe("document-shell pattern — server render (#3000)", () => { const [shell, ...late] = chunks; const rest = late.join(""); - mkdirSync(artifactsDir, { recursive: true }); - writeFileSync( - resolve(artifactsDir, "document-shell-styled.json"), - JSON.stringify({ chunks: [shell, rest] }, null, 2) - ); + writeArtifact("document-shell-styled", { chunks: [shell, rest] }); expect(shell).toContain(``); expect(shell).toContain("waiting"); diff --git a/packages/web/test/server/dynamic-async-loading-3666.spec.tsx b/packages/web/test/server/dynamic-async-loading-3666.spec.tsx index 0ed0eae9f..20986cd10 100644 --- a/packages/web/test/server/dynamic-async-loading-3666.spec.tsx +++ b/packages/web/test/server/dynamic-async-loading-3666.spec.tsx @@ -7,37 +7,8 @@ * replays into jsdom. */ import { describe, expect, test } from "vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { renderToStream } from "@solidjs/web"; import { variants } from "../harness/dynamic-async-loading-3666.jsx"; - -const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); -mkdirSync(artifactsDir, { recursive: true }); - -function collectChunks(code: () => any): Promise<{ shell: string; rest: string }> { - return new Promise(resolvePromise => { - const chunks: string[] = []; - let shell = ""; - let shellDone = false; - renderToStream(code, { - onCompleteShell() { - shellDone = true; - } - }).pipe({ - write(chunk: string) { - chunks.push(chunk); - if (shellDone && !shell) shell = chunks.join(""); - }, - end() { - const full = chunks.join(""); - if (!shell) shell = full; - resolvePromise({ shell, rest: full.slice(shell.length) }); - } - }); - }); -} +import { recordStream, writeArtifact } from "./artifact-recorder.js"; const visibleText = (html: string) => html.replace(//g, "").replace(/<[^>]*>/g, ""); @@ -45,7 +16,7 @@ const visibleText = (html: string) => describe("async dynamic() inside Loading (#3666) — server render", () => { for (const variant of variants) { test(`${variant.name}: writes the artifact`, async () => { - const { shell, rest } = await collectChunks(() => ); + const { shell, rest } = await recordStream(() => ); console.log(`${variant.name} SHELL:\n${shell}\n\nREST:\n${rest}`); if (variant.streams) { @@ -57,10 +28,7 @@ describe("async dynamic() inside Loading (#3666) — server render", () => { expect(rest).toBe(""); } - writeFileSync( - resolve(artifactsDir, `${variant.name}.json`), - JSON.stringify({ name: variant.name, shell, rest }, null, 2) - ); + writeArtifact(variant.name, { name: variant.name, shell, rest }); }); } }); diff --git a/packages/web/test/server/dynamic-component-parity.spec.tsx b/packages/web/test/server/dynamic-component-parity.spec.tsx index 7e229dfd4..4110f47af 100644 --- a/packages/web/test/server/dynamic-component-parity.spec.tsx +++ b/packages/web/test/server/dynamic-component-parity.spec.tsx @@ -12,10 +12,6 @@ // Writes the chunk artifacts test/hydration/dynamic-component-parity.spec.tsx // replays (from the `dynamicComponent` render — the documented mount). import { describe, expect, test } from "vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { renderToStream } from "@solidjs/web"; import { frameTransformDirectResult, ServerComponentPlugin } from "../../frames/src/frame-sink.js"; import { ARGS, @@ -27,35 +23,12 @@ import { makeNoteComponent, type Via } from "../harness/dynamic-component-parity.jsx"; - -const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); -mkdirSync(artifactsDir, { recursive: true }); +import { recordStream, writeArtifact } from "./artifact-recorder.js"; const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); -function collectChunks(code: () => any): Promise<{ shell: string; rest: string }> { - return new Promise(resolvePromise => { - const chunks: string[] = []; - let shell = ""; - let shellDone = false; - renderToStream(code, { - plugins: [ServerComponentPlugin], - onCompleteShell() { - shellDone = true; - } - } as any).pipe({ - write(chunk: string) { - chunks.push(chunk); - if (shellDone && !shell) shell = chunks.join(""); - }, - end() { - const full = chunks.join(""); - if (!shell) shell = full; - resolvePromise({ shell, rest: full.slice(shell.length) }); - } - }); - }); -} +const collectChunks = (code: () => any) => + recordStream(code, { plugins: [ServerComponentPlugin] } as any); const visibleText = (html: string) => html.replace(//g, "").replace(/<[^>]*>/g, ""); @@ -110,10 +83,7 @@ describe("dynamicComponent — the server twin renders exactly what dynamic rend expect(rendered.dynamicComponent.rest).toBe(rendered.dynamic.rest); const { shell, rest } = rendered.dynamicComponent; - writeFileSync( - resolve(artifactsDir, `${artifactFor(variant)}.json`), - JSON.stringify({ name: artifactFor(variant), shell, rest }, null, 2) - ); + writeArtifact(artifactFor(variant), { name: artifactFor(variant), shell, rest }); }); } }); diff --git a/packages/web/test/server/frame-live-document-artifact.spec.tsx b/packages/web/test/server/frame-live-document-artifact.spec.tsx index 81543d4e0..14b931621 100644 --- a/packages/web/test/server/frame-live-document-artifact.spec.tsx +++ b/packages/web/test/server/frame-live-document-artifact.spec.tsx @@ -8,10 +8,6 @@ // the document completes. Writes the chunk artifact the hydration spec // replays (test/hydration/frame-live-document.spec.tsx). import { describe, expect, test } from "vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { renderToStream } from "@solidjs/web"; import { frameTransformDirectResult, ServerComponentPlugin } from "../../frames/src/frame-sink.js"; import { frameAddress } from "../../server-functions/src/shared.js"; import { @@ -21,34 +17,12 @@ import { makeApp, makeRoomComponent } from "../harness/frame-live-document.jsx"; +import { recordStream, writeArtifact } from "./artifact-recorder.js"; const LIVE_SOURCE = Symbol.for("solid.LiveSource"); -const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); -mkdirSync(artifactsDir, { recursive: true }); -function collectChunks(code: () => any): Promise<{ shell: string; rest: string }> { - return new Promise(resolvePromise => { - const chunks: string[] = []; - let shell = ""; - let shellDone = false; - renderToStream(code, { - plugins: [ServerComponentPlugin], - onCompleteShell() { - shellDone = true; - } - } as any).pipe({ - write(chunk: string) { - chunks.push(chunk); - if (shellDone && !shell) shell = chunks.join(""); - }, - end() { - const full = chunks.join(""); - if (!shell) shell = full; - resolvePromise({ shell, rest: full.slice(shell.length) }); - } - }); - }); -} +const collectChunks = (code: () => any) => + recordStream(code, { plugins: [ServerComponentPlugin] } as any); const visibleText = (html: string) => html.replace(//g, "").replace(/<[^>]*>/g, ""); @@ -85,10 +59,11 @@ describe("document face — live server component (server render, writes the art expect(full).toContain("slot:composer"); expect(full).toContain(`.r("${FID}","${frameAddress(FID, ARGS)}")`); - writeFileSync( - resolve(artifactsDir, `frame-live-document-${mode}.json`), - JSON.stringify({ name: `frame-live-document-${mode}`, shell, rest }, null, 2) - ); + writeArtifact(`frame-live-document-${mode}`, { + name: `frame-live-document-${mode}`, + shell, + rest + }); }); } }); diff --git a/packages/web/test/server/frame-nonlive-document-3666.spec.tsx b/packages/web/test/server/frame-nonlive-document-3666.spec.tsx index 4e2a2d419..9eefc9c8e 100644 --- a/packages/web/test/server/frame-nonlive-document-3666.spec.tsx +++ b/packages/web/test/server/frame-nonlive-document-3666.spec.tsx @@ -6,10 +6,6 @@ // component wrapped by `frameTransformDirectResult`. Writes the chunk // artifacts test/hydration/frame-nonlive-document-3666.spec.tsx replays. import { describe, expect, test } from "vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { renderToStream } from "@solidjs/web"; import { frameTransformDirectResult, ServerComponentPlugin } from "../../frames/src/frame-sink.js"; import { ARGS, @@ -18,35 +14,12 @@ import { makeApp, makeNoteComponent } from "../harness/frame-nonlive-document-3666.jsx"; - -const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); -mkdirSync(artifactsDir, { recursive: true }); +import { recordStream, writeArtifact } from "./artifact-recorder.js"; const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); -function collectChunks(code: () => any): Promise<{ shell: string; rest: string }> { - return new Promise(resolvePromise => { - const chunks: string[] = []; - let shell = ""; - let shellDone = false; - renderToStream(code, { - plugins: [ServerComponentPlugin], - onCompleteShell() { - shellDone = true; - } - } as any).pipe({ - write(chunk: string) { - chunks.push(chunk); - if (shellDone && !shell) shell = chunks.join(""); - }, - end() { - const full = chunks.join(""); - if (!shell) shell = full; - resolvePromise({ shell, rest: full.slice(shell.length) }); - } - }); - }); -} +const collectChunks = (code: () => any) => + recordStream(code, { plugins: [ServerComponentPlugin] } as any); const visibleText = (html: string) => html.replace(//g, "").replace(/<[^>]*>/g, ""); @@ -83,14 +56,11 @@ describe("document face — NON-LIVE server component under Loading (#3666) — expect(rest).toContain(`data-fid="${FID}"`); } - writeFileSync( - resolve(artifactsDir, `frame-nonlive-document-3666-${variant}-${mode}.json`), - JSON.stringify( - { name: `frame-nonlive-document-3666-${variant}-${mode}`, shell, rest }, - null, - 2 - ) - ); + writeArtifact(`frame-nonlive-document-3666-${variant}-${mode}`, { + name: `frame-nonlive-document-3666-${variant}-${mode}`, + shell, + rest + }); }); } } diff --git a/packages/web/test/server/generic-hydration.gen.spec.tsx b/packages/web/test/server/generic-hydration.gen.spec.tsx index 5a77cc8e3..260336e73 100644 --- a/packages/web/test/server/generic-hydration.gen.spec.tsx +++ b/packages/web/test/server/generic-hydration.gen.spec.tsx @@ -5,82 +5,38 @@ * consistency pins and harness): renders test/harness/generic-hydration.tsx * in both fragment orders and writes the shell plus each later chunk. */ -import { afterEach, expect, test, vi } from "vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { renderToStream } from "@solidjs/web"; +import { expect, test } from "vitest"; import { createGenericApp, type Order } from "../harness/generic-hydration.jsx"; - -const artifactsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../harness/__artifacts__"); -mkdirSync(artifactsDir, { recursive: true }); - -afterEach(() => { - vi.useRealTimers(); -}); +import { recordStream, writeArtifact } from "./artifact-recorder.js"; // The page's three flush points are timers 10–25 ms apart (the first // boundary's `data` value at 5 ms, its fragment at 15 ms once `shared` -// settles, the other fragment at 40 ms) and the stream coalesces whatever -// settles in one event-loop turn into one chunk (`deferFlush`). Under load -// — the parallel server suite, a CI runner — two of them fall due in the -// same turn and the artifact records two chunks (or the two settled values -// in the other order), which shifts every schedule's chunk indices in -// test/consistency/generic (a `C2` with no chunk; a "write between the -// reveals" that is a write after both). So the render runs on a fake clock -// stepped one millisecond at a time: each timer fires in its own turn, with -// its microtasks and its flush, and the chunks are the page's three flush -// points on every host. -async function renderOrder(order: Order) { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "setImmediate", "clearImmediate"] }); - const { App } = createGenericApp(order); - const chunks: string[] = []; - let shell: string | undefined; - let shellDone = false; - let ended = false; - renderToStream(() => , { - onCompleteShell() { - shellDone = true; - } - }).pipe({ - write(c: string) { - chunks.push(c); - if (shellDone && shell === undefined) shell = chunks.splice(0).join(""); - }, - end() { - if (shell === undefined) shell = chunks.splice(0).join(""); - ended = true; - } - }); - for (let ms = 0; ms < 1000 && !ended; ms++) await vi.advanceTimersByTimeAsync(1); - expect(ended).toBe(true); - return { shell: shell!, chunks }; -} - +// settles, the other fragment at 40 ms). test/consistency/generic names +// them C0 C1 C2 and schedules writes between them, so the artifact must be +// exactly three chunks — which the recorder's stepped clock guarantees +// (see artifact-recorder.ts; this spec is where #3849 first needed it). for (const order of ["ab", "ba"] as Order[]) { test(`render generic-hydration chunks (${order})`, async () => { - const out = await renderOrder(order); - expect(out.shell).toContain("a-loading"); - expect(out.shell).toContain("b-loading"); - expect(out.shell).toContain("label:/a"); + const { App } = createGenericApp(order); + const { shell, chunks } = await recordStream(() => ); + expect(shell).toContain("a-loading"); + expect(shell).toContain("b-loading"); + expect(shell).toContain("label:/a"); // The fragments land in the order the server settled them; the chunks // are the three flush points the schedules name as C0 C1 C2: the first // boundary's `data` value alone, that boundary's fragment (with // `shared`), the other fragment. - const all = out.chunks.join(""); + const all = chunks.join(""); const first = all.indexOf(order === "ab" ? "data:a" : "data:b"); const second = all.indexOf(order === "ab" ? "data:b" : "data:a"); expect(first).toBeGreaterThan(-1); expect(second).toBeGreaterThan(first); expect(all).toContain("shared:/a"); - expect(out.chunks.length).toBe(3); - expect(out.chunks[0]).toMatch(/^