@ultimat3/cli 18.0.0 → 19.1.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.
@@ -0,0 +1,147 @@
1
+ // The app `x dev` boots in its tests, and the one place it is declared: every assertion that
2
+ // needs a booted app lives in `cmd-dev.test.ts`, because a process has ONE lifecycle — the second
3
+ // in-process boot is refused (X_LIFECYCLE_DRAINED) — and the coverage gate runs a package in one
4
+ // process. So the fixture is a module of its own, and that file keeps its line budget for tests.
5
+ //
6
+ // What the app declares, and what each declaration is here to prove:
7
+ // app.config.ts the root marker a real `x dev` cannot start without; `ai.mcp` by default
8
+ // apps/web/mcp.ts the app's own MCP endpoint, mounted by the web role
9
+ // apps/web/runtime.ts the app's middleware, reaching a development process
10
+ // apps/web/app/notes/* a memory-backed entity and a live query, fed by the in-process bridge
11
+ // apps/web/app/posts/* an action, a policy and a query, mounted as HTTP routes
12
+ // apps/web/site/pricing/* a static page with its own stylesheet, under the CSP `x dev` sends
13
+ import { resetRegistry as resetActions } from '@ultimat3/action';
14
+ import { clearRegistry as clearEntities } from '@ultimat3/entity';
15
+ import { resetJobs, resetTasks } from '@ultimat3/jobs';
16
+ import { clearPermissions, clearRoles } from '@ultimat3/policy';
17
+ import { resetRegistry as resetQueries } from '@ultimat3/query';
18
+ import type { Frame } from '@ultimat3/realtime';
19
+ import { decode } from '@ultimat3/realtime';
20
+ import type { WsLike } from '@ultimat3/realtime/server';
21
+ import { clearRoutes } from '@ultimat3/render';
22
+ import { resetAppLoad } from './app-load';
23
+
24
+ export const DEV_FIXTURE_FILES: Readonly<Record<string, string>> = {
25
+ 'package.json': JSON.stringify({ name: 'dev-fixture', version: '1.4.0' }),
26
+
27
+ // The root marker a real `x dev` cannot start without, and where `ai.mcp` is declared — by
28
+ // default `{ expose: true, path: '/mcp' }`, which is what the MCP mount reads.
29
+ 'app.config.ts': `import { defineConfig } from '@ultimat3/core';
30
+ export const config = defineConfig({ name: 'dev-fixture' });
31
+ `,
32
+
33
+ // The app's own MCP endpoint, in the contract `app-mcp.ts` reads. `resolveToken` answering
34
+ // `null` rejects every bearer, which is enough to prove the ROUTE is mounted (401, not 404).
35
+ 'apps/web/mcp.ts': `import { defineAppMcp } from '@ultimat3/mcp';
36
+ export const mcp = defineAppMcp({ include: 'exposed', resolveToken: () => null });
37
+ `,
38
+
39
+ // The app's own middleware, in the contract `app-runtime.ts` reads: a header on every response
40
+ // is the cheapest proof that the chain `x dev` composed is the app's and not only the replica's.
41
+ 'apps/web/runtime.ts': `const stamp = async (request, ctx, next) => {
42
+ const response = await next(request, ctx);
43
+ const headers = new Headers(response.headers);
44
+ headers.set('x-dev-runtime', 'app');
45
+ return new Response(response.body, { status: response.status, headers });
46
+ };
47
+ export const runtime = { middleware: [stamp] };
48
+ `,
49
+
50
+ // A memory-backed entity, so the fixture needs no migration: the row observer sits on
51
+ // `database()`'s repo wrapper, the same seam a Postgres-backed repo writes through.
52
+ 'apps/web/app/notes/entity.ts': `import { database, entity, memoryDriver, text, uuid } from '@ultimat3/entity';
53
+ export const notes = entity('notes', { columns: { id: uuid().primaryKey(), title: text() } });
54
+ export const db = database({ notes }, { driver: memoryDriver() });
55
+ `,
56
+ 'apps/web/app/notes/live.ts': `import { allow } from '@ultimat3/policy';
57
+ import { from, query, t } from '@ultimat3/query';
58
+ import { db } from './entity';
59
+ export const liveNotes = query({
60
+ input: t.object({}),
61
+ policy: allow('public'),
62
+ live: true,
63
+ subscribes: ['notes'],
64
+ sql: () =>
65
+ from<{ id: string; title: string }>('notes', () => db.notes.where({}).all())
66
+ .orderBy('id')
67
+ .limit(50),
68
+ });
69
+ `,
70
+
71
+ 'apps/web/app/posts/policy.ts': `import { allow, can, definePermissions, defineRoles } from '@ultimat3/policy';
72
+ export const permissions = definePermissions(['post:publish'] as const);
73
+ export const roles = defineRoles({
74
+ author: { grants: ['post:publish'] },
75
+ reader: { grants: [] },
76
+ });
77
+ export const canPostWrite = can('post:publish');
78
+ export const anyone = allow();
79
+ `,
80
+
81
+ 'apps/web/app/posts/actions.ts': `import { action, t } from '@ultimat3/action';
82
+ import { anyone, canPostWrite } from './policy';
83
+
84
+ export const publishPost = action({
85
+ input: t.object({ id: t.uuid }),
86
+ output: t.object({ id: t.uuid }),
87
+ policy: canPostWrite,
88
+ async handle({ input }) {
89
+ return { id: input.id };
90
+ },
91
+ });
92
+
93
+ export const echoPost = action({
94
+ input: t.object({ word: t.string }),
95
+ output: t.object({ word: t.string }),
96
+ policy: anyone,
97
+ async handle({ input }) {
98
+ return { word: input.word };
99
+ },
100
+ });
101
+ `,
102
+
103
+ // A stylesheet the page imports, because that import is what registers it — and the document's
104
+ // inline `<style>` is what the CSP has to name. Without one this file served no styled page and
105
+ // could not have caught the policy that blanked every deployed app.
106
+ 'apps/web/site/pricing/page.module.scss': `.price { color: #123456; }
107
+ `,
108
+
109
+ 'apps/web/site/pricing/page.tsx': `import { defineRoute } from '@ultimat3/render';
110
+ import './page.module.scss';
111
+
112
+ export const config = defineRoute({
113
+ render: 'static',
114
+ offline: 'precache',
115
+ hydrate: 'never',
116
+ budget: { js: '0kb' },
117
+ meta: () => ({ title: 'Pricing', description: 'What it costs' }),
118
+ });
119
+ `,
120
+ };
121
+
122
+ export const resetRegistries = (): void => {
123
+ resetActions();
124
+ resetQueries();
125
+ clearEntities();
126
+ clearRoutes();
127
+ resetJobs();
128
+ resetTasks();
129
+ clearPermissions();
130
+ clearRoles();
131
+ resetAppLoad();
132
+ };
133
+
134
+ /** A `WsLike` that keeps every frame the node sends, decoded — what a live assertion reads. */
135
+ export class FakeWs implements WsLike {
136
+ readonly frames: Frame[] = [];
137
+ send(data: string): number {
138
+ this.frames.push(decode(data));
139
+ return data.length;
140
+ }
141
+ close(): void {}
142
+ subscribe(): void {}
143
+ unsubscribe(): void {}
144
+ getBufferedAmount(): number {
145
+ return 0;
146
+ }
147
+ }
package/src/cmd-dev.ts CHANGED
@@ -14,15 +14,19 @@ import type { OverlayNotice, RequestContext, Route } from '@ultimat3/http';
14
14
  import { asCtx } from '@ultimat3/http';
15
15
  import type { Manifest } from '@ultimat3/manifest';
16
16
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
17
+ import { describeRoutes } from '@ultimat3/render';
17
18
  import { apiRoutes } from './api-routes';
18
19
  import { loadSignInPath } from './app-auth';
19
20
  import { loadApp } from './app-load';
20
21
  import { appManifest } from './app-manifest';
22
+ import { mountAppMcp } from './app-mcp';
21
23
  import { requireAppRoot } from './app-root';
24
+ import { loadAppRuntime } from './app-runtime';
22
25
  import type { CliCommand, CommandContext } from './command';
23
26
  import { assetRoutes } from './dev-assets';
24
27
  import type { DevDashboardInput, DevStatus } from './dev-dashboard';
25
28
  import { devDashboardRoutes, devPanels } from './dev-dashboard';
29
+ import { liveFeedLabel } from './dev-live-feed';
26
30
  import { clearLock, preflight, writeLock } from './dev-lock';
27
31
  import { createStatementLedger } from './dev-n-plus-one';
28
32
  import { appRoutes } from './dev-render';
@@ -49,6 +53,8 @@ import { flagString } from './parse';
49
53
  import { loadPwaArtifacts } from './pwa-artifacts';
50
54
  import { metricsPortFor } from './serve';
51
55
  import { loopFacts, loopFinding, loopNotice } from './statement-loop';
56
+ import { serviceWorkerArtifacts } from './sw-artifacts';
57
+ import { serviceWorkerRoutes } from './sw-routes';
52
58
 
53
59
  const DEFAULT_PORT = 3000;
54
60
 
@@ -68,6 +74,8 @@ export interface DevServer {
68
74
  readonly runtime: RunningServices;
69
75
  /** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
70
76
  readonly panels: readonly string[];
77
+ /** `POST <path>` of the app's own MCP endpoint, or `null` when nothing was mounted. */
78
+ readonly mcp: string | null;
71
79
  stop(): Promise<void>;
72
80
  }
73
81
 
@@ -176,12 +184,25 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
176
184
  // name it. `undefined` for an app that is not installable, and then nothing is mounted and no
177
185
  // document changes — the 0kb baseline is not spent on a `<link>` to a file that does not exist.
178
186
  const pwa = await loadPwaArtifacts(options.root);
187
+ // Built once at boot, from this process's own route table and island bundle. `x dev` rebuilds
188
+ // islands on the watcher tick and the worker is NOT rebuilt with them, deliberately: a service
189
+ // worker that changes under a page it already controls is the update path, and re-emitting one
190
+ // per keystroke would exercise it on every save.
191
+ const serviceWorker =
192
+ pwa === undefined
193
+ ? undefined
194
+ : serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands: state.islands });
179
195
 
196
+ // The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
197
+ // call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
198
+ // one of them asked. Warned once here when `expose` is true and nothing can be mounted.
199
+ const mcpMount = await mountAppMcp(options.root);
180
200
  const routes: readonly Route[] = [
181
201
  ...devDashboardRoutes(dashboard),
182
202
  // The same API table the container serves: a read that answers here and 404s in production
183
203
  // is exactly the drift one composition exists to prevent.
184
204
  ...apiRoutes(),
205
+ ...mcpMount.routes,
185
206
  // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
186
207
  // variants every `srcset` promises. Mounted before the app's own routes so a page route can
187
208
  // never shadow `/icons` or `/media`.
@@ -203,14 +224,22 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
203
224
  islands: () => state.islands,
204
225
  states: () => loadIslandStates(options.root),
205
226
  }),
227
+ ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
206
228
  ...appRoutes({
207
229
  buildId,
208
230
  resolveIsland: (file) => state.islands.resolverFor(file),
209
- ...(pwa === undefined ? {} : { pwaHead: pwa.head }),
231
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
210
232
  }),
211
233
  ];
212
234
 
213
- const replicaOverride = replicaOverrides(undefined, services.db, options.env);
235
+ // The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
236
+ // `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
237
+ // first argument was `undefined` here and an app's middleware reached no development process.
238
+ const replicaOverride = replicaOverrides(
239
+ await loadAppRuntime(options.root),
240
+ services.db,
241
+ options.env,
242
+ );
214
243
  const running = await startRoles({
215
244
  roles: options.roles ?? DEV_ROLES,
216
245
  port: options.port,
@@ -266,6 +295,7 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
266
295
  url: running.url ?? `http://localhost:${options.port}`,
267
296
  services,
268
297
  roles: running.roles,
298
+ mcp: mcpMount.path,
269
299
  get buildId(): string {
270
300
  return state.manifest.buildId;
271
301
  },
@@ -368,7 +398,7 @@ export const devCommand: CliCommand = {
368
398
  panels: server.panels.length,
369
399
  // Rendered text, so the mail and CDN halves come from the catalog; `data` below carries the
370
400
  // status values a script parses, which is why the two are different calls and not one.
371
- services: `${describeServices(server.services)} ${mailLabel(server.runtime)} ${cdnLabel(server.runtime)}`,
401
+ services: `${describeServices(server.services)} ${mailLabel(server.runtime)} ${cdnLabel(server.runtime)} ${liveFeedLabel(server.running.liveFeed)}`,
372
402
  }),
373
403
  findings: server.findings,
374
404
  // Every fact `lines` prints is a fact `--json` carries, `manifest` included — or the two
@@ -393,10 +423,15 @@ export const devCommand: CliCommand = {
393
423
  // on one database is the one topology mistake that cannot be seen from the outside, so the
394
424
  // slot is a scriptable fact rather than a line in a log.
395
425
  replicationSlot: server.running.replicator?.slot ?? null,
426
+ // Which change feed the sync node has: `in-process` under the embedded database, where
427
+ // this process's own writes reach subscribers; `replication` with a real one; `none`
428
+ // when no sync role runs here. The label on the ready line is this same fact.
429
+ liveFeed: server.running.liveFeed,
396
430
  buildId: server.buildId,
397
431
  manifest: join(root, MANIFEST_FILENAME),
398
432
  introspect: `${server.url}/_x`,
399
433
  panels: [...server.panels],
434
+ mcp: server.mcp,
400
435
  },
401
436
  lines: [
402
437
  // A hard kill leaves the lock behind; clearing it is normal and worth one line, never a
@@ -406,6 +441,9 @@ export const devCommand: CliCommand = {
406
441
  msg('cli.dev.panels', { panels: server.panels.join(', ') }),
407
442
  msg('cli.dev.manifest', { path: join(root, MANIFEST_FILENAME) }),
408
443
  msg('cli.dev.introspect', { url: `${server.url}/_x` }),
444
+ // Only when something was mounted: the unmounted case has already said why, once, as a
445
+ // warning with a fix, and a summary line reading `mcp none` would be a second copy of it.
446
+ ...(server.mcp === null ? [] : [msg('cli.dev.mcp', { path: server.mcp })]),
409
447
  ],
410
448
  };
411
449
  await writeLock(services.stateDir, {
@@ -11,10 +11,17 @@
11
11
  * anyway, and that is the whole failure: Bun 1.3 refuses the build with
12
12
  * `Could not resolve: "@babel/preset-typescript/package.json"`, while Bun 1.4 bundles the
13
13
  * unresolvable `require` as a runtime throw — so one tree compiled on a laptop and did not in CI.
14
- * That skew is closed `As of 2026-08-20`: CI pins `1.4.x` (`.github/actions/setup/action.yml`),
15
- * `docker/Dockerfile` builds on `oven/bun:1.4-slim` and `scripts/setup.ts` holds contributors to
16
- * 1.4.0, so every builder now takes the second branch. The external stays regardless — it is the
17
- * `--compile` graph that must not reach an unresolvable `require`, on either Bun.
14
+ *
15
+ * **This list is what closed that, and the version pin never was.** The paragraph here said the
16
+ * skew was closed `As of 2026-08-20` by CI moving to `1.4.x`, "so every builder now takes the
17
+ * second branch", and added that the external stays regardless. Read together those are two fixes
18
+ * for one bug, and only the second is a fix: pinning the whole repository to the Bun that TOLERATES
19
+ * an unresolvable `require` leaves the `--compile` graph still reaching one, so the next Bun that
20
+ * tightens resolution breaks the build again. Marking the specifier external means the graph never
21
+ * reaches it, on either Bun — measured on 2026-08-27, when the 1.4 pin was trialled in reverse:
22
+ * `docker build -f docker/Dockerfile` is green on `oven/bun:1.3-slim` and the image answers
23
+ * `--version`. So this file does not depend on the series above it, and a future move of that pin
24
+ * costs it nothing.
18
25
  *
19
26
  * Marking the dead specifier external rather than the two live ones: `serve.ts` calls
20
27
  * `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
@@ -0,0 +1,61 @@
1
+ // Where a `sync` node's changes come from when this process boots one. Production decodes the
2
+ // write-ahead log (`x dev --role replicator`, `PgLogicalReplicationFeed`, a real `DATABASE_URL`).
3
+ // The embedded database has no walsender, and until 2026-09-05 nothing stood in for it: a live
4
+ // subscription under `x dev` took its snapshot and then heard nothing, so every `--live` query in
5
+ // every scaffolded app was dead in development — which is where an author first tries one.
6
+ //
7
+ // The bridge is `@ultimat3/testing`'s `startLiveReplicator`, the same in-process row observer the
8
+ // framework's own live tests run on: a repository write in THIS process becomes a `ChangeEvent`
9
+ // shaped exactly as the WAL decoder shapes one, fanned into the node's registry. Its honest bound
10
+ // is stated there and repeated here — a write another process makes is invisible — and `x dev` is
11
+ // the one boot where that bound holds by construction: every role runs in this one process.
12
+
13
+ import { logger, renderThrowable } from '@ultimat3/core';
14
+ import type { LiveReplicator } from '@ultimat3/testing';
15
+ import { startLiveReplicator } from '@ultimat3/testing';
16
+ import type { ServiceMode } from './dev-services';
17
+ import type { RunningSync } from './dev-sync';
18
+
19
+ /** What feeds the sync node, said out loud on the boot line and in `--json`. */
20
+ export type LiveFeed = 'in-process' | 'replication' | 'none';
21
+
22
+ export interface RunningLiveFeed {
23
+ readonly feed: LiveFeed;
24
+ /** The installed bridge, so a test can await `settled()`; `null` for the other two feeds. */
25
+ readonly bridge: LiveReplicator | null;
26
+ stop(): void;
27
+ }
28
+
29
+ export interface LiveFeedInput {
30
+ /** The sync node this process booted, or `null` when the role was not selected. */
31
+ readonly sync: RunningSync | null;
32
+ /** The database's binding: `embedded` is PGlite, which has no log to decode. */
33
+ readonly dbMode: ServiceMode;
34
+ }
35
+
36
+ /** The label the `x dev` boot line carries beside `db=`, `events=` and `storage=`. */
37
+ export const liveFeedLabel = (feed: LiveFeed): string => `live=${feed}`;
38
+
39
+ /**
40
+ * `embedded` → the in-process bridge; anything else → replication, which is the WAL decoder's
41
+ * job whether the replicator role runs in this process or another. Never both: with a real
42
+ * database the decoder already delivers this process's own writes, and a bridge beside it would
43
+ * deliver every one of them twice. No sync node, no feed to speak of.
44
+ */
45
+ export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFeed> {
46
+ if (input.sync === null) return { feed: 'none', bridge: null, stop: () => undefined };
47
+ if (input.dbMode !== 'embedded') {
48
+ return { feed: 'replication', bridge: null, stop: () => undefined };
49
+ }
50
+ const bridge = await startLiveReplicator({
51
+ registry: input.sync.registry,
52
+ // Logged, never thrown: one change nobody could fan out must not take the dev server down.
53
+ onError: (error) =>
54
+ logger.warn('live.bridge_delivery_failed', { error: renderThrowable(error) }),
55
+ });
56
+ logger.info('live feed in-process', {
57
+ detail:
58
+ 'the embedded database has no walsender; repository writes in this process reach subscribers',
59
+ });
60
+ return { feed: 'in-process', bridge, stop: () => bridge.stop() };
61
+ }
package/src/dev-roles.ts CHANGED
@@ -26,7 +26,11 @@ import {
26
26
  jobDriver,
27
27
  pgSchedulerState,
28
28
  } from '@ultimat3/jobs';
29
+ import type { LiveQueryRegistry } from '@ultimat3/realtime/server';
30
+ import type { LiveReplicator } from '@ultimat3/testing';
29
31
  import { devHooks } from './dev-hooks';
32
+ import type { LiveFeed } from './dev-live-feed';
33
+ import { startLiveFeed } from './dev-live-feed';
30
34
  import { pgExecutorFor } from './dev-queue';
31
35
  import type { RunningReplicator } from './dev-replicator';
32
36
  import { startReplicator } from './dev-replicator';
@@ -130,6 +134,12 @@ export interface RunningRoles {
130
134
  readonly scheduler: Scheduler | null;
131
135
  /** The slot and feed this process holds; null when the replicator was not selected. */
132
136
  readonly replicator: RunningReplicator | null;
137
+ /** What feeds the sync node: this process's own writes, the WAL decoder, or nothing. */
138
+ readonly liveFeed: LiveFeed;
139
+ /** The in-process bridge when `liveFeed` is `in-process`, so a test can await `settled()`. */
140
+ readonly liveBridge: LiveReplicator | null;
141
+ /** The sync node's registry, so a test can hold a real subscription; null without the role. */
142
+ readonly liveRegistry: LiveQueryRegistry | null;
133
143
  stop(): Promise<void>;
134
144
  }
135
145
 
@@ -426,6 +436,12 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
426
436
  : null;
427
437
  if (replicator !== null) started.push(() => replicator.stop());
428
438
 
439
+ // What feeds the sync node this process booted. The embedded database has no log to decode,
440
+ // so under it the node is fed by this process's own repository writes (`dev-live-feed.ts`);
441
+ // with a real database the WAL decoder above is the feed, here or in another process.
442
+ const live = await startLiveFeed({ sync, dbMode: options.runtime.services.db.mode });
443
+ started.push(async () => live.stop());
444
+
429
445
  return {
430
446
  roles: selected,
431
447
  url: server === null ? null : server.url(),
@@ -435,8 +451,12 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
435
451
  worker,
436
452
  scheduler,
437
453
  replicator,
454
+ liveFeed: live.feed,
455
+ liveBridge: live.bridge,
456
+ liveRegistry: sync?.registry ?? null,
438
457
  async stop() {
439
458
  // Reverse boot order, so the slot is released before the bus it published to closes.
459
+ live.stop();
440
460
  await replicator?.stop();
441
461
  await scheduler?.stop();
442
462
  // Before the worker, so nothing publishes into a queue whose consumer has already gone —
package/src/dev-sync.ts CHANGED
@@ -96,6 +96,8 @@ export function syncPortFor(port: number): number {
96
96
  /** What `startRoles` holds on to: where the node listens, and how to take it down. */
97
97
  export interface RunningSync {
98
98
  readonly url: string;
99
+ /** The node's registry, so the boot can hand it a change feed the database cannot produce. */
100
+ readonly registry: LiveQueryRegistry;
99
101
  stop(): Promise<void>;
100
102
  }
101
103
 
@@ -154,9 +156,10 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
154
156
  // override is how a deployment states a window its credential already declares (a token's
155
157
  // `exp`), or resolves identity from a header the adapter deliberately does not retain.
156
158
  const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
159
+ const registry = registerLiveQueries(options);
157
160
  const node = createSyncNode({
158
161
  hub,
159
- registry: registerLiveQueries(options),
162
+ registry,
160
163
  transport: options.runtime.transport,
161
164
  buildId: options.buildId,
162
165
  sockets,
@@ -177,6 +180,7 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
177
180
  const listener = listenSyncNode(node, { port });
178
181
  return {
179
182
  url: listener.url,
183
+ registry,
180
184
  stop: async () => {
181
185
  listener.stop();
182
186
  await node.stop();
package/src/e2e-driver.ts CHANGED
@@ -11,34 +11,52 @@ import {
11
11
  unavailableFixture,
12
12
  useE2eDriver,
13
13
  } from '@ultimat3/testing';
14
- import type { E2ePageOptions } from './e2e-page';
14
+ import type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
15
15
  import { e2ePage } from './e2e-page';
16
16
 
17
17
  export type E2eDriverOptions = E2ePageOptions;
18
18
 
19
19
  /**
20
- * The three `E2eFixtures` members this driver cannot build, and why each is a REFUSAL rather than
21
- * a no-op. A fixture that silently did nothing would make the assertion after it read as proof:
22
- * `offline()` followed by "the fallback rendered" is the app's ONLINE page passing an offline test.
23
- *
24
- * All three are genuinely out of reach of the shipped port, not merely unimplemented:
25
- * `CdpPageLike` (`packages/scraping/src/cdp-port.ts`) declares twelve methods and none of them is
26
- * `setOfflineMode`, and a new build id is a fact about the SERVER, which no page port has ever
27
- * been able to speak for.
20
+ * A member this driver cannot build is a REFUSAL, never a no-op. A fixture that silently did
21
+ * nothing would make the assertion after it read as proof: `offline()` followed by "the fallback
22
+ * rendered" is the app's ONLINE page passing an offline test.
28
23
  */
29
24
  const refuse =
30
25
  (name: string, needs: string): (() => Promise<void>) =>
31
26
  () =>
32
27
  Promise.reject(new FixtureUnavailableError({ name, needs }));
33
28
 
34
- /** What `e2eTest` hands its body: a real page, and three members that say what they are missing. */
35
- export const e2eFixtures = (page: PageLike): E2eFixtures => ({
29
+ /**
30
+ * `offline()`/`online()` FORWARD, `As of 2026-08-27`. They refused until then on a reason the tree
31
+ * contradicted on the day it was written: this file said `CdpPageLike`
32
+ * (`packages/scraping/src/cdp-port.ts`) "declares twelve methods and none of them is
33
+ * `setOfflineMode`". It declares it at line 71 — optional, guarded, with a coded
34
+ * `X_NOT_IMPLEMENTED` in `cdp-target.ts` for a launcher that lacks it — and `page-over-target.ts`
35
+ * exposes it as `ScrapePage.offline()`. All of that landed in **the same commit as the comment**
36
+ * (#351), so the refusal was never true, and it is the reason issue #390 records a real browser
37
+ * check as out of reach.
38
+ *
39
+ * Optional on `E2eBrowserPage` rather than required, for the reason `CdpPageLike` gives about the
40
+ * same method: this port is the shape of somebody ELSE's object, and a six-line test double must
41
+ * still satisfy it. Absent, the refusal stands — and now it names the method the double is missing
42
+ * rather than a capability the framework does not have.
43
+ */
44
+ const networkFixtures = (browser: E2eBrowserPage): Pick<E2eFixtures, 'offline' | 'online'> => {
45
+ const setOffline = browser.offline?.bind(browser);
46
+ if (setOffline === undefined) {
47
+ const needs =
48
+ "a page whose driver implements offline(enabled) — @ultimat3/scraping's ScrapePage does; a hand-rolled E2eBrowserPage may not";
49
+ return { offline: refuse('offline', needs), online: refuse('online', needs) };
50
+ }
51
+ return { offline: () => setOffline(true), online: () => setOffline(false) };
52
+ };
53
+
54
+ /** What `e2eTest` hands its body: a real page, the network condition, and one honest refusal. */
55
+ export const e2eFixtures = (page: PageLike, browser: E2eBrowserPage): E2eFixtures => ({
36
56
  page,
37
- offline: refuse(
38
- 'offline',
39
- "a CDP method for the browser's own network state — the shipped CdpPageLike has no setOfflineMode",
40
- ),
41
- online: refuse('online', 'the same CDP method offline() needs, in order to undo it'),
57
+ ...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.
42
60
  update: refuse(
43
61
  'update',
44
62
  'a second build served under a new immutable build id, which is a server fact',
@@ -65,7 +83,7 @@ export function installE2eDriver(options: E2eDriverOptions): () => void {
65
83
  const page = e2ePage(options);
66
84
  defineFixtures({ page: () => page });
67
85
  useE2eDriver((name, body: E2eBody) => {
68
- bunTest(name, () => body(e2eFixtures(page)));
86
+ bunTest(name, () => body(e2eFixtures(page, options.page)));
69
87
  });
70
88
  return () => {
71
89
  // Both halves, because both were installed. Putting the DECLARATION back — rather than
package/src/e2e-page.ts CHANGED
@@ -10,15 +10,27 @@ import { e2eLocator } from './e2e-locator';
10
10
  import type { E2eSelection } from './e2e-selection';
11
11
 
12
12
  /**
13
- * What this adapter needs of a browser: four members, every one of them on `ScrapePage`. Declared
14
- * structurally rather than as `ScrapePage` so a test can stand one up in six lines — the same
15
- * bargain `cdp-port.ts` makes about puppeteer, one layer up.
13
+ * What this adapter needs of a browser: four required members, every one of them on `ScrapePage`.
14
+ * Declared structurally rather than as `ScrapePage` so a test can stand one up in six lines — the
15
+ * same bargain `cdp-port.ts` makes about puppeteer, one layer up.
16
16
  */
17
17
  export interface E2eBrowserPage {
18
18
  url(): string;
19
19
  goto(url: string, options?: { readonly timeout?: number | undefined }): Promise<unknown>;
20
20
  evaluate(expression: string): Promise<unknown>;
21
21
  click(selector: string): Promise<void>;
22
+ /**
23
+ * The browser's own network condition, which `E2eFixtures.offline()`/`online()` forward to.
24
+ * `ScrapePage` has it (`page-over-target.ts`), reaching `CdpPageLike.setOfflineMode` through
25
+ * `cdp-target.ts`'s guard.
26
+ *
27
+ * OPTIONAL for the reason the four above are structural: this port is the shape of somebody
28
+ * ELSE's object, and requiring it would cost every six-line double a type error for a capability
29
+ * a test that never goes offline does not need. Absent, `e2e-driver.ts` keeps refusing by name —
30
+ * a coded refusal, never a silent no-op, because an `offline()` that did nothing would let the
31
+ * app's ONLINE page pass an offline test.
32
+ */
33
+ offline?(enabled: boolean): Promise<void>;
22
34
  }
23
35
 
24
36
  export interface E2ePageOptions {
@@ -166,6 +166,13 @@ export const CLI_OWNED_ERROR_CODES = [
166
166
  'X_E2E_LOCATOR_EMPTY',
167
167
  'X_E2E_LOCATOR_AMBIGUOUS',
168
168
  'X_E2E_SERVICE_WORKER_ABSENT',
169
+ // The raw-CDP browser under that driver — `cdp-launch.ts`, `cdp-connection.ts`,
170
+ // `cdp-e2e-page.ts`, `cdp-browser.ts`. Four codes and not one, because the four repairs differ:
171
+ // install a browser, read the browser's own stderr, look at the page, raise a deadline.
172
+ 'X_CDP_BROWSER_MISSING',
173
+ 'X_CDP_LAUNCH_FAILED',
174
+ 'X_CDP_CALL_FAILED',
175
+ 'X_CDP_TIMEOUT',
169
176
  'X_GH_UNAVAILABLE',
170
177
  'X_GH_NOT_AUTHENTICATED',
171
178
  'X_GH_COMMAND_FAILED',
@@ -294,6 +301,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
294
301
  X_E2E_LOCATOR_EMPTY: 'an e2e locator matched no element',
295
302
  X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
296
303
  X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
304
+ X_CDP_BROWSER_MISSING: 'no Chrome or Chromium is installed for the e2e driver to launch',
305
+ X_CDP_LAUNCH_FAILED: 'the browser started and never announced a DevTools endpoint',
306
+ X_CDP_CALL_FAILED: 'the browser refused a DevTools call',
307
+ X_CDP_TIMEOUT: 'a DevTools call did not answer inside its deadline',
297
308
  X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
298
309
  X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
299
310
  X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
package/src/index.ts CHANGED
@@ -25,6 +25,33 @@ export type { BoundaryCut, BoundarySplit } from './boundary-cuts';
25
25
  export { planBoundaryCuts } from './boundary-cuts';
26
26
  export type { BuildStats, RouteStats } from './budgets';
27
27
  export { BUILD_STATS_FILE, checkBudgets, readBuildStats } from './budgets';
28
+ // The raw-CDP browser the driver above runs on. `openE2eBrowserIfAvailable()` is what an app's
29
+ // test preload calls: it answers `undefined` on a machine with no Chrome, so the browser-backed
30
+ // suite SKIPS rather than turning a gate red for a reason unrelated to the change.
31
+ export type { E2eBrowser, OpenE2eBrowserOptions } from './cdp-browser';
32
+ export {
33
+ DEFAULT_CDP_TIMEOUT_MS,
34
+ openE2eBrowser,
35
+ openE2eBrowserIfAvailable,
36
+ } from './cdp-browser';
37
+ export type { CdpConnection, CdpConnectionOptions, CdpResult } from './cdp-connection';
38
+ export { cdpConnect } from './cdp-connection';
39
+ export type { CdpE2ePageOptions } from './cdp-e2e-page';
40
+ export { cdpE2ePage } from './cdp-e2e-page';
41
+ export {
42
+ CdpBrowserMissingError,
43
+ CdpCallFailedError,
44
+ CdpLaunchFailedError,
45
+ CdpTimeoutError,
46
+ } from './cdp-errors';
47
+ export type { LaunchedBrowser, LaunchOptions } from './cdp-launch';
48
+ export {
49
+ CHROME_CANDIDATES,
50
+ CHROME_PATH_ENV,
51
+ findChrome,
52
+ launchChrome,
53
+ launchFoundChrome,
54
+ } from './cdp-launch';
28
55
  export type { BuildTarget } from './cmd-build';
29
56
  export {
30
57
  argsFor,