DataTable replaces the 20-line Table scaffold entirely: columns, sorting, filtering, pagination, selection, bulk actions, comparison layout, sticky first column, custom HTML cells, and a remote source driven by a `request` output rather than a function prop (props travel as HTML attributes, so a function arrives as its own source text). Toaster replaces the hand-rolled status div: tone icons, actions, hover pause/resume and a progress bar. Overlays audit -- Modal and Drawer declared aria-modal="true" but nothing ever moved focus into the panel, so the @keydown handler on their root never ran and closeOnEscape did nothing. Focus, focus restore, a Tab trap and a body scroll lock now live in the reactive runtime, shared by both. ContextMenu placed pointer menus by subtracting a guessed 340x420 from the viewport, which pushed every menu that was not that size away from the pointer; it now positions at the pointer and lets the anchored clamp pull it back once it can be measured. The reactive runtime size budget moves 150k -> 175k to cover anchored overlays, dialog behaviour, the toaster and the DataTable client half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
5.0 KiB
TypeScript
131 lines
5.0 KiB
TypeScript
import { afterAll, expect, test } from "bun:test";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { runBuild } from "../src/build.ts";
|
|
|
|
// This is the regression test for a CRITICAL boot-order bug (C1): in the
|
|
// generated production entry, app middleware was emitted as a static import
|
|
// AFTER the authz merge/set happened in the entry's own body. ES modules
|
|
// evaluate every static import (including middleware) before the importing
|
|
// module's body runs, so a middleware module reading getAuthzCatalog() at its
|
|
// own module scope — the SAME eager shape authzMiddleware({ catalog, ... })
|
|
// itself requires, and the same pattern examples/basic-app's
|
|
// app/middleware/logger.ts uses for `export default requestLogger({...})` —
|
|
// saw an unset catalog and threw, taking the app down at deploy while every
|
|
// other gate (typecheck/lint/tests/a plain `bun run build`) stayed green.
|
|
// A manual build+boot caught it once; this makes that check permanent.
|
|
//
|
|
// Fixtures live inside the repo tree, not os.tmpdir(): both the scaffolded
|
|
// app files AND the code Bun.build bundles from them import "@wrnexus/authz"
|
|
// by bare specifier, which resolves via the root tsconfig.json `paths` map
|
|
// walked from the *importing file's* location — an out-of-tree path never
|
|
// reaches it.
|
|
const scratchRoot = join(import.meta.dir, ".tmp-authz-coldstart");
|
|
mkdirSync(scratchRoot, { recursive: true });
|
|
|
|
afterAll(() => {
|
|
rmSync(scratchRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
test("a module-eval getAuthzCatalog() in app middleware survives a real production cold start", async () => {
|
|
const root = mkdtempSync(join(scratchRoot, "app-"));
|
|
const appDir = join(root, "app");
|
|
mkdirSync(join(appDir, "api"), { recursive: true });
|
|
mkdirSync(join(appDir, "authz"), { recursive: true });
|
|
mkdirSync(join(appDir, "middleware"), { recursive: true });
|
|
writeFileSync(
|
|
join(root, "package.json"),
|
|
JSON.stringify({ name: "authz-coldstart-fixture" }),
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
join(appDir, "api", "health.ts"),
|
|
`export function GET() {
|
|
return Response.json({ ok: true });
|
|
}
|
|
`,
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
join(appDir, "authz", "main.ts"),
|
|
`import { defineAuthz } from "@wrnexus/authz";
|
|
|
|
export default defineAuthz({ permissions: { "post:read": {} } });
|
|
`,
|
|
"utf8",
|
|
);
|
|
writeFileSync(
|
|
join(appDir, "middleware", "authz-probe.ts"),
|
|
`import { authzMiddleware, getAuthzCatalog, memoryPermissionStore } from "@wrnexus/authz";
|
|
|
|
// Module-eval-time read, on purpose: this is exactly the pattern the
|
|
// setAuthzCatalog() singleton exists for, and exactly what took the app down
|
|
// under the pre-fix boot order. If getAuthzCatalog() throws here, this WHOLE
|
|
// MODULE fails to evaluate and the entry crashes at import time, before
|
|
// Bun.serve is ever reached.
|
|
export default authzMiddleware({ catalog: getAuthzCatalog(), store: memoryPermissionStore() });
|
|
`,
|
|
"utf8",
|
|
);
|
|
|
|
await runBuild(root);
|
|
|
|
const serverPath = join(root, "dist", "server.js");
|
|
const proc = Bun.spawn({
|
|
cmd: ["bun", serverPath],
|
|
env: { ...process.env, PORT: "0" },
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
cwd: root,
|
|
});
|
|
|
|
let port: number | undefined;
|
|
try {
|
|
const reader = proc.stdout.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffered = "";
|
|
|
|
/*
|
|
* One outstanding read at a time, with the deadline enforced by killing
|
|
* the process rather than by racing the read.
|
|
*
|
|
* This loop used to race a FRESH reader.read() against a 250ms timer each
|
|
* pass. When the timer won, the abandoned read stayed pending and later
|
|
* resolved with the next chunk — which nothing was waiting for any more,
|
|
* so that output was dropped on the floor. On a loaded machine the very
|
|
* chunk carrying "listening on" could be swallowed, and the loop then
|
|
* spun to the full deadline and failed a server that had in fact started
|
|
* immediately. Reading without racing cannot lose a chunk: if the server
|
|
* really never starts, the kill below ends the stream and read() reports
|
|
* done.
|
|
*/
|
|
const giveUp = setTimeout(() => proc.kill(), 20_000);
|
|
try {
|
|
while (port === undefined) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
buffered += decoder.decode(value, { stream: true });
|
|
const match = /listening on http:\/\/[^:]+:(\d+)/.exec(buffered);
|
|
if (match) port = Number(match[1]);
|
|
}
|
|
} finally {
|
|
clearTimeout(giveUp);
|
|
reader.releaseLock();
|
|
}
|
|
|
|
if (port === undefined) {
|
|
const stderrText = await new Response(proc.stderr).text().catch(() => "");
|
|
throw new Error(
|
|
`production server never printed a "listening on" line within 20s. stderr:\n${stderrText}`,
|
|
);
|
|
}
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ ok: true });
|
|
} finally {
|
|
proc.kill();
|
|
await proc.exited;
|
|
}
|
|
}, 30_000);
|