@akanjs/devkit 3.0.0-beta.22 → 3.0.0-beta.24

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.
@@ -39,12 +39,27 @@ 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
- await rename(temp, configPath);
42
+ await GlobalConfig.#replace(temp, configPath);
43
43
  } catch (error) {
44
44
  await rm(temp, { force: true });
45
45
  throw error;
46
46
  }
47
47
  }
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
+ }
48
63
  static readonly #lockPath = `${configPath}.lock`;
49
64
  // Every write re-reads under the lock: one built on an earlier read could restore a rotated-away refresh token.
50
65
  static async #update(mutate: (akanConfig: AkanGlobalConfig) => AkanGlobalConfig) {
@@ -0,0 +1,400 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ export interface CsrE2eOptions {
6
+ app: string;
7
+ /** An `akan start` that is already running; without it the harness starts one on `port`. */
8
+ url?: string;
9
+ port?: number;
10
+ /** Extra env for the `akan start` it spawns, e.g. `{ AKAN_DEV_CSR: "registry" }`. */
11
+ env?: Record<string, string>;
12
+ lang?: string;
13
+ mobileTarget?: string;
14
+ workspaceRoot?: string;
15
+ startTimeoutMs?: number;
16
+ viewport?: { width: number; height: number };
17
+ /** `webkit` is macOS only; elsewhere, or with `AKAN_CSR_E2E_BACKEND=chrome`, an installed Chrome is driven. */
18
+ backend?: "webkit" | "chrome";
19
+ }
20
+
21
+ export interface CsrE2ePageContainer {
22
+ key: string;
23
+ path: string;
24
+ hidden: boolean;
25
+ inert: boolean;
26
+ ariaHidden: boolean;
27
+ }
28
+
29
+ export interface CsrE2eProbe {
30
+ renders: number;
31
+ ticks: number;
32
+ mountId: string;
33
+ mounted: boolean;
34
+ activity: string;
35
+ focused: boolean;
36
+ focusCount: number;
37
+ }
38
+
39
+ interface CsrE2eServer {
40
+ proc: ReturnType<typeof Bun.spawn>;
41
+ logPath: string;
42
+ }
43
+
44
+ const DEFAULT_PORT = 8391;
45
+ const RELOAD_MARKER = "__akanE2eBootMarker";
46
+ //? Longer than the frame's navigation lock (360ms after a transition commits, `webkit/useCsrValues.ts`).
47
+ const SETTLE_MS = 450;
48
+
49
+ /** Drives a CSR page (`?csr=true` + a mobile target) of a running `akan start` in a headless `Bun.WebView`. */
50
+ export class CsrE2eHarness {
51
+ readonly origin: string;
52
+ readonly #view: Bun.WebView;
53
+ readonly #server: CsrE2eServer | null;
54
+ readonly #lang: string;
55
+ readonly #mobileTarget: string;
56
+ readonly #edited = new Map<string, string>();
57
+ #marker = "";
58
+
59
+ private constructor(options: {
60
+ origin: string;
61
+ view: Bun.WebView;
62
+ server: CsrE2eServer | null;
63
+ lang: string;
64
+ mobileTarget: string;
65
+ }) {
66
+ this.origin = options.origin;
67
+ this.#view = options.view;
68
+ this.#server = options.server;
69
+ this.#lang = options.lang;
70
+ this.#mobileTarget = options.mobileTarget;
71
+ }
72
+
73
+ /** E2E suites run only with `AKAN_CSR_E2E=1`: they boot a dev server and a browser. */
74
+ static get enabled() {
75
+ return process.env.AKAN_CSR_E2E === "1";
76
+ }
77
+
78
+ static async start(options: CsrE2eOptions): Promise<CsrE2eHarness> {
79
+ const workspaceRoot = options.workspaceRoot ?? CsrE2eHarness.#findWorkspaceRoot();
80
+ const url = options.url ?? process.env.AKAN_CSR_E2E_URL;
81
+ const port = options.port ?? Number(process.env.AKAN_CSR_E2E_PORT ?? DEFAULT_PORT);
82
+ const server = url ? null : await CsrE2eHarness.#startServer({ ...options, workspaceRoot, port });
83
+ const origin = new URL(url ?? `http://localhost:${port}`).origin;
84
+ try {
85
+ await CsrE2eHarness.#waitForHealth(origin, server, options.startTimeoutMs ?? 180_000);
86
+ const { width, height } = { width: 390, height: 844, ...options.viewport };
87
+ const backend = options.backend ?? CsrE2eHarness.#defaultBackend();
88
+ //? `url: false` spawns a fresh Chrome instead of attaching to one the developer runs with remote debugging on.
89
+ const view = new Bun.WebView({
90
+ width,
91
+ height,
92
+ backend: backend === "chrome" ? { type: "chrome", url: false } : backend,
93
+ });
94
+ return new CsrE2eHarness({
95
+ origin,
96
+ view,
97
+ server,
98
+ lang: options.lang ?? "en",
99
+ mobileTarget: options.mobileTarget ?? "e2e",
100
+ });
101
+ } catch (error) {
102
+ await CsrE2eHarness.#stopServer(server);
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ /** Loads `path` (without the locale) as a CSR page and plants the marker `reloaded()` checks. */
108
+ async open(path: string) {
109
+ const url = new URL(`/${this.#lang}${path === "/" ? "" : path}`, this.origin);
110
+ url.searchParams.set("csr", "true");
111
+ url.searchParams.set("akanMobileTarget", this.#mobileTarget);
112
+ await this.#view.navigate(url.toString());
113
+ await this.#markBoot();
114
+ }
115
+
116
+ /** Reloads the page where it is, as a WebView does after its content process died, and marks the new boot. */
117
+ async reload() {
118
+ await this.#view.reload();
119
+ //? On the WebKit backend `reload()` resolves before the old document is gone; its marker going is the reload.
120
+ await this.waitFor(
121
+ (key: string, previous: string) => (window as unknown as Record<string, string | undefined>)[key] !== previous,
122
+ { args: [RELOAD_MARKER, this.#marker], timeout: 30_000 },
123
+ );
124
+ await this.#markBoot();
125
+ }
126
+
127
+ async #markBoot() {
128
+ await this.waitFor(() => document.querySelector('[id^="pageContainer-"]') !== null, { timeout: 30_000 });
129
+ await Bun.sleep(SETTLE_MS);
130
+ this.#marker = Math.random().toString(36).slice(2);
131
+ await this.evaluate(
132
+ (key: string, value: string) => {
133
+ (window as unknown as Record<string, string>)[key] = value;
134
+ },
135
+ RELOAD_MARKER,
136
+ this.#marker,
137
+ );
138
+ }
139
+
140
+ /** Reports the document as `state` and tells the page, as an app moving to or from the background does. */
141
+ async setVisibility(state: "hidden" | "visible") {
142
+ await this.evaluate((next: string) => {
143
+ Object.defineProperty(document, "visibilityState", { value: next, configurable: true });
144
+ document.dispatchEvent(new Event("visibilitychange"));
145
+ }, state);
146
+ await Bun.sleep(50);
147
+ }
148
+
149
+ //? The CSR frame's own dev sync-navigation event: it calls the app router's push/replace, as a click would.
150
+ //? The frame drops a navigation that starts while a transition runs, so each one waits the transition out.
151
+ async navigate(href: string, kind: "push" | "replace" = "push", { settleMs = SETTLE_MS } = {}) {
152
+ const target = new URL(href, this.origin);
153
+ await this.evaluate(
154
+ (next: string, how: string) => {
155
+ window.dispatchEvent(new CustomEvent("akan:sync-navigation", { detail: { href: next, kind: how } }));
156
+ },
157
+ href,
158
+ kind,
159
+ );
160
+ await this.waitFor(
161
+ (pathname: string, search: string) =>
162
+ location.pathname.endsWith(pathname) &&
163
+ [...new URLSearchParams(search)].every(
164
+ ([key, value]) => new URLSearchParams(location.search).get(key) === value,
165
+ ),
166
+ { args: [target.pathname, target.search], timeout: 10_000 },
167
+ );
168
+ await Bun.sleep(settleMs);
169
+ }
170
+
171
+ /** A history back through popstate, the path a native back button or a browser back takes. */
172
+ async back({ settleMs = SETTLE_MS } = {}) {
173
+ await this.go(-1, { settleMs });
174
+ }
175
+
176
+ async go(delta: number, { settleMs = SETTLE_MS } = {}) {
177
+ const before = await this.currentPath();
178
+ await this.evaluate((steps: number) => {
179
+ window.history.go(steps);
180
+ }, delta);
181
+ await this.waitFor((path: string) => window.location.pathname + window.location.search !== path, {
182
+ args: [before],
183
+ timeout: 10_000,
184
+ });
185
+ await Bun.sleep(settleMs);
186
+ }
187
+
188
+ /**
189
+ * Runs `fn` in the page. Only its source crosses, so it closes over nothing; `args` and the result travel as JSON.
190
+ */
191
+ async evaluate<Args extends unknown[], Result>(fn: (...args: Args) => Result, ...args: Args) {
192
+ return await this.#view.evaluate<Awaited<Result>>(`(${fn.toString()})(...${JSON.stringify(args)})`);
193
+ }
194
+
195
+ //? A poll that lands while the page reloads rejects; it is retried, so a wait spans a reload.
196
+ async waitFor<Args extends unknown[]>(
197
+ predicate: (...args: Args) => unknown,
198
+ { timeout = 5_000, args = [] as unknown as Args }: { timeout?: number; args?: Args } = {},
199
+ ) {
200
+ const deadline = Date.now() + timeout;
201
+ let lastError: unknown = null;
202
+ for (;;) {
203
+ try {
204
+ if (await this.evaluate(predicate, ...args)) return;
205
+ lastError = null;
206
+ } catch (error) {
207
+ lastError = error;
208
+ }
209
+ if (Date.now() >= deadline) break;
210
+ await Bun.sleep(50);
211
+ }
212
+ throw new Error(`[csr-e2e] waitFor timed out after ${timeout}ms: ${predicate.toString()}`, { cause: lastError });
213
+ }
214
+
215
+ async currentPath() {
216
+ return await this.evaluate(() => window.location.pathname + window.location.search);
217
+ }
218
+
219
+ async containers(): Promise<CsrE2ePageContainer[]> {
220
+ return await this.evaluate(() =>
221
+ [...document.querySelectorAll<HTMLElement>('[id^="pageContainer-"]')].map((element) => ({
222
+ key: element.id.slice("pageContainer-".length),
223
+ path: element.dataset.path ?? element.id.slice("pageContainer-".length),
224
+ hidden: getComputedStyle(element).display === "none",
225
+ inert: element.inert || element.closest("[inert]") !== null,
226
+ ariaHidden: element.closest('[aria-hidden="true"]') !== null,
227
+ })),
228
+ );
229
+ }
230
+
231
+ async probe(name: string): Promise<CsrE2eProbe | null> {
232
+ return await this.evaluate(
233
+ (probeName: string) =>
234
+ (window as unknown as { __akanE2eProbes?: Record<string, CsrE2eProbe> }).__akanE2eProbes?.[probeName] ?? null,
235
+ name,
236
+ );
237
+ }
238
+
239
+ /** Ticks a probe gained over `ms` — 0 while its page's effects are paused. */
240
+ async ticksOver(name: string, ms = 400) {
241
+ const before = (await this.probe(name))?.ticks ?? 0;
242
+ await Bun.sleep(ms);
243
+ return ((await this.probe(name))?.ticks ?? 0) - before;
244
+ }
245
+
246
+ /** The tool names the in-page agent is offered right now, built-ins included. */
247
+ async agentTools(): Promise<string[]> {
248
+ return await this.evaluate(() => {
249
+ const holder = globalThis as unknown as {
250
+ [key: symbol]: { snapshot: () => { tools: { name: string }[] } } | undefined;
251
+ };
252
+ return holder[Symbol.for("useAgentic.sharedSurface")]?.snapshot().tools.map((tool) => tool.name) ?? [];
253
+ });
254
+ }
255
+
256
+ /** Text of every rendered match; `{ mounted: true }` reads hidden ones too, which is how a parked page is seen. */
257
+ async text(selector: string, { mounted = false }: { mounted?: boolean } = {}) {
258
+ return await this.evaluate(
259
+ (target: string, all: boolean) =>
260
+ [...document.querySelectorAll<HTMLElement>(target)]
261
+ .filter((element) => all || element.checkVisibility())
262
+ .map((element) => element.textContent ?? ""),
263
+ selector,
264
+ mounted,
265
+ );
266
+ }
267
+
268
+ /** Waits until the rendered matches read `expected`, for a step whose cost is a mount rather than a transition. */
269
+ async waitForText(selector: string, expected: string[], { timeout = 5_000 } = {}) {
270
+ await this.waitFor(
271
+ (target: string, want: string[]) =>
272
+ JSON.stringify(
273
+ [...document.querySelectorAll<HTMLElement>(target)]
274
+ .filter((element) => element.checkVisibility())
275
+ .map((element) => element.textContent ?? ""),
276
+ ) === JSON.stringify(want),
277
+ { args: [selector, expected], timeout },
278
+ );
279
+ }
280
+
281
+ async values(selector: string) {
282
+ return await this.evaluate(
283
+ (target: string) => [...document.querySelectorAll<HTMLInputElement>(target)].map((element) => element.value),
284
+ selector,
285
+ );
286
+ }
287
+
288
+ //? `Bun.WebView.type` inserts into the focused element as a paste does: `input` fires, `keydown` does not.
289
+ async type(selector: string, text: string) {
290
+ const focused = await this.evaluate((target: string) => {
291
+ const element = document.querySelector<HTMLElement>(target);
292
+ element?.focus();
293
+ return element !== null && document.activeElement === element;
294
+ }, selector);
295
+ if (!focused) throw new Error(`[csr-e2e] type: ${selector} is not a focusable element on the page`);
296
+ await this.#view.type(text);
297
+ }
298
+
299
+ /** Whether the page reloaded since `open()`: the boot marker lives only in the page `open()` loaded. */
300
+ async reloaded() {
301
+ const marker = await this.evaluate(
302
+ (key: string) => (window as unknown as Record<string, string | undefined>)[key] ?? "",
303
+ RELOAD_MARKER,
304
+ ).catch(() => "");
305
+ return marker !== this.#marker;
306
+ }
307
+
308
+ /** Rewrites a workspace file for the duration of `run`, and always puts the original back. */
309
+ async editSource<T>(file: string, transform: (source: string) => string, run: () => Promise<T>): Promise<T> {
310
+ const absolute = path.resolve(file);
311
+ const original = this.#edited.get(absolute) ?? (await Bun.file(absolute).text());
312
+ const next = transform(original);
313
+ if (next === original) throw new Error(`[csr-e2e] editSource left ${file} unchanged`);
314
+ this.#edited.set(absolute, original);
315
+ await Bun.write(absolute, next);
316
+ try {
317
+ return await run();
318
+ } finally {
319
+ await Bun.write(absolute, original);
320
+ this.#edited.delete(absolute);
321
+ }
322
+ }
323
+
324
+ async close() {
325
+ for (const [file, original] of this.#edited) await Bun.write(file, original);
326
+ this.#edited.clear();
327
+ this.#view.close();
328
+ await CsrE2eHarness.#stopServer(this.#server);
329
+ }
330
+
331
+ static #defaultBackend() {
332
+ const requested = process.env.AKAN_CSR_E2E_BACKEND;
333
+ if (requested === "webkit" || requested === "chrome") return requested;
334
+ return process.platform === "darwin" ? "webkit" : "chrome";
335
+ }
336
+
337
+ static #findWorkspaceRoot() {
338
+ let dir = import.meta.dir;
339
+ for (;;) {
340
+ if (existsSync(path.join(dir, "pkgs/@akanjs/cli/build.ts"))) return dir;
341
+ const parent = path.dirname(dir);
342
+ if (parent === dir) throw new Error("[csr-e2e] workspace root not found above the harness");
343
+ dir = parent;
344
+ }
345
+ }
346
+
347
+ static async #startServer({
348
+ app,
349
+ port,
350
+ env = {},
351
+ workspaceRoot,
352
+ }: CsrE2eOptions & { port: number; workspaceRoot: string }): Promise<CsrE2eServer> {
353
+ 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
+ const logDir = path.join(workspaceRoot, "local", "csr-e2e");
357
+ await mkdir(logDir, { recursive: true });
358
+ const logPath = path.join(logDir, `${app}-${port}.log`);
359
+ //? Its own process group, so stopping it reaches the gateway, the builder and every replica it spawned.
360
+ const proc = Bun.spawn(["bun", cli, "start", app, "--plain"], {
361
+ cwd: workspaceRoot,
362
+ env: { ...process.env, AKAN_DEV_PORT: String(port), ...env },
363
+ stdout: Bun.file(logPath),
364
+ stderr: Bun.file(logPath.replace(/\.log$/, ".err.log")),
365
+ detached: true,
366
+ });
367
+ return { proc, logPath };
368
+ }
369
+
370
+ static async #waitForHealth(origin: string, server: CsrE2eServer | null, timeoutMs: number) {
371
+ const deadline = Date.now() + timeoutMs;
372
+ while (Date.now() < deadline) {
373
+ if (server && server.proc.exitCode !== null)
374
+ throw new Error(`[csr-e2e] akan start exited with ${server.proc.exitCode}; see ${server.logPath}`);
375
+ const ok = await fetch(`${origin}/_akan/app/health`)
376
+ .then((res) => res.ok)
377
+ .catch(() => false);
378
+ if (ok) return;
379
+ await Bun.sleep(500);
380
+ }
381
+ throw new Error(
382
+ `[csr-e2e] ${origin} did not become healthy in ${timeoutMs}ms${server ? `; see ${server.logPath}` : ""}`,
383
+ );
384
+ }
385
+
386
+ static async #stopServer(server: CsrE2eServer | null) {
387
+ if (!server || server.proc.exitCode !== null) return;
388
+ const group = -server.proc.pid;
389
+ const signal = (name: NodeJS.Signals) => {
390
+ try {
391
+ process.kill(group, name);
392
+ } catch {
393
+ // The group is already gone.
394
+ }
395
+ };
396
+ signal("SIGINT");
397
+ const exited = await Promise.race([server.proc.exited.then(() => true), Bun.sleep(15_000).then(() => false)]);
398
+ if (!exited) signal("SIGKILL");
399
+ }
400
+ }
@@ -0,0 +1,233 @@
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 TAB_A = "/e2e/stack/tab-a";
6
+ const workspaceRoot = path.resolve(import.meta.dir, "../../../..");
7
+ const probeFile = path.join(workspaceRoot, "apps/minimal/ui/StackProbe.tsx");
8
+ const tabAFile = path.join(workspaceRoot, "apps/minimal/page/(home)/e2e/stack/tab-a.tsx");
9
+ const tabBFile = path.join(workspaceRoot, "apps/minimal/page/(home)/e2e/stack/tab-b.tsx");
10
+ const itemFile = path.join(workspaceRoot, "apps/minimal/page/(home)/e2e/stack/item.tsx");
11
+ const layoutFile = path.join(workspaceRoot, "apps/minimal/page/(home)/e2e/_layout.tsx");
12
+ const storeFile = path.join(workspaceRoot, "apps/minimal/lib/_minimal/minimal.store.ts");
13
+ const port = Number(process.env.AKAN_CSR_E2E_FAST_REFRESH_PORT ?? 8491);
14
+
15
+ interface CsrWindow {
16
+ __akan?: { generation: number; inspect(): { modules: number } };
17
+ __AKAN_CSR_LAST_UPDATE__?: { generation: number; executed: string[] };
18
+ }
19
+
20
+ const markProbe = (source: string) =>
21
+ source.replace("data-e2e-probe={name}>", 'data-e2e-probe={name} data-e2e-hot="1">');
22
+ const ownSources = (executed: string[] = []) => executed.filter((id) => !id.includes("/.akan/generated/"));
23
+
24
+ describe.skipIf(!CsrE2eHarness.enabled)("CSR Fast Refresh (minimal, AKAN_DEV_CSR=registry)", () => {
25
+ let csr: CsrE2eHarness;
26
+
27
+ beforeAll(async () => {
28
+ csr = await CsrE2eHarness.start({ app: "minimal", port, env: { AKAN_DEV_CSR: "registry" } });
29
+ }, 240_000);
30
+
31
+ afterAll(async () => {
32
+ // biome-ignore lint/suspicious/noUnnecessaryConditions: unassigned when beforeAll threw before the harness started
33
+ await csr?.close();
34
+ }, 60_000);
35
+
36
+ const lastUpdate = async () =>
37
+ await csr.evaluate(() => (window as unknown as CsrWindow).__AKAN_CSR_LAST_UPDATE__ ?? null);
38
+ const generation = async () => await csr.evaluate(() => (window as unknown as CsrWindow).__akan?.generation);
39
+ const generationPasses = async (previous: number) =>
40
+ await csr.waitFor((last: number) => ((window as unknown as CsrWindow).__akan?.generation ?? 0) > last, {
41
+ args: [previous],
42
+ timeout: 20_000,
43
+ });
44
+ const inputValue = async () =>
45
+ await csr.evaluate(
46
+ () => (document.querySelector('[data-e2e-probe="tab-a"] input') as HTMLInputElement | null)?.value,
47
+ );
48
+ const tabAItemIdIs = async (itemId: string) =>
49
+ await csr.waitFor(
50
+ (expected: string) =>
51
+ document.querySelector('[data-e2e-probe="tab-a"] [data-e2e="item-id"]')?.textContent === expected,
52
+ { args: [itemId], timeout: 30_000 },
53
+ );
54
+
55
+ test("the page boots from the registry dev bundle", async () => {
56
+ await csr.open(TAB_A);
57
+ const booted = await csr.evaluate(() => {
58
+ const runtime = (window as unknown as CsrWindow).__akan;
59
+ return { generation: runtime?.generation, modules: runtime?.inspect().modules ?? 0 };
60
+ });
61
+ expect(booted.generation).toBeGreaterThan(0);
62
+ expect(booted.modules).toBeGreaterThan(100);
63
+ }, 60_000);
64
+
65
+ test("a component edit applies in place, keeping hook state and the DOM", async () => {
66
+ await csr.open(TAB_A);
67
+ await csr.type('[data-e2e-probe="tab-a"] input', "typed");
68
+ const mountId = (await csr.probe("tab-a"))?.mountId;
69
+ await csr.editSource(probeFile, markProbe, async () => {
70
+ await csr.waitFor(() => document.querySelector('[data-e2e-hot="1"]') !== null, { timeout: 20_000 });
71
+ expect(await csr.reloaded()).toBe(false);
72
+ expect((await csr.probe("tab-a"))?.mountId).toBe(mountId);
73
+ expect(await inputValue()).toBe("typed");
74
+ expect((await lastUpdate())?.executed).toEqual(["apps/minimal/ui/StackProbe.tsx"]);
75
+ });
76
+ await csr.waitFor(() => document.querySelector('[data-e2e-hot="1"]') === null, { timeout: 20_000 });
77
+ expect(await csr.reloaded()).toBe(false);
78
+ }, 90_000);
79
+
80
+ test("a store edit re-runs the store root and nothing above it", async () => {
81
+ await csr.open(TAB_A);
82
+ await csr.type('[data-e2e-probe="tab-a"] input', "kept");
83
+ const before = (await generation()) ?? 0;
84
+ const edited = await csr.editSource(
85
+ storeFile,
86
+ (source) => source.replace(" // action\n", " // action\n touchForE2e() {\n this.set({});\n }\n"),
87
+ async () => {
88
+ await generationPasses(before);
89
+ expect(await csr.reloaded()).toBe(false);
90
+ expect(((await lastUpdate())?.executed ?? []).sort()).toEqual([
91
+ "apps/minimal/lib/_minimal/minimal.store.ts",
92
+ "apps/minimal/lib/st.ts",
93
+ ]);
94
+ expect(await inputValue()).toBe("kept");
95
+ return (await generation()) ?? 0;
96
+ },
97
+ );
98
+ await generationPasses(edited);
99
+ }, 90_000);
100
+
101
+ test("a build error shows the overlay without reloading, and the fix applies in place", async () => {
102
+ await csr.open(TAB_A);
103
+ await csr.editSource(
104
+ probeFile,
105
+ (source) => source.replace(" return (\n", " return ((\n"),
106
+ async () => {
107
+ await csr.waitFor(() => document.querySelector('.__akan_hmr_overlay[data-status="error"]') !== null, {
108
+ timeout: 20_000,
109
+ });
110
+ expect(await csr.reloaded()).toBe(false);
111
+ },
112
+ );
113
+ await csr.editSource(probeFile, markProbe, async () => {
114
+ await csr.waitFor(() => document.querySelector('[data-e2e-hot="1"]') !== null, { timeout: 20_000 });
115
+ expect(await csr.reloaded()).toBe(false);
116
+ });
117
+ await csr.waitFor(() => document.querySelector('[data-e2e-hot="1"]') === null, { timeout: 20_000 });
118
+ }, 90_000);
119
+
120
+ test("a page edit swaps the route in place, keeping the page's state", async () => {
121
+ await csr.open(TAB_A);
122
+ await csr.type('[data-e2e-probe="tab-a"] input', "kept");
123
+ const mountId = (await csr.probe("tab-a"))?.mountId;
124
+ await csr.editSource(
125
+ tabAFile,
126
+ (source) => source.replace('name="tab-a"', 'name="tab-a" itemId="edited"'),
127
+ async () => {
128
+ await tabAItemIdIs("edited");
129
+ expect(await csr.reloaded()).toBe(false);
130
+ expect((await csr.probe("tab-a"))?.mountId).toBe(mountId);
131
+ expect(await inputValue()).toBe("kept");
132
+ expect((await lastUpdate())?.executed).toEqual(["apps/minimal/page/(home)/e2e/stack/tab-a.tsx"]);
133
+ },
134
+ );
135
+ await tabAItemIdIs("");
136
+ expect(await csr.reloaded()).toBe(false);
137
+ }, 90_000);
138
+
139
+ test("an edit to another page leaves the current page's render alone", async () => {
140
+ await csr.open(TAB_A);
141
+ const before = (await generation()) ?? 0;
142
+ const renders = (await csr.probe("tab-a"))?.renders;
143
+ const edited = await csr.editSource(
144
+ tabBFile,
145
+ (source) => source.replace('name="tab-b"', 'name="tab-b" itemId="edited"'),
146
+ async () => {
147
+ await generationPasses(before);
148
+ await Bun.sleep(300);
149
+ expect((await lastUpdate())?.executed).toEqual(["apps/minimal/page/(home)/e2e/stack/tab-b.tsx"]);
150
+ expect((await csr.probe("tab-a"))?.renders).toBe(renders);
151
+ expect(await csr.reloaded()).toBe(false);
152
+ return (await generation()) ?? 0;
153
+ },
154
+ );
155
+ await generationPasses(edited);
156
+ }, 90_000);
157
+
158
+ test("a layout edit swaps the layout in place, keeping the page under it", async () => {
159
+ await csr.open(TAB_A);
160
+ await csr.type('[data-e2e-probe="tab-a"] input', "kept");
161
+ const mountId = (await csr.probe("tab-a"))?.mountId;
162
+ await csr.editSource(
163
+ layoutFile,
164
+ (source) => source.replace("<>{children}</>", '<>{children}<span data-e2e-layout="edited" /></>'),
165
+ async () => {
166
+ await csr.waitFor(() => document.querySelector('[data-e2e-layout="edited"]') !== null, { timeout: 30_000 });
167
+ expect(await csr.reloaded()).toBe(false);
168
+ expect((await csr.probe("tab-a"))?.mountId).toBe(mountId);
169
+ expect(await inputValue()).toBe("kept");
170
+ //? A layout reaches the entry through the root-layout wrapper route discovery generates around it.
171
+ expect(ownSources((await lastUpdate())?.executed)).toEqual(["apps/minimal/page/(home)/e2e/_layout.tsx"]);
172
+ },
173
+ );
174
+ await csr.waitFor(() => document.querySelector('[data-e2e-layout="edited"]') === null, { timeout: 30_000 });
175
+ expect(await csr.reloaded()).toBe(false);
176
+ }, 90_000);
177
+
178
+ test("a page edit keeps the stack: the page it covers comes back with its state", async () => {
179
+ await csr.open(TAB_A);
180
+ await csr.type('[data-e2e-probe="tab-a"] input', "under");
181
+ const mountId = (await csr.probe("tab-a"))?.mountId;
182
+ await csr.navigate("/e2e/stack/item?id=1");
183
+ const currentItemId = async () =>
184
+ await csr.text('[id^="pageContainer-"]:not([aria-hidden="true"]) [data-e2e-probe="item"] [data-e2e="item-id"]');
185
+ await csr.editSource(
186
+ itemFile,
187
+ (source) => source.replace('itemId={id ?? ""}', 'itemId={"edited-" + (id ?? "")}'),
188
+ async () => {
189
+ await csr.waitFor(
190
+ () =>
191
+ [...document.querySelectorAll('[data-e2e-probe="item"] [data-e2e="item-id"]')].some(
192
+ (element) => element.textContent === "edited-1",
193
+ ),
194
+ { timeout: 30_000 },
195
+ );
196
+ expect(await currentItemId()).toEqual(["edited-1"]);
197
+ expect((await lastUpdate())?.executed).toEqual(["apps/minimal/page/(home)/e2e/stack/item.tsx"]);
198
+ },
199
+ );
200
+ await csr.waitFor(
201
+ () =>
202
+ [...document.querySelectorAll('[data-e2e-probe="item"] [data-e2e="item-id"]')].some(
203
+ (element) => element.textContent === "1",
204
+ ),
205
+ { timeout: 30_000 },
206
+ );
207
+ await csr.back();
208
+ expect(await inputValue()).toBe("under");
209
+ expect((await csr.probe("tab-a"))?.mountId).toBe(mountId);
210
+ expect(await csr.reloaded()).toBe(false);
211
+ }, 90_000);
212
+ });
213
+
214
+ describe.skipIf(!CsrE2eHarness.enabled)("CSR single-file artifact (minimal, AKAN_DEV_CSR=artifact)", () => {
215
+ let csr: CsrE2eHarness;
216
+
217
+ beforeAll(async () => {
218
+ csr = await CsrE2eHarness.start({ app: "minimal", port, env: { AKAN_DEV_CSR: "artifact" } });
219
+ }, 240_000);
220
+
221
+ afterAll(async () => {
222
+ // biome-ignore lint/suspicious/noUnnecessaryConditions: unassigned when beforeAll threw before the harness started
223
+ await csr?.close();
224
+ }, 60_000);
225
+
226
+ test("a component edit reloads the tab onto the rebuilt artifact", async () => {
227
+ await csr.open(TAB_A);
228
+ await csr.editSource(probeFile, markProbe, async () => {
229
+ await csr.waitFor(() => document.querySelector('[data-e2e-hot="1"]') !== null, { timeout: 30_000 });
230
+ expect(await csr.reloaded()).toBe(true);
231
+ });
232
+ }, 90_000);
233
+ });