@ultimat3/testing 22.15.0 → 23.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.
- package/CLAUDE.md +29 -36
- package/README.md +247 -8
- package/package.json +16 -14
- package/src/auth-request.ts +54 -0
- package/src/determinism.ts +20 -0
- package/src/e2e-app.ts +14 -1
- package/src/e2e-driver.ts +11 -2
- package/src/e2e-run.ts +5 -0
- package/src/e2e-spawn.ts +3 -0
- package/src/errors.ts +2 -0
- package/src/failure-context.ts +42 -0
- package/src/fixture-jobs.ts +110 -25
- package/src/fixtures.ts +9 -4
- package/src/frozen-scheduler.ts +49 -0
- package/src/index.ts +29 -1
- package/src/island-state-errors.ts +19 -0
- package/src/island-state-mount.ts +129 -0
- package/src/local-tx.ts +87 -0
- package/src/matcher-row.ts +93 -0
- package/src/matcher-surface.ts +7 -0
- package/src/matchers.ts +6 -0
- package/src/per-test-reset.ts +43 -0
- package/src/preload.ts +13 -0
- package/src/quiet-logs.ts +56 -0
- package/src/render-view.ts +118 -0
- package/src/test-seal-key.ts +51 -0
- package/src/test-types.ts +53 -30
- package/src/e2e-dom-fixture.ts +0 -122
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// One declared island STATE, mounted: the lookup every island test hand-rolled, and the suite
|
|
2
|
+
// form that owns the `beforeAll` / `afterAll` a mount needs because it installs a process-global
|
|
3
|
+
// `document`. The props a test asserts on are the props `x shot --island` photographs, so the
|
|
4
|
+
// picture and the test cannot be of two different components.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe } from 'bun:test';
|
|
7
|
+
import { assert } from '@ultimat3/core';
|
|
8
|
+
import type {
|
|
9
|
+
IslandBuilder,
|
|
10
|
+
IslandBundleLike,
|
|
11
|
+
MountedIsland,
|
|
12
|
+
MountIslandOptions,
|
|
13
|
+
} from './fixture-island';
|
|
14
|
+
import { mountIsland } from './fixture-island';
|
|
15
|
+
import { IslandStateUnknownError } from './island-state-errors';
|
|
16
|
+
import type { IslandStatesManifest } from './island-states';
|
|
17
|
+
import type { TestOptions } from './test-types';
|
|
18
|
+
import { testName } from './test-types';
|
|
19
|
+
|
|
20
|
+
/** What a state's mount takes: `mountIsland`'s options, minus what the manifest already says. */
|
|
21
|
+
export type IslandStateMountOptions = Omit<MountIslandOptions, 'file' | 'props'>;
|
|
22
|
+
|
|
23
|
+
/** The suite form's: the mount's own, plus the deadline of the `beforeAll` that runs it. */
|
|
24
|
+
export interface IslandStateSuiteOptions extends IslandStateMountOptions, TestOptions {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The deadline a block's mount gets when it names none. The first mount in a file pays the island
|
|
28
|
+
* build — a Babel pass and a browser bundle, seconds not milliseconds — and Bun's 5 s default is
|
|
29
|
+
* what a cold CI runner misses.
|
|
30
|
+
*/
|
|
31
|
+
export const ISLAND_MOUNT_TIMEOUT_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bundles already built in this process, per builder, by `<root>\0<island>`. A file with three
|
|
35
|
+
* states used to build its island three times; the source cannot change between two mounts of one
|
|
36
|
+
* test run, so the second is a lookup. A build that REJECTS is forgotten, so the next mount
|
|
37
|
+
* reports its own failure rather than a cached one.
|
|
38
|
+
*/
|
|
39
|
+
const bundles = new WeakMap<IslandBuilder, Map<string, Promise<IslandBundleLike>>>();
|
|
40
|
+
|
|
41
|
+
const buildOnce =
|
|
42
|
+
(build: IslandBuilder): IslandBuilder =>
|
|
43
|
+
(root, options) => {
|
|
44
|
+
const built = bundles.get(build) ?? new Map<string, Promise<IslandBundleLike>>();
|
|
45
|
+
bundles.set(build, built);
|
|
46
|
+
const key = `${root}\0${options?.only ?? ''}`;
|
|
47
|
+
const known = built.get(key);
|
|
48
|
+
if (known !== undefined) return known;
|
|
49
|
+
const pending = build(root, options);
|
|
50
|
+
built.set(key, pending);
|
|
51
|
+
pending.catch(() => built.delete(key));
|
|
52
|
+
return pending;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mount one state the manifest declares. `using island = await mountIslandState(states, 'idle',
|
|
57
|
+
* { build: buildIslands, root })` inside one test — `using` owns the teardown. A file asserting
|
|
58
|
+
* several things about one state uses `describeIslandState`, which owns it for the block.
|
|
59
|
+
*/
|
|
60
|
+
export async function mountIslandState(
|
|
61
|
+
manifest: IslandStatesManifest,
|
|
62
|
+
id: string,
|
|
63
|
+
options: IslandStateMountOptions,
|
|
64
|
+
): Promise<MountedIsland> {
|
|
65
|
+
const state = manifest.states.find((one) => one.id === id);
|
|
66
|
+
if (state === undefined) {
|
|
67
|
+
throw new IslandStateUnknownError({
|
|
68
|
+
island: manifest.island,
|
|
69
|
+
id,
|
|
70
|
+
known: manifest.states.map((one) => one.id),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return mountIsland({
|
|
74
|
+
...options,
|
|
75
|
+
build: buildOnce(options.build),
|
|
76
|
+
file: manifest.island,
|
|
77
|
+
props: state.props,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One block per declared state: mounted before its first test, disposed after its last, with the
|
|
83
|
+
* island built once per file. `body` is handed an ACCESSOR, for `describeApp`'s reason — the
|
|
84
|
+
* block is declared at module scope and the mount does not exist until `beforeAll` has run:
|
|
85
|
+
*
|
|
86
|
+
* ```ts
|
|
87
|
+
* describeIslandState(counterStates, 'idle', { build: buildIslands, root }, (island) => {
|
|
88
|
+
* test('a click reaches the DOM', () => {
|
|
89
|
+
* expect(island().fire('button', 'click')).toBe(true);
|
|
90
|
+
* });
|
|
91
|
+
* });
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* Calls `describe`, so it goes at declaration scope. The block is named for the `unit` step — an
|
|
95
|
+
* island test is `<name>.island.test.ts`, which that step owns.
|
|
96
|
+
*/
|
|
97
|
+
export function describeIslandState(
|
|
98
|
+
manifest: IslandStatesManifest,
|
|
99
|
+
id: string,
|
|
100
|
+
options: IslandStateSuiteOptions,
|
|
101
|
+
body: (island: () => MountedIsland) => void,
|
|
102
|
+
): void {
|
|
103
|
+
const { timeoutMs, ...mount } = options;
|
|
104
|
+
describe(testName('unit', `the ${manifest.name} island, ${id}`), () => {
|
|
105
|
+
let mounted: MountedIsland | undefined;
|
|
106
|
+
let over = false;
|
|
107
|
+
beforeAll(async () => {
|
|
108
|
+
const island = await mountIslandState(manifest, id, mount);
|
|
109
|
+
// A mount that outlived its deadline lands after `afterAll` has already run: disposed here,
|
|
110
|
+
// or its fake `document` is every later block's.
|
|
111
|
+
if (over) island[Symbol.dispose]();
|
|
112
|
+
else mounted = island;
|
|
113
|
+
}, timeoutMs ?? ISLAND_MOUNT_TIMEOUT_MS);
|
|
114
|
+
// Bun runs `afterAll` whether or not the mount resolved, so this reads what is there.
|
|
115
|
+
afterAll(() => {
|
|
116
|
+
over = true;
|
|
117
|
+
mounted?.[Symbol.dispose]();
|
|
118
|
+
mounted = undefined;
|
|
119
|
+
});
|
|
120
|
+
body(() => {
|
|
121
|
+
assert(
|
|
122
|
+
mounted !== undefined,
|
|
123
|
+
`the ${manifest.name} island's "${id}" state is not mounted — its mount failed, or the accessor was called outside this block's tests`,
|
|
124
|
+
'call the accessor inside a test of this describeIslandState block; a failed mount is reported above this line, by its own error',
|
|
125
|
+
);
|
|
126
|
+
return mounted;
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
package/src/local-tx.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A mutator's `local()` half, run in a unit test: a `LocalTx` over rows held in memory, with the
|
|
2
|
+
// semantics of the page's record store. The twin under test is the app's; this is only the store
|
|
3
|
+
// it writes into, so a test asserts on rows and holds no cast and no hand-rolled table.
|
|
4
|
+
|
|
5
|
+
import type { LocalTable, LocalTx } from '@ultimat3/action';
|
|
6
|
+
|
|
7
|
+
/** `{ <table>: { <key>: <row> } }` — what the store holds, as a test seeds it and reads it back. */
|
|
8
|
+
export type LocalRows<TRow extends object = object> = Readonly<
|
|
9
|
+
Record<string, Readonly<Record<string, TRow>>>
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
export interface MemoryLocalTx {
|
|
13
|
+
/** What `mutator.local(tx, input)` is handed. `tx.<table>` and `tx.table('<name>')` are one table. */
|
|
14
|
+
readonly tx: LocalTx;
|
|
15
|
+
/** What a table holds now, by key — after however many applications. `{}` for one never seen. */
|
|
16
|
+
rows<TRow extends object = object>(table: string): Readonly<Record<string, TRow>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `undefined` in a patch means "leave it alone", exactly as the record store reads it. */
|
|
20
|
+
const definedOf = (columns: object): Record<string, unknown> =>
|
|
21
|
+
Object.fromEntries(Object.entries(columns).filter(([, value]) => value !== undefined));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A client store for one test. Keyed, exactly like the real one: the browser holds no entity
|
|
25
|
+
* schema, so a row is addressed by the key its server twin answers under, never by a column.
|
|
26
|
+
*
|
|
27
|
+
* | Call | Does |
|
|
28
|
+
* |---|---|
|
|
29
|
+
* | `insert(key, row)` | stores the row under the key |
|
|
30
|
+
* | `upsert(key, row)` | merges over what is held; an `undefined` field leaves the column alone |
|
|
31
|
+
* | `update(key, patch)` | changed fields, or a function of the held row. A NO-OP for a key the table does not hold — a twin must not invent a row |
|
|
32
|
+
* | `delete(key)` | removes it |
|
|
33
|
+
*/
|
|
34
|
+
export function memoryLocalTx(seed: LocalRows = {}): MemoryLocalTx {
|
|
35
|
+
const tables = new Map<string, Map<string, object>>();
|
|
36
|
+
const held = (name: string): Map<string, object> => {
|
|
37
|
+
const existing = tables.get(name);
|
|
38
|
+
if (existing !== undefined) return existing;
|
|
39
|
+
const created = new Map<string, object>();
|
|
40
|
+
tables.set(name, created);
|
|
41
|
+
return created;
|
|
42
|
+
};
|
|
43
|
+
// Frozen on the way in, as the record store freezes: a twin that mutated a row in place would
|
|
44
|
+
// pass here and corrupt the synced truth it was handed in a browser.
|
|
45
|
+
const stored = (row: object): object => Object.freeze({ ...row });
|
|
46
|
+
for (const [name, rows] of Object.entries(seed)) {
|
|
47
|
+
for (const [key, row] of Object.entries(rows)) held(name).set(key, stored(row));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The one widening in this file: the store holds rows of whatever shape a test seeded, and a
|
|
51
|
+
// table is read back as the shape its caller names — the same door `tx.table<TRow>()` is.
|
|
52
|
+
const table = <TRow extends object>(name: string): LocalTable<TRow> => {
|
|
53
|
+
const rows = held(name) as Map<string, TRow>;
|
|
54
|
+
return {
|
|
55
|
+
get: (key) => rows.get(key),
|
|
56
|
+
all: () => [...rows.values()],
|
|
57
|
+
insert: (key, row) => {
|
|
58
|
+
rows.set(key, stored(row) as TRow);
|
|
59
|
+
},
|
|
60
|
+
upsert: (key, row) => {
|
|
61
|
+
rows.set(key, stored({ ...rows.get(key), ...definedOf(row) }) as TRow);
|
|
62
|
+
},
|
|
63
|
+
update: (key, patch) => {
|
|
64
|
+
const current = rows.get(key);
|
|
65
|
+
if (current === undefined) return;
|
|
66
|
+
const changed = typeof patch === 'function' ? patch(current) : patch;
|
|
67
|
+
rows.set(key, stored({ ...current, ...definedOf(changed) }) as TRow);
|
|
68
|
+
},
|
|
69
|
+
delete: (key) => {
|
|
70
|
+
rows.delete(key);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const tx = new Proxy({} as LocalTx, {
|
|
76
|
+
get: (_target, property) => {
|
|
77
|
+
if (typeof property !== 'string') return undefined;
|
|
78
|
+
return property === 'table' ? table : table(property);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
tx,
|
|
84
|
+
rows: <TRow extends object = object>(name: string): Readonly<Record<string, TRow>> =>
|
|
85
|
+
Object.fromEntries(tables.get(name) ?? []) as Record<string, TRow>,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -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
|
+
}
|
package/src/matcher-surface.ts
CHANGED
|
@@ -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,6 +5,8 @@
|
|
|
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';
|
|
@@ -12,8 +14,15 @@ import { installAppJsxLoader } from './app-jsx-loader';
|
|
|
12
14
|
import { onFileBoundary } from './file-boundary';
|
|
13
15
|
import { disposeLiveIslands } from './fixture-island';
|
|
14
16
|
import { releasePluginsAfterIsolatedFile } from './isolated-plugins';
|
|
17
|
+
import { installPerTestReset } from './per-test-reset';
|
|
15
18
|
import { installRegistryLeakGuard } from './registry-leak-guard';
|
|
16
19
|
import { sealNetwork } from './sealed-network';
|
|
20
|
+
import { installTestSealKey } from './test-seal-key';
|
|
21
|
+
|
|
22
|
+
// `.secrets.key` is gitignored and CI names no `ULTIMATE_SECRETS_KEY`: without a key every test
|
|
23
|
+
// over a `.sealed()` column is `X_SEAL_KEY_MISSING` on a fresh clone. A throwaway one, only in a
|
|
24
|
+
// test process (`NODE_ENV=test`) and only when the app supplied none — see the file.
|
|
25
|
+
installTestSealKey();
|
|
17
26
|
|
|
18
27
|
const seed = Number.parseInt(Bun.env['ULTIMATE_TEST_SEED'] ?? '', 10);
|
|
19
28
|
const now = Bun.env['ULTIMATE_TEST_NOW'];
|
|
@@ -40,6 +49,10 @@ installRegistryLeakGuard();
|
|
|
40
49
|
// back to what the first file saw.
|
|
41
50
|
onFileBoundary(disposeLiveIslands);
|
|
42
51
|
|
|
52
|
+
// And per TEST: the jobs event bus stores what it is handed, so one test's published answer
|
|
53
|
+
// resumed the next test's waiting run (`per-test-reset.ts`).
|
|
54
|
+
installPerTestReset();
|
|
55
|
+
|
|
43
56
|
// A shared worker only (an isolated file is a fresh registry anyway): the app's
|
|
44
57
|
// `defineApi({ pathStyle })` evaluates once per worker, in whichever file first imports it, and
|
|
45
58
|
// must not be refused because an EARLIER file derived action paths under the default style.
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
* `&` last: decoding it first would turn the text `&lt;` — an author's literal `<` —
|
|
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('<', '<')
|
|
60
|
+
.replaceAll('>', '>')
|
|
61
|
+
.replaceAll('&', '&')
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|