@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
@@ -0,0 +1,31 @@
1
+ // Where a page's one socket dials — the framework's answer, so no app owns a `sync-url.ts`. Read
2
+ // once at boot from the deployment's env and handed to every document as `ultimate-sync`.
3
+
4
+ import { ConfigInvalidError } from '@ultimat3/core';
5
+
6
+ /**
7
+ * The sync node's own path (`createSyncNode`'s default in `@ultimat3/realtime`). Same origin by
8
+ * default because every rung already serves it there: `x dev` and a combined-role container mount
9
+ * the node on the web port, and `docker/helm`'s ingress routes `/_x/sync` to the `sync` service.
10
+ */
11
+ export const SYNC_PATH = '/_x/sync';
12
+
13
+ /**
14
+ * `SYNC_URL` verbatim when the deployment states one — the Compose rung publishes `sync` on its
15
+ * own port with no proxy in front, so only the deployment knows that URL — else `SYNC_PATH`,
16
+ * resolved by the browser against its own origin. Never derived from a port: behind any ingress a
17
+ * neighbouring port is a URL nothing publishes.
18
+ */
19
+ export function syncUrlFrom(env: Readonly<Record<string, string | undefined>>): string {
20
+ const declared = env['SYNC_URL']?.trim() ?? '';
21
+ if (declared === '') return SYNC_PATH;
22
+ const parsed = URL.parse(declared);
23
+ if (parsed === null || (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:')) {
24
+ throw new ConfigInvalidError({
25
+ cause: 'SYNC_URL is set but is not a ws:// or wss:// URL, so no browser could dial it',
26
+ fix: 'export SYNC_URL="wss://sync.example.com/_x/sync" # or unset it to dial /_x/sync on the page origin',
27
+ meta: { key: 'SYNC_URL' },
28
+ });
29
+ }
30
+ return declared;
31
+ }
@@ -37,6 +37,7 @@ const formIslandSource = (
37
37
  // <${feature.pascal}Form endpoint={derivePath('create${feature.pascal}').path} locale={locale} labels={labels} />
38
38
  // A string has no import edge, so the page's bundle graph stays the page's (axiom 6).
39
39
 
40
+ import { clientTransport } from '@ultimat3/core';
40
41
  import { Button, Form, Input, setSolidRuntime, UiProvider } from '@ultimat3/ui';
41
42
  import type { JSX } from 'solid-js';
42
43
  import {
@@ -71,25 +72,21 @@ type SaveState = 'idle' | 'saved' | 'failed';
71
72
  * Presentation only: the action this submits to owns validation server-side, so the form never
72
73
  * re-implements the invariant — a blank title fails at the boundary, not in the DOM.
73
74
  *
74
- * A plain \`fetch\` to the path the server minted, not the typed client: \`rpc()\` pulls
75
- * \`@ultimat3/action\` into the chunk, which is larger than everything else here put together. The
76
- * naming rule is still the framework's; only the transport is second.
75
+ * \`clientTransport\` — the framework's one browser HTTP function — to the path the server minted.
76
+ * Never a raw \`fetch\`: the transport is what decodes a refusal into its code, fences a sign-out,
77
+ * and hands any entity rows the answer carries to the page's store.
77
78
  */
78
79
  function ${feature.pascal}FormBody(props: ${feature.pascal}FormProps): JSX.Element {
79
80
  const [title, setTitle] = createSignal('');
80
81
  const [state, setState] = createSignal<SaveState>('idle');
81
82
 
82
- // A rejected \`fetch\` — offline, DNS, an aborted request — is the same OUTCOME as a refused one,
83
- // and it is the one \`retry\` exists for. Without the catch, \`setState\` is never reached: the
84
- // status line stays empty, and the rejection escapes the \`void send()\` below as an unhandled one.
83
+ // A refusal and a request that never got a response both REJECT here — the transport turns a
84
+ // non-2xx into its code and an offline \`fetch\` into X_CLIENT_TRANSPORT_FAILED — and both are the
85
+ // outcome \`retry\` exists for. Without the catch the rejection escapes \`void send()\` unhandled.
85
86
  const send = async (): Promise<void> => {
86
87
  try {
87
- const response = await fetch(props.endpoint, {
88
- method: 'POST',
89
- headers: { 'content-type': 'application/json' },
90
- body: JSON.stringify({ title: title() }),
91
- });
92
- setState(response.ok ? 'saved' : 'failed');
88
+ await clientTransport({ method: 'POST', url: props.endpoint, body: { title: title() } });
89
+ setState('saved');
93
90
  } catch {
94
91
  setState('failed');
95
92
  }
@@ -198,13 +195,14 @@ beforeAll(async () => {
198
195
  // What the server rendered inside the island's wrapper. \`mount\` replaces it.
199
196
  shell: '<p>Loading</p>',
200
197
  globals: {
201
- fetch: (url: string, init: { body: string }): Promise<{ ok: boolean }> => {
198
+ // The form sends through \`clientTransport\`, which calls \`globalThis.fetch\` — this stub.
199
+ fetch: (url: string, init: { body: string }): Promise<Response> => {
202
200
  calls.push({ url, body: JSON.parse(init.body) as Record<string, unknown> });
203
- // What a browser rejects with when there is no network. Not a response: \`response.ok\`
204
- // is never read on this path, which is exactly why the form has to catch it.
201
+ // What a browser rejects with when there is no network. Not a response, which is exactly
202
+ // why the form has to catch it.
205
203
  return networkFails
206
204
  ? Promise.reject(new TypeError('Failed to fetch'))
207
- : Promise.resolve({ ok: true });
205
+ : Promise.resolve(Response.json({ id: 'created' }));
208
206
  },
209
207
  },
210
208
  });
@@ -221,6 +219,18 @@ afterAll(() => {
221
219
  mounted?.[Symbol.dispose]();
222
220
  });
223
221
 
222
+ /**
223
+ * Until the status line changes, a bounded number of macrotasks: the send is several awaits deep
224
+ * inside \`clientTransport\`, so counting microtasks would pin the transport, not the form.
225
+ */
226
+ async function statusSettled(): Promise<void> {
227
+ const before = mounted.text('[data-role="status"]');
228
+ for (let tick = 0; tick < 50; tick += 1) {
229
+ if (mounted.text('[data-role="status"]') !== before) return;
230
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
231
+ }
232
+ }
233
+
224
234
  /**
225
235
  * One mount, driven as a session: the cases below run in order against the same island, because
226
236
  * building the real chunk costs seconds and repeating it per case would pay them for state each
@@ -242,7 +252,7 @@ describe('the ${feature.kebab} form island', () => {
242
252
  // identical to a selector typo otherwise.
243
253
  expect(mounted.fire(field, 'input')).toBe(true);
244
254
  expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
245
- await Promise.resolve();
255
+ await statusSettled();
246
256
 
247
257
  expect(calls).toEqual([{ url: ENDPOINT, body: { title: 'First ${feature.camel}' } }]);
248
258
  });
@@ -253,12 +263,11 @@ describe('the ${feature.kebab} form island', () => {
253
263
  });
254
264
 
255
265
  test('a request that never got a response still reaches retry', async () => {
256
- // The outcome \`retry\` is FOR. A \`fetch\` that rejects reaches no \`response.ok\`, so without
257
- // the catch in \`send\` the status line stays on its last value and the rejection escapes.
266
+ // The outcome \`retry\` is FOR. A \`fetch\` that rejects produces no answer, so without the
267
+ // catch in \`send\` the status line stays on its last value and the rejection escapes.
258
268
  networkFails = true;
259
269
  expect(mounted.fire('form', 'submit', { preventDefault: () => {} })).toBe(true);
260
- await Promise.resolve();
261
- await Promise.resolve();
270
+ await statusSettled();
262
271
 
263
272
  expect(mounted.text('[data-role="status"]')).toBe(LABELS.retry);
264
273
  });
@@ -219,6 +219,9 @@ import { e2eTest, expect } from '@ultimat3/testing';
219
219
  // \`e2eTest\` reports itself skipped, naming the command that builds what it would drive.
220
220
  e2eTest('/${path} renders offline', async ({ page, offline }) => {
221
221
  await page.goto('/${sampleUrl(path)}');
222
+ // \`offline()\` cuts the service worker's network too, so the reload is answered from its cache
223
+ // or not at all — which needs the worker in control of this page before the cut.
224
+ await page.waitForServiceWorker();
222
225
  await offline();
223
226
  await page.reload();
224
227
  expect(await page.title()).not.toBe('');
@@ -112,6 +112,13 @@ coverage
112
112
  **/playwright-report
113
113
  `;
114
114
 
115
+ /**
116
+ * The production env file, relative to the app root. Named once because two readers must agree:
117
+ * the compose file's \`env_file:\` (what the containers see) and \`x deploy\`'s \`--env-file\` (what
118
+ * compose interpolates \`\${VAR:?…}\` from). Two spellings would let them drift apart silently.
119
+ */
120
+ export const PROD_ENV_FILE = '.env.production';
121
+
115
122
  const composeProd = (
116
123
  app: NameSet,
117
124
  ): string => `# The production topology: one service per role, one image, differing only by ROLE and replicas.
@@ -119,6 +126,12 @@ const composeProd = (
119
126
  #
120
127
  # IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
121
128
  #
129
+ # By hand, always with \`--env-file ${PROD_ENV_FILE}\`: Compose fills \`\${VAR:?…}\` below from the shell
130
+ # and \`--env-file\` only, NEVER from \`env_file:\`, so without it a value set only in that file is
131
+ # "missing" and the parse fails. \`x deploy\` passes it on every step.
132
+ #
133
+ # docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d
134
+ #
122
135
  # A published host port has exactly one binder, so \`web\` and \`sync\` run at 1 here. Compose is one
123
136
  # box; horizontal scaling of those two belongs to an orchestrator — \`docker/helm\`, beside this
124
137
  # file, is the chart \`x deploy --method helm\` installs. To scale them on one box anyway, drop
@@ -128,7 +141,7 @@ name: ${app.kebab}
128
141
 
129
142
  x-image: &image
130
143
  image: \${IMAGE:-${app.kebab}:dev}
131
- env_file: [../.env.production]
144
+ env_file: [../${PROD_ENV_FILE}]
132
145
  restart: unless-stopped
133
146
  stop_grace_period: 30s # SIGTERM → drain in-flight requests, jobs and sockets
134
147
  depends_on:
@@ -193,7 +206,9 @@ services:
193
206
 
194
207
  web:
195
208
  <<: *image
196
- environment: [ROLE=web]
209
+ # The page dials SYNC_URL; unset, it dials /_x/sync on :3000, which web does not serve here.
210
+ # Set it in ${PROD_ENV_FILE}: \`--env-file\` (header) is what lets this line read it there.
211
+ environment: [ROLE=web, 'SYNC_URL=\${SYNC_URL:?set SYNC_URL=ws://<host>:3001/_x/sync, see wiki/Deployment.md}']
197
212
  depends_on:
198
213
  db: { condition: service_healthy }
199
214
  migrate: { condition: service_completed_successfully }
@@ -276,7 +291,7 @@ docker run -p 3000:3000 -e DATABASE_URL=postgres://... ${app.kebab}:dev
276
291
  ## One box, every role
277
292
 
278
293
  \`\`\`sh
279
- docker compose -f docker/docker-compose.prod.yml up -d # db → migrate → the rest
294
+ docker compose --env-file ${PROD_ENV_FILE} -f docker/docker-compose.prod.yml up -d # db → migrate → the rest
280
295
  x deploy --image ${app.kebab}:dev --dry-run --json # the same plan, printed
281
296
  \`\`\`
282
297
 
@@ -17,10 +17,13 @@ export const routeConfig = (load: string): string => `export const config = defi
17
17
  // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
18
18
  policy: { permission: 'dashboard:read' },
19
19
  // Only the toggle island hydrates; the tiles, the chart and the table are server markup. The
20
- // island measured 60.9kb minified under Bun 1.4.2 — solid-js is 12.6kb of it, the catalog's
21
- // toggle, provider and the ui runtime they share are the rest — so 64kb is that figure plus
22
- // the few hundred bytes the minifier moves between Bun patch versions, not headroom to spend.
23
- budget: { js: '64kb' },${load}
20
+ // island measured 34.0kb minified under Bun 1.4.0 (37.0kb under 1.4.2, which honours
21
+ // \`sideEffects\` and keeps core's declared modules) — solid-js is 15.1kb of it, the catalog's
22
+ // toggle, provider, the ui runtime they share and the error registry are the rest. It was
23
+ // 60.9kb before the framework stopped shipping solid-js twice and the i18n catalog with it
24
+ // (issue #490), which is what put this at 64kb; 60kb is the figure the scaffold budgets held
25
+ // before that, kept rather than tightened so a Bun patch cannot red a first \`bin/check\`.
26
+ budget: { js: '60kb' },${load}
24
27
  meta: ({ t }) => ({
25
28
  title: t('app.dashboard.title'),
26
29
  description: t('app.dashboard.description'),
@@ -69,6 +72,6 @@ unitTest('the dashboard renders on the server, is gated, and has an offline stra
69
72
 
70
73
  unitTest('the dashboard hydrates its one island inside a stated budget', () => {
71
74
  expect(config.hydrate).toBe('visible');
72
- expect(config.budget.js).toBe('64kb');
75
+ expect(config.budget.js).toBe('60kb');
73
76
  });
74
77
  `;
@@ -29,6 +29,12 @@ export const SCAFFOLD_ENV_SCHEMA = {
29
29
  role: 'sync',
30
30
  description: 'Realtime fan-out cluster. Only the sync role is asked for it.',
31
31
  },
32
+ SYNC_URL: {
33
+ type: 'string',
34
+ required: false,
35
+ role: 'web',
36
+ description: 'Where a page dials the sync socket. Compose: ws://<host>:3001/_x/sync',
37
+ },
32
38
  SESSION_SECRET: {
33
39
  type: 'string',
34
40
  required: false,
@@ -0,0 +1,38 @@
1
+ // What the gate's `e2e` step does around the suite in an APP: when this machine has a browser, it
2
+ // runs the suite with the e2e preload and names the app root, and the PRELOAD spawns the app — so
3
+ // the app lives in the test process, where `deploy.newBuild()` can restart it. No browser, or not an
4
+ // app: the suite runs as before and its browser-backed cases skip — or refuse under
5
+ // `E2E_BROWSER_REQUIRED=1`.
6
+
7
+ // why: Bun exposes no path API — the preload is addressed by an absolute path the child resolves.
8
+ import { join } from 'node:path';
9
+ import { findChrome } from './cdp-launch';
10
+ import { E2E_ROOT_ENV } from './e2e-browser-handle';
11
+ import type { ExecResult } from './exec';
12
+
13
+ /** The preload `bun test` is handed, beside the app's own from `bunfig.toml`. */
14
+ export const E2E_PRELOAD = join(import.meta.dir, 'e2e-preload.ts');
15
+
16
+ export interface E2eRun {
17
+ readonly command: readonly string[];
18
+ readonly env: Readonly<Record<string, string | undefined>>;
19
+ }
20
+
21
+ /** Wrap one e2e `bun test` invocation: `run` receives the command and extra environment to use. */
22
+ export async function withE2eApp(
23
+ input: {
24
+ readonly root: string;
25
+ readonly isApp: boolean;
26
+ readonly command: readonly string[];
27
+ readonly env: Readonly<Record<string, string | undefined>>;
28
+ },
29
+ run: (e2e: E2eRun) => Promise<ExecResult>,
30
+ ): Promise<ExecResult> {
31
+ const chrome = input.isApp ? await findChrome(Bun.env) : undefined;
32
+ if (chrome === undefined) return run({ command: input.command, env: input.env });
33
+ const [bun = 'bun', test = 'test', ...rest] = input.command;
34
+ return run({
35
+ command: [bun, test, '--preload', E2E_PRELOAD, ...rest],
36
+ env: { ...input.env, [E2E_ROOT_ENV]: input.root },
37
+ });
38
+ }
package/src/verify-run.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
15
15
 
16
16
  /**
17
- * Run every step in order, never bailing early: an agent fixing three things at once needs all
17
+ * Run every step, never bailing early: an agent fixing three things at once needs all
18
18
  * three findings from one run, not one per round-trip.
19
19
  *
20
20
  * `ctx.only` narrows the list to one step. The narrowing lives HERE rather than in `cmd-verify.ts`
@@ -26,61 +26,43 @@ export async function runVerify(
26
26
  ctx: VerifyContext,
27
27
  ): Promise<CommandResult> {
28
28
  const floor = await readVerifyFloor(ctx.root);
29
- const results: StepResult[] = [];
30
29
  const selected = ctx.only === undefined ? steps : steps.filter((step) => step.name === ctx.only);
30
+ const byName = new Map<string, StepResult>();
31
+ const began = performance.now();
32
+ // The static steps wait for the serial suites and then run BESIDE them — only when `live` is in
33
+ // the list, so a one-step run (`--only`) and a list with no serial suite keep today's order.
34
+ const overlapping = selected.some((step) => step.name === SERIAL_SUITES[0]);
35
+ const beside = overlapping ? selected.filter((step) => BESIDE_SERIAL_SUITES.has(step.name)) : [];
36
+ let pending: Promise<void> | undefined;
37
+ const join = async (): Promise<void> => {
38
+ await pending;
39
+ pending = undefined;
40
+ };
31
41
  for (const step of selected) {
32
- const applies = step.applies === undefined ? true : await step.applies(ctx);
33
- if (!applies) {
34
- // A skip this repo already ruled out is not a skip. The step ran here before — the floor is
35
- // that claim, committed — so "nothing to check" now means the suite was deleted, and the
36
- // gate says so on the step's own line rather than counting one more thing not to worry
37
- // about. Recorded as failed and NOT as skipped, so every reader of a step table sees it:
38
- // the summary, `data.failed`, and the reference-app gate's own red list.
39
- const required = floorRequires(floor, step.name);
40
- results.push({
41
- name: step.name,
42
- ok: !required,
43
- durationMs: 0,
44
- skipped: !required,
45
- findings: required ? [vanishedSuiteFinding(step.name)] : [],
46
- });
47
- continue;
42
+ if (beside.includes(step)) continue;
43
+ if (step.name === SERIAL_SUITES[0]) {
44
+ pending = Promise.all(
45
+ beside.map(async (other) => {
46
+ byName.set(other.name, await runStep(other, ctx, floor));
47
+ }),
48
+ ).then(() => undefined);
49
+ } else if (!SERIAL_SUITES.includes(step.name)) {
50
+ await join();
48
51
  }
49
- const started = performance.now();
50
- const outcome = await step.run(ctx).catch(
51
- (error: unknown): StepOutcome => ({
52
- ok: false,
53
- findings: [findingOf(error, step.name)],
54
- }),
55
- );
56
- // A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
57
- // over an all-skipped file, so the counts are the only channel that can tell the two apart.
58
- // ONE definition of "nothing ran", read twice, because the floor decides which of the two
59
- // things it means — exactly as it already does for a step whose `applies` said no.
60
- const tests = outcome.tests;
61
- const nothingRan = tests !== undefined && tests.ran === 0;
62
- const required = floorRequires(floor, step.name);
63
- // A step the floor requires whose suite executed nothing is the same vanished suite as a step
64
- // with no files at all — the run just had to finish before it could be seen. Appended to the
65
- // step's own findings so `data.failed`, the counts and every gate reading this table carry it.
66
- const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
67
- results.push({
68
- name: step.name,
69
- ok: outcome.ok && vanished.length === 0,
70
- durationMs: Math.round(performance.now() - started),
71
- // Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
72
- // the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
73
- // step table may never do — a reader cannot tell a lane that ran from a lane that did not.
74
- skipped: nothingRan && !required,
75
- findings: [...outcome.findings, ...vanished],
76
- ...(outcome.output === undefined ? {} : { output: outcome.output }),
77
- ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
78
- ...(tests === undefined ? {} : { tests }),
79
- });
52
+ byName.set(step.name, await runStep(step, ctx, floor));
80
53
  }
54
+ await join();
55
+ // Reported in the declared order, whatever order the steps finished in: the table, `--json` and
56
+ // every gate parsing either read the same sequence they always did.
57
+ const results = selected.flatMap((step) => {
58
+ const result = byName.get(step.name);
59
+ return result === undefined ? [] : [result];
60
+ });
81
61
  const failedSteps = results.filter((step) => !step.ok).map((step) => step.name);
82
62
  const skippedSteps = results.filter((step) => step.skipped === true).map((step) => step.name);
83
- const totalMs = results.reduce((sum, step) => sum + step.durationMs, 0);
63
+ // WALL time, not the sum of step times: with steps overlapping, the sum overstates what a run
64
+ // costs, and the wall clock is the number a CI job waits on.
65
+ const totalMs = Math.round(performance.now() - began);
84
66
  const summary = verifySummary({
85
67
  results,
86
68
  failed: failedSteps,
@@ -139,6 +121,79 @@ function verifySummary(input: {
139
121
  return msg(clean ? 'cli.verify.fail' : 'cli.verify.failSkipped', params);
140
122
  }
141
123
 
124
+ /**
125
+ * `x verify`'s wall time was the SUM of 20 serial steps (#14, the DX ledger): measured locally, 395s,
126
+ * of which `lint`, `boundaries`, `filesize`, `package-shape` and `errors` were 127s spent while
127
+ * nothing else ran. They read the tree and write nothing a later step reads (`lint` is biome over
128
+ * files; the rest are in-process scans), so they run BESIDE the serial suites — `live` and `e2e`
129
+ * are one worker each, Postgres- and browser-bound, and mostly waiting. `typecheck` stays first
130
+ * and alone: `tsc -b` writes `.tsbuildinfo` and `dist/`, and `unit` saturates every core.
131
+ */
132
+ export const BESIDE_SERIAL_SUITES: ReadonlySet<string> = new Set([
133
+ 'lint',
134
+ 'boundaries',
135
+ 'filesize',
136
+ 'package-shape',
137
+ 'errors',
138
+ ]);
139
+
140
+ /** The consecutive run of steps the static group overlaps, in the order `VERIFY_STEP_NAMES` holds. */
141
+ export const SERIAL_SUITES: readonly string[] = ['live', 'job', 'e2e', 'eval'];
142
+
143
+ async function runStep(
144
+ step: VerifyStep,
145
+ ctx: VerifyContext,
146
+ floor: Awaited<ReturnType<typeof readVerifyFloor>>,
147
+ ): Promise<StepResult> {
148
+ const applies = step.applies === undefined ? true : await step.applies(ctx);
149
+ if (!applies) {
150
+ // A skip this repo already ruled out is not a skip. The step ran here before — the floor is
151
+ // that claim, committed — so "nothing to check" now means the suite was deleted, and the
152
+ // gate says so on the step's own line rather than counting one more thing not to worry
153
+ // about. Recorded as failed and NOT as skipped, so every reader of a step table sees it:
154
+ // the summary, `data.failed`, and the reference-app gate's own red list.
155
+ const required = floorRequires(floor, step.name);
156
+ return {
157
+ name: step.name,
158
+ ok: !required,
159
+ durationMs: 0,
160
+ skipped: !required,
161
+ findings: required ? [vanishedSuiteFinding(step.name)] : [],
162
+ };
163
+ }
164
+ const started = performance.now();
165
+ const outcome = await step.run(ctx).catch(
166
+ (error: unknown): StepOutcome => ({
167
+ ok: false,
168
+ findings: [findingOf(error, step.name)],
169
+ }),
170
+ );
171
+ // A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
172
+ // over an all-skipped file, so the counts are the only channel that can tell the two apart.
173
+ // ONE definition of "nothing ran", read twice, because the floor decides which of the two
174
+ // things it means — exactly as it already does for a step whose `applies` said no.
175
+ const tests = outcome.tests;
176
+ const nothingRan = tests !== undefined && tests.ran === 0;
177
+ const required = floorRequires(floor, step.name);
178
+ // A step the floor requires whose suite executed nothing is the same vanished suite as a step
179
+ // with no files at all — the run just had to finish before it could be seen. Appended to the
180
+ // step's own findings so `data.failed`, the counts and every gate reading this table carry it.
181
+ const vanished = nothingRan && required ? [skippedSuiteFinding(step.name, tests.skipped)] : [];
182
+ return {
183
+ name: step.name,
184
+ ok: outcome.ok && vanished.length === 0,
185
+ durationMs: Math.round(performance.now() - started),
186
+ // Without a floor to require it, a suite that ran nothing is a SKIP and not a pass (#434):
187
+ // the `e2e` step printed `✓ e2e 46ms` over its one skipped test, which is the one thing a
188
+ // step table may never do — a reader cannot tell a lane that ran from a lane that did not.
189
+ skipped: nothingRan && !required,
190
+ findings: [...outcome.findings, ...vanished],
191
+ ...(outcome.output === undefined ? {} : { output: outcome.output }),
192
+ ...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
193
+ ...(tests === undefined ? {} : { tests }),
194
+ };
195
+ }
196
+
142
197
  function findingOf(error: unknown, step: string): Finding {
143
198
  // A step may throw anything, including an Error that fights being read: `instanceof` runs a
144
199
  // Proxy's `getPrototypeOf` trap and `.message` runs a getter, so a hostile throw would take the
@@ -19,6 +19,7 @@ import { testEnvOverrides } from './test-dotenv';
19
19
  import type { TestFile } from './test-select';
20
20
  import { discoverTests } from './test-select';
21
21
  import { defaultWorkers } from './test-workers';
22
+ import { withE2eApp } from './verify-e2e';
22
23
  import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
23
24
  import { fromExec, fromFindings } from './verify-step';
24
25
  import { runParallel } from './verify-test-run';
@@ -162,6 +163,12 @@ const ignoreFlags = (patterns: readonly string[]): readonly string[] =>
162
163
  export const typeFiltersOf = (type: Exclude<TestType, 'unit'>): readonly string[] =>
163
164
  OWNERSHIP.filter(([owner]) => owner === type).map(([, filter]) => filter);
164
165
 
166
+ /**
167
+ * An e2e test's own budget: a first navigation waits on `x dev` compiling the route and its island,
168
+ * which bun's default 5 s does not cover — a timeout there reads as an app bug that is not one.
169
+ */
170
+ export const E2E_TEST_TIMEOUT_MS = 60_000;
171
+
165
172
  /** Unit is everything the typed suites do not claim, so no test can fall between two steps. */
166
173
  export const testStepCommand = (type: TestType): readonly string[] =>
167
174
  type === 'unit'
@@ -173,6 +180,7 @@ export const testStepCommand = (type: TestType): readonly string[] =>
173
180
  : [
174
181
  'bun',
175
182
  'test',
183
+ ...(type === 'e2e' ? [`--timeout=${String(E2E_TEST_TIMEOUT_MS)}`] : []),
176
184
  ...ignoreFlags([...NEVER_A_TEST, ...disownedBy(type)]),
177
185
  ...typeFiltersOf(type),
178
186
  ];
@@ -203,10 +211,19 @@ export const resetTestDiscovery = (): void => discovered.clear();
203
211
  const runSerial = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
204
212
  const command = testStepCommand(type);
205
213
  const envOverrides = testEnvOverrides(ctx.root, ctx.env ?? Bun.env);
206
- const result = await ctx.runner(command, {
207
- cwd: ctx.root,
208
- ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
209
- });
214
+ const exec = (e2e: { command: readonly string[]; env: typeof envOverrides }) =>
215
+ ctx.runner(e2e.command, {
216
+ cwd: ctx.root,
217
+ ...(Object.keys(e2e.env).length === 0 ? {} : { env: e2e.env }),
218
+ });
219
+ // The e2e step drives a real browser against the app it spawns, when this machine has one.
220
+ const result =
221
+ type === 'e2e'
222
+ ? await withE2eApp(
223
+ { root: ctx.root, isApp: isApp(ctx.root), command, env: envOverrides },
224
+ exec,
225
+ )
226
+ : await exec({ command, env: envOverrides });
210
227
  return {
211
228
  ...fromExec(result, {
212
229
  code: 'X_TEST_FAILED',