@ultimat3/cli 20.2.0 → 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 (53) hide show
  1. package/CLAUDE.md +70 -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/cmd-shot.ts +30 -4
  16. package/src/dev-live-feed.ts +2 -0
  17. package/src/dev-render.ts +119 -20
  18. package/src/dev-route-table.ts +119 -0
  19. package/src/dev-services.ts +4 -1
  20. package/src/dev-sync.ts +5 -3
  21. package/src/e2e-app.ts +103 -0
  22. package/src/e2e-browser-handle.ts +55 -0
  23. package/src/e2e-driver.ts +32 -12
  24. package/src/e2e-errors.ts +14 -0
  25. package/src/e2e-page.ts +5 -2
  26. package/src/e2e-preload.ts +64 -0
  27. package/src/e2e-probe.ts +23 -0
  28. package/src/e2e-spawn.ts +169 -0
  29. package/src/error-codes.ts +7 -0
  30. package/src/error-unthrown.ts +130 -0
  31. package/src/errors.ts +8 -29
  32. package/src/index.ts +18 -4
  33. package/src/island-bundle.ts +38 -11
  34. package/src/island-realtime.ts +91 -0
  35. package/src/island-solid-dedupe.ts +108 -0
  36. package/src/island-verdict.ts +1 -1
  37. package/src/live-routes.ts +82 -42
  38. package/src/mcp-errors.ts +3 -0
  39. package/src/page-sync.ts +54 -0
  40. package/src/realtime-browser-probe-fixture.ts +2 -2
  41. package/src/serve.ts +9 -0
  42. package/src/shot-theme.ts +52 -0
  43. package/src/sw-artifacts.ts +13 -3
  44. package/src/sync-url.ts +31 -0
  45. package/src/templates/resource-form-island.ts +30 -21
  46. package/src/templates/route.ts +3 -0
  47. package/src/templates/scaffold-container.ts +18 -3
  48. package/src/templates/scaffold-dashboard-shared.ts +8 -5
  49. package/src/templates/scaffold-env.ts +6 -0
  50. package/src/verify-e2e.ts +38 -0
  51. package/src/verify-run.ts +105 -50
  52. package/src/verify-tests.ts +21 -4
  53. package/src/worker-bundle.ts +192 -0
package/src/dev-render.ts CHANGED
@@ -14,15 +14,32 @@
14
14
  // navigation, with a re-parse on top. The static export writes the file (`writeStyles`), so the
15
15
  // "second file" cost is one `Bun.write`.
16
16
 
17
+ // why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
18
+ import { posix } from 'node:path';
19
+ import { clientScopeOf } from '@ultimat3/auth';
17
20
  import type { Ctx } from '@ultimat3/core';
21
+ import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
18
22
  import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
19
23
  import { asCtx, html, stream } from '@ultimat3/http';
20
24
  import { currentLocale } from '@ultimat3/i18n';
21
- import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
25
+ import type {
26
+ ClientSyncHead,
27
+ IslandCollector,
28
+ RenderResult,
29
+ RouteData,
30
+ RouteEntry,
31
+ } from '@ultimat3/render';
22
32
  import {
33
+ clientBootTags,
34
+ clientPersistTags,
35
+ clientScopeTag,
36
+ clientSyncTags,
23
37
  createIslandCollector,
38
+ documentCarriesScope,
24
39
  headFromMeta,
25
40
  hydrateRuntime,
41
+ islandModuleId,
42
+ islandModuleIds,
26
43
  metaContextFor,
27
44
  renderHead,
28
45
  routeDataFor,
@@ -39,9 +56,11 @@ import {
39
56
  ROOT_ELEMENT_ID,
40
57
  renderComponent,
41
58
  renderSsr,
59
+ ssrHeaders,
42
60
  staticHeaders,
43
61
  streamResult,
44
62
  } from '@ultimat3/render/server';
63
+ import { realtimeIslandFiles } from './island-realtime';
45
64
  import { styleBundle } from './style-bundle';
46
65
 
47
66
  /**
@@ -71,6 +90,18 @@ export interface DocumentOptions {
71
90
  * decided by `app.config.ts`, which the boot read and the renderer cannot.
72
91
  */
73
92
  readonly themeHead?: string;
93
+ /**
94
+ * The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
95
+ * document this process serves. Principal-free, so a shareable document carries it too; absent
96
+ * for a caller that serves no socket at all (the static export).
97
+ */
98
+ readonly sync?: ClientSyncHead;
99
+ /**
100
+ * The record types the app persists (`entity(…, { persist: true })`), read per render. Rendered
101
+ * as `ultimate-persist` beside the scope tag only — persistence is per principal, so a document
102
+ * with no scope carries none.
103
+ */
104
+ readonly persisted?: () => readonly string[];
74
105
  }
75
106
 
76
107
  export interface DevRenderOptions extends DocumentOptions {
@@ -94,16 +125,32 @@ export interface DevRouteData extends Record<string, unknown> {
94
125
  */
95
126
  const lang = (): string => currentLocale();
96
127
 
128
+ /**
129
+ * `scope` is present only on a PRIVATE document (a gated `ssr` page, every `stream`): the page's
130
+ * client scope (`@ultimat3/auth`'s `clientScopeOf`), which core's `pageClient()` reads to fence its
131
+ * one store per principal. A shareable document (`static`, `isr`, ungated `ssr`) carries NO scope
132
+ * tag — absent means "not rendered for anyone", a different answer from `''`, the anonymous page.
133
+ */
97
134
  const headFor = async (
98
135
  entry: RouteEntry,
99
136
  ctx: DevRouteData,
100
137
  data: RouteData,
101
138
  options: DocumentOptions,
139
+ scope?: string,
102
140
  ): Promise<string> =>
103
141
  renderHead(
104
142
  headFromMeta(
105
143
  await entry.config.meta(metaContextFor(ctx, data)),
106
144
  seoRenderers({ path: new URL(ctx.url).pathname }),
145
+ [
146
+ ...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
147
+ // The page boot rides the scope tag: its whole job — restoring a principal's persisted
148
+ // records and replaying its queued writes — is per principal, and a shareable document
149
+ // (no scope tag) has neither. Cheaper than walking the page's islands, and exact.
150
+ ...(scope === undefined
151
+ ? []
152
+ : [clientScopeTag(scope), ...clientPersistTags(options.persisted?.() ?? [])]),
153
+ ],
107
154
  ),
108
155
  ) +
109
156
  (options.themeHead ?? '') +
@@ -166,6 +213,33 @@ export async function routeBody(
166
213
  * an island never declares its own timing, and `resolve` is the build's — identity when nothing
167
214
  * built any, which fails at the first island by name rather than emitting an unusable entry.
168
215
  */
216
+ /**
217
+ * Realtime's page boot, as one deferred script — or nothing. Two conditions, both exact: the
218
+ * document carries a principal scope (restoring persisted records and replaying queued writes are
219
+ * per principal; a shareable document has neither), AND one of the islands this render emitted
220
+ * reaches `@ultimat3/realtime` (a page whose islands never touch a record has nothing to restore
221
+ * into and no write to replay). After the body, because which islands rendered is a fact the walk
222
+ * just recorded; still before the hydration runtime, so it runs first among the deferred scripts.
223
+ */
224
+ function bootScript(
225
+ entry: RouteEntry,
226
+ islands: IslandCollector,
227
+ options: DocumentOptions,
228
+ scope: string | undefined,
229
+ ): string {
230
+ if (scope === undefined || options.sync === undefined) return '';
231
+ const rendered = new Set(islandModuleIds(islands.directives));
232
+ if (rendered.size === 0) return '';
233
+ // An island's module id is derived from its `src`, written relative to the page that renders it:
234
+ // each realtime island file, spelled from THIS page, is the id its directive would carry.
235
+ const pageDir = posix.dirname(entry.file);
236
+ const reaches = [...realtimeIslandFiles()].some((file) => {
237
+ const src = posix.relative(pageDir, file);
238
+ return rendered.has(islandModuleId(src.startsWith('.') ? src : `./${src}`));
239
+ });
240
+ return reaches ? renderHead(clientBootTags(options.sync)) : '';
241
+ }
242
+
169
243
  const collectorFor = (entry: RouteEntry, options: DocumentOptions): IslandCollector =>
170
244
  createIslandCollector({
171
245
  file: entry.file,
@@ -199,15 +273,16 @@ async function documentFrom(
199
273
  ctx: DevRouteData,
200
274
  data: RouteData,
201
275
  options: DocumentOptions,
276
+ scope?: string,
202
277
  ): Promise<string> {
203
278
  const islands = collectorFor(entry, options);
204
279
  const [head, body] = await Promise.all([
205
- headFor(entry, ctx, data, options),
280
+ headFor(entry, ctx, data, options, scope),
206
281
  routeBody(entry, ctx, data, islands),
207
282
  ]);
208
283
  return (
209
284
  `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head>` +
210
- `<body>${body}${hydrateRuntime(islands.directives)}</body></html>`
285
+ `<body>${body}${bootScript(entry, islands, options, scope)}${hydrateRuntime(islands.directives)}</body></html>`
211
286
  );
212
287
  }
213
288
 
@@ -256,31 +331,55 @@ async function resultFor(
256
331
  // correct output, no streaming benefit.
257
332
  const islands = collectorFor(entry, options);
258
333
  const [head, shell] = await Promise.all([
259
- headFor(entry, request, data, options),
334
+ // A stream is always `private, no-store` (`streamResult`), so it always carries the scope.
335
+ headFor(entry, request, data, options, clientScopeOf(ctx.actor)),
260
336
  routeBody(entry, request, data, islands),
261
337
  ]);
262
- return streamResult(
263
- {
264
- head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
265
- // The runtime rides the first flush, with the shell it boots. A later chunk would leave
266
- // the window between flush one and the close with inert islands and no listeners on
267
- // them — which is exactly the first-click-lost failure `interaction` replay exists for.
268
- shell: `${shell}${hydrateRuntime(islands.directives)}`,
269
- holes: [],
270
- },
271
- { buildId: options.buildId },
272
- status,
338
+ return withScope(
339
+ streamResult(
340
+ {
341
+ head: `<!doctype html><html lang="${lang()}"><head>${head}${styleTag(entry)}</head><body>`,
342
+ // The runtime rides the first flush, with the shell it boots. A later chunk would leave
343
+ // the window between flush one and the close with inert islands and no listeners on
344
+ // them — which is exactly the first-click-lost failure `interaction` replay exists for.
345
+ shell: `${shell}${bootScript(entry, islands, options, clientScopeOf(ctx.actor))}${hydrateRuntime(islands.directives)}`,
346
+ holes: [],
347
+ },
348
+ { buildId: options.buildId },
349
+ status,
350
+ ),
351
+ clientScopeOf(ctx.actor),
273
352
  );
274
353
  }
275
- default:
276
- return renderSsr(
277
- { entry, params: request.params, url, ctx },
278
- () => documentFrom(entry, request, data, options),
279
- { buildId: options.buildId, status },
354
+ default: {
355
+ // Asked of the headers `renderSsr` is about to send: a gated page is private and carries the
356
+ // scope; an ungated one is `public, s-maxage` and a CDN may hand it to anyone, so it carries
357
+ // none — absent, which core reads as "not rendered for anyone", never as anonymous.
358
+ const scope = documentCarriesScope(ssrHeaders(entry, { buildId: options.buildId }))
359
+ ? clientScopeOf(ctx.actor)
360
+ : undefined;
361
+ return withScope(
362
+ await renderSsr(
363
+ { entry, params: request.params, url, ctx },
364
+ () => documentFrom(entry, request, data, options, scope),
365
+ { buildId: options.buildId, status },
366
+ ),
367
+ scope,
280
368
  );
369
+ }
281
370
  }
282
371
  }
283
372
 
373
+ /**
374
+ * A private document's scope, as a RESPONSE header too: the service worker partitions its offline
375
+ * pages by principal and never parses HTML, so the meta alone cannot reach it. Exactly the
376
+ * documents that carry the scope tag carry this — a shareable one carries neither.
377
+ */
378
+ const withScope = (result: RenderResult, scope: string | undefined): RenderResult =>
379
+ scope === undefined
380
+ ? result
381
+ : { ...result, headers: { ...result.headers, [CLIENT_SCOPE_HEADER]: scope } };
382
+
284
383
  const responseOf = (result: RenderResult): Response =>
285
384
  typeof result.body === 'string'
286
385
  ? html(result.body, { status: result.status, headers: result.headers })
@@ -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
  /**