@ultimat3/testing 23.0.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.
- package/CLAUDE.md +9 -9
- package/README.md +50 -5
- package/package.json +15 -15
- package/src/budget-store-conformance.ts +81 -0
- package/src/cdp-browser.ts +5 -10
- package/src/cdp-connection.ts +23 -6
- package/src/cdp-e2e-session.ts +10 -1
- package/src/cdp-errors.ts +113 -8
- package/src/cdp-launch-attempt.ts +192 -0
- package/src/cdp-launch.ts +49 -122
- package/src/cdp-offline-script.ts +33 -3
- package/src/cdp-wire-watch.ts +113 -0
- package/src/determinism.ts +33 -4
- package/src/e2e-app.ts +3 -0
- package/src/e2e-run.ts +2 -2
- package/src/e2e-spawn.ts +1 -8
- package/src/errors.ts +14 -1
- package/src/factories.ts +4 -6
- package/src/file-boundary.ts +12 -7
- package/src/fixture-island.ts +18 -45
- package/src/fixture-jobs.ts +9 -5
- package/src/free-port.ts +46 -0
- package/src/harness.ts +2 -1
- package/src/index.ts +12 -0
- package/src/island-dom.ts +8 -4
- package/src/island-html-entities.ts +27 -0
- package/src/island-scratch.ts +41 -0
- package/src/job-driver-checks.ts +177 -0
- package/src/job-driver-conformance.ts +21 -0
- package/src/live-node.ts +2 -0
- package/src/preload.ts +2 -8
- package/src/registry-leak-guard.ts +3 -1
- package/src/registry-snapshot.ts +22 -1
- package/src/sealed-network.ts +5 -1
- package/src/template-db.ts +6 -3
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The `JobDriver` contract as shared examples: `behavesLike(jobDriverConformance, () => driver)`
|
|
2
|
+
// runs every check in `job-driver-checks.ts` against a driver of your own, one test per check.
|
|
3
|
+
// The subject is called once per test — hand back a fresh driver, or one store with no rows a
|
|
4
|
+
// check could mistake for its own (each check claims only from a queue it named).
|
|
5
|
+
|
|
6
|
+
import { test } from 'bun:test';
|
|
7
|
+
import type { JobDriver } from '@ultimat3/jobs';
|
|
8
|
+
import { JOB_DRIVER_CHECKS } from './job-driver-checks';
|
|
9
|
+
import type { SharedExamples } from './shared-examples';
|
|
10
|
+
import { sharedExamples } from './shared-examples';
|
|
11
|
+
|
|
12
|
+
export const jobDriverConformance: SharedExamples<JobDriver | Promise<JobDriver>> = sharedExamples(
|
|
13
|
+
'a job driver',
|
|
14
|
+
(subject) => {
|
|
15
|
+
for (const check of JOB_DRIVER_CHECKS) {
|
|
16
|
+
test(check.name, async () => {
|
|
17
|
+
await check.run(await subject());
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
);
|
package/src/live-node.ts
CHANGED
|
@@ -178,6 +178,8 @@ export async function createLiveNode(options: LiveNodeOptions = {}): Promise<Liv
|
|
|
178
178
|
data = upgradeOptions.data;
|
|
179
179
|
return true;
|
|
180
180
|
},
|
|
181
|
+
// No socket behind this harness, so no peer address: a health path answers the verdict.
|
|
182
|
+
requestIP: () => null,
|
|
181
183
|
};
|
|
182
184
|
// `/_x/sync` is `SyncNodeOptions.path`'s default and `handleUpgrade` answers 404 to
|
|
183
185
|
// anything else. The `c` parameter is this connection's actor key — see `ACTORS` above.
|
package/src/preload.ts
CHANGED
|
@@ -12,8 +12,7 @@ import { registerFrameworkFixtures } from './framework-fixtures';
|
|
|
12
12
|
import './matchers';
|
|
13
13
|
import { installAppJsxLoader } from './app-jsx-loader';
|
|
14
14
|
import { onFileBoundary } from './file-boundary';
|
|
15
|
-
import {
|
|
16
|
-
import { releasePluginsAfterIsolatedFile } from './isolated-plugins';
|
|
15
|
+
import { ISOLATED_ENV, releasePluginsAfterIsolatedFile } from './isolated-plugins';
|
|
17
16
|
import { installPerTestReset } from './per-test-reset';
|
|
18
17
|
import { installRegistryLeakGuard } from './registry-leak-guard';
|
|
19
18
|
import { sealNetwork } from './sealed-network';
|
|
@@ -44,11 +43,6 @@ Bun.env['ULTIMATE_TEST_SLOT_HELD'] ??= '1';
|
|
|
44
43
|
// fails a later file in another package, for a reason nothing in that file explains.
|
|
45
44
|
installRegistryLeakGuard();
|
|
46
45
|
|
|
47
|
-
// A worker runs many files in one global unless the repo opted into `--isolate` (22.7): a mount a
|
|
48
|
-
// file never disposed is disposed between files, and `file-boundary.ts` then puts `globalThis`
|
|
49
|
-
// back to what the first file saw.
|
|
50
|
-
onFileBoundary(disposeLiveIslands);
|
|
51
|
-
|
|
52
46
|
// And per TEST: the jobs event bus stores what it is handed, so one test's published answer
|
|
53
47
|
// resumed the next test's waiting run (`per-test-reset.ts`).
|
|
54
48
|
installPerTestReset();
|
|
@@ -56,7 +50,7 @@ installPerTestReset();
|
|
|
56
50
|
// A shared worker only (an isolated file is a fresh registry anyway): the app's
|
|
57
51
|
// `defineApi({ pathStyle })` evaluates once per worker, in whichever file first imports it, and
|
|
58
52
|
// must not be refused because an EARLIER file derived action paths under the default style.
|
|
59
|
-
if (Bun.env[
|
|
53
|
+
if (Bun.env[ISOLATED_ENV] !== '1') {
|
|
60
54
|
const { forgetHandedOutActionPaths } = await import('@ultimat3/action');
|
|
61
55
|
onFileBoundary(forgetHandedOutActionPaths);
|
|
62
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
|
});
|
package/src/registry-snapshot.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/sealed-network.ts
CHANGED
|
@@ -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)
|
|
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
|
};
|
package/src/template-db.ts
CHANGED
|
@@ -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
|
|
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({
|