@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.
Files changed (49) hide show
  1. package/CLAUDE.md +69 -1
  2. package/package.json +30 -30
  3. package/src/app-env.ts +2 -2
  4. package/src/budgets.ts +45 -12
  5. package/src/build-errors.ts +54 -0
  6. package/src/cdp-browser.ts +21 -27
  7. package/src/cdp-connection.ts +66 -30
  8. package/src/cdp-e2e-page.ts +84 -113
  9. package/src/cdp-e2e-session.ts +199 -0
  10. package/src/cdp-launch.ts +95 -41
  11. package/src/cdp-offline-script.ts +73 -0
  12. package/src/cdp-pipe.ts +77 -0
  13. package/src/cmd-deploy.ts +7 -0
  14. package/src/cmd-dev.ts +23 -86
  15. package/src/dev-live-feed.ts +2 -0
  16. package/src/dev-render.ts +119 -20
  17. package/src/dev-route-table.ts +119 -0
  18. package/src/dev-services.ts +4 -1
  19. package/src/dev-sync.ts +5 -3
  20. package/src/e2e-app.ts +103 -0
  21. package/src/e2e-browser-handle.ts +55 -0
  22. package/src/e2e-driver.ts +32 -12
  23. package/src/e2e-errors.ts +14 -0
  24. package/src/e2e-page.ts +5 -2
  25. package/src/e2e-preload.ts +64 -0
  26. package/src/e2e-probe.ts +23 -0
  27. package/src/e2e-spawn.ts +169 -0
  28. package/src/error-codes.ts +7 -0
  29. package/src/error-unthrown.ts +130 -0
  30. package/src/errors.ts +8 -29
  31. package/src/index.ts +18 -4
  32. package/src/island-bundle.ts +33 -11
  33. package/src/island-realtime.ts +91 -0
  34. package/src/island-verdict.ts +1 -1
  35. package/src/live-routes.ts +82 -42
  36. package/src/mcp-errors.ts +3 -0
  37. package/src/page-sync.ts +54 -0
  38. package/src/realtime-browser-probe-fixture.ts +2 -2
  39. package/src/serve.ts +9 -0
  40. package/src/sw-artifacts.ts +13 -3
  41. package/src/sync-url.ts +31 -0
  42. package/src/templates/resource-form-island.ts +30 -21
  43. package/src/templates/route.ts +3 -0
  44. package/src/templates/scaffold-container.ts +18 -3
  45. package/src/templates/scaffold-env.ts +6 -0
  46. package/src/verify-e2e.ts +38 -0
  47. package/src/verify-run.ts +105 -50
  48. package/src/verify-tests.ts +21 -4
  49. 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
+ }
@@ -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
- const stateDir = join(root, '.x');
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
- registry: LiveQueryRegistry,
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
- registry,
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 type E2eDriverOptions = E2ePageOptions;
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 = (page: PageLike, browser: E2eBrowserPage): 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
- // The one that is still genuinely out of reach, and it is not a port gap: a new build id is a
59
- // fact about the SERVER, which no page port has ever been able to speak for.
60
- update: refuse(
61
- 'update',
62
- 'a second build served under a new immutable build id, which is a server fact',
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`, `signIn` and `deploy` are deliberately NOT registered here. Each needs something a
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
- defineFixtures({ page: () => page });
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({ page: unavailableFixture('page') });
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
+ }
@@ -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
+ }