@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
package/akanApp/devHostPolicy.ts
CHANGED
|
@@ -61,12 +61,20 @@ export type BackendLifecycleState = "starting" | "ready" | "restart-pending" | "
|
|
|
61
61
|
/** Reported, not scraped: in interleaved child output "this app is up" reads like any line mentioning it. */
|
|
62
62
|
export type DevHostState = "starting" | "ready" | "restarting" | "recovering" | "suspended" | "failed" | "stopped";
|
|
63
63
|
|
|
64
|
-
export interface
|
|
64
|
+
export interface DevHostStateEvent {
|
|
65
65
|
app: string;
|
|
66
66
|
state: DevHostState;
|
|
67
67
|
detail?: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Sent once: the app serves and its boot builds have settled, which is what the next boot wave waits for. */
|
|
71
|
+
export interface DevHostBootEvent {
|
|
72
|
+
app: string;
|
|
73
|
+
booted: true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type DevHostEvent = DevHostStateEvent | DevHostBootEvent;
|
|
77
|
+
|
|
70
78
|
const devHostStateByBackendState = {
|
|
71
79
|
starting: "starting",
|
|
72
80
|
ready: "ready",
|
|
@@ -112,14 +120,29 @@ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string)
|
|
|
112
120
|
);
|
|
113
121
|
};
|
|
114
122
|
|
|
123
|
+
//? The backend's rule (akanjs `DevBuildRecovery`), copied: no exported akanjs subpath reaches it from the CLI bundle.
|
|
115
124
|
export const shouldMarkBuildPhaseRecovered = (
|
|
116
125
|
previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
117
126
|
status: DevBuildStatus,
|
|
118
127
|
): boolean => {
|
|
119
128
|
const previous = previousByPhase.get(status.phase);
|
|
120
|
-
|
|
129
|
+
if (!previous || previous.ok || !status.ok) return false;
|
|
130
|
+
const generation = generationValue(status.generation);
|
|
131
|
+
if (status.phase === "route" && status.scope !== undefined && previous.scope !== undefined)
|
|
132
|
+
return status.scope === previous.scope && generation >= previous.generation;
|
|
133
|
+
if (generation !== previous.generation) return generation > previous.generation;
|
|
134
|
+
return status.phase === "backend";
|
|
121
135
|
};
|
|
122
136
|
|
|
137
|
+
/** An ok that recovers nothing leaves the failure standing, for hello and for whatever waits on a clean build. */
|
|
138
|
+
export const shouldKeepBuildFailure = (
|
|
139
|
+
previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
140
|
+
status: DevBuildStatus,
|
|
141
|
+
): boolean =>
|
|
142
|
+
status.ok &&
|
|
143
|
+
previousByPhase.get(status.phase)?.ok === false &&
|
|
144
|
+
!shouldMarkBuildPhaseRecovered(previousByPhase, status);
|
|
145
|
+
|
|
123
146
|
export const createBackendBuildStatus = ({
|
|
124
147
|
generation,
|
|
125
148
|
ok,
|
|
@@ -180,7 +203,8 @@ export const hasBuildFailureForGeneration = (
|
|
|
180
203
|
): boolean => {
|
|
181
204
|
if (typeof generation !== "number") return false;
|
|
182
205
|
for (const status of statusByPhase.values()) {
|
|
183
|
-
|
|
206
|
+
//? Not a route's: a replacement builder or a restarted dev host boots without building one, so it cannot hit it.
|
|
207
|
+
if (!status.ok && status.phase !== "route" && status.generation === generation) return true;
|
|
184
208
|
}
|
|
185
209
|
return false;
|
|
186
210
|
};
|
|
@@ -268,8 +292,9 @@ export const resolveIdleSuspendMs = (raw: string | undefined): number | null =>
|
|
|
268
292
|
return Math.round(parsed);
|
|
269
293
|
};
|
|
270
294
|
|
|
295
|
+
//? Not a route's: a broken route builds again on its next request, which wakes a suspended builder anyway.
|
|
271
296
|
export const hasAnyBuildFailure = (statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>): boolean =>
|
|
272
|
-
[...statusByPhase.values()].some((status) => !status.ok);
|
|
297
|
+
[...statusByPhase.values()].some((status) => !status.ok && status.phase !== "route");
|
|
273
298
|
|
|
274
299
|
export const shouldRefreshConfigOnIdleWake = (batch: ChangeBatch | null): boolean =>
|
|
275
300
|
!!batch && batch.kinds.has("config");
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir,
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import dayjs from "dayjs";
|
|
3
3
|
import { FileSys } from "../fileSys";
|
|
4
4
|
import { ConfigLock } from "./configLock";
|
|
@@ -39,27 +39,13 @@ export class GlobalConfig {
|
|
|
39
39
|
const temp = `${configPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
40
40
|
try {
|
|
41
41
|
await writeFile(temp, JSON.stringify(akanConfig, null, 2), { mode: 0o600 });
|
|
42
|
-
|
|
42
|
+
// Every CLI reads this file outside the lock, which on Windows makes the rename wait for the reader.
|
|
43
|
+
await FileSys.replace(temp, configPath);
|
|
43
44
|
} catch (error) {
|
|
44
45
|
await rm(temp, { force: true });
|
|
45
46
|
throw error;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
|
-
static readonly #replaceAttempts = 100;
|
|
49
|
-
// Windows refuses to rename over a file another process has open (EPERM, or EACCES/EBUSY), and every CLI reads this
|
|
50
|
-
// one outside the lock. A reader holds it for one read, so the rename is retried for a few seconds instead of failing.
|
|
51
|
-
static async #replace(temp: string, target: string) {
|
|
52
|
-
for (let attempt = 1; ; attempt++) {
|
|
53
|
-
try {
|
|
54
|
-
return await rename(temp, target);
|
|
55
|
-
} catch (error) {
|
|
56
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
57
|
-
const busy = process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY");
|
|
58
|
-
if (!busy || attempt >= GlobalConfig.#replaceAttempts) throw error;
|
|
59
|
-
await Bun.sleep(Math.min(10 * attempt, 50));
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
49
|
static readonly #lockPath = `${configPath}.lock`;
|
|
64
50
|
// Every write re-reads under the lock: one built on an earlier read could restore a rotated-away refresh token.
|
|
65
51
|
static async #update(mutate: (akanConfig: AkanGlobalConfig) => AkanGlobalConfig) {
|
|
@@ -46,7 +46,7 @@ const RELOAD_MARKER = "__akanE2eBootMarker";
|
|
|
46
46
|
//? Longer than the frame's navigation lock (360ms after a transition commits, `webkit/useCsrValues.ts`).
|
|
47
47
|
const SETTLE_MS = 450;
|
|
48
48
|
|
|
49
|
-
/** Drives a
|
|
49
|
+
/** Drives a page of a running `akan start` in a headless `Bun.WebView`: CSR (`?csr=true` + a mobile target) or SSR. */
|
|
50
50
|
export class CsrE2eHarness {
|
|
51
51
|
readonly origin: string;
|
|
52
52
|
readonly #view: Bun.WebView;
|
|
@@ -55,6 +55,7 @@ export class CsrE2eHarness {
|
|
|
55
55
|
readonly #mobileTarget: string;
|
|
56
56
|
readonly #edited = new Map<string, string>();
|
|
57
57
|
#marker = "";
|
|
58
|
+
#csr: boolean = true;
|
|
58
59
|
|
|
59
60
|
private constructor(options: {
|
|
60
61
|
origin: string;
|
|
@@ -78,6 +79,11 @@ export class CsrE2eHarness {
|
|
|
78
79
|
static async start(options: CsrE2eOptions): Promise<CsrE2eHarness> {
|
|
79
80
|
const workspaceRoot = options.workspaceRoot ?? CsrE2eHarness.#findWorkspaceRoot();
|
|
80
81
|
const url = options.url ?? process.env.AKAN_CSR_E2E_URL;
|
|
82
|
+
//? A running server took its env when it started: a suite's hooks would be dropped and its checks pass for nothing.
|
|
83
|
+
if (url && Object.keys(options.env ?? {}).length > 0)
|
|
84
|
+
throw new Error(
|
|
85
|
+
`[csr-e2e] this suite starts its dev server with ${Object.keys(options.env ?? {}).join(", ")}, which the server at ${url} does not have; unset AKAN_CSR_E2E_URL`,
|
|
86
|
+
);
|
|
81
87
|
const port = options.port ?? Number(process.env.AKAN_CSR_E2E_PORT ?? DEFAULT_PORT);
|
|
82
88
|
const server = url ? null : await CsrE2eHarness.#startServer({ ...options, workspaceRoot, port });
|
|
83
89
|
const origin = new URL(url ?? `http://localhost:${port}`).origin;
|
|
@@ -90,6 +96,8 @@ export class CsrE2eHarness {
|
|
|
90
96
|
width,
|
|
91
97
|
height,
|
|
92
98
|
backend: backend === "chrome" ? { type: "chrome", url: false } : backend,
|
|
99
|
+
//? AKAN_CSR_E2E_CONSOLE=1 prints the page's console: the only view of a hydration warning or a failed patch.
|
|
100
|
+
...(process.env.AKAN_CSR_E2E_CONSOLE === "1" ? { console: globalThis.console } : {}),
|
|
93
101
|
});
|
|
94
102
|
return new CsrE2eHarness({
|
|
95
103
|
origin,
|
|
@@ -104,28 +112,43 @@ export class CsrE2eHarness {
|
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
/** Loads `path` (without the locale) as
|
|
108
|
-
async open(path: string) {
|
|
115
|
+
/** Loads `path` (without the locale), as CSR unless `csr: false`, and plants the marker `reloaded()` checks. */
|
|
116
|
+
async open(path: string, { csr = true }: { csr?: boolean } = {}) {
|
|
109
117
|
const url = new URL(`/${this.#lang}${path === "/" ? "" : path}`, this.origin);
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
if (csr) {
|
|
119
|
+
url.searchParams.set("csr", "true");
|
|
120
|
+
url.searchParams.set("akanMobileTarget", this.#mobileTarget);
|
|
121
|
+
}
|
|
122
|
+
this.#csr = csr;
|
|
112
123
|
await this.#view.navigate(url.toString());
|
|
113
124
|
await this.#markBoot();
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Reloads the page where it is, as a WebView does after its content process died, and marks the new boot. Returns
|
|
129
|
+
* the `performance.timeOrigin` of the first document seen after it: one that differs later was reloaded again.
|
|
130
|
+
*/
|
|
131
|
+
async reload(): Promise<number> {
|
|
118
132
|
await this.#view.reload();
|
|
119
133
|
//? On the WebKit backend `reload()` resolves before the old document is gone; its marker going is the reload.
|
|
120
134
|
await this.waitFor(
|
|
121
135
|
(key: string, previous: string) => (window as unknown as Record<string, string | undefined>)[key] !== previous,
|
|
122
136
|
{ args: [RELOAD_MARKER, this.#marker], timeout: 30_000 },
|
|
123
137
|
);
|
|
138
|
+
const timeOrigin = await this.evaluate(() => performance.timeOrigin);
|
|
124
139
|
await this.#markBoot();
|
|
140
|
+
return timeOrigin;
|
|
125
141
|
}
|
|
126
142
|
|
|
143
|
+
//? An SSR page has no page stack; `rscClient` installs its refresh hook in the root's layout effect, once hydrated.
|
|
127
144
|
async #markBoot() {
|
|
128
|
-
|
|
145
|
+
if (this.#csr)
|
|
146
|
+
await this.waitFor(() => document.querySelector('[id^="pageContainer-"]') !== null, { timeout: 30_000 });
|
|
147
|
+
else
|
|
148
|
+
await this.waitFor(
|
|
149
|
+
() => typeof (globalThis as unknown as { __AKAN_RSC_REFRESH__?: unknown }).__AKAN_RSC_REFRESH__ === "function",
|
|
150
|
+
{ timeout: 30_000 },
|
|
151
|
+
);
|
|
129
152
|
await Bun.sleep(SETTLE_MS);
|
|
130
153
|
this.#marker = Math.random().toString(36).slice(2);
|
|
131
154
|
await this.evaluate(
|
|
@@ -296,6 +319,33 @@ export class CsrE2eHarness {
|
|
|
296
319
|
await this.#view.type(text);
|
|
297
320
|
}
|
|
298
321
|
|
|
322
|
+
/** The process group of the `akan start` this harness spawned; null when it drives a running one. */
|
|
323
|
+
get serverGroup(): number | null {
|
|
324
|
+
return this.#server?.proc.pid ?? null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Where the spawned `akan start`'s stdout and stderr end now, for `serverLogSince`. */
|
|
328
|
+
async serverLogMark(): Promise<{ out: number; err: number }> {
|
|
329
|
+
const [out, err] = await this.#serverLogs();
|
|
330
|
+
return { out: out.length, err: err.length };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** What the spawned `akan start` wrote to stdout and stderr since `mark`; empty when it drives a running one. */
|
|
334
|
+
async serverLogSince(mark: { out: number; err: number } = { out: 0, err: 0 }): Promise<string> {
|
|
335
|
+
const [out, err] = await this.#serverLogs();
|
|
336
|
+
return `${out.slice(mark.out)}${err.slice(mark.err)}`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async #serverLogs(): Promise<[string, string]> {
|
|
340
|
+
if (!this.#server) return ["", ""];
|
|
341
|
+
const read = async (file: string) =>
|
|
342
|
+
await Bun.file(file)
|
|
343
|
+
.text()
|
|
344
|
+
.catch(() => "");
|
|
345
|
+
const { logPath } = this.#server;
|
|
346
|
+
return [await read(logPath), await read(logPath.replace(/\.log$/, ".err.log"))];
|
|
347
|
+
}
|
|
348
|
+
|
|
299
349
|
/** Whether the page reloaded since `open()`: the boot marker lives only in the page `open()` loaded. */
|
|
300
350
|
async reloaded() {
|
|
301
351
|
const marker = await this.evaluate(
|
|
@@ -350,18 +400,23 @@ export class CsrE2eHarness {
|
|
|
350
400
|
env = {},
|
|
351
401
|
workspaceRoot,
|
|
352
402
|
}: CsrE2eOptions & { port: number; workspaceRoot: string }): Promise<CsrE2eServer> {
|
|
403
|
+
//? The dev host runs from the CLI's dist bundle while the builder runs from source: a stale dist tests an old host.
|
|
404
|
+
//? The build is a no-op when its stamp matches the sources.
|
|
405
|
+
const built = Bun.spawnSync(["bun", path.join(workspaceRoot, "pkgs/@akanjs/cli/build.ts")], { cwd: workspaceRoot });
|
|
406
|
+
if (built.exitCode !== 0) throw new Error(`[csr-e2e] building the CLI failed: ${built.stderr.toString()}`);
|
|
353
407
|
const cli = path.join(workspaceRoot, "dist/pkgs/@akanjs/cli/index.js");
|
|
354
|
-
if (!(await Bun.file(cli).exists()))
|
|
355
|
-
throw new Error(`[csr-e2e] ${cli} is missing; build the CLI first (bun pkgs/@akanjs/cli/build.ts)`);
|
|
356
408
|
const logDir = path.join(workspaceRoot, "local", "csr-e2e");
|
|
357
409
|
await mkdir(logDir, { recursive: true });
|
|
358
410
|
const logPath = path.join(logDir, `${app}-${port}.log`);
|
|
411
|
+
const errPath = logPath.replace(/\.log$/, ".err.log");
|
|
412
|
+
//? A `Bun.file` sink writes from offset 0 without truncating, so the last run's tail would outlive this one's start.
|
|
413
|
+
await Promise.all([Bun.write(logPath, ""), Bun.write(errPath, "")]);
|
|
359
414
|
//? Its own process group, so stopping it reaches the gateway, the builder and every replica it spawned.
|
|
360
415
|
const proc = Bun.spawn(["bun", cli, "start", app, "--plain"], {
|
|
361
416
|
cwd: workspaceRoot,
|
|
362
417
|
env: { ...process.env, AKAN_DEV_PORT: String(port), ...env },
|
|
363
418
|
stdout: Bun.file(logPath),
|
|
364
|
-
stderr: Bun.file(
|
|
419
|
+
stderr: Bun.file(errPath),
|
|
365
420
|
detached: true,
|
|
366
421
|
});
|
|
367
422
|
return { proc, logPath };
|
|
@@ -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
|
+
});
|