@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEV_RUNTIME_KNOBS.md +44 -26
- package/akanApp/akanApp.host.ts +113 -23
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +71 -1
- package/akanApp/devHostPolicy.ts +29 -4
- package/cloud/globalConfig.ts +3 -17
- package/csrE2e/csrE2eHarness.fixture.ts +66 -11
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/fileSys.ts +16 -1
- package/frontendBuild/buildRouteClient.test.ts +61 -0
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +5 -1
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +117 -70
- package/frontendBuild/csrArtifactBuilder.ts +3 -6
- package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
- package/frontendBuild/csrDevArtifactWriter.ts +158 -24
- package/frontendBuild/csrDevBundler.ts +44 -205
- package/frontendBuild/csrDevModuleCompiler.test.ts +89 -0
- package/frontendBuild/csrDevModuleCompiler.ts +56 -10
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +81 -1
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +119 -18
- package/frontendBuild/csrDevTypes.ts +16 -1
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/hmrWatcher.test.ts +51 -0
- package/frontendBuild/hmrWatcher.ts +17 -3
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/pagesBundleBuilder.ts +24 -1
- package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +79 -18
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/ssrDevBundler.ts +85 -0
- package/incrementalBuilder/buildBatch.proc.ts +94 -16
- package/incrementalBuilder/buildBatchProtocol.ts +6 -3
- package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
- package/incrementalBuilder/buildBatchRunner.ts +17 -8
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +35 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +190 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +560 -60
- package/integration/devStability.integration.test.ts +25 -7
- package/package.json +2 -2
- package/transforms/rscUseClientTransform.ts +7 -0
- package/transforms/transforms.test.ts +13 -0
- package/transforms/useClientBundlePlugin.ts +4 -1
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, rename } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { HmrTrace } from "akanjs/server";
|
|
6
|
+
import { CsrE2eHarness } from "./csrE2eHarness.fixture";
|
|
7
|
+
|
|
8
|
+
//? A measurement, not a check: it runs with AKAN_HMR_LATENCY=1 and writes its report to local/csr-e2e.
|
|
9
|
+
const enabled = CsrE2eHarness.enabled && process.env.AKAN_HMR_LATENCY === "1";
|
|
10
|
+
const workspaceRoot = path.resolve(import.meta.dir, "../../../..");
|
|
11
|
+
const port = Number(process.env.AKAN_HMR_LATENCY_PORT ?? 8492);
|
|
12
|
+
const runs = Number(process.env.AKAN_HMR_LATENCY_RUNS ?? 5);
|
|
13
|
+
const probeFile = path.join(workspaceRoot, "apps/minimal/ui/StackProbe.tsx");
|
|
14
|
+
const pageFile = path.join(workspaceRoot, "apps/minimal/page/(home)/e2e/stack/tab-a.tsx");
|
|
15
|
+
const layoutFile = path.join(workspaceRoot, "apps/minimal/page/(home)/e2e/_layout.tsx");
|
|
16
|
+
const probe = `document.querySelector('[data-e2e-probe="tab-a"]')`;
|
|
17
|
+
const input = `document.querySelector('[data-e2e-probe="tab-a"] input')`;
|
|
18
|
+
|
|
19
|
+
interface TraceEntry {
|
|
20
|
+
kind: string;
|
|
21
|
+
generation: number;
|
|
22
|
+
trace: HmrTrace | null;
|
|
23
|
+
receivedAt: number;
|
|
24
|
+
appliedAt: number | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface LatencyWindow {
|
|
28
|
+
__latencyProbe?: { hit: number };
|
|
29
|
+
__latencySeen?: Record<string, number>;
|
|
30
|
+
__latencyObserver?: MutationObserver;
|
|
31
|
+
__AKAN_HMR_TRACES__?: TraceEntry[];
|
|
32
|
+
__AKAN_CSR_LAST_UPDATE__?: { generation: number; appliedAt?: number; refreshedAt?: number };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Sample {
|
|
36
|
+
total: number;
|
|
37
|
+
stages: Record<string, number>;
|
|
38
|
+
updates: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Edit {
|
|
42
|
+
file: string;
|
|
43
|
+
transform: (source: string) => string;
|
|
44
|
+
applied: string;
|
|
45
|
+
restored: string;
|
|
46
|
+
write?: (file: string, content: string) => Promise<void>;
|
|
47
|
+
restore?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const median = (values: number[]) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? 0;
|
|
51
|
+
|
|
52
|
+
const summarize = (samples: Sample[]) => {
|
|
53
|
+
const stageNames = [...new Set(samples.flatMap((sample) => Object.keys(sample.stages)))];
|
|
54
|
+
return {
|
|
55
|
+
total: { median: median(samples.map((sample) => sample.total)), samples: samples.map((sample) => sample.total) },
|
|
56
|
+
stages: Object.fromEntries(
|
|
57
|
+
stageNames.map((name) => [
|
|
58
|
+
name,
|
|
59
|
+
median(samples.flatMap((sample) => (name in sample.stages ? [sample.stages[name] ?? 0] : []))),
|
|
60
|
+
]),
|
|
61
|
+
),
|
|
62
|
+
updates: samples.map((sample) => sample.updates),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
//? Durations between the marks one update collected, in hop order; a mark a path does not reach is skipped.
|
|
67
|
+
const stagesOf = (
|
|
68
|
+
writtenAt: number,
|
|
69
|
+
hit: number,
|
|
70
|
+
entry: TraceEntry | undefined,
|
|
71
|
+
applied: LatencyWindow["__AKAN_CSR_LAST_UPDATE__"],
|
|
72
|
+
) => {
|
|
73
|
+
const trace = entry?.trace ?? {};
|
|
74
|
+
const marks: [string, number | null | undefined][] = [
|
|
75
|
+
["event", trace.eventAt],
|
|
76
|
+
["flush", trace.flushAt],
|
|
77
|
+
["batch", trace.batchAt],
|
|
78
|
+
["spawn", trace.spawnAt],
|
|
79
|
+
["workerStart", trace.workerStartAt],
|
|
80
|
+
["worker", trace.workerAt],
|
|
81
|
+
["patch", trace.patchAt],
|
|
82
|
+
["sent", trace.sentAt],
|
|
83
|
+
["broadcast", trace.broadcastAt],
|
|
84
|
+
["received", entry?.receivedAt],
|
|
85
|
+
["applied", applied?.appliedAt ?? entry?.appliedAt],
|
|
86
|
+
["refreshed", applied?.refreshedAt],
|
|
87
|
+
["dom", hit],
|
|
88
|
+
];
|
|
89
|
+
const stages: Record<string, number> = {};
|
|
90
|
+
let previous = { name: "write", at: writtenAt };
|
|
91
|
+
for (const [name, at] of marks) {
|
|
92
|
+
if (typeof at !== "number" || at < previous.at) continue;
|
|
93
|
+
stages[`${previous.name}→${name}`] = at - previous.at;
|
|
94
|
+
previous = { name, at };
|
|
95
|
+
}
|
|
96
|
+
return stages;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const writeAtomic = async (file: string, content: string) => {
|
|
100
|
+
const temp = `${file}.latency-tmp`;
|
|
101
|
+
await Bun.write(temp, content);
|
|
102
|
+
await rename(temp, file);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
describe.skipIf(!enabled)("dev HMR latency (minimal)", () => {
|
|
106
|
+
let csr: CsrE2eHarness | null = null;
|
|
107
|
+
const harness = () => {
|
|
108
|
+
if (!csr) throw new Error("[hmr-latency] the harness did not start");
|
|
109
|
+
return csr;
|
|
110
|
+
};
|
|
111
|
+
const loadBefore = os.loadavg();
|
|
112
|
+
const report: Record<string, unknown> = {};
|
|
113
|
+
//? The page stack, patch-first and fast-refresh suites edit these files too: a sample that times out mid-edit must not
|
|
114
|
+
//? leave them changed.
|
|
115
|
+
const pristine = new Map<string, string>();
|
|
116
|
+
|
|
117
|
+
beforeAll(async () => {
|
|
118
|
+
for (const file of [probeFile, pageFile, layoutFile]) pristine.set(file, await Bun.file(file).text());
|
|
119
|
+
csr = await CsrE2eHarness.start({ app: "minimal", port, workspaceRoot });
|
|
120
|
+
}, 240_000);
|
|
121
|
+
|
|
122
|
+
afterEach(async () => {
|
|
123
|
+
for (const [file, source] of pristine) if ((await Bun.file(file).text()) !== source) await Bun.write(file, source);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterAll(async () => {
|
|
127
|
+
await csr?.close();
|
|
128
|
+
const summary = {
|
|
129
|
+
at: new Date().toISOString(),
|
|
130
|
+
bun: Bun.version,
|
|
131
|
+
platform: `${process.platform}-${process.arch}`,
|
|
132
|
+
cpus: os.cpus().length,
|
|
133
|
+
loadavg: { before: loadBefore, after: os.loadavg() },
|
|
134
|
+
debounceMs: process.env.AKAN_DEV_WATCH_DEBOUNCE_MS ?? "default",
|
|
135
|
+
csrPatcher: process.env.AKAN_DEV_CSR_PATCHER ?? "on",
|
|
136
|
+
backend: process.env.AKAN_CSR_E2E_BACKEND ?? (process.platform === "darwin" ? "webkit" : "chrome"),
|
|
137
|
+
runs,
|
|
138
|
+
...report,
|
|
139
|
+
};
|
|
140
|
+
const dir = path.join(workspaceRoot, "local", "csr-e2e");
|
|
141
|
+
await mkdir(dir, { recursive: true });
|
|
142
|
+
const file = path.join(dir, `hmr-latency-${Date.now()}.json`);
|
|
143
|
+
await Bun.write(file, `${JSON.stringify(summary, null, 2)}\n`);
|
|
144
|
+
console.info(`[hmr-latency] report: ${file}`);
|
|
145
|
+
console.info(JSON.stringify(summary));
|
|
146
|
+
}, 60_000);
|
|
147
|
+
|
|
148
|
+
const armProbe = async (expression: string) =>
|
|
149
|
+
await harness().evaluate((source: string) => {
|
|
150
|
+
const predicate = new Function(`return (${source});`) as () => boolean;
|
|
151
|
+
const state: { hit: number } = { hit: 0 };
|
|
152
|
+
(window as unknown as LatencyWindow).__latencyProbe = state;
|
|
153
|
+
const check = () => {
|
|
154
|
+
if (!state.hit && predicate()) state.hit = Date.now();
|
|
155
|
+
};
|
|
156
|
+
const observer = new MutationObserver(check);
|
|
157
|
+
observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
158
|
+
const tick = () => {
|
|
159
|
+
check();
|
|
160
|
+
if (state.hit) observer.disconnect();
|
|
161
|
+
else requestAnimationFrame(tick);
|
|
162
|
+
};
|
|
163
|
+
requestAnimationFrame(tick);
|
|
164
|
+
return true;
|
|
165
|
+
}, expression);
|
|
166
|
+
|
|
167
|
+
const waitForHit = async () =>
|
|
168
|
+
await harness().waitFor(() => ((window as unknown as LatencyWindow).__latencyProbe?.hit ?? 0) > 0, {
|
|
169
|
+
timeout: 30_000,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const timeEdit = async (
|
|
173
|
+
{
|
|
174
|
+
file,
|
|
175
|
+
transform,
|
|
176
|
+
applied,
|
|
177
|
+
restored,
|
|
178
|
+
write = async (target, content) => {
|
|
179
|
+
await Bun.write(target, content);
|
|
180
|
+
},
|
|
181
|
+
restore = true,
|
|
182
|
+
}: Edit,
|
|
183
|
+
kinds: string[],
|
|
184
|
+
) => {
|
|
185
|
+
const original = await Bun.file(file).text();
|
|
186
|
+
await armProbe(applied);
|
|
187
|
+
const writtenAt = Date.now();
|
|
188
|
+
await write(file, transform(original));
|
|
189
|
+
await waitForHit();
|
|
190
|
+
const page = await harness().evaluate(() => {
|
|
191
|
+
const w = window as unknown as LatencyWindow;
|
|
192
|
+
return { hit: w.__latencyProbe?.hit ?? 0, traces: w.__AKAN_HMR_TRACES__ ?? [], last: w.__AKAN_CSR_LAST_UPDATE__ };
|
|
193
|
+
});
|
|
194
|
+
const updates = page.traces.filter((entry) => kinds.includes(entry.kind) && entry.receivedAt >= writtenAt);
|
|
195
|
+
const entry = updates.filter((candidate) => candidate.receivedAt <= page.hit).at(-1);
|
|
196
|
+
const applies = entry && page.last?.generation === entry.generation ? page.last : undefined;
|
|
197
|
+
if (restore) {
|
|
198
|
+
await armProbe(restored);
|
|
199
|
+
await Bun.write(file, original);
|
|
200
|
+
await waitForHit();
|
|
201
|
+
}
|
|
202
|
+
await Bun.sleep(1_500);
|
|
203
|
+
return {
|
|
204
|
+
total: page.hit - writtenAt,
|
|
205
|
+
stages: stagesOf(writtenAt, page.hit, entry, applies),
|
|
206
|
+
updates: updates.length,
|
|
207
|
+
} satisfies Sample;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const componentEdit = (value: string): Edit => ({
|
|
211
|
+
file: probeFile,
|
|
212
|
+
transform: (source) => source.replace("data-e2e-probe={name}>", `data-e2e-probe={name} data-e2e-hot="${value}">`),
|
|
213
|
+
applied: `${probe}?.getAttribute("data-e2e-hot") === "${value}"`,
|
|
214
|
+
restored: `${probe}?.getAttribute("data-e2e-hot") === null`,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const pageEdit = (value: string): Edit => ({
|
|
218
|
+
file: pageFile,
|
|
219
|
+
transform: (source) => source.replace('name="tab-a"', `name="tab-a" itemId="${value}"`),
|
|
220
|
+
applied: `${probe}?.querySelector('[data-e2e="item-id"]')?.textContent === "${value}"`,
|
|
221
|
+
restored: `${probe}?.querySelector('[data-e2e="item-id"]')?.textContent === ""`,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
//? SSR first: the first CSR page arms per-save CSR builds, which would then run ahead of pages in every batch.
|
|
225
|
+
//? Component edits each start from the pristine source and are not undone between samples; the undo to it is timed
|
|
226
|
+
//? once, after them.
|
|
227
|
+
test("SSR tab: component and page edits", async () => {
|
|
228
|
+
const ssrKinds = ["rsc-refresh", "ssr"];
|
|
229
|
+
await harness().open("/e2e/stack/tab-a", { csr: false });
|
|
230
|
+
await Bun.sleep(1_000);
|
|
231
|
+
const component: Sample[] = [];
|
|
232
|
+
const page: Sample[] = [];
|
|
233
|
+
const pristine = await Bun.file(probeFile).text();
|
|
234
|
+
for (let i = 0; i < runs; i += 1) {
|
|
235
|
+
const edit = componentEdit(`s${i}`);
|
|
236
|
+
component.push(await timeEdit({ ...edit, transform: () => edit.transform(pristine), restore: false }, ssrKinds));
|
|
237
|
+
}
|
|
238
|
+
await Bun.write(probeFile, pristine);
|
|
239
|
+
await armProbe(`${probe}?.getAttribute("data-e2e-hot") === null`);
|
|
240
|
+
const undoApplied = await harness()
|
|
241
|
+
.waitFor(() => ((window as unknown as LatencyWindow).__latencyProbe?.hit ?? 0) > 0, { timeout: 5_000 })
|
|
242
|
+
.then(() => true)
|
|
243
|
+
.catch(() => false);
|
|
244
|
+
for (let i = 0; i < runs; i += 1) page.push(await timeEdit(pageEdit(`s${i}`), ssrKinds));
|
|
245
|
+
report.ssr = {
|
|
246
|
+
component: summarize(component),
|
|
247
|
+
page: summarize(page),
|
|
248
|
+
undoApplied,
|
|
249
|
+
reloaded: await harness().reloaded(),
|
|
250
|
+
};
|
|
251
|
+
expect(await harness().reloaded()).toBe(false);
|
|
252
|
+
}, 600_000);
|
|
253
|
+
|
|
254
|
+
//? The loop a typo makes: the error shows, and the fix must patch at the speed of any other edit (a failed build
|
|
255
|
+
//? neither blocks the fast lane nor makes the fixing save wait for its pages build).
|
|
256
|
+
test("SSR tab: a syntax error, then its fix", async () => {
|
|
257
|
+
await harness().open("/e2e/stack/tab-a", { csr: false });
|
|
258
|
+
await Bun.sleep(1_000);
|
|
259
|
+
const pristine = await Bun.file(probeFile).text();
|
|
260
|
+
const samples: number[] = [];
|
|
261
|
+
try {
|
|
262
|
+
for (let i = 0; i < runs; i += 1) {
|
|
263
|
+
await Bun.write(probeFile, pristine.replace("data-e2e-probe={name}>", "data-e2e-probe={name} {>"));
|
|
264
|
+
await harness().waitFor(() => document.querySelector(".__akan_hmr_overlay[data-status=error]") !== null, {
|
|
265
|
+
timeout: 20_000,
|
|
266
|
+
});
|
|
267
|
+
const fixed = componentEdit(`f${i}`);
|
|
268
|
+
await armProbe(fixed.applied);
|
|
269
|
+
const writtenAt = Date.now();
|
|
270
|
+
await Bun.write(probeFile, fixed.transform(pristine));
|
|
271
|
+
await waitForHit();
|
|
272
|
+
const hit = await harness().evaluate(() => (window as unknown as LatencyWindow).__latencyProbe?.hit ?? 0);
|
|
273
|
+
samples.push(hit - writtenAt);
|
|
274
|
+
await Bun.sleep(1_500);
|
|
275
|
+
}
|
|
276
|
+
} finally {
|
|
277
|
+
await Bun.write(probeFile, pristine);
|
|
278
|
+
}
|
|
279
|
+
report.errorFix = { median: median(samples), samples };
|
|
280
|
+
expect(await harness().reloaded()).toBe(false);
|
|
281
|
+
}, 300_000);
|
|
282
|
+
|
|
283
|
+
test("CSR tab: component, page, layout and Tailwind class edits", async () => {
|
|
284
|
+
await harness().open("/e2e/stack/tab-a");
|
|
285
|
+
await Bun.sleep(1_000);
|
|
286
|
+
const samples: { component: Sample[]; page: Sample[]; layout: Sample[]; tailwind: Sample[] } = {
|
|
287
|
+
component: [],
|
|
288
|
+
page: [],
|
|
289
|
+
layout: [],
|
|
290
|
+
tailwind: [],
|
|
291
|
+
};
|
|
292
|
+
for (let i = 0; i < runs; i += 1) {
|
|
293
|
+
samples.component.push(await timeEdit(componentEdit(`v${i}`), ["csr"]));
|
|
294
|
+
samples.page.push(await timeEdit(pageEdit(`v${i}`), ["csr"]));
|
|
295
|
+
samples.layout.push(
|
|
296
|
+
await timeEdit(
|
|
297
|
+
{
|
|
298
|
+
file: layoutFile,
|
|
299
|
+
transform: (source) => source.replace("<>{children}</>", `<>{children}<span data-e2e-layout="v${i}" /></>`),
|
|
300
|
+
applied: `document.querySelector('[data-e2e-layout="v${i}"]') !== null`,
|
|
301
|
+
restored: `document.querySelector('[data-e2e-layout]') === null`,
|
|
302
|
+
},
|
|
303
|
+
["csr"],
|
|
304
|
+
),
|
|
305
|
+
);
|
|
306
|
+
//? An arbitrary value no other file uses: the rule exists only after the css build, the class alone changes nothing.
|
|
307
|
+
const padding = 11 + i;
|
|
308
|
+
samples.tailwind.push(
|
|
309
|
+
await timeEdit(
|
|
310
|
+
{
|
|
311
|
+
file: probeFile,
|
|
312
|
+
transform: (source) => source.replace("bg-background p-2", `bg-background p-[${padding}px]`),
|
|
313
|
+
applied: `${input} && getComputedStyle(${input}).paddingTop === "${padding}px"`,
|
|
314
|
+
restored: `${input} && getComputedStyle(${input}).paddingTop !== "${padding}px"`,
|
|
315
|
+
},
|
|
316
|
+
["csr"],
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
report.csr = Object.fromEntries(Object.entries(samples).map(([name, list]) => [name, summarize(list)]));
|
|
321
|
+
expect(await harness().reloaded()).toBe(false);
|
|
322
|
+
}, 600_000);
|
|
323
|
+
|
|
324
|
+
test("CSR tab: ten saves 300ms apart", async () => {
|
|
325
|
+
const original = await Bun.file(probeFile).text();
|
|
326
|
+
await harness().evaluate(() => {
|
|
327
|
+
const w = window as unknown as LatencyWindow;
|
|
328
|
+
const seen: Record<string, number> = {};
|
|
329
|
+
w.__latencySeen = seen;
|
|
330
|
+
const check = () => {
|
|
331
|
+
const value = document.querySelector('[data-e2e-probe="tab-a"]')?.getAttribute("data-e2e-hot");
|
|
332
|
+
if (value && !(value in seen)) seen[value] = Date.now();
|
|
333
|
+
};
|
|
334
|
+
w.__latencyObserver = new MutationObserver(check);
|
|
335
|
+
w.__latencyObserver.observe(document, { subtree: true, attributes: true, childList: true });
|
|
336
|
+
});
|
|
337
|
+
const writtenAt: number[] = [];
|
|
338
|
+
for (let i = 0; i < 10; i += 1) {
|
|
339
|
+
writtenAt.push(Date.now());
|
|
340
|
+
await Bun.write(probeFile, componentEdit(`c${i}`).transform(original));
|
|
341
|
+
await Bun.sleep(300);
|
|
342
|
+
}
|
|
343
|
+
await harness().waitFor(() => (window as unknown as LatencyWindow).__latencySeen?.c9 !== undefined, {
|
|
344
|
+
timeout: 60_000,
|
|
345
|
+
});
|
|
346
|
+
const seen = await harness().evaluate(() => {
|
|
347
|
+
const w = window as unknown as LatencyWindow;
|
|
348
|
+
w.__latencyObserver?.disconnect();
|
|
349
|
+
return w.__latencySeen ?? {};
|
|
350
|
+
});
|
|
351
|
+
await Bun.write(probeFile, original);
|
|
352
|
+
await Bun.sleep(2_000);
|
|
353
|
+
//? A value the page never showed was overtaken; its save counts until the first later value appeared.
|
|
354
|
+
const latencies = writtenAt.map((at, i) => {
|
|
355
|
+
const later = Object.entries(seen)
|
|
356
|
+
.filter(([value]) => Number(value.slice(1)) >= i)
|
|
357
|
+
.map(([, hitAt]) => hitAt);
|
|
358
|
+
return Math.min(...later) - at;
|
|
359
|
+
});
|
|
360
|
+
report.consecutive = {
|
|
361
|
+
latencies,
|
|
362
|
+
median: median(latencies),
|
|
363
|
+
shown: Object.keys(seen).sort(),
|
|
364
|
+
settledAfterLastSave: (seen.c9 ?? 0) - (writtenAt.at(-1) ?? 0),
|
|
365
|
+
};
|
|
366
|
+
expect(await harness().reloaded()).toBe(false);
|
|
367
|
+
}, 300_000);
|
|
368
|
+
|
|
369
|
+
test("CSR tab: an editor's atomic save and a formatter's second write", async () => {
|
|
370
|
+
const atomic: Sample[] = [];
|
|
371
|
+
const doubled: Sample[] = [];
|
|
372
|
+
for (let i = 0; i < runs; i += 1) {
|
|
373
|
+
atomic.push(await timeEdit({ ...componentEdit(`a${i}`), write: writeAtomic }, ["csr"]));
|
|
374
|
+
doubled.push(
|
|
375
|
+
await timeEdit(
|
|
376
|
+
{
|
|
377
|
+
...componentEdit(`d${i}b`),
|
|
378
|
+
write: async (file, content) => {
|
|
379
|
+
await Bun.write(file, content.replace(`d${i}b`, `d${i}a`));
|
|
380
|
+
await Bun.sleep(30);
|
|
381
|
+
await Bun.write(file, content);
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
["csr"],
|
|
385
|
+
),
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
report.editorWrites = { atomic: summarize(atomic), formatterSecondWrite: summarize(doubled) };
|
|
389
|
+
expect(await harness().reloaded()).toBe(false);
|
|
390
|
+
}, 600_000);
|
|
391
|
+
});
|