@akanjs/devkit 3.0.0-beta.26 → 3.0.0-beta.27
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/csrE2e/csrE2eHarness.fixture.ts +55 -5
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
- package/frontendBuild/csrDevArtifactWriter.ts +4 -0
- package/frontendBuild/csrDevBundler.ts +1 -1
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +178 -6
- package/frontendBuild/csrDevModuleCompiler.ts +89 -18
- package/frontendBuild/csrDevPaths.ts +1 -0
- package/frontendBuild/csrDevResolver.ts +89 -9
- package/frontendBuild/ssrDevBundler.ts +1 -1
- package/mobile/nativeApp.test.ts +15 -0
- package/mobile/nativeApp.ts +54 -14
- package/mobile/nativeAppLine.test.ts +62 -0
- package/mobile/nativeAppLine.ts +63 -0
- package/package.json +2 -2
|
@@ -54,6 +54,7 @@ export class CsrE2eHarness {
|
|
|
54
54
|
readonly #lang: string;
|
|
55
55
|
readonly #mobileTarget: string;
|
|
56
56
|
readonly #edited = new Map<string, string>();
|
|
57
|
+
readonly #console: string[];
|
|
57
58
|
#marker = "";
|
|
58
59
|
#csr: boolean = true;
|
|
59
60
|
|
|
@@ -63,14 +64,39 @@ export class CsrE2eHarness {
|
|
|
63
64
|
server: CsrE2eServer | null;
|
|
64
65
|
lang: string;
|
|
65
66
|
mobileTarget: string;
|
|
67
|
+
console: string[];
|
|
66
68
|
}) {
|
|
67
69
|
this.origin = options.origin;
|
|
68
70
|
this.#view = options.view;
|
|
71
|
+
this.#console = options.console;
|
|
69
72
|
this.#server = options.server;
|
|
70
73
|
this.#lang = options.lang;
|
|
71
74
|
this.#mobileTarget = options.mobileTarget;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/** What keeps this host from driving a page, or null when a `Bun.WebView` opens one and answers. */
|
|
78
|
+
static async preflight(backend = CsrE2eHarness.#defaultBackend()): Promise<string | null> {
|
|
79
|
+
if (typeof (Bun as { WebView?: unknown }).WebView !== "function") return `Bun ${Bun.version} has no Bun.WebView`;
|
|
80
|
+
let view: Bun.WebView | null = null;
|
|
81
|
+
try {
|
|
82
|
+
const opened = new Bun.WebView({
|
|
83
|
+
width: 320,
|
|
84
|
+
height: 240,
|
|
85
|
+
backend: backend === "chrome" ? { type: "chrome", url: false } : backend,
|
|
86
|
+
});
|
|
87
|
+
view = opened;
|
|
88
|
+
const answer = await Promise.race([
|
|
89
|
+
opened.navigate("about:blank").then(async () => await opened.evaluate<number>("1 + 1")),
|
|
90
|
+
Bun.sleep(30_000).then(() => null),
|
|
91
|
+
]);
|
|
92
|
+
return answer === 2 ? null : `a ${backend} Bun.WebView did not answer within 30s`;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return `a ${backend} Bun.WebView cannot start: ${error instanceof Error ? error.message : String(error)}`;
|
|
95
|
+
} finally {
|
|
96
|
+
view?.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
/** E2E suites run only with `AKAN_CSR_E2E=1`: they boot a dev server and a browser. */
|
|
75
101
|
static get enabled() {
|
|
76
102
|
return process.env.AKAN_CSR_E2E === "1";
|
|
@@ -91,13 +117,19 @@ export class CsrE2eHarness {
|
|
|
91
117
|
await CsrE2eHarness.#waitForHealth(origin, server, options.startTimeoutMs ?? 180_000);
|
|
92
118
|
const { width, height } = { width: 390, height: 844, ...options.viewport };
|
|
93
119
|
const backend = options.backend ?? CsrE2eHarness.#defaultBackend();
|
|
120
|
+
const messages: string[] = [];
|
|
121
|
+
const print = process.env.AKAN_CSR_E2E_CONSOLE === "1";
|
|
94
122
|
//? `url: false` spawns a fresh Chrome instead of attaching to one the developer runs with remote debugging on.
|
|
95
123
|
const view = new Bun.WebView({
|
|
96
124
|
width,
|
|
97
125
|
height,
|
|
98
126
|
backend: backend === "chrome" ? { type: "chrome", url: false } : backend,
|
|
99
|
-
//? AKAN_CSR_E2E_CONSOLE=1 prints
|
|
100
|
-
|
|
127
|
+
//? Kept for `consoleMessages()`; AKAN_CSR_E2E_CONSOLE=1 also prints it, the only view of a hydration warning or
|
|
128
|
+
//? a failed patch. An uncaught error is not a console call and never arrives here.
|
|
129
|
+
console: (type: string, ...args: unknown[]) => {
|
|
130
|
+
messages.push(`[${type}] ${args.map(CsrE2eHarness.#formatArg).join(" ")}`);
|
|
131
|
+
if (print) globalThis.console.info(`[page ${type}]`, ...args);
|
|
132
|
+
},
|
|
101
133
|
});
|
|
102
134
|
return new CsrE2eHarness({
|
|
103
135
|
origin,
|
|
@@ -105,6 +137,7 @@ export class CsrE2eHarness {
|
|
|
105
137
|
server,
|
|
106
138
|
lang: options.lang ?? "en",
|
|
107
139
|
mobileTarget: options.mobileTarget ?? "e2e",
|
|
140
|
+
console: messages,
|
|
108
141
|
});
|
|
109
142
|
} catch (error) {
|
|
110
143
|
await CsrE2eHarness.#stopServer(server);
|
|
@@ -120,6 +153,7 @@ export class CsrE2eHarness {
|
|
|
120
153
|
url.searchParams.set("akanMobileTarget", this.#mobileTarget);
|
|
121
154
|
}
|
|
122
155
|
this.#csr = csr;
|
|
156
|
+
this.#console.length = 0;
|
|
123
157
|
await this.#view.navigate(url.toString());
|
|
124
158
|
await this.#markBoot();
|
|
125
159
|
}
|
|
@@ -346,6 +380,11 @@ export class CsrE2eHarness {
|
|
|
346
380
|
return [await read(logPath), await read(logPath.replace(/\.log$/, ".err.log"))];
|
|
347
381
|
}
|
|
348
382
|
|
|
383
|
+
/** The page's console calls since `open()`, as `[type] text`. */
|
|
384
|
+
consoleMessages(): string[] {
|
|
385
|
+
return [...this.#console];
|
|
386
|
+
}
|
|
387
|
+
|
|
349
388
|
/** Whether the page reloaded since `open()`: the boot marker lives only in the page `open()` loaded. */
|
|
350
389
|
async reloaded() {
|
|
351
390
|
const marker = await this.evaluate(
|
|
@@ -378,7 +417,17 @@ export class CsrE2eHarness {
|
|
|
378
417
|
await CsrE2eHarness.#stopServer(this.#server);
|
|
379
418
|
}
|
|
380
419
|
|
|
381
|
-
static #
|
|
420
|
+
static #formatArg(arg: unknown): string {
|
|
421
|
+
if (typeof arg === "string") return arg;
|
|
422
|
+
try {
|
|
423
|
+
return JSON.stringify(arg) ?? String(arg);
|
|
424
|
+
} catch {
|
|
425
|
+
// A circular descriptor still names itself.
|
|
426
|
+
return String(arg);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
static #defaultBackend(): "webkit" | "chrome" {
|
|
382
431
|
const requested = process.env.AKAN_CSR_E2E_BACKEND;
|
|
383
432
|
if (requested === "webkit" || requested === "chrome") return requested;
|
|
384
433
|
return process.platform === "darwin" ? "webkit" : "chrome";
|
|
@@ -411,8 +460,9 @@ export class CsrE2eHarness {
|
|
|
411
460
|
const errPath = logPath.replace(/\.log$/, ".err.log");
|
|
412
461
|
//? A `Bun.file` sink writes from offset 0 without truncating, so the last run's tail would outlive this one's start.
|
|
413
462
|
await Promise.all([Bun.write(logPath, ""), Bun.write(errPath, "")]);
|
|
414
|
-
//? Its own process group, so stopping it reaches the gateway, the builder and every replica it spawned.
|
|
415
|
-
|
|
463
|
+
//? Its own process group, so stopping it reaches the gateway, the builder and every replica it spawned. That group
|
|
464
|
+
//? outlives a run killed before it could stop it, so `--kill` takes the suite's own port back from it first.
|
|
465
|
+
const proc = Bun.spawn(["bun", cli, "start", app, "--plain", "--kill"], {
|
|
416
466
|
cwd: workspaceRoot,
|
|
417
467
|
env: { ...process.env, AKAN_DEV_PORT: String(port), ...env },
|
|
418
468
|
stdout: Bun.file(logPath),
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { CsrE2eHarness } from "./csrE2eHarness.fixture";
|
|
3
|
+
|
|
4
|
+
//* Where a CSR page lands, not only what its console says. CSR's root layout renders the pages in a frame of its own
|
|
5
|
+
//* and hides what it wraps, so a layout the route table puts at the root wraps no page: an app with its root layout
|
|
6
|
+
//* in a route group once rendered nothing, then rendered its dashboard outside the lock screen that wraps it.
|
|
7
|
+
const EXPLORE = "/explore";
|
|
8
|
+
const TAB_A = "/e2e/stack/tab-a";
|
|
9
|
+
//? The web layout draws no bottom chrome, so the `(tab)` layout shows in the route table, not in the text.
|
|
10
|
+
const exploreText = ["Where are you headed today?", "Popular stays right now"];
|
|
11
|
+
|
|
12
|
+
interface RouteTableWindow {
|
|
13
|
+
__akan?: { inspect(): { executed: string[] }; require(id: string): unknown };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
//? A page's element is inside its layout's marker, outside the hidden provider container, and on screen.
|
|
17
|
+
const placement = async (harness: CsrE2eHarness, page: string) =>
|
|
18
|
+
await harness.evaluate((selector: string) => {
|
|
19
|
+
const element = document.querySelector(selector);
|
|
20
|
+
return {
|
|
21
|
+
rendered: element !== null,
|
|
22
|
+
inLayout: element?.closest('[data-e2e="public-layout"]') != null,
|
|
23
|
+
hidden: element?.closest("#csr-provider-children") != null,
|
|
24
|
+
visible: element?.checkVisibility() ?? false,
|
|
25
|
+
};
|
|
26
|
+
}, `[data-e2e="${page}"]`);
|
|
27
|
+
|
|
28
|
+
describe.skipIf(!CsrE2eHarness.enabled)("CSR layouts under a root layout (minimal, (tab))", () => {
|
|
29
|
+
let csr: CsrE2eHarness;
|
|
30
|
+
|
|
31
|
+
beforeAll(async () => {
|
|
32
|
+
csr = await CsrE2eHarness.start({
|
|
33
|
+
app: "minimal",
|
|
34
|
+
port: Number(process.env.AKAN_CSR_E2E_ROOT_LAYOUT_PORT ?? 8497),
|
|
35
|
+
});
|
|
36
|
+
}, 240_000);
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
// biome-ignore lint/suspicious/noUnnecessaryConditions: unassigned when beforeAll threw before the harness started
|
|
40
|
+
await csr?.close();
|
|
41
|
+
}, 60_000);
|
|
42
|
+
|
|
43
|
+
const rendered = async (texts: string[]) =>
|
|
44
|
+
await csr.waitFor(
|
|
45
|
+
(want: string[]) => want.every((text) => (document.getElementById("root")?.innerText ?? "").includes(text)),
|
|
46
|
+
{ args: [texts], timeout: 30_000 },
|
|
47
|
+
);
|
|
48
|
+
//? The table the running bundle built, read through the dev registry: what the router renders from.
|
|
49
|
+
const layerCounts = async () =>
|
|
50
|
+
await csr.evaluate(() => {
|
|
51
|
+
const akan = (window as unknown as RouteTableWindow).__akan;
|
|
52
|
+
const id = akan?.inspect().executed.find((executed) => executed.endsWith("webkit/CsrRouteTable.ts"));
|
|
53
|
+
const table = id
|
|
54
|
+
? (akan?.require(id) as {
|
|
55
|
+
CsrRouteTable: {
|
|
56
|
+
active: {
|
|
57
|
+
snapshot(): { pathRoutes: { path: string; renderRootLayouts: unknown[]; renderLayouts: unknown[] }[] };
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
})
|
|
61
|
+
: null;
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
(table?.CsrRouteTable.active.snapshot().pathRoutes ?? []).map((route) => [
|
|
64
|
+
route.path,
|
|
65
|
+
{ root: route.renderRootLayouts.length, layouts: route.renderLayouts.length },
|
|
66
|
+
]),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a nested root boundary renders with its pages, not at the root", async () => {
|
|
71
|
+
await csr.open(EXPLORE);
|
|
72
|
+
await rendered(exploreText);
|
|
73
|
+
const counts = await layerCounts();
|
|
74
|
+
expect(counts["/:lang/explore"]?.root).toBe(counts["/:lang/e2e/stack/tab-a"]?.root);
|
|
75
|
+
expect(counts["/:lang/explore"]?.layouts).toBe(1);
|
|
76
|
+
expect(csr.consoleMessages().filter((message) => /no root layout|error occurred/.test(message))).toEqual([]);
|
|
77
|
+
}, 60_000);
|
|
78
|
+
|
|
79
|
+
test("leaving the group and coming back renders each side", async () => {
|
|
80
|
+
await csr.open(EXPLORE);
|
|
81
|
+
await rendered(exploreText);
|
|
82
|
+
await csr.navigate(TAB_A);
|
|
83
|
+
await csr.waitFor(() => document.querySelector('[data-e2e-probe="tab-a"]') !== null, { timeout: 30_000 });
|
|
84
|
+
await csr.back();
|
|
85
|
+
await rendered(exploreText);
|
|
86
|
+
}, 90_000);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe.skipIf(!CsrE2eHarness.enabled)("a root layout in a route group (groupedroot, page/(app)/_layout)", () => {
|
|
90
|
+
let harness: CsrE2eHarness;
|
|
91
|
+
|
|
92
|
+
beforeAll(async () => {
|
|
93
|
+
harness = await CsrE2eHarness.start({
|
|
94
|
+
app: "groupedroot",
|
|
95
|
+
port: Number(process.env.AKAN_CSR_E2E_GROUPED_ROOT_PORT ?? 8498),
|
|
96
|
+
});
|
|
97
|
+
}, 240_000);
|
|
98
|
+
|
|
99
|
+
afterAll(async () => {
|
|
100
|
+
// biome-ignore lint/suspicious/noUnnecessaryConditions: unassigned when beforeAll threw before the harness started
|
|
101
|
+
await harness?.close();
|
|
102
|
+
}, 60_000);
|
|
103
|
+
|
|
104
|
+
const open = async (path: string, page: string, csr: boolean) => {
|
|
105
|
+
await harness.open(path, { csr });
|
|
106
|
+
await harness.waitFor((selector: string) => document.querySelector(selector) !== null, {
|
|
107
|
+
args: [`[data-e2e="${page}"]`],
|
|
108
|
+
timeout: 30_000,
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
test.each([
|
|
113
|
+
["SSR", false],
|
|
114
|
+
["CSR", true],
|
|
115
|
+
] as const)(
|
|
116
|
+
"%s: a page renders inside the (public) layout that wraps it",
|
|
117
|
+
async (_mode, csr) => {
|
|
118
|
+
for (const [path, page] of [
|
|
119
|
+
["/dashboard", "dashboard"],
|
|
120
|
+
["/", "index"],
|
|
121
|
+
] as const) {
|
|
122
|
+
await open(path, page, csr);
|
|
123
|
+
expect(await placement(harness, page)).toEqual({
|
|
124
|
+
rendered: true,
|
|
125
|
+
inLayout: true,
|
|
126
|
+
hidden: false,
|
|
127
|
+
visible: true,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
expect(harness.consoleMessages().filter((message) => /no root layout|error occurred/.test(message))).toEqual([]);
|
|
131
|
+
},
|
|
132
|
+
90_000,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CsrE2eHarness } from "./csrE2eHarness.fixture";
|
|
5
|
+
|
|
6
|
+
//* Package shapes that once turned a whole dev vendor file into a SyntaxError or a startup throw, each followed by
|
|
7
|
+
//* nothing but distant MissingModuleErrors. Written in for the run only: no app of the repo depends on them.
|
|
8
|
+
const workspaceRoot = path.resolve(import.meta.dir, "../../../..");
|
|
9
|
+
const vendorRoot = path.join(workspaceRoot, "node_modules/@akan-e2e");
|
|
10
|
+
const probeFile = path.join(workspaceRoot, "apps/minimal/ui/RegistryVendor.tsx");
|
|
11
|
+
const pageFile = path.join(workspaceRoot, "apps/minimal/page/(home)/e2e/vendor.tsx");
|
|
12
|
+
const cliFile = path.join(workspaceRoot, "dist/pkgs/@akanjs/cli/index.js");
|
|
13
|
+
const VENDOR = "/e2e/vendor";
|
|
14
|
+
const expected = { prebundled: 3, metaDev: true, metaUrl: "http:", used: "used", inspectish: "yes" };
|
|
15
|
+
const broken =
|
|
16
|
+
/SyntaxError|MissingModuleError|no module registered|Node built-in|left out of the dev registry|error occurred in one of your React components/;
|
|
17
|
+
|
|
18
|
+
const packages: Record<string, string> = {
|
|
19
|
+
//? esbuild's own interop helpers, as the @mermaid-js/parser chunks ship them.
|
|
20
|
+
"prebundled/package.json": JSON.stringify({ name: "@akan-e2e/prebundled", type: "module", main: "chunk.mjs" }),
|
|
21
|
+
"prebundled/chunk.mjs": `var __create = Object.create;
|
|
22
|
+
var __defProp = Object.defineProperty;
|
|
23
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
24
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
25
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
26
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
27
|
+
var __copyProps = (to, from, except, desc) => {
|
|
28
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
29
|
+
for (let key of __getOwnPropNames(from))
|
|
30
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
31
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
32
|
+
}
|
|
33
|
+
return to;
|
|
34
|
+
};
|
|
35
|
+
var __reExport = (target, mod, secondTarget) => (__copyProps(target, mod, "default"), secondTarget && __copyProps(secondTarget, mod, "default"));
|
|
36
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod));
|
|
37
|
+
var parts = {};
|
|
38
|
+
__reExport(parts, { a: 1 });
|
|
39
|
+
var wrapped = __toESM({ b: 2 });
|
|
40
|
+
export const prebundled = parts.a + wrapped.b;
|
|
41
|
+
`,
|
|
42
|
+
//? jotai's and zustand's ESM development check.
|
|
43
|
+
"meta-env/package.json": JSON.stringify({
|
|
44
|
+
name: "@akan-e2e/meta-env",
|
|
45
|
+
type: "module",
|
|
46
|
+
exports: { ".": "./esm/index.mjs" },
|
|
47
|
+
}),
|
|
48
|
+
"meta-env/esm/index.mjs": `export const metaDev = (import.meta.env ? import.meta.env.MODE : void 0) !== "production";
|
|
49
|
+
export const metaUrl = new URL(import.meta.url).protocol;
|
|
50
|
+
`,
|
|
51
|
+
//? vfile behind unified: a side-effect-free barrel whose unused re-exports reach Node-only files through `#imports`.
|
|
52
|
+
"barrel/package.json": JSON.stringify({
|
|
53
|
+
name: "@akan-e2e/barrel",
|
|
54
|
+
type: "module",
|
|
55
|
+
main: "index.js",
|
|
56
|
+
sideEffects: false,
|
|
57
|
+
imports: {
|
|
58
|
+
"#where": { node: "./where.node.js", default: "./where.browser.js" },
|
|
59
|
+
"#other": { node: "./other.node.js", default: "./other.browser.js" },
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
"barrel/index.js": `export { used } from "./used.js";
|
|
63
|
+
export { unused } from "./unused.js";
|
|
64
|
+
export { other } from "#other";
|
|
65
|
+
`,
|
|
66
|
+
"barrel/used.js": `export const used = "used";\n`,
|
|
67
|
+
"barrel/unused.js": `import { where } from "#where";\nexport const unused = where;\n`,
|
|
68
|
+
"barrel/where.node.js": `import path from "node:path";\nexport const where = path.sep;\n`,
|
|
69
|
+
"barrel/where.browser.js": `export const where = "browser";\n`,
|
|
70
|
+
"barrel/other.node.js": `import path from "node:path";\nexport const other = path.sep;\n`,
|
|
71
|
+
"barrel/other.browser.js": `export const other = "browser";\n`,
|
|
72
|
+
//? object-inspect: a `browser` map that turns a Node-only file off.
|
|
73
|
+
"inspectish/package.json": JSON.stringify({
|
|
74
|
+
name: "@akan-e2e/inspectish",
|
|
75
|
+
main: "index.js",
|
|
76
|
+
browser: { "./util.inspect.js": false },
|
|
77
|
+
}),
|
|
78
|
+
//? Bun's own bundle gives the require `() => ({})` where the registry gives `{}`; either way it must not throw.
|
|
79
|
+
"inspectish/index.js": `var inspect = require("./util.inspect");\nmodule.exports = { loaded: inspect ? "yes" : "no" };\n`,
|
|
80
|
+
"inspectish/util.inspect.js": `module.exports = require("util").inspect;\n`,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const probeSource = `"use client";
|
|
84
|
+
import { used } from "@akan-e2e/barrel";
|
|
85
|
+
import inspectish from "@akan-e2e/inspectish";
|
|
86
|
+
import { metaDev, metaUrl } from "@akan-e2e/meta-env";
|
|
87
|
+
import { prebundled } from "@akan-e2e/prebundled";
|
|
88
|
+
import { useEffect, useState } from "react";
|
|
89
|
+
|
|
90
|
+
export const RegistryVendor = () => {
|
|
91
|
+
const [hydrated, setHydrated] = useState(false);
|
|
92
|
+
useEffect(() => setHydrated(true), []);
|
|
93
|
+
return (
|
|
94
|
+
<div data-e2e="vendor" data-e2e-hydrated={hydrated ? "1" : "0"} data-e2e-hot="0">
|
|
95
|
+
{JSON.stringify({ prebundled, metaDev, metaUrl, used, inspectish: inspectish.loaded })}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
const pageSource = `import { page } from "akanjs/client";
|
|
102
|
+
import { RegistryVendor } from "../../../ui/RegistryVendor";
|
|
103
|
+
|
|
104
|
+
export default page()
|
|
105
|
+
.config({ transition: "none", gesture: false })
|
|
106
|
+
.render(() => (
|
|
107
|
+
<main>
|
|
108
|
+
<RegistryVendor />
|
|
109
|
+
</main>
|
|
110
|
+
));
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
interface VendorWindow {
|
|
114
|
+
__akan?: { has(id: string): boolean; inspect(): { started: boolean; failed: boolean; executed: string[] } };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe.skipIf(!CsrE2eHarness.enabled)("dev registries with the package shapes that broke them (minimal)", () => {
|
|
118
|
+
//? Run first too, and safe to run twice: a run killed mid-way (a timeout, SIGKILL, a Jenkins abort on a reused
|
|
119
|
+
//? workspace) leaves the packages, the probe and the page behind, and `akan sync` rebuilds the barrel it joined.
|
|
120
|
+
const cleanup = async () => {
|
|
121
|
+
await Promise.all([
|
|
122
|
+
rm(vendorRoot, { recursive: true, force: true }),
|
|
123
|
+
rm(probeFile, { force: true }),
|
|
124
|
+
rm(pageFile, { force: true }),
|
|
125
|
+
]);
|
|
126
|
+
for (const command of [
|
|
127
|
+
["bun", path.join(workspaceRoot, "pkgs/@akanjs/cli/build.ts")],
|
|
128
|
+
["bun", cliFile, "sync", "minimal"],
|
|
129
|
+
]) {
|
|
130
|
+
const done = Bun.spawnSync(command, { cwd: workspaceRoot, stdout: "ignore" });
|
|
131
|
+
if (done.exitCode !== 0)
|
|
132
|
+
throw new Error(`[csr-e2e] ${command.slice(1).join(" ")} failed: ${done.stderr.toString()}`);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
beforeAll(async () => {
|
|
137
|
+
await cleanup();
|
|
138
|
+
for (const [file, text] of Object.entries(packages)) await Bun.write(path.join(vendorRoot, file), text);
|
|
139
|
+
await Bun.write(probeFile, probeSource);
|
|
140
|
+
await Bun.write(pageFile, pageSource);
|
|
141
|
+
}, 120_000);
|
|
142
|
+
|
|
143
|
+
afterAll(cleanup, 120_000);
|
|
144
|
+
|
|
145
|
+
describe.each([
|
|
146
|
+
["registry", 8495],
|
|
147
|
+
["artifact", 8496],
|
|
148
|
+
] as const)("AKAN_DEV_CSR=%s", (mode, port) => {
|
|
149
|
+
let harness: CsrE2eHarness;
|
|
150
|
+
|
|
151
|
+
beforeAll(async () => {
|
|
152
|
+
harness = await CsrE2eHarness.start({ app: "minimal", port, env: { AKAN_DEV_CSR: mode } });
|
|
153
|
+
}, 240_000);
|
|
154
|
+
|
|
155
|
+
afterAll(async () => {
|
|
156
|
+
// biome-ignore lint/suspicious/noUnnecessaryConditions: unassigned when beforeAll threw before the harness started
|
|
157
|
+
await harness?.close();
|
|
158
|
+
}, 60_000);
|
|
159
|
+
|
|
160
|
+
const probeValues = async () =>
|
|
161
|
+
JSON.parse((await harness.text('[data-e2e="vendor"]', { mounted: true }))[0] ?? "null") as typeof expected | null;
|
|
162
|
+
const openVendor = async (csr: boolean) => {
|
|
163
|
+
await harness.open(VENDOR, { csr });
|
|
164
|
+
await harness.waitFor(
|
|
165
|
+
() => document.querySelector('[data-e2e="vendor"]')?.getAttribute("data-e2e-hydrated") === "1",
|
|
166
|
+
{ timeout: 60_000 },
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
const registryState = async () =>
|
|
170
|
+
await harness.evaluate(() => {
|
|
171
|
+
const akan = (window as unknown as VendorWindow).__akan;
|
|
172
|
+
if (!akan) return null;
|
|
173
|
+
const { started, failed, executed } = akan.inspect();
|
|
174
|
+
return {
|
|
175
|
+
started,
|
|
176
|
+
failed,
|
|
177
|
+
refresh: executed.some((id) => id.includes("react-refresh/")),
|
|
178
|
+
vendors: [
|
|
179
|
+
"node_modules/@akan-e2e/prebundled/chunk.mjs",
|
|
180
|
+
"node_modules/@akan-e2e/meta-env/esm/index.mjs",
|
|
181
|
+
"node_modules/@akan-e2e/barrel/unused.js",
|
|
182
|
+
"node_modules/@akan-e2e/inspectish/index.js",
|
|
183
|
+
].filter((id) => akan.has(id)),
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("an SSR page hydrates its client code with every package shape in it", async () => {
|
|
188
|
+
await openVendor(false);
|
|
189
|
+
expect(await probeValues()).toEqual(expected);
|
|
190
|
+
expect(await registryState()).toMatchObject({ started: true, failed: false });
|
|
191
|
+
expect((await registryState())?.vendors).toHaveLength(4);
|
|
192
|
+
expect(harness.consoleMessages().filter((message) => broken.test(message))).toEqual([]);
|
|
193
|
+
}, 120_000);
|
|
194
|
+
|
|
195
|
+
test("a CSR page boots and renders with every package shape in it", async () => {
|
|
196
|
+
await openVendor(true);
|
|
197
|
+
expect(await probeValues()).toEqual(expected);
|
|
198
|
+
const state = await registryState();
|
|
199
|
+
if (mode === "registry") expect(state).toMatchObject({ started: true, failed: false, refresh: true });
|
|
200
|
+
if (mode === "registry") expect(state?.vendors).toHaveLength(4);
|
|
201
|
+
expect(harness.consoleMessages().filter((message) => broken.test(message))).toEqual([]);
|
|
202
|
+
}, 120_000);
|
|
203
|
+
|
|
204
|
+
test("a save to the page's client code shows on the CSR page", async () => {
|
|
205
|
+
await openVendor(true);
|
|
206
|
+
await harness.editSource(
|
|
207
|
+
probeFile,
|
|
208
|
+
(source) => source.replace('data-e2e-hot="0"', 'data-e2e-hot="1"'),
|
|
209
|
+
async () => {
|
|
210
|
+
await harness.waitFor(
|
|
211
|
+
() => document.querySelector('[data-e2e="vendor"]')?.getAttribute("data-e2e-hot") === "1",
|
|
212
|
+
{ timeout: 60_000 },
|
|
213
|
+
);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
//? The registry patches in place; the single-file artifact reloads on every save, which is its contract.
|
|
217
|
+
expect(await harness.reloaded()).toBe(mode === "artifact");
|
|
218
|
+
expect(await probeValues()).toEqual(expected);
|
|
219
|
+
}, 120_000);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -76,6 +76,14 @@ describe("CsrDevArtifactWriter", () => {
|
|
|
76
76
|
expect(existsSync(path.join(dir, "graph.json"))).toBe(true);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test("a module a package's browser field turns off is written empty, and a Node built-in still throws", async () => {
|
|
80
|
+
const { writer, graph, dir } = await makeWriter();
|
|
81
|
+
graph.modules["a.ts"] = { vendor: false, mtimeMs: 0, hash: "", deps: ["stub:empty:fs", "stub:path"] };
|
|
82
|
+
const vendor = readFileSync(path.join(dir, await writer.writeVendor(graph)), "utf8");
|
|
83
|
+
expect(vendor).toContain('__akan.define("stub:empty:fs", function () {});');
|
|
84
|
+
expect(vendor).toContain("[akan-csr] path is a Node built-in the browser does not have");
|
|
85
|
+
});
|
|
86
|
+
|
|
79
87
|
test("rewrites an app.js a crash left behind the manifest's generation", async () => {
|
|
80
88
|
const { writer, graph, read } = await makeWriter();
|
|
81
89
|
await writer.writeApp(graph, { generation: 5, vendorFile: "v.js" });
|
|
@@ -114,6 +114,10 @@ export class CsrDevArtifactWriter {
|
|
|
114
114
|
const stubs = new Set(Object.values(graph.modules).flatMap((module) => module.deps.filter(CsrDevPaths.isStub)));
|
|
115
115
|
const lines = [await this.#helperDefinitions(graph, ids), ...(await this.#defineLines(ids))];
|
|
116
116
|
for (const stub of [...stubs].sort()) {
|
|
117
|
+
if (stub.startsWith(CsrDevPaths.emptyStubPrefix)) {
|
|
118
|
+
lines.push(`__akan.define(${JSON.stringify(stub)}, function () {});\n`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
117
121
|
const message = `[akan-csr] ${stub.slice(CsrDevPaths.stubPrefix.length)} is a Node built-in the browser does not have`;
|
|
118
122
|
lines.push(
|
|
119
123
|
`__akan.define(${JSON.stringify(stub)}, function () {\n throw new Error(${JSON.stringify(message)});\n});\n`,
|
|
@@ -13,7 +13,7 @@ import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
|
|
|
13
13
|
|
|
14
14
|
//* Dev-only CSR as a module registry, under `.akan/artifact/csr-dev`: one entry per basePath boots every page.
|
|
15
15
|
export class CsrDevBundler extends DevRegistryBundler {
|
|
16
|
-
static readonly #formatVersion =
|
|
16
|
+
static readonly #formatVersion = 4;
|
|
17
17
|
readonly reloadsOnEntryChange = true;
|
|
18
18
|
//? Detecting a page's async default parses it with TypeScript: on apps/akan every page each save cost ~100ms.
|
|
19
19
|
readonly #asyncDefaults = new Map<string, { mtimeMs: number; detected: Promise<boolean> }>();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { CsrDevFactoryCheck } from "./csrDevFactoryCheck";
|
|
3
|
+
|
|
4
|
+
const factoryOf = (body: string) =>
|
|
5
|
+
`function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {\n${body}\n}`;
|
|
6
|
+
|
|
7
|
+
describe("CsrDevFactoryCheck", () => {
|
|
8
|
+
test("a factory that parses as a classic script has no problem, twice over", () => {
|
|
9
|
+
const factory = factoryOf('var a = require("akan-module:x");\nmodule.exports = { a, label: "import.meta" };');
|
|
10
|
+
expect(CsrDevFactoryCheck.problemOf(factory)).toBeNull();
|
|
11
|
+
expect(CsrDevFactoryCheck.problemOf(factory)).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("an import.meta left in a factory is named with its compiled line", () => {
|
|
15
|
+
const problem = CsrDevFactoryCheck.problemOf(factoryOf("var a = 1;\nvar url = import.meta.url;"));
|
|
16
|
+
expect(problem).toStartWith("import.meta is left in it (compiled line 2), which a classic script cannot hold");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a top-level await left in a factory is named as one", () => {
|
|
20
|
+
const problem = CsrDevFactoryCheck.problemOf(factoryOf("var a = await Promise.resolve(1);"));
|
|
21
|
+
expect(problem).toStartWith("a top-level await is left in it, which a CommonJS factory cannot wait on");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("any other syntax error answers the parser's message, and a broken helper preamble counts too", () => {
|
|
25
|
+
expect(CsrDevFactoryCheck.problemOf(factoryOf("var = ;"))).toBeString();
|
|
26
|
+
expect(CsrDevFactoryCheck.problemOf(factoryOf("module.exports = 1;"), "var { a } = ;\n")).toBeString();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("the stand-in for a module left out throws its id and the reason", () => {
|
|
30
|
+
const thrower = new Function(`return (${CsrDevFactoryCheck.thrower("node_modules/x/index.js", "why")});`)();
|
|
31
|
+
expect(() => thrower()).toThrow("[akan-csr] node_modules/x/index.js was left out of the dev registry: why");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//* A module factory runs inside one classic script with every module written beside it, so a factory that does not
|
|
2
|
+
//* parse as a script drops them all, far from the cause. Each is parsed once, as the page will, before it is written.
|
|
3
|
+
export class CsrDevFactoryCheck {
|
|
4
|
+
static readonly #parsed = new Set<number | bigint>();
|
|
5
|
+
|
|
6
|
+
/** Why the page could not parse `factory` after `helperDefinition`, or null when it can. */
|
|
7
|
+
static problemOf(factory: string, helperDefinition = ""): string | null {
|
|
8
|
+
const key = Bun.hash(`${helperDefinition}\0${factory}`);
|
|
9
|
+
if (CsrDevFactoryCheck.#parsed.has(key)) return null;
|
|
10
|
+
const problem = CsrDevFactoryCheck.#syntaxError(`${helperDefinition}return (${factory});`);
|
|
11
|
+
if (problem === null) {
|
|
12
|
+
CsrDevFactoryCheck.#parsed.add(key);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (problem.includes("import.meta")) {
|
|
16
|
+
//? JSC's own line is the caller's, not the factory's; the factory's line N is line N of Bun's output.
|
|
17
|
+
const line = factory.split("\n").findIndex((text) => /(?<![\w$.])import\.meta\b/.test(text));
|
|
18
|
+
return `import.meta is left in it${line > 0 ? ` (compiled line ${line})` : ""}, which a classic script cannot hold: ${problem}`;
|
|
19
|
+
}
|
|
20
|
+
if (CsrDevFactoryCheck.#syntaxError(`${helperDefinition}return (async ${factory});`) === null)
|
|
21
|
+
return `a top-level await is left in it, which a CommonJS factory cannot wait on: ${problem}`;
|
|
22
|
+
return problem;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Stands in for a module left out of the registry: the page runs, and requiring the module says why. */
|
|
26
|
+
static thrower(id: string, problem: string): string {
|
|
27
|
+
const message = `[akan-csr] ${id} was left out of the dev registry: ${problem}`;
|
|
28
|
+
return `function () {\n throw new Error(${JSON.stringify(message)});\n}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static #syntaxError(source: string): string | null {
|
|
32
|
+
try {
|
|
33
|
+
new Function(source);
|
|
34
|
+
return null;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return error instanceof Error ? error.message : String(error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|