@ultimat3/testing 22.15.0 → 24.0.0

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.
Files changed (52) hide show
  1. package/CLAUDE.md +37 -44
  2. package/README.md +297 -13
  3. package/package.json +16 -14
  4. package/src/auth-request.ts +54 -0
  5. package/src/budget-store-conformance.ts +81 -0
  6. package/src/cdp-browser.ts +5 -10
  7. package/src/cdp-connection.ts +23 -6
  8. package/src/cdp-e2e-session.ts +10 -1
  9. package/src/cdp-errors.ts +113 -8
  10. package/src/cdp-launch-attempt.ts +192 -0
  11. package/src/cdp-launch.ts +49 -122
  12. package/src/cdp-offline-script.ts +33 -3
  13. package/src/cdp-wire-watch.ts +113 -0
  14. package/src/determinism.ts +53 -4
  15. package/src/e2e-app.ts +17 -1
  16. package/src/e2e-driver.ts +11 -2
  17. package/src/e2e-run.ts +7 -2
  18. package/src/e2e-spawn.ts +4 -8
  19. package/src/errors.ts +16 -1
  20. package/src/factories.ts +4 -6
  21. package/src/failure-context.ts +42 -0
  22. package/src/file-boundary.ts +12 -7
  23. package/src/fixture-island.ts +18 -45
  24. package/src/fixture-jobs.ts +117 -28
  25. package/src/fixtures.ts +9 -4
  26. package/src/free-port.ts +46 -0
  27. package/src/frozen-scheduler.ts +49 -0
  28. package/src/harness.ts +2 -1
  29. package/src/index.ts +41 -1
  30. package/src/island-dom.ts +8 -4
  31. package/src/island-html-entities.ts +27 -0
  32. package/src/island-scratch.ts +41 -0
  33. package/src/island-state-errors.ts +19 -0
  34. package/src/island-state-mount.ts +129 -0
  35. package/src/job-driver-checks.ts +177 -0
  36. package/src/job-driver-conformance.ts +21 -0
  37. package/src/live-node.ts +2 -0
  38. package/src/local-tx.ts +87 -0
  39. package/src/matcher-row.ts +93 -0
  40. package/src/matcher-surface.ts +7 -0
  41. package/src/matchers.ts +6 -0
  42. package/src/per-test-reset.ts +43 -0
  43. package/src/preload.ts +14 -7
  44. package/src/quiet-logs.ts +56 -0
  45. package/src/registry-leak-guard.ts +3 -1
  46. package/src/registry-snapshot.ts +22 -1
  47. package/src/render-view.ts +118 -0
  48. package/src/sealed-network.ts +5 -1
  49. package/src/template-db.ts +6 -3
  50. package/src/test-seal-key.ts +51 -0
  51. package/src/test-types.ts +53 -30
  52. package/src/e2e-dom-fixture.ts +0 -122
@@ -0,0 +1,93 @@
1
+ // Single responsibility: equality over a repository ROW, own properties included. `@ultimat3/entity`
2
+ // answers a `.sealed()` column as a server-only property — own, readable by name, not enumerable —
3
+ // and `toEqual` walks enumerable properties only: `expect(row).toEqual({ …, password })` fails with
4
+ // an EMPTY diff (both sides print alike), and `toEqual(otherRow)` cannot see a wrong secret at all.
5
+ // `toEqualRow` compares what the row HOLDS, and names what differs without printing a secret.
6
+
7
+ import { describeValue, renderCauseValue } from '@ultimat3/core';
8
+ import type { MatcherResult } from './matcher-result';
9
+
10
+ const isPlain = (value: unknown): value is Record<string, unknown> => {
11
+ if (typeof value !== 'object' || value === null) return false;
12
+ const proto: unknown = Object.getPrototypeOf(value);
13
+ return proto === Object.prototype || proto === null;
14
+ };
15
+
16
+ /**
17
+ * `value` with every own property of every plain object in it made enumerable — the form
18
+ * `Bun.deepEquals` can compare. A `Date`, a `Map` or a class instance is left as it is: equality
19
+ * already knows them, and none of them is a row. `seen` maps an object to its copy, so a cycle
20
+ * comes back as a cycle instead of a stack overflow.
21
+ */
22
+ const reveal = (value: unknown, seen: Map<object, unknown>): unknown => {
23
+ if (Array.isArray(value)) {
24
+ const known = seen.get(value);
25
+ if (known !== undefined) return known;
26
+ const out: unknown[] = [];
27
+ seen.set(value, out);
28
+ for (const item of value) out.push(reveal(item, seen));
29
+ return out;
30
+ }
31
+ if (!isPlain(value)) return value;
32
+ const known = seen.get(value);
33
+ if (known !== undefined) return known;
34
+ const out: Record<string, unknown> = {};
35
+ seen.set(value, out);
36
+ for (const key of Object.getOwnPropertyNames(value)) {
37
+ // `defineProperty`, never assignment: a key named `__proto__` must stay a key.
38
+ Object.defineProperty(out, key, {
39
+ value: reveal(value[key], seen),
40
+ enumerable: true,
41
+ writable: true,
42
+ configurable: true,
43
+ });
44
+ }
45
+ return out;
46
+ };
47
+
48
+ const same = (received: unknown, expected: unknown): boolean =>
49
+ Bun.deepEquals(reveal(received, new Map()), reveal(expected, new Map()));
50
+
51
+ const serverOnly = (row: unknown, key: string): boolean =>
52
+ isPlain(row) && Object.getOwnPropertyDescriptor(row, key)?.enumerable === false;
53
+
54
+ /**
55
+ * One line per top-level property that differs. A server-only property is NAMED and nothing more —
56
+ * not its value and not its length: the message lands in a CI log, and that property is a secret.
57
+ */
58
+ const differences = (received: unknown, expected: unknown): readonly string[] => {
59
+ if (!isPlain(received) || !isPlain(expected)) {
60
+ return [`received ${describeValue(received)}, expected ${describeValue(expected)}`];
61
+ }
62
+ const keys = new Set([
63
+ ...Object.getOwnPropertyNames(received),
64
+ ...Object.getOwnPropertyNames(expected),
65
+ ]);
66
+ const lines: string[] = [];
67
+ for (const key of keys) {
68
+ if (same(received[key], expected[key])) continue;
69
+ if (!serverOnly(received, key) && !serverOnly(expected, key)) {
70
+ lines.push(
71
+ `${key}: received ${renderCauseValue(received[key])}, expected ${renderCauseValue(expected[key])}`,
72
+ );
73
+ } else if (!Object.hasOwn(expected, key)) {
74
+ lines.push(`${key} (server-only): the row holds one and the expected value names none`);
75
+ } else if (!Object.hasOwn(received, key)) {
76
+ lines.push(`${key} (server-only): the expected value holds one and the row has none`);
77
+ } else {
78
+ lines.push(`${key} (server-only): differs — a server-only value is never printed`);
79
+ }
80
+ }
81
+ return lines;
82
+ };
83
+
84
+ export function rowEquality(received: unknown, expected: unknown): MatcherResult {
85
+ const pass = same(received, expected);
86
+ return {
87
+ pass,
88
+ message: () =>
89
+ pass
90
+ ? 'expected the row not to equal the given one, own non-enumerable properties included'
91
+ : `expected the row to equal the given one, own non-enumerable properties included — ${differences(received, expected).join('; ')}`,
92
+ };
93
+ }
@@ -24,6 +24,13 @@ export interface UltimateMatchers<T> {
24
24
  toBeWithinBudget(limit: number): T;
25
25
  toRejectInput(input: unknown): Promise<T>;
26
26
  toAcceptInput(input: unknown): Promise<T>;
27
+ /**
28
+ * `toEqual` for a repository ROW. A `.sealed()` column is an own property a row does not
29
+ * ENUMERATE, so `toEqual({ …, password })` fails with an empty diff and `toEqual(otherRow)`
30
+ * cannot see a wrong secret. This compares every own property, and its message names what
31
+ * differs — a server-only property by shape, never by value.
32
+ */
33
+ toEqualRow(expected: unknown): T;
27
34
  /**
28
35
  * The one matcher that WAITS. Retries `isVisible()` to a budget — 5000ms every 100ms unless
29
36
  * narrowed — and `.not.toBeVisible()` waits for the element to GO rather than inverting one look.
package/src/matchers.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  TestPolicyExpectedError,
13
13
  } from './matcher-receiver-errors';
14
14
  import type { MatcherResult } from './matcher-result';
15
+ import { rowEquality } from './matcher-row';
15
16
  import type { UltimateMatchers } from './matcher-surface';
16
17
  import type { VisibleOptions } from './matcher-visible';
17
18
  import { assertVisibilityProbe, visibilityResult } from './matcher-visible';
@@ -296,6 +297,11 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
296
297
  );
297
298
  },
298
299
 
300
+ // Any receiver is comparable, so there is nothing to refuse: a non-row simply is not equal.
301
+ toEqualRow(received: unknown, expected: unknown) {
302
+ return rowEquality(received, expected);
303
+ },
304
+
299
305
  toAcceptInput(received: unknown, input: unknown) {
300
306
  const schema = assertStandardSchema(received);
301
307
  return hasIssues(schema, input).then((rejected) =>
@@ -0,0 +1,43 @@
1
+ // What every test starts from, put back by the preload before each one — the per-TEST half of
2
+ // isolation, beside `file-boundary.ts`'s per-file half. Today one thing: the jobs event bus, which
3
+ // STORES what it is handed, so an answer one test published resumed the next test's waiting run.
4
+
5
+ import { beforeEach } from 'bun:test';
6
+ import type { resetEventBus } from '@ultimat3/jobs';
7
+
8
+ let installed = false;
9
+
10
+ /**
11
+ * The module that holds the ambient bus, when something in this process has loaded it — and only
12
+ * then. A test that never touched `@ultimat3/jobs` has no bus to reset, and loading the package to
13
+ * reset one would put the jobs graph into every `packages/core` test.
14
+ */
15
+ function loadedEventsModule(): string | undefined {
16
+ // Resolved once per process: every test asks, and the answer is a path that does not move.
17
+ eventsPath ??= Bun.resolveSync('@ultimat3/jobs', import.meta.dir).replace(
18
+ /index\.ts$/,
19
+ 'events.ts',
20
+ );
21
+ return eventsPath in require.cache ? eventsPath : undefined;
22
+ }
23
+
24
+ let eventsPath: string | undefined;
25
+
26
+ /** Before each test: a fresh ambient event bus, when the jobs package is loaded at all. */
27
+ export async function resetPerTestState(): Promise<void> {
28
+ const events = loadedEventsModule();
29
+ if (events === undefined) return;
30
+ // Already evaluated — this import answers from the module registry, the same instance.
31
+ const loaded = (await import(events)) as { readonly resetEventBus: typeof resetEventBus };
32
+ loaded.resetEventBus();
33
+ }
34
+
35
+ /**
36
+ * Called by the app preload (`./preload`), once per process. In a preload, `beforeEach` runs
37
+ * before every test of every file; idempotent so a second caller cannot register it twice.
38
+ */
39
+ export function installPerTestReset(): void {
40
+ if (installed) return;
41
+ installed = true;
42
+ beforeEach(resetPerTestState);
43
+ }
package/src/preload.ts CHANGED
@@ -5,15 +5,23 @@
5
5
  // [test]
6
6
  // preload = ["@ultimat3/testing/preload"]
7
7
 
8
+ // A green run prints the reporter and nothing else: the process logger's lines go to a sink.
9
+ import './quiet-logs';
8
10
  import { installDeterminism } from './determinism';
9
11
  import { registerFrameworkFixtures } from './framework-fixtures';
10
12
  import './matchers';
11
13
  import { installAppJsxLoader } from './app-jsx-loader';
12
14
  import { onFileBoundary } from './file-boundary';
13
- import { disposeLiveIslands } from './fixture-island';
14
- import { releasePluginsAfterIsolatedFile } from './isolated-plugins';
15
+ import { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
16
+ import { installPerTestReset } from './per-test-reset';
15
17
  import { installRegistryLeakGuard } from './registry-leak-guard';
16
18
  import { sealNetwork } from './sealed-network';
19
+ import { installTestSealKey } from './test-seal-key';
20
+
21
+ // `.secrets.key` is gitignored and CI names no `ULTIMATE_SECRETS_KEY`: without a key every test
22
+ // over a `.sealed()` column is `X_SEAL_KEY_MISSING` on a fresh clone. A throwaway one, only in a
23
+ // test process (`NODE_ENV=test`) and only when the app supplied none — see the file.
24
+ installTestSealKey();
17
25
 
18
26
  const seed = Number.parseInt(Bun.env['ULTIMATE_TEST_SEED'] ?? '', 10);
19
27
  const now = Bun.env['ULTIMATE_TEST_NOW'];
@@ -35,15 +43,14 @@ Bun.env['ULTIMATE_TEST_SLOT_HELD'] ??= '1';
35
43
  // fails a later file in another package, for a reason nothing in that file explains.
36
44
  installRegistryLeakGuard();
37
45
 
38
- // A worker runs many files in one global unless the repo opted into `--isolate` (22.7): a mount a
39
- // file never disposed is disposed between files, and `file-boundary.ts` then puts `globalThis`
40
- // back to what the first file saw.
41
- onFileBoundary(disposeLiveIslands);
46
+ // And per TEST: the jobs event bus stores what it is handed, so one test's published answer
47
+ // resumed the next test's waiting run (`per-test-reset.ts`).
48
+ installPerTestReset();
42
49
 
43
50
  // A shared worker only (an isolated file is a fresh registry anyway): the app's
44
51
  // `defineApi({ pathStyle })` evaluates once per worker, in whichever file first imports it, and
45
52
  // must not be refused because an EARLIER file derived action paths under the default style.
46
- if (Bun.env['ULTIMATE_TEST_ISOLATED'] !== '1') {
53
+ if (Bun.env[ISOLATED_ENV] !== '1') {
47
54
  const { forgetHandedOutActionPaths } = await import('@ultimat3/action');
48
55
  onFileBoundary(forgetHandedOutActionPaths);
49
56
  }
@@ -0,0 +1,56 @@
1
+ // Single responsibility: where the process logger's lines go under `bun test`. Both preloads
2
+ // import this FIRST, before anything that loads `@ultimat3/core`.
3
+ //
4
+ // LOG_LEVEL unset a green run prints the reporter and nothing else: every line is dropped
5
+ // LOG_LEVEL=info (or lower) the escape hatch — `LOG_LEVEL=info bun test <file>` prints them
6
+ // LOG_LEVEL=warn (or higher) the terminal shows that level and above
7
+ //
8
+ // The third row is a FILTER ON THE TERMINAL, never the logger's own threshold. Core reads
9
+ // `LOG_LEVEL` once, when it is first imported, so `LOG_LEVEL=error bun test` used to raise the
10
+ // process logger itself to `error` — and a test that installs a sink to assert on an `info` audit
11
+ // line collected nothing and failed for an environment variable. The level is therefore pinned at
12
+ // `info` for the one moment core loads, and what the author named decides what is PRINTED.
13
+ //
14
+ // A test that asserts on log output hands `createLogger({ level, writer })` its own writer, or
15
+ // installs its own `setLogSink` and restores the previous one. Never `process.stdout`.
16
+
17
+ import type { LogLevel } from '@ultimat3/core';
18
+
19
+ /** The levels above `info`, least severe first: the ones a named `LOG_LEVEL` must not cost a sink. */
20
+ const ABOVE_INFO = ['warn', 'error', 'fatal', 'silent'] as const satisfies readonly LogLevel[];
21
+
22
+ export type LogRouting =
23
+ | { readonly lines: 'dropped' }
24
+ | { readonly lines: 'printed' }
25
+ | { readonly lines: 'filtered'; readonly from: (typeof ABOVE_INFO)[number] };
26
+
27
+ /**
28
+ * What a named `LOG_LEVEL` means for a test process. `info` and below are core's own to print, at
29
+ * the level named — asking for `debug` is asking for more lines, and a collecting sink is handed
30
+ * them too. Anything core would not read as a level is `info` there, so it is `printed` here.
31
+ */
32
+ export function logRouting(named: string | undefined): LogRouting {
33
+ if (named === undefined || named === '') return { lines: 'dropped' };
34
+ const from = ABOVE_INFO.find((level) => level === named);
35
+ return from === undefined ? { lines: 'printed' } : { lines: 'filtered', from };
36
+ }
37
+
38
+ const named = Bun.env['LOG_LEVEL'];
39
+ const routing = logRouting(named);
40
+
41
+ // Core's `logger` is built at module init from this variable, so the pin goes in before the
42
+ // import and the author's value goes back after it: a child this process spawns inherits what
43
+ // was named, never the pin. A dynamic import, because a static one is hoisted above this line.
44
+ if (routing.lines === 'filtered') Bun.env['LOG_LEVEL'] = 'info';
45
+ const { setLogSink } = await import('@ultimat3/core');
46
+
47
+ if (routing.lines === 'dropped') {
48
+ setLogSink(() => undefined);
49
+ } else if (routing.lines === 'filtered') {
50
+ Bun.env['LOG_LEVEL'] = routing.from;
51
+ const shown = new Set<LogLevel>(ABOVE_INFO.slice(ABOVE_INFO.indexOf(routing.from)));
52
+ // Core's own split: `error` and above to stderr, the rest to stdout.
53
+ setLogSink((line, level) => {
54
+ if (shown.has(level)) (level === 'warn' ? process.stdout : process.stderr).write(`${line}\n`);
55
+ });
56
+ }
@@ -7,7 +7,7 @@ import { afterAll } from 'bun:test';
7
7
  import { knownTags, registeredTiers } from '@ultimat3/cache';
8
8
  import { isAppRoot } from './app-jsx-loader';
9
9
  import { RegistryLeakError } from './errors';
10
- import { runFileBoundary } from './file-boundary';
10
+ import { runBoundaryHooks, runFileBoundary } from './file-boundary';
11
11
  import type { ProcessRegistrySnapshot } from './registry-snapshot';
12
12
  import {
13
13
  captureProcessRegistries,
@@ -219,6 +219,8 @@ export function installRegistryLeakGuard(): void {
219
219
  });
220
220
 
221
221
  afterAll(() => {
222
+ // The run's LAST file has no boundary after it: its undisposed island mounts end here.
223
+ runBoundaryHooks();
222
224
  close();
223
225
  if (leaks.length > 0) throw new RegistryLeakError({ leaks });
224
226
  });
@@ -24,6 +24,7 @@ import { registeredTasks, restoreTasks } from '@ultimat3/jobs';
24
24
  import type { RoleMap } from '@ultimat3/policy';
25
25
  import {
26
26
  knownPermissions,
27
+ permissionDeclarationSites,
27
28
  restorePermissions,
28
29
  restoreRoles,
29
30
  roleDeclarationSites,
@@ -39,6 +40,8 @@ export interface ProcessRegistrySnapshot {
39
40
  readonly locales: LocaleConfig;
40
41
  readonly catalogs: readonly (readonly [Locale, Catalog])[];
41
42
  readonly permissions: readonly string[];
43
+ /** Restored WITH the names: a site-less permission is one the policy step can never judge. */
44
+ readonly permissionSites: Readonly<Record<string, readonly string[]>>;
42
45
  readonly roles: RoleMap;
43
46
  /** Kept beside the map: restoring through `defineRoles()` would rewrite every site. */
44
47
  readonly roleSites: Readonly<Record<string, string>>;
@@ -57,6 +60,7 @@ export function captureProcessRegistries(): ProcessRegistrySnapshot {
57
60
  locales: localeConfig(),
58
61
  catalogs: registeredLocales().map((locale) => [locale, catalogFor(locale)] as const),
59
62
  permissions: knownPermissions(),
63
+ permissionSites: permissionDeclarationSites(),
60
64
  roles: roleDefinitions(),
61
65
  roleSites: roleDeclarationSites(),
62
66
  tasks: registeredTasks(),
@@ -74,7 +78,7 @@ export function restoreProcessRegistries(snapshot: ProcessRegistrySnapshot): voi
74
78
  // a partial call can never widen `supported` back.
75
79
  configureLocales(snapshot.locales);
76
80
  restoreCatalogs(snapshot.catalogs);
77
- restorePermissions(snapshot.permissions);
81
+ restorePermissions(snapshot.permissions, snapshot.permissionSites);
78
82
  restoreRoles(snapshot.roles, snapshot.roleSites);
79
83
  restoreTasks(snapshot.tasks);
80
84
  }
@@ -138,9 +142,26 @@ export function mergeSnapshots(
138
142
  locales: newer.locales,
139
143
  catalogs: [...catalogs],
140
144
  permissions: [...new Set([...older.permissions, ...newer.permissions])],
145
+ permissionSites: mergeSites(older.permissionSites, newer.permissionSites),
141
146
  roles: { ...older.roles, ...newer.roles },
142
147
  roleSites: { ...older.roleSites, ...newer.roleSites },
143
148
  tasks: [...tasks.values()],
144
149
  catalogDeclarations: Math.max(older.catalogDeclarations, newer.catalogDeclarations),
145
150
  };
146
151
  }
152
+
153
+ /** Per name, every module either snapshot saw declare it — the union the names themselves are. */
154
+ function mergeSites(
155
+ older: Readonly<Record<string, readonly string[]>>,
156
+ newer: Readonly<Record<string, readonly string[]>>,
157
+ ): Readonly<Record<string, readonly string[]>> {
158
+ const merged = new Map<string, Set<string>>();
159
+ for (const side of [older, newer]) {
160
+ for (const [name, at] of Object.entries(side)) {
161
+ const into = merged.get(name) ?? new Set<string>();
162
+ for (const site of at) into.add(site);
163
+ merged.set(name, into);
164
+ }
165
+ }
166
+ return Object.fromEntries([...merged].map(([name, at]) => [name, [...at]]));
167
+ }
@@ -0,0 +1,118 @@
1
+ // What the server renders, for a unit test: `renderView` for one component, `renderRoute` for a
2
+ // whole route module — its `load`, its `meta` and its page, resolved the way a request resolves
3
+ // them. No browser, no server, no cast: a test asserts on markup and on visible text.
4
+
5
+ import type { Actor } from '@ultimat3/core';
6
+ import { assert, createContext, runWithContext } from '@ultimat3/core';
7
+ import type { IslandDirective, RouteConfig, RouteParams } from '@ultimat3/render';
8
+
9
+ /** What a component rendered. */
10
+ export interface RenderedView {
11
+ /** The markup, exactly as the document would carry it. */
12
+ readonly html: string;
13
+ /**
14
+ * What a reader sees: tags, scripts and styles removed, entities decoded, whitespace collapsed
15
+ * to single spaces. Compare it with `t('<key>')` — the markup escapes what the catalog does not.
16
+ */
17
+ readonly text: string;
18
+ }
19
+
20
+ /** What a route rendered for one request. */
21
+ export interface RenderedRoute<TData> extends RenderedView {
22
+ /** What `load` resolved — the one object `meta` and the page were both given. */
23
+ readonly data: TData;
24
+ readonly meta: Awaited<ReturnType<RouteConfig<TData>['meta']>>;
25
+ /** The islands this render emitted, in order. Empty on a page that ships no JavaScript. */
26
+ readonly islands: readonly IslandDirective[];
27
+ }
28
+
29
+ /**
30
+ * A route file's exports, passed whole: `import * as page from './page'`. Only `config` is typed —
31
+ * the page component is found among the rest by the rule the router finds it by.
32
+ */
33
+ export interface RouteModule<TData> {
34
+ readonly config: RouteConfig<TData>;
35
+ /** Everything else the file exports — the page among it. Open, so a literal module typechecks. */
36
+ readonly [exported: string]: unknown;
37
+ }
38
+
39
+ export interface RouteRequest {
40
+ /** Absolute, as a request's is. Its search string becomes the page's `query`. */
41
+ readonly url?: string;
42
+ readonly params?: RouteParams;
43
+ /** Who is asking. `load` and the page run inside a request context holding this actor. */
44
+ readonly actor?: Actor;
45
+ }
46
+
47
+ const DEFAULT_URL = 'http://localhost/';
48
+
49
+ const SCRIPT_OR_STYLE = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/g;
50
+
51
+ /**
52
+ * `&amp;` last: decoding it first would turn the text `&amp;lt;` — an author's literal `&lt;` —
53
+ * into `<`, a character the page never showed.
54
+ */
55
+ const visibleText = (html: string): string =>
56
+ html
57
+ .replace(SCRIPT_OR_STYLE, ' ')
58
+ .replace(/<[^>]*>/g, ' ')
59
+ .replaceAll('&lt;', '<')
60
+ .replaceAll('&gt;', '>')
61
+ .replaceAll('&amp;', '&')
62
+ .replace(/\s+/g, ' ')
63
+ .trim();
64
+
65
+ const viewOf = (html: string): RenderedView => ({ html, text: visibleText(html) });
66
+
67
+ /**
68
+ * Render one component with the props a page would hand it. The props are the component's own
69
+ * type, so a renamed prop is a compile error in the test rather than an `undefined` in the markup.
70
+ *
71
+ * No island collector: a component that renders an `island()` belongs to a route, which is where
72
+ * its hydration timing comes from — render it through `renderRoute`.
73
+ */
74
+ export async function renderView<Props>(
75
+ component: (props: Props) => unknown,
76
+ props: Props,
77
+ ): Promise<RenderedView> {
78
+ const { renderToHtml } = await import('@ultimat3/render/server');
79
+ return viewOf(await renderToHtml(component(props)));
80
+ }
81
+
82
+ /**
83
+ * Render a route module the way a request does: `load` once, `meta` and the page on that same
84
+ * data, islands collected under the route's own `hydrate`. Pass the module whole —
85
+ * `import * as page from './page'` — so the page is found by the rule the router finds it by.
86
+ */
87
+ export async function renderRoute<TData>(
88
+ module: RouteModule<TData>,
89
+ request: RouteRequest = {},
90
+ ): Promise<RenderedRoute<TData>> {
91
+ const render = await import('@ultimat3/render');
92
+ const { renderToHtml } = await import('@ultimat3/render/server');
93
+ const { config } = module;
94
+ // A copy typed by its own shape: the page is one of the exports `RouteModule` does not name.
95
+ const exported: Readonly<Record<string, unknown>> = { ...module };
96
+ const component = render.pageComponentOf(exported);
97
+ assert(
98
+ component !== undefined,
99
+ 'renderRoute was handed a route module that exports no page component',
100
+ "export the page beside `config` — `export function Page()` — and pass the module whole: import * as page from './page'",
101
+ );
102
+ const url = request.url ?? DEFAULT_URL;
103
+ const ctx = { params: request.params ?? {}, url };
104
+ const islands = render.createIslandCollector({ file: url, hydrate: config.hydrate });
105
+
106
+ const run = async (): Promise<RenderedRoute<TData>> => {
107
+ const data = await render.routeDataFor(config, ctx);
108
+ const meta = await config.meta(render.metaContextFor(ctx, data));
109
+ const query = Object.fromEntries(new URL(url).searchParams);
110
+ const html = await renderToHtml(component({ data, params: ctx.params, url, query }), {
111
+ islands,
112
+ });
113
+ return { ...viewOf(html), data, meta, islands: islands.directives };
114
+ };
115
+
116
+ if (request.actor === undefined) return run();
117
+ return runWithContext(createContext({ actor: request.actor }), run);
118
+ }
@@ -36,7 +36,11 @@ const state: SealState = {
36
36
  };
37
37
 
38
38
  const matches = (route: MockRoute, url: string): boolean => {
39
- if (route.match instanceof RegExp) return route.match.test(url);
39
+ if (route.match instanceof RegExp) {
40
+ // A `g`/`y` pattern's `test` resumes at `lastIndex`: without the reset, every other call missed.
41
+ route.match.lastIndex = 0;
42
+ return route.match.test(url);
43
+ }
40
44
  if (route.match.endsWith('*')) return url.startsWith(route.match.slice(0, -1));
41
45
  return route.match === url;
42
46
  };
@@ -18,7 +18,7 @@ export interface TemplateDbConfig {
18
18
  /** Connection URL with rights to CREATE DATABASE. Absent means "fall back to PGlite". */
19
19
  readonly adminUrl?: string | undefined;
20
20
  readonly templateName?: string;
21
- /** Applied once, into the template, under an advisory lock. */
21
+ /** Applied into the template under an advisory lock, which every worker's clone also holds. */
22
22
  readonly migrate?: (url: string) => Promise<void>;
23
23
  }
24
24
 
@@ -144,11 +144,14 @@ export async function acquireWorkerDatabase(
144
144
  // serialises them, so a failure here is a real failure and `alreadyExists` (a substring match
145
145
  // on the message) would read `relation "x_jobs" already exists` as success.
146
146
  if (config.migrate !== undefined) await config.migrate(urlFor(adminUrl, template));
147
+ // INSIDE the lock: `CREATE DATABASE … TEMPLATE` waits 5 s for every session on the template
148
+ // to leave, then refuses — and the next worker's migration opens one the moment the lock is
149
+ // free. Measured: a clone outside it failed whenever that migration ran past 5 s.
150
+ await admin.exec(dropSql(database));
151
+ await admin.exec(cloneSql(template, database));
147
152
  } finally {
148
153
  await admin.exec(unlockSql(template));
149
154
  }
150
- await admin.exec(dropSql(database));
151
- await admin.exec(cloneSql(template, database));
152
155
  } catch (error) {
153
156
  await admin.close();
154
157
  throw new TestDatabaseUnavailableError({
@@ -0,0 +1,51 @@
1
+ // Single responsibility: the master key a TEST process seals under when the app handed it none.
2
+ // `.secrets.key` is gitignored and CI has no `ULTIMATE_SECRETS_KEY`, so without this every test
3
+ // over an entity with a `.sealed()` column is `X_SEAL_KEY_MISSING` on a fresh clone. The preload
4
+ // is the one caller; the key itself is not exported, from this module or from the package.
5
+
6
+ import { findMasterKey, SECRETS_KEY_ENV } from '@ultimat3/core';
7
+
8
+ /**
9
+ * Public by construction — it is in the package on npm — so it protects nothing and must never
10
+ * seal a real row. 64 hex characters, which is all `parseMasterKey` asks of a key.
11
+ */
12
+ const TEST_SEAL_KEY = '7e57'.repeat(16);
13
+
14
+ type EnvRecord = Record<string, string | undefined>;
15
+
16
+ export interface TestSealKeySource {
17
+ /** The app root a `.secrets.key` would sit in. Defaults to the working directory. */
18
+ readonly root?: string;
19
+ /** Read for the mode and the key, and written to. Defaults to `process.env`. */
20
+ readonly env?: EnvRecord;
21
+ }
22
+
23
+ /**
24
+ * Installs the throwaway key, and answers whether it did. Two refusals, both silent because both
25
+ * are the correct outcome rather than a mistake to report:
26
+ *
27
+ * - **Not a test process.** `bun test` sets `NODE_ENV=test`; anything else — a server that
28
+ * imported the preload, `NODE_ENV=production` — gets no key, so a sealed write there is still
29
+ * `X_SEAL_KEY_MISSING` and never a row sealed under a key the whole world holds.
30
+ * - **A key is already there**, in the variable or in `.secrets.key`. The app's own key wins: a
31
+ * suite that reads the committed `secrets.enc.json` needs the key that sealed it.
32
+ */
33
+ export function installTestSealKey(source: TestSealKeySource = {}): boolean {
34
+ const env = source.env ?? (process.env as EnvRecord);
35
+ const added = testSealKeyEnv({ ...source, env });
36
+ Object.assign(env, added);
37
+ return SECRETS_KEY_ENV in added;
38
+ }
39
+
40
+ /**
41
+ * The same decision as a value: the variable to ADD to a child's environment, or nothing. For a
42
+ * harness that spawns the app under test (`startE2eApp`) — the child is a server, not a `bun test`
43
+ * process, so it can never install a key for itself, and it must not be able to. The rule is still
44
+ * judged in THIS process: only a test process hands the key down.
45
+ */
46
+ export function testSealKeyEnv(source: TestSealKeySource = {}): Readonly<Record<string, string>> {
47
+ const env = source.env ?? (process.env as EnvRecord);
48
+ if (env['NODE_ENV'] !== 'test') return {};
49
+ if (findMasterKey(source.root ?? process.cwd(), env) !== undefined) return {};
50
+ return { [SECRETS_KEY_ENV]: TEST_SEAL_KEY };
51
+ }