@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.
- package/CLAUDE.md +70 -1
- package/package.json +30 -30
- package/src/app-env.ts +2 -2
- package/src/budgets.ts +45 -12
- package/src/build-errors.ts +54 -0
- package/src/cdp-browser.ts +21 -27
- package/src/cdp-connection.ts +66 -30
- package/src/cdp-e2e-page.ts +84 -113
- package/src/cdp-e2e-session.ts +199 -0
- package/src/cdp-launch.ts +95 -41
- package/src/cdp-offline-script.ts +73 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/cmd-deploy.ts +7 -0
- package/src/cmd-dev.ts +23 -86
- package/src/cmd-shot.ts +30 -4
- package/src/dev-live-feed.ts +2 -0
- package/src/dev-render.ts +119 -20
- package/src/dev-route-table.ts +119 -0
- package/src/dev-services.ts +4 -1
- package/src/dev-sync.ts +5 -3
- package/src/e2e-app.ts +103 -0
- package/src/e2e-browser-handle.ts +55 -0
- package/src/e2e-driver.ts +32 -12
- package/src/e2e-errors.ts +14 -0
- package/src/e2e-page.ts +5 -2
- package/src/e2e-preload.ts +64 -0
- package/src/e2e-probe.ts +23 -0
- package/src/e2e-spawn.ts +169 -0
- package/src/error-codes.ts +7 -0
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +8 -29
- package/src/index.ts +18 -4
- package/src/island-bundle.ts +38 -11
- package/src/island-realtime.ts +91 -0
- package/src/island-solid-dedupe.ts +108 -0
- package/src/island-verdict.ts +1 -1
- package/src/live-routes.ts +82 -42
- package/src/mcp-errors.ts +3 -0
- package/src/page-sync.ts +54 -0
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/serve.ts +9 -0
- package/src/shot-theme.ts +52 -0
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/resource-form-island.ts +30 -21
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-container.ts +18 -3
- package/src/templates/scaffold-dashboard-shared.ts +8 -5
- package/src/templates/scaffold-env.ts +6 -0
- package/src/verify-e2e.ts +38 -0
- package/src/verify-run.ts +105 -50
- package/src/verify-tests.ts +21 -4
- package/src/worker-bundle.ts +192 -0
package/src/sync-url.ts
ADDED
|
@@ -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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
204
|
-
//
|
|
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({
|
|
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
|
|
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
|
|
257
|
-
//
|
|
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
|
|
261
|
-
await Promise.resolve();
|
|
270
|
+
await statusSettled();
|
|
262
271
|
|
|
263
272
|
expect(mounted.text('[data-role="status"]')).toBe(LABELS.retry);
|
|
264
273
|
});
|
package/src/templates/route.ts
CHANGED
|
@@ -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: [
|
|
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
|
-
|
|
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
|
|
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
|
|
21
|
-
//
|
|
22
|
-
// the
|
|
23
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/verify-tests.ts
CHANGED
|
@@ -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
|
|
207
|
-
|
|
208
|
-
|
|
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',
|