@akanjs/devkit 3.0.0-beta.23 → 3.0.0-beta.25
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 +37 -22
- package/akanApp/akanApp.host.ts +34 -1
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +25 -0
- package/akanApp/devHostPolicy.ts +27 -2
- package/cloud/globalConfig.ts +3 -2
- package/csrE2e/csrE2eHarness.fixture.ts +455 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +187 -0
- 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 +37 -0
- package/frontendBuild/clientEntryDiscovery.ts +10 -5
- package/frontendBuild/csrArtifactBuilder.ts +3 -6
- package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
- package/frontendBuild/csrDevArtifactWriter.ts +303 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevModuleCompiler.ts +304 -0
- package/frontendBuild/csrDevPatcher.test.ts +340 -0
- package/frontendBuild/csrDevPatcher.ts +374 -0
- package/frontendBuild/csrDevPaths.ts +124 -0
- package/frontendBuild/csrDevResolver.test.ts +165 -0
- package/frontendBuild/csrDevResolver.ts +234 -0
- package/frontendBuild/csrDevTypes.ts +59 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCompiler.ts +2 -4
- package/frontendBuild/devRegistryBundler.ts +177 -0
- package/frontendBuild/hmrWatcher.test.ts +51 -0
- package/frontendBuild/hmrWatcher.ts +17 -3
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +24 -1
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- 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 +91 -0
- package/incrementalBuilder/buildBatch.proc.ts +119 -6
- 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 +1 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +33 -3
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
- package/integration/devStability.integration.test.ts +18 -7
- package/package.json +3 -3
- package/transforms/rscUseClientTransform.ts +7 -0
- package/transforms/transforms.test.ts +13 -0
- package/transforms/useClientBundlePlugin.ts +4 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { CsrE2eHarness } from "./csrE2eHarness.fixture";
|
|
3
|
+
|
|
4
|
+
const TAB_A = "/e2e/stack/tab-a";
|
|
5
|
+
const TAB_B = "/e2e/stack/tab-b";
|
|
6
|
+
const item = (id: string) => `/e2e/stack/item?id=${id}`;
|
|
7
|
+
|
|
8
|
+
const routeOf = (pathWithSearch: string) => pathWithSearch.split("?")[0].replace(/^\/[^/]+/, "/:lang");
|
|
9
|
+
const CURRENT = '[id^="pageContainer-"]:not([aria-hidden="true"])';
|
|
10
|
+
const UNDER = '[id^="pageContainer-"][aria-hidden="true"]';
|
|
11
|
+
|
|
12
|
+
describe.skipIf(!CsrE2eHarness.enabled)("CSR page stack (minimal, /e2e/stack)", () => {
|
|
13
|
+
let csr: CsrE2eHarness;
|
|
14
|
+
|
|
15
|
+
beforeAll(async () => {
|
|
16
|
+
csr = await CsrE2eHarness.start({ app: "minimal" });
|
|
17
|
+
}, 240_000);
|
|
18
|
+
|
|
19
|
+
afterAll(async () => {
|
|
20
|
+
// biome-ignore lint/suspicious/noUnnecessaryConditions: unassigned when beforeAll threw before the harness started
|
|
21
|
+
await csr?.close();
|
|
22
|
+
}, 60_000);
|
|
23
|
+
|
|
24
|
+
const itemIdsIn = async (scope: string, options?: { mounted?: boolean }) =>
|
|
25
|
+
await csr.text(`${scope} [data-e2e-probe="item"] [data-e2e="item-id"]`, options);
|
|
26
|
+
const currentInput = `${CURRENT} [data-e2e-probe="item"] [data-e2e="input"]`;
|
|
27
|
+
|
|
28
|
+
test("an entry of the same route is its own page, and the one it covers waits under it with its state", async () => {
|
|
29
|
+
await csr.open(TAB_A);
|
|
30
|
+
await csr.navigate(item("1"));
|
|
31
|
+
await csr.type(currentInput, "first");
|
|
32
|
+
await csr.navigate(item("2"));
|
|
33
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["2"]);
|
|
34
|
+
expect(await itemIdsIn(UNDER)).toEqual(["1"]);
|
|
35
|
+
expect(await csr.values(currentInput)).toEqual([""]);
|
|
36
|
+
await csr.back();
|
|
37
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["1"]);
|
|
38
|
+
expect(await csr.values(currentInput)).toEqual(["first"]);
|
|
39
|
+
expect(await csr.reloaded()).toBe(false);
|
|
40
|
+
}, 60_000);
|
|
41
|
+
|
|
42
|
+
test("the stack keeps three hidden entries under the previous one, releases older ones, and reveals each on back", async () => {
|
|
43
|
+
await csr.open(TAB_A);
|
|
44
|
+
for (const id of ["11", "12", "13"]) await csr.navigate(item(id));
|
|
45
|
+
await csr.type(currentInput, "thirteen");
|
|
46
|
+
for (const id of ["14", "15", "16"]) await csr.navigate(item(id));
|
|
47
|
+
expect((await itemIdsIn('[id^="pageContainer-"]', { mounted: true })).sort()).toEqual([
|
|
48
|
+
"12",
|
|
49
|
+
"13",
|
|
50
|
+
"14",
|
|
51
|
+
"15",
|
|
52
|
+
"16",
|
|
53
|
+
]);
|
|
54
|
+
for (const expected of ["15", "14", "13"]) {
|
|
55
|
+
await csr.back();
|
|
56
|
+
expect(await itemIdsIn(CURRENT)).toEqual([expected]);
|
|
57
|
+
}
|
|
58
|
+
expect(await csr.values(currentInput)).toEqual(["thirteen"]);
|
|
59
|
+
await csr.back();
|
|
60
|
+
await csr.back();
|
|
61
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["11"]);
|
|
62
|
+
expect(await csr.values(currentInput)).toEqual([""]);
|
|
63
|
+
expect(await csr.reloaded()).toBe(false);
|
|
64
|
+
}, 90_000);
|
|
65
|
+
|
|
66
|
+
test("a replace within one route updates the page in place", async () => {
|
|
67
|
+
await csr.open(item("21"));
|
|
68
|
+
await csr.type(currentInput, "kept");
|
|
69
|
+
await csr.navigate(item("22"), "replace");
|
|
70
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["22"]);
|
|
71
|
+
expect(await csr.values(currentInput)).toEqual(["kept"]);
|
|
72
|
+
expect((await csr.containers()).filter((container) => container.path.endsWith("/item"))).toHaveLength(1);
|
|
73
|
+
}, 60_000);
|
|
74
|
+
|
|
75
|
+
test("the page the session opened on is cached like any other cached page", async () => {
|
|
76
|
+
await csr.open(TAB_A);
|
|
77
|
+
const mountedAs = (await csr.probe("tab-a"))?.mountId;
|
|
78
|
+
await csr.navigate(TAB_B);
|
|
79
|
+
await csr.navigate(item("3"));
|
|
80
|
+
await csr.navigate(TAB_A);
|
|
81
|
+
expect((await csr.probe("tab-a"))?.mountId).toBe(mountedAs);
|
|
82
|
+
expect(await csr.reloaded()).toBe(false);
|
|
83
|
+
}, 60_000);
|
|
84
|
+
|
|
85
|
+
test("a cached page that is not visible pauses its effects and resumes them with its state", async () => {
|
|
86
|
+
await csr.open(TAB_B);
|
|
87
|
+
await csr.navigate(TAB_A);
|
|
88
|
+
const mountedAs = (await csr.probe("tab-a"))?.mountId;
|
|
89
|
+
await csr.navigate(TAB_B);
|
|
90
|
+
await csr.navigate(item("4"));
|
|
91
|
+
expect((await csr.containers()).find((container) => container.path.endsWith("/tab-a"))?.hidden).toBe(true);
|
|
92
|
+
expect(await csr.ticksOver("tab-a")).toBe(0);
|
|
93
|
+
await csr.navigate(TAB_A);
|
|
94
|
+
expect((await csr.probe("tab-a"))?.mountId).toBe(mountedAs);
|
|
95
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
96
|
+
}, 60_000);
|
|
97
|
+
|
|
98
|
+
test("the page a transition-less switch left pauses at once, and runs again when switched back", async () => {
|
|
99
|
+
await csr.open(TAB_A);
|
|
100
|
+
const mountedAs = (await csr.probe("tab-a"))?.mountId;
|
|
101
|
+
await csr.navigate(TAB_B);
|
|
102
|
+
expect(await csr.ticksOver("tab-a")).toBe(0);
|
|
103
|
+
await csr.navigate(TAB_A);
|
|
104
|
+
expect((await csr.probe("tab-a"))?.mountId).toBe(mountedAs);
|
|
105
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
106
|
+
}, 60_000);
|
|
107
|
+
|
|
108
|
+
test("every page container but the current one is inert and hidden from assistive tech", async () => {
|
|
109
|
+
await csr.open(TAB_A);
|
|
110
|
+
await csr.navigate(TAB_B);
|
|
111
|
+
await csr.navigate(item("5"));
|
|
112
|
+
const current = routeOf(await csr.currentPath());
|
|
113
|
+
const containers = await csr.containers();
|
|
114
|
+
expect(containers.filter((container) => !container.inert).map((container) => container.path)).toEqual([current]);
|
|
115
|
+
expect(containers.every((container) => container.inert === container.ariaHidden)).toBe(true);
|
|
116
|
+
}, 60_000);
|
|
117
|
+
|
|
118
|
+
test("only the current page offers the agent its tools, not the page kept under it for a swipe back", async () => {
|
|
119
|
+
await csr.open(TAB_A);
|
|
120
|
+
expect(await csr.agentTools()).toContain("bumpTabA");
|
|
121
|
+
await csr.navigate(item("7"));
|
|
122
|
+
const tools = await csr.agentTools();
|
|
123
|
+
expect(tools).toContain("bumpItem");
|
|
124
|
+
expect(tools).not.toContain("bumpTabA");
|
|
125
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
126
|
+
await csr.back();
|
|
127
|
+
const back = await csr.agentTools();
|
|
128
|
+
expect(back).toContain("bumpTabA");
|
|
129
|
+
expect(back).not.toContain("bumpItem");
|
|
130
|
+
}, 60_000);
|
|
131
|
+
|
|
132
|
+
test("a page is focused while current and settled; the page under it stays live but unfocused", async () => {
|
|
133
|
+
await csr.open(TAB_A);
|
|
134
|
+
expect((await csr.probe("tab-a"))?.focused).toBe(true);
|
|
135
|
+
const focusCount = (await csr.probe("tab-a"))?.focusCount ?? 0;
|
|
136
|
+
await csr.navigate(item("8"));
|
|
137
|
+
expect(await csr.probe("tab-a")).toMatchObject({ activity: "prev", focused: false, mounted: true });
|
|
138
|
+
expect(await csr.probe("item")).toMatchObject({ activity: "current", focused: true });
|
|
139
|
+
await csr.back();
|
|
140
|
+
expect(await csr.probe("tab-a")).toMatchObject({ activity: "current", focused: true, focusCount: focusCount + 1 });
|
|
141
|
+
}, 60_000);
|
|
142
|
+
|
|
143
|
+
test("a reload keeps the stack: the page under the current one is back, the rest waits, and back walks it", async () => {
|
|
144
|
+
const runtimeItem = (id: string) => `${item(id)}&csr=true&akanMobileTarget=e2e`;
|
|
145
|
+
await csr.open(TAB_A);
|
|
146
|
+
await csr.navigate(runtimeItem("31"));
|
|
147
|
+
await csr.navigate(runtimeItem("32"));
|
|
148
|
+
await csr.reload();
|
|
149
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["32"]);
|
|
150
|
+
expect(await itemIdsIn(UNDER)).toEqual(["31"]);
|
|
151
|
+
expect((await csr.containers()).some((container) => container.path.endsWith("/tab-a"))).toBe(false);
|
|
152
|
+
//? The first back after a reload mounts the entry under the one it reveals, which a boot left dormant.
|
|
153
|
+
await csr.back();
|
|
154
|
+
await csr.waitForText(`${CURRENT} [data-e2e-probe="item"] [data-e2e="item-id"]`, ["31"]);
|
|
155
|
+
await csr.back();
|
|
156
|
+
expect(routeOf(await csr.currentPath())).toBe("/:lang/e2e/stack/tab-a");
|
|
157
|
+
expect((await csr.probe("tab-a"))?.mounted).toBe(true);
|
|
158
|
+
expect(await csr.reloaded()).toBe(false);
|
|
159
|
+
}, 90_000);
|
|
160
|
+
|
|
161
|
+
test("a popstate several entries away lands on that entry", async () => {
|
|
162
|
+
await csr.open(TAB_A);
|
|
163
|
+
for (const id of ["41", "42", "43"]) await csr.navigate(item(id));
|
|
164
|
+
await csr.go(-2);
|
|
165
|
+
expect(await itemIdsIn(CURRENT)).toEqual(["41"]);
|
|
166
|
+
expect(await csr.reloaded()).toBe(false);
|
|
167
|
+
}, 60_000);
|
|
168
|
+
|
|
169
|
+
test("the page under the current one pauses while the app is in the background", async () => {
|
|
170
|
+
await csr.open(TAB_A);
|
|
171
|
+
await csr.navigate(item("51"));
|
|
172
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
173
|
+
await csr.setVisibility("hidden");
|
|
174
|
+
expect(await csr.ticksOver("tab-a")).toBe(0);
|
|
175
|
+
await csr.setVisibility("visible");
|
|
176
|
+
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
177
|
+
}, 60_000);
|
|
178
|
+
|
|
179
|
+
test("back returns to the previous entry without a reload", async () => {
|
|
180
|
+
await csr.open(TAB_A);
|
|
181
|
+
await csr.navigate(item("6"));
|
|
182
|
+
await csr.back();
|
|
183
|
+
expect(routeOf(await csr.currentPath())).toBe("/:lang/e2e/stack/tab-a");
|
|
184
|
+
expect(await csr.text('[data-e2e-probe="tab-a"] [data-e2e="item-id"]')).toHaveLength(1);
|
|
185
|
+
expect(await csr.reloaded()).toBe(false);
|
|
186
|
+
}, 60_000);
|
|
187
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CsrE2eHarness } from "./csrE2eHarness.fixture";
|
|
4
|
+
|
|
5
|
+
const workspaceRoot = path.resolve(import.meta.dir, "../../../..");
|
|
6
|
+
const probeFile = path.join(workspaceRoot, "apps/minimal/ui/StackProbe.tsx");
|
|
7
|
+
const port = Number(process.env.AKAN_CSR_E2E_PATCH_FIRST_PORT ?? 8493);
|
|
8
|
+
|
|
9
|
+
interface CsrWindow {
|
|
10
|
+
__akan?: { generation: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
//? AKAN_CSR_DEV_APP_WRITE_DELAY_MS holds app.js back after the patch is announced, so a tab can boot inside the gap.
|
|
14
|
+
describe.skipIf(!CsrE2eHarness.enabled)("CSR patch before app.js (minimal, app.js written 1.5s late)", () => {
|
|
15
|
+
let csr: CsrE2eHarness | null = null;
|
|
16
|
+
const harness = () => {
|
|
17
|
+
if (!csr) throw new Error("[csr-e2e] the harness did not start");
|
|
18
|
+
return csr;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
csr = await CsrE2eHarness.start({
|
|
23
|
+
app: "minimal",
|
|
24
|
+
port,
|
|
25
|
+
workspaceRoot,
|
|
26
|
+
env: { AKAN_CSR_DEV_APP_WRITE_DELAY_MS: "1500" },
|
|
27
|
+
});
|
|
28
|
+
}, 240_000);
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await csr?.close();
|
|
32
|
+
}, 60_000);
|
|
33
|
+
|
|
34
|
+
const generation = async () =>
|
|
35
|
+
await harness().evaluate(() => (window as unknown as CsrWindow).__akan?.generation ?? 0);
|
|
36
|
+
const hot = async () =>
|
|
37
|
+
await harness().evaluate(
|
|
38
|
+
() => document.querySelector('[data-e2e-probe="tab-a"]')?.getAttribute("data-e2e-hot") ?? null,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
test("an open tab takes the patch before app.js is rewritten", async () => {
|
|
42
|
+
await harness().open("/e2e/stack/tab-a");
|
|
43
|
+
const before = await generation();
|
|
44
|
+
await harness().editSource(
|
|
45
|
+
probeFile,
|
|
46
|
+
(source) => source.replace("data-e2e-probe={name}>", 'data-e2e-probe={name} data-e2e-hot="early">'),
|
|
47
|
+
async () => {
|
|
48
|
+
const startedAt = Date.now();
|
|
49
|
+
await harness().waitFor(
|
|
50
|
+
() => document.querySelector('[data-e2e-probe="tab-a"]')?.getAttribute("data-e2e-hot") === "early",
|
|
51
|
+
{ timeout: 20_000 },
|
|
52
|
+
);
|
|
53
|
+
expect(Date.now() - startedAt).toBeLessThan(1_500);
|
|
54
|
+
expect(await generation()).toBeGreaterThan(before);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
expect(await harness().reloaded()).toBe(false);
|
|
58
|
+
}, 120_000);
|
|
59
|
+
|
|
60
|
+
test("a tab booting between the patch and app.js starts at the new generation with no second reload", async () => {
|
|
61
|
+
await harness().open("/e2e/stack/tab-a");
|
|
62
|
+
//? The previous test's restore may still be on its way: wait until the page shows it.
|
|
63
|
+
await harness().waitFor(
|
|
64
|
+
() => document.querySelector('[data-e2e-probe="tab-a"]')?.getAttribute("data-e2e-hot") === null,
|
|
65
|
+
{ timeout: 20_000 },
|
|
66
|
+
);
|
|
67
|
+
await harness().editSource(
|
|
68
|
+
probeFile,
|
|
69
|
+
(source) => source.replace("data-e2e-probe={name}>", 'data-e2e-probe={name} data-e2e-hot="booted">'),
|
|
70
|
+
async () => {
|
|
71
|
+
await harness().waitFor(
|
|
72
|
+
() => document.querySelector('[data-e2e-probe="tab-a"]')?.getAttribute("data-e2e-hot") === "booted",
|
|
73
|
+
{ timeout: 20_000 },
|
|
74
|
+
);
|
|
75
|
+
const target = await generation();
|
|
76
|
+
//? The first document after the reload: a second reload inside the harness's settle would take the marker.
|
|
77
|
+
const booted = await harness().reload();
|
|
78
|
+
expect(await generation()).toBe(target);
|
|
79
|
+
expect(await hot()).toBe("booted");
|
|
80
|
+
await Bun.sleep(2_000);
|
|
81
|
+
expect(await harness().reloaded()).toBe(false);
|
|
82
|
+
expect(await harness().evaluate(() => performance.timeOrigin)).toBe(booted);
|
|
83
|
+
expect(await generation()).toBe(target);
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
}, 120_000);
|
|
87
|
+
});
|
|
@@ -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
|
+
});
|