@ultimat3/cli 20.2.1 → 21.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 +69 -1
- package/package.json +30 -30
- package/src/app-env.ts +2 -2
- package/src/budgets.ts +45 -12
- package/src/build-errors.ts +54 -0
- package/src/cdp-browser.ts +21 -27
- package/src/cdp-connection.ts +66 -30
- package/src/cdp-e2e-page.ts +84 -113
- package/src/cdp-e2e-session.ts +199 -0
- package/src/cdp-launch.ts +95 -41
- package/src/cdp-offline-script.ts +73 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/cmd-deploy.ts +7 -0
- package/src/cmd-dev.ts +23 -86
- package/src/dev-live-feed.ts +2 -0
- package/src/dev-render.ts +119 -20
- package/src/dev-route-table.ts +119 -0
- package/src/dev-services.ts +4 -1
- package/src/dev-sync.ts +5 -3
- package/src/e2e-app.ts +103 -0
- package/src/e2e-browser-handle.ts +55 -0
- package/src/e2e-driver.ts +32 -12
- package/src/e2e-errors.ts +14 -0
- package/src/e2e-page.ts +5 -2
- package/src/e2e-preload.ts +64 -0
- package/src/e2e-probe.ts +23 -0
- package/src/e2e-spawn.ts +169 -0
- package/src/error-codes.ts +7 -0
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +8 -29
- package/src/index.ts +18 -4
- package/src/island-bundle.ts +33 -11
- package/src/island-realtime.ts +91 -0
- package/src/island-verdict.ts +1 -1
- package/src/live-routes.ts +82 -42
- package/src/mcp-errors.ts +3 -0
- package/src/page-sync.ts +54 -0
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/serve.ts +9 -0
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/resource-form-island.ts +30 -21
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-container.ts +18 -3
- package/src/templates/scaffold-env.ts +6 -0
- package/src/verify-e2e.ts +38 -0
- package/src/verify-run.ts +105 -50
- package/src/verify-tests.ts +21 -4
- package/src/worker-bundle.ts +192 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The route table `x dev` serves, in mount order: the dashboard, the API, the assets a document
|
|
2
|
+
// names, the island and sync-worker scripts, and the app's pages last. Split from `cmd-dev.ts` at its
|
|
3
|
+
// 500-line ceiling; `serve.ts` composes the production table from the same builders.
|
|
4
|
+
|
|
5
|
+
import type { Route } from '@ultimat3/http';
|
|
6
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
7
|
+
import type { Storage } from '@ultimat3/storage';
|
|
8
|
+
import { apiRoutes } from './api-routes';
|
|
9
|
+
import { mountAppMcp } from './app-mcp';
|
|
10
|
+
import { assetRoutes } from './dev-assets';
|
|
11
|
+
import type { DevDashboardInput } from './dev-dashboard';
|
|
12
|
+
import { devDashboardRoutes } from './dev-dashboard';
|
|
13
|
+
import { appRoutes } from './dev-render';
|
|
14
|
+
import { storageRoutes } from './dev-storage';
|
|
15
|
+
import { errorPageStyleSources } from './error-page-csp';
|
|
16
|
+
import type { IslandBundle } from './island-bundle';
|
|
17
|
+
import { islandHarnessRoutes } from './island-harness-route';
|
|
18
|
+
import { islandRoutes } from './island-routes';
|
|
19
|
+
import { loadIslandStates } from './island-states-load';
|
|
20
|
+
import { pageSync } from './page-sync';
|
|
21
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
22
|
+
import { styleBundle } from './style-bundle';
|
|
23
|
+
import { styleRoutes } from './style-routes';
|
|
24
|
+
import { serviceWorkerArtifacts } from './sw-artifacts';
|
|
25
|
+
import { serviceWorkerRoutes } from './sw-routes';
|
|
26
|
+
import type { ThemeBoot } from './theme-boot';
|
|
27
|
+
import { loadThemeMode, themeBoot } from './theme-boot';
|
|
28
|
+
|
|
29
|
+
export interface DevRouteTableInput {
|
|
30
|
+
readonly root: string;
|
|
31
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
32
|
+
readonly buildId: string;
|
|
33
|
+
readonly storage: Storage;
|
|
34
|
+
readonly dashboard: DevDashboardInput;
|
|
35
|
+
/** A getter: the watcher tick rebuilds the islands, and a captured bundle would serve the first. */
|
|
36
|
+
readonly islands: () => IslandBundle;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DevRouteTable {
|
|
40
|
+
readonly routes: readonly Route[];
|
|
41
|
+
/** The theme boot, whose `cspSource` the web role admits. */
|
|
42
|
+
readonly theme: ThemeBoot;
|
|
43
|
+
/** The app's own error pages' inline styles, admitted the same way. */
|
|
44
|
+
readonly errorStyles: readonly string[];
|
|
45
|
+
/** Where the app's MCP endpoint was mounted, or `undefined`. */
|
|
46
|
+
readonly mcpPath: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function devRouteTable(input: DevRouteTableInput): Promise<DevRouteTable> {
|
|
50
|
+
// Resolved once, before the first route. `undefined` for an app that is not installable: nothing
|
|
51
|
+
// is mounted, and the 0kb baseline is not spent on a `<link>` to a file that does not exist.
|
|
52
|
+
const pwa = await loadPwaArtifacts(input.root);
|
|
53
|
+
const theme = themeBoot(await loadThemeMode(input.root));
|
|
54
|
+
// The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
|
|
55
|
+
const sync = await pageSync(input.root, input.env, input.buildId);
|
|
56
|
+
const errorStyles = await errorPageStyleSources(input.root);
|
|
57
|
+
// Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
|
|
58
|
+
// changes under a page it controls is the update path, and one per keystroke exercises it per save.
|
|
59
|
+
const serviceWorker =
|
|
60
|
+
pwa === undefined
|
|
61
|
+
? undefined
|
|
62
|
+
: serviceWorkerArtifacts({
|
|
63
|
+
pwa,
|
|
64
|
+
buildId: input.buildId,
|
|
65
|
+
routes: describeRoutes(),
|
|
66
|
+
islands: input.islands(),
|
|
67
|
+
styles: styleBundle(),
|
|
68
|
+
scripts: sync.scripts,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
|
|
72
|
+
// call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
|
|
73
|
+
// one of them asked. Warned once here when `expose` is true and nothing can be mounted.
|
|
74
|
+
const mcpMount = await mountAppMcp(input.root);
|
|
75
|
+
const routes: readonly Route[] = [
|
|
76
|
+
...devDashboardRoutes(input.dashboard),
|
|
77
|
+
// The same API table the container serves: a read that answers here and 404s in production
|
|
78
|
+
// is exactly the drift one composition exists to prevent.
|
|
79
|
+
...apiRoutes(),
|
|
80
|
+
...mcpMount.routes,
|
|
81
|
+
// The image pipeline's only HTTP surface: the icons the web manifest declares, and the
|
|
82
|
+
// variants every `srcset` promises. Mounted before the app's own routes so a page route can
|
|
83
|
+
// never shadow `/icons` or `/media`.
|
|
84
|
+
...assetRoutes({
|
|
85
|
+
root: input.root,
|
|
86
|
+
storage: input.storage,
|
|
87
|
+
...(pwa === undefined ? {} : { pwa }),
|
|
88
|
+
}),
|
|
89
|
+
...storageRoutes({ storage: input.storage }),
|
|
90
|
+
// The chunks the documents below name. Mounted before the app's routes for the reason
|
|
91
|
+
// `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
|
|
92
|
+
...islandRoutes(() => input.islands()),
|
|
93
|
+
// And the stylesheet every one of those documents links. Read through the getter for the
|
|
94
|
+
// reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
|
|
95
|
+
// captured at boot would answer 404 for the href the document now carries.
|
|
96
|
+
...styleRoutes(() => styleBundle()),
|
|
97
|
+
// `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
|
|
98
|
+
// lives here rather than in a second server because everything it needs is in THIS process:
|
|
99
|
+
// the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
|
|
100
|
+
// may have. The states are read per REQUEST — an author editing a state and re-running the
|
|
101
|
+
// command must not need a restart to see it.
|
|
102
|
+
...islandHarnessRoutes({
|
|
103
|
+
islands: () => input.islands(),
|
|
104
|
+
states: () => loadIslandStates(input.root),
|
|
105
|
+
}),
|
|
106
|
+
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
107
|
+
...sync.routes,
|
|
108
|
+
...appRoutes({
|
|
109
|
+
buildId: input.buildId,
|
|
110
|
+
resolveIsland: (file) => input.islands().resolverFor(file),
|
|
111
|
+
sync: sync.head,
|
|
112
|
+
persisted: sync.persisted,
|
|
113
|
+
themeHead: theme.head,
|
|
114
|
+
...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
|
|
115
|
+
}),
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
return { routes, theme, errorStyles, mcpPath: mcpMount.path };
|
|
119
|
+
}
|
package/src/dev-services.ts
CHANGED
|
@@ -40,7 +40,10 @@ const nonEmpty = (value: string | undefined): string | undefined =>
|
|
|
40
40
|
* is a directory delete rather than a container dance.
|
|
41
41
|
*/
|
|
42
42
|
export function resolveServices(root: string, env: Env): DevServices {
|
|
43
|
-
|
|
43
|
+
// `ULTIMATE_STATE_DIR` relocates the whole of `.x/` — the embedded database, the local disk and
|
|
44
|
+
// the dev lock — for one process tree. It is how an e2e run boots the app on a THROWAWAY database
|
|
45
|
+
// (`e2e-app.ts`) instead of resetting the developer's own, beside a running `x dev`.
|
|
46
|
+
const stateDir = nonEmpty(env['ULTIMATE_STATE_DIR']) ?? join(root, '.x');
|
|
44
47
|
const databaseUrl = nonEmpty(env['DATABASE_URL']);
|
|
45
48
|
const natsUrl = nonEmpty(env['NATS_URL']);
|
|
46
49
|
const s3Endpoint = nonEmpty(env['S3_ENDPOINT']);
|
package/src/dev-sync.ts
CHANGED
|
@@ -131,6 +131,8 @@ export interface RunningSync {
|
|
|
131
131
|
readonly url: string;
|
|
132
132
|
/** The node's registry, so the boot can hand it a change feed the database cannot produce. */
|
|
133
133
|
readonly registry: LiveQueryRegistry;
|
|
134
|
+
/** The node's channel hub — fed the same changes, so a declared channel's `records` flow in dev. */
|
|
135
|
+
readonly hub: ChannelHub;
|
|
134
136
|
stop(): Promise<void>;
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -212,7 +214,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
212
214
|
// second copy of `/_x/sync` here is the copy that stays behind when it moves.
|
|
213
215
|
mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
|
|
214
216
|
stop: () => node.stop(),
|
|
215
|
-
listen: async (appUrl) => await listen(options, node, registry, appUrl),
|
|
217
|
+
listen: async (appUrl) => await listen(options, node, { registry, hub }, appUrl),
|
|
216
218
|
};
|
|
217
219
|
}
|
|
218
220
|
|
|
@@ -241,7 +243,7 @@ function syncPortFrom(requested: number, appUrl: string | null): number {
|
|
|
241
243
|
async function listen(
|
|
242
244
|
options: StartRolesOptions,
|
|
243
245
|
node: SyncNode,
|
|
244
|
-
|
|
246
|
+
feeds: Pick<RunningSync, 'registry' | 'hub'>,
|
|
245
247
|
appUrl: string | null,
|
|
246
248
|
): Promise<RunningSync> {
|
|
247
249
|
const port = syncPortFrom(options.port, appUrl);
|
|
@@ -266,7 +268,7 @@ async function listen(
|
|
|
266
268
|
});
|
|
267
269
|
return {
|
|
268
270
|
url: listener.url,
|
|
269
|
-
|
|
271
|
+
...feeds,
|
|
270
272
|
stop: async () => {
|
|
271
273
|
listener.stop();
|
|
272
274
|
await node.stop();
|
package/src/e2e-app.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The app an e2e suite drives, spawned on a THROWAWAY state directory: its own embedded database,
|
|
2
|
+
// its own disk, its own dev lock, created per call and removed on `stop()`. Never the developer's
|
|
3
|
+
// `.x/pgdata` — resetting that from a test run destroys the data an `x dev` beside it is using.
|
|
4
|
+
// This file is the DATABASE half; spawning, readiness and the restart are `e2e-spawn.ts`'s.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no temp-directory primitive or recursive remove; `tmpdir()` is node:os's alone.
|
|
7
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
8
|
+
// why: Bun exposes no tmpdir() — only node:os answers the platform temp root.
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
// why: Bun exposes no path API — the state dir is joined, not concatenated.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { finiteCount } from '@ultimat3/core';
|
|
13
|
+
import type { E2eAppMode } from './e2e-spawn';
|
|
14
|
+
import { inherited, refuse, spawnE2eApp, X_BIN } from './e2e-spawn';
|
|
15
|
+
|
|
16
|
+
export type { E2eAppMode } from './e2e-spawn';
|
|
17
|
+
|
|
18
|
+
export interface StartE2eAppOptions {
|
|
19
|
+
/** The app root — the directory holding `app.config.ts`. */
|
|
20
|
+
readonly root: string;
|
|
21
|
+
readonly mode?: E2eAppMode | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The arguments after `x db seed`, or `false` for no seeding. Default `['--tier', 'dev']`: every
|
|
24
|
+
* dev-tier seed, which is what a developer's own `x dev` starts from.
|
|
25
|
+
*/
|
|
26
|
+
readonly seed?: readonly string[] | false | undefined;
|
|
27
|
+
/** Extra environment for every process — the reset, the seed and the app. */
|
|
28
|
+
readonly env?: Readonly<Record<string, string>> | undefined;
|
|
29
|
+
/** How long the app may take to answer `/readyz`. */
|
|
30
|
+
readonly readyTimeoutMs?: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface E2eApp {
|
|
34
|
+
/** `http://localhost:<port>`, no trailing slash. */
|
|
35
|
+
readonly base: string;
|
|
36
|
+
/** The throwaway `.x` this app runs on — the one directory a test may inspect or corrupt. */
|
|
37
|
+
readonly stateDir: string;
|
|
38
|
+
/** Kill the app and delete its state directory. Idempotent. */
|
|
39
|
+
stop(): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Stop the app and start it again on the SAME port and state directory, with `env` added — a
|
|
42
|
+
* deploy. `{ BUILD_ID: 'b2' }` is a new build the open tabs have not seen (`deploy.newBuild()`).
|
|
43
|
+
*/
|
|
44
|
+
restart(env?: Readonly<Record<string, string>>): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_READY_TIMEOUT_MS = 90_000;
|
|
48
|
+
|
|
49
|
+
function x(args: readonly string[], root: string, env: Record<string, string>): void {
|
|
50
|
+
const run = Bun.spawnSync(['bun', X_BIN, ...args], {
|
|
51
|
+
cwd: root,
|
|
52
|
+
env: { ...inherited(), ...env },
|
|
53
|
+
stdout: 'pipe',
|
|
54
|
+
stderr: 'pipe',
|
|
55
|
+
});
|
|
56
|
+
if (run.exitCode !== 0) {
|
|
57
|
+
throw refuse(`x ${args.join(' ')}`, `${run.stdout.toString()}${run.stderr.toString()}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reset and seed a fresh state directory, then spawn the app on a free port and wait for `/readyz`.
|
|
63
|
+
* The reset runs against the throwaway directory, so it is a first migration, never a data loss.
|
|
64
|
+
*/
|
|
65
|
+
export async function startE2eApp(options: StartE2eAppOptions): Promise<E2eApp> {
|
|
66
|
+
// Screened FIRST, before a directory or a process exists: `waited < NaN` is false, so a NaN budget would never poll and report a dead app.
|
|
67
|
+
const deadline = finiteCount(
|
|
68
|
+
'startE2eApp',
|
|
69
|
+
'readyTimeoutMs',
|
|
70
|
+
options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
|
|
71
|
+
);
|
|
72
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'ultimate-e2e-'));
|
|
73
|
+
const env: Record<string, string> = { ...options.env, ULTIMATE_STATE_DIR: stateDir };
|
|
74
|
+
const cleanup = (): Promise<void> => rm(stateDir, { recursive: true, force: true });
|
|
75
|
+
try {
|
|
76
|
+
x(['db', 'reset'], options.root, env);
|
|
77
|
+
const seed = options.seed ?? ['--tier', 'dev'];
|
|
78
|
+
if (seed !== false) x(['db', 'seed', ...seed], options.root, env);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
await cleanup();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const spawned = await spawnE2eApp({
|
|
85
|
+
root: options.root,
|
|
86
|
+
mode: options.mode ?? 'dev',
|
|
87
|
+
env,
|
|
88
|
+
readyTimeoutMs: deadline,
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
base: spawned.base,
|
|
92
|
+
stateDir,
|
|
93
|
+
restart: (next) => spawned.restart(next),
|
|
94
|
+
async stop(): Promise<void> {
|
|
95
|
+
await spawned.stop();
|
|
96
|
+
await cleanup();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await cleanup();
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The browser and the app the e2e step opened, reachable from a test file: `e2eBrowser()` and
|
|
2
|
+
// `e2eApp()`. On `globalThis` under one `Symbol.for` key, because the preload and a test may each
|
|
3
|
+
// hold their own copy of this module — the page-client handle's reason, one runtime over.
|
|
4
|
+
|
|
5
|
+
import type { E2eBrowser } from './cdp-browser';
|
|
6
|
+
import { CdpBrowserMissingError } from './cdp-errors';
|
|
7
|
+
import { CHROME_CANDIDATES } from './cdp-launch';
|
|
8
|
+
import type { E2eApp } from './e2e-app';
|
|
9
|
+
|
|
10
|
+
/** Set by the e2e step to the app root; the preload spawns that app and opens a browser. */
|
|
11
|
+
export const E2E_ROOT_ENV = 'ULTIMATE_E2E_ROOT';
|
|
12
|
+
|
|
13
|
+
interface E2eRun {
|
|
14
|
+
browser: E2eBrowser;
|
|
15
|
+
readonly app: E2eApp;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const KEY = Symbol.for('ultimate.e2e.run');
|
|
19
|
+
|
|
20
|
+
export function publishE2eRun(run: E2eRun): void {
|
|
21
|
+
Object.defineProperty(globalThis, KEY, { value: run, configurable: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const current = (): E2eRun | undefined => Reflect.get(globalThis, KEY) as E2eRun | undefined;
|
|
25
|
+
|
|
26
|
+
/** Swap in a relaunched browser — the app stays; only the dead browser is replaced. */
|
|
27
|
+
export function republishE2eBrowser(browser: E2eBrowser): void {
|
|
28
|
+
const run = current();
|
|
29
|
+
if (run !== undefined) run.browser = browser;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const missing = (): CdpBrowserMissingError =>
|
|
33
|
+
new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The run's browser: `page`, and `session` for a second tab, an init script, the socket and request
|
|
37
|
+
* log and the offline switch for every worker. Refuses by name outside an e2e run that found one.
|
|
38
|
+
*/
|
|
39
|
+
export function e2eBrowser(): E2eBrowser {
|
|
40
|
+
const run = current();
|
|
41
|
+
if (run === undefined) throw missing();
|
|
42
|
+
return run.browser;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The app the run spawned: `base`, `stateDir`, and `restart({ BUILD_ID })` — a deploy. */
|
|
46
|
+
export function e2eApp(): E2eApp {
|
|
47
|
+
const run = current();
|
|
48
|
+
if (run === undefined) throw missing();
|
|
49
|
+
return run.app;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The spawned app's origin, or `undefined` outside an e2e run. */
|
|
53
|
+
export function e2eBaseUrl(): string | undefined {
|
|
54
|
+
return current()?.app.base;
|
|
55
|
+
}
|
package/src/e2e-driver.ts
CHANGED
|
@@ -14,7 +14,14 @@ import {
|
|
|
14
14
|
import type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
|
|
15
15
|
import { e2ePage } from './e2e-page';
|
|
16
16
|
|
|
17
|
-
export
|
|
17
|
+
export interface E2eDriverOptions extends E2ePageOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Switch the running app to a new build — the SERVER half no page port can speak for. Given, it
|
|
20
|
+
* becomes the `deploy` fixture's `newBuild()` and `e2eTest`'s `update()`; absent, both refuse by
|
|
21
|
+
* name. The gate's e2e preload passes the spawned app's `restart({ BUILD_ID })`.
|
|
22
|
+
*/
|
|
23
|
+
readonly newBuild?: (() => Promise<void>) | undefined;
|
|
24
|
+
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* A member this driver cannot build is a REFUSAL, never a no-op. A fixture that silently did
|
|
@@ -52,15 +59,21 @@ const networkFixtures = (browser: E2eBrowserPage): Pick<E2eFixtures, 'offline' |
|
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
/** What `e2eTest` hands its body: a real page, the network condition, and one honest refusal. */
|
|
55
|
-
export const e2eFixtures = (
|
|
62
|
+
export const e2eFixtures = (
|
|
63
|
+
page: PageLike,
|
|
64
|
+
browser: E2eBrowserPage,
|
|
65
|
+
newBuild?: () => Promise<void>,
|
|
66
|
+
): E2eFixtures => ({
|
|
56
67
|
page,
|
|
57
68
|
...networkFixtures(browser),
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
update:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
69
|
+
// A new build id is a fact about the SERVER, which no page port can speak for — so it is
|
|
70
|
+
// forwarded when whoever spawned the app can restart it, and refused by name otherwise.
|
|
71
|
+
update:
|
|
72
|
+
newBuild ??
|
|
73
|
+
refuse(
|
|
74
|
+
'update',
|
|
75
|
+
'a second build served under a new immutable build id, which is a server fact',
|
|
76
|
+
),
|
|
64
77
|
});
|
|
65
78
|
|
|
66
79
|
/**
|
|
@@ -73,7 +86,7 @@ export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixture
|
|
|
73
86
|
* `test.skip` — which the gate now reports as a SKIPPED step rather than the green check it
|
|
74
87
|
* printed until #434, and which a repo whose `x.verify.json` names `e2e` gets red for.
|
|
75
88
|
*
|
|
76
|
-
* `budget
|
|
89
|
+
* `budget` and `signIn` are deliberately NOT registered here, and `deploy` only with `newBuild`. Each needs something a
|
|
77
90
|
* page cannot supply — byte counts off a built `dist/`, an app's own sign-in route, a second build
|
|
78
91
|
* — so each keeps refusing with `X_TEST_FIXTURE_UNAVAILABLE` naming what it waits for.
|
|
79
92
|
*
|
|
@@ -82,16 +95,23 @@ export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixture
|
|
|
82
95
|
*/
|
|
83
96
|
export function installE2eDriver(options: E2eDriverOptions): () => void {
|
|
84
97
|
const page = e2ePage(options);
|
|
85
|
-
|
|
98
|
+
const newBuild = options.newBuild;
|
|
99
|
+
defineFixtures({
|
|
100
|
+
page: () => page,
|
|
101
|
+
...(newBuild === undefined ? {} : { deploy: () => ({ newBuild }) }),
|
|
102
|
+
});
|
|
86
103
|
useE2eDriver((name, body: E2eBody) => {
|
|
87
|
-
bunTest(name, () => body(e2eFixtures(page, options.page)));
|
|
104
|
+
bunTest(name, () => body(e2eFixtures(page, options.page, newBuild)));
|
|
88
105
|
});
|
|
89
106
|
return () => {
|
|
90
107
|
// Both halves, because both were installed. Putting the DECLARATION back — rather than
|
|
91
108
|
// deleting the key — is what keeps a later file's `{ page }` failing as
|
|
92
109
|
// `X_TEST_FIXTURE_UNAVAILABLE` (a driver is missing) instead of `X_TEST_FIXTURE_UNKNOWN`
|
|
93
110
|
// (register it), which is the wrong instruction for a name the framework declares.
|
|
94
|
-
defineFixtures({
|
|
111
|
+
defineFixtures({
|
|
112
|
+
page: unavailableFixture('page'),
|
|
113
|
+
...(newBuild === undefined ? {} : { deploy: unavailableFixture('deploy') }),
|
|
114
|
+
});
|
|
95
115
|
resetE2eDriver();
|
|
96
116
|
};
|
|
97
117
|
}
|
package/src/e2e-errors.ts
CHANGED
|
@@ -101,3 +101,17 @@ export class E2eServiceWorkerAbsentError extends UltimateError {
|
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The app an e2e run spawns (`e2e-app.ts`) did not come up: its reset, its seed, or its boot. The
|
|
107
|
+
* cause carries that process's own output, rendered, because it is the only place the reason is.
|
|
108
|
+
*/
|
|
109
|
+
export class E2eAppFailedError extends UltimateError {
|
|
110
|
+
constructor(input: { readonly step: string; readonly output: string }) {
|
|
111
|
+
super({
|
|
112
|
+
code: 'X_E2E_APP_FAILED',
|
|
113
|
+
cause: `${renderCauseValue(input.step)} failed for the e2e app: ${renderCauseValue(input.output)}`,
|
|
114
|
+
fix: 'x dev --json # boot the same app by hand and read why it would not start; the e2e run used a throwaway ULTIMATE_STATE_DIR, so your own .x is untouched',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/e2e-page.ts
CHANGED
|
@@ -67,11 +67,14 @@ const TITLE = '(() => JSON.stringify({ title: document.title }))()';
|
|
|
67
67
|
* The cost, stated rather than hidden: this is a SECOND request to the same route, so what it
|
|
68
68
|
* measures is that route's streaming behaviour and not the byte-for-byte first chunk the open
|
|
69
69
|
* document received. It runs in the page, so it carries the page's cookies and its origin — a
|
|
70
|
-
* `fetch` from the test process would carry neither.
|
|
70
|
+
* `fetch` from the test process would carry neither. The reader is CANCELLED after that chunk: a
|
|
71
|
+
* streamed response nobody pulls stays open until its last hole fills, holding one of the page's
|
|
72
|
+
* six connections to its origin for the rest of the test.
|
|
71
73
|
*/
|
|
72
74
|
const firstFlushExpression = (url: string): string =>
|
|
73
75
|
`(() => fetch(${JSON.stringify(url)}, { credentials: 'same-origin' })
|
|
74
|
-
.then((response) => response.body.getReader().read()
|
|
76
|
+
.then((response) => { const reader = response.body.getReader(); return reader.read()
|
|
77
|
+
.then((chunk) => { reader.cancel().catch(() => {}); return chunk; }); })
|
|
75
78
|
.then((chunk) => JSON.stringify({ html: new TextDecoder().decode(chunk.value || new Uint8Array()) })))()`;
|
|
76
79
|
|
|
77
80
|
/**
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The e2e step's own preload: when the gate names an app root (`ULTIMATE_E2E_ROOT`, set by
|
|
2
|
+
// `verify-e2e.ts`), spawn that app on a throwaway database, open ONE browser, install it as the
|
|
3
|
+
// `page` fixture and the `e2eTest` driver, register `deploy.newBuild()` as a restart of the app on
|
|
4
|
+
// its own port, and hand both to any test that asks `e2eBrowser()` / `e2eApp()`.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeEach } from 'bun:test';
|
|
7
|
+
import type { E2eBrowser } from './cdp-browser';
|
|
8
|
+
import { openE2eBrowser } from './cdp-browser';
|
|
9
|
+
import { startE2eApp } from './e2e-app';
|
|
10
|
+
import { E2E_ROOT_ENV, e2eBrowser, publishE2eRun, republishE2eBrowser } from './e2e-browser-handle';
|
|
11
|
+
import { installE2eDriver } from './e2e-driver';
|
|
12
|
+
import type { E2eBrowserPage } from './e2e-page';
|
|
13
|
+
import { answersWithin } from './e2e-probe';
|
|
14
|
+
|
|
15
|
+
/** How long a live browser gets to answer `1` before it is declared hung and relaunched. */
|
|
16
|
+
const PROBE_MS = 5_000;
|
|
17
|
+
|
|
18
|
+
const root = Bun.env[E2E_ROOT_ENV];
|
|
19
|
+
if (root !== undefined && root !== '') {
|
|
20
|
+
const app = await startE2eApp({ root });
|
|
21
|
+
let builds = 0;
|
|
22
|
+
// A deploy leaves the browser holding the OLD build's state: a SharedWorker whose socket went
|
|
23
|
+
// down with the restart and is now deep in its reconnect backoff, and tabs rendered by the old
|
|
24
|
+
// build. The test that deployed asserts on exactly that; the NEXT test must not inherit it.
|
|
25
|
+
let deployed = false;
|
|
26
|
+
const newBuild = async (): Promise<void> => {
|
|
27
|
+
deployed = true;
|
|
28
|
+
builds += 1;
|
|
29
|
+
await app.restart({ BUILD_ID: `e2e-build-${String(builds)}-${String(Date.now())}` });
|
|
30
|
+
};
|
|
31
|
+
// `openE2eBrowser`, never the `IfAvailable` door: the step only names a root after finding one.
|
|
32
|
+
let browser: E2eBrowser = await openE2eBrowser();
|
|
33
|
+
publishE2eRun({ browser, app });
|
|
34
|
+
// Installed ONCE, over a page that delegates to whichever browser is current: an `e2eTest` body
|
|
35
|
+
// is bound to its fixtures when the file DEFINES it, so reinstalling on a relaunch would leave
|
|
36
|
+
// every test defined before it driving a closed browser.
|
|
37
|
+
const current: E2eBrowserPage = {
|
|
38
|
+
url: () => browser.page.url(),
|
|
39
|
+
goto: (url, options) => browser.page.goto(url, options),
|
|
40
|
+
evaluate: (expression) => browser.page.evaluate(expression),
|
|
41
|
+
click: (selector) => browser.page.click(selector),
|
|
42
|
+
offline: (enabled) => browser.page.offline(enabled),
|
|
43
|
+
};
|
|
44
|
+
const uninstall = installE2eDriver({ page: current, baseUrl: app.base, newBuild });
|
|
45
|
+
|
|
46
|
+
// A browser that stopped answering takes every later suite down with it, one call deadline per
|
|
47
|
+
// call (run 8). So each test starts with a short probe, and a browser that fails it — or one a
|
|
48
|
+
// deploy ran under — is closed and relaunched: the app is untouched, and the test gets a fresh
|
|
49
|
+
// profile, a fresh SharedWorker and a fresh tab on the same origin.
|
|
50
|
+
beforeEach(async () => {
|
|
51
|
+
const alive = !deployed && (await answersWithin(e2eBrowser().page, PROBE_MS));
|
|
52
|
+
if (alive) return;
|
|
53
|
+
deployed = false;
|
|
54
|
+
browser.close();
|
|
55
|
+
browser = await openE2eBrowser();
|
|
56
|
+
republishE2eBrowser(browser);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterAll(async () => {
|
|
60
|
+
uninstall();
|
|
61
|
+
browser.close();
|
|
62
|
+
await app.stop();
|
|
63
|
+
});
|
|
64
|
+
}
|
package/src/e2e-probe.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Is the browser still answering? One cheap `evaluate('1')` raced against a short budget — the
|
|
2
|
+
// question the e2e preload asks before every test, because a hung browser otherwise costs every
|
|
3
|
+
// later suite one full CDP deadline per call (run 8) instead of one relaunch.
|
|
4
|
+
|
|
5
|
+
/** `true` when the page evaluated `1` within `ms`; a rejection or a stall is `false`, never a throw. */
|
|
6
|
+
export async function answersWithin(
|
|
7
|
+
page: { evaluate(expression: string): Promise<unknown> },
|
|
8
|
+
ms: number,
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
const stalled = new Promise<false>((resolve) => {
|
|
12
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
13
|
+
});
|
|
14
|
+
const answered = page.evaluate('1').then(
|
|
15
|
+
() => true,
|
|
16
|
+
() => false,
|
|
17
|
+
);
|
|
18
|
+
try {
|
|
19
|
+
return await Promise.race([answered, stalled]);
|
|
20
|
+
} finally {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
}
|
|
23
|
+
}
|