@ultimat3/cli 22.2.2 → 22.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.2.2",
3
+ "version": "22.3.1",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,33 +38,33 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@babel/core": "^8.0.1",
41
- "@ultimat3/action": "22.2.2",
42
- "@ultimat3/admin": "22.2.2",
43
- "@ultimat3/ai": "22.2.2",
44
- "@ultimat3/auth": "22.2.2",
45
- "@ultimat3/cache": "22.2.2",
46
- "@ultimat3/core": "22.2.2",
47
- "@ultimat3/db": "22.2.2",
48
- "@ultimat3/entity": "22.2.2",
49
- "@ultimat3/flags": "22.2.2",
50
- "@ultimat3/http": "22.2.2",
51
- "@ultimat3/i18n": "22.2.2",
52
- "@ultimat3/jobs": "22.2.2",
53
- "@ultimat3/mail": "22.2.2",
54
- "@ultimat3/manifest": "22.2.2",
55
- "@ultimat3/mcp": "22.2.2",
56
- "@ultimat3/money": "22.2.2",
57
- "@ultimat3/notify": "22.2.2",
58
- "@ultimat3/policy": "22.2.2",
59
- "@ultimat3/pwa": "22.2.2",
60
- "@ultimat3/query": "22.2.2",
61
- "@ultimat3/realtime": "22.2.2",
62
- "@ultimat3/render": "22.2.2",
63
- "@ultimat3/schema": "22.2.2",
64
- "@ultimat3/seo": "22.2.2",
65
- "@ultimat3/storage": "22.2.2",
66
- "@ultimat3/testing": "22.2.2",
67
- "@ultimat3/time": "22.2.2",
41
+ "@ultimat3/action": "22.3.1",
42
+ "@ultimat3/admin": "22.3.1",
43
+ "@ultimat3/ai": "22.3.1",
44
+ "@ultimat3/auth": "22.3.1",
45
+ "@ultimat3/cache": "22.3.1",
46
+ "@ultimat3/core": "22.3.1",
47
+ "@ultimat3/db": "22.3.1",
48
+ "@ultimat3/entity": "22.3.1",
49
+ "@ultimat3/flags": "22.3.1",
50
+ "@ultimat3/http": "22.3.1",
51
+ "@ultimat3/i18n": "22.3.1",
52
+ "@ultimat3/jobs": "22.3.1",
53
+ "@ultimat3/mail": "22.3.1",
54
+ "@ultimat3/manifest": "22.3.1",
55
+ "@ultimat3/mcp": "22.3.1",
56
+ "@ultimat3/money": "22.3.1",
57
+ "@ultimat3/notify": "22.3.1",
58
+ "@ultimat3/policy": "22.3.1",
59
+ "@ultimat3/pwa": "22.3.1",
60
+ "@ultimat3/query": "22.3.1",
61
+ "@ultimat3/realtime": "22.3.1",
62
+ "@ultimat3/render": "22.3.1",
63
+ "@ultimat3/schema": "22.3.1",
64
+ "@ultimat3/seo": "22.3.1",
65
+ "@ultimat3/storage": "22.3.1",
66
+ "@ultimat3/testing": "22.3.1",
67
+ "@ultimat3/time": "22.3.1",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/app-load.ts CHANGED
@@ -13,7 +13,13 @@ import { registeredJobs, registeredTasks } from '@ultimat3/jobs';
13
13
  import type { ErrorCodeFact } from '@ultimat3/manifest';
14
14
  import { listQueries, registerQueries } from '@ultimat3/query';
15
15
  import type { RouteConfig } from '@ultimat3/render';
16
- import { isRouteConfig, pageComponentOf, registerRoute, routeEntries } from '@ultimat3/render';
16
+ import {
17
+ isRouteConfig,
18
+ pageComponentOf,
19
+ registerRoute,
20
+ routeEntries,
21
+ setAssetResolver,
22
+ } from '@ultimat3/render';
17
23
  // For the SIDE EFFECT, and it is this module's to hold: importing `@ultimat3/render/server`
18
24
  // installs the `.tsx`/`.scss` Bun plugin, a plugin only transforms modules loaded AFTER it, and
19
25
  // every app module below is loaded by the dynamic `import()` in this file. Before the render
@@ -27,6 +33,7 @@ import { collectDeclaredCodes } from './error-contract';
27
33
  import type { Finding } from './output';
28
34
  import { findingFrom } from './output';
29
35
  import { hasPathSegment } from './path-segments';
36
+ import { siteAssetTable } from './site-assets';
30
37
  import { isTest } from './source-files';
31
38
 
32
39
  /** Every place an app keeps code the framework has to see. */
@@ -113,6 +120,10 @@ export async function loadApp(root: string): Promise<LoadedApp> {
113
120
  // path — under the container's `WORKDIR /app` that path's first segment is `app/`, and every
114
121
  // sheet, the site's included, classified as app CSS.
115
122
  setStylesheetRoot(root);
123
+ // And before any page renders: `asset('assets/…')` answers from this app's own table, in
124
+ // `x dev`, the container and the static build alike — one install, three processes.
125
+ const assets = siteAssetTable(root);
126
+ setAssetResolver((path) => assets.resolve(path).url);
116
127
  const files: string[] = [];
117
128
  const findings: Finding[] = [];
118
129
 
@@ -26,6 +26,8 @@ export interface FakeShotPage {
26
26
  readonly url: string;
27
27
  readonly html: string;
28
28
  readonly evaluate?: Readonly<Record<string, string>> | undefined;
29
+ /** The document's HTTP status. Absent is 200 — the page was recorded because it exists. */
30
+ readonly status?: number | undefined;
29
31
  }
30
32
 
31
33
  /** A PNG signature, and nothing behind it: deterministic bytes, never a render. */
@@ -76,7 +78,8 @@ function fakePage(byUrl: ReadonlyMap<string, FakeShotPage>, init: ShotSessionIni
76
78
  network.push({
77
79
  method: 'GET',
78
80
  url: page.url,
79
- status: 200,
81
+ // Data the page answers with, never a bound — a recorded page exists, so absent is a 200.
82
+ status: page.status === undefined ? 200 : page.status,
80
83
  resourceType: 'document',
81
84
  at: init.clock.now().getTime(),
82
85
  });
@@ -145,6 +145,13 @@ export interface ShotSessionInit {
145
145
  readonly clock: ShotClock;
146
146
  /** Per-operation default, in ms. */
147
147
  readonly timeoutMs: number;
148
+ /** CSS pixels the page is laid out in. Absent: the driver's own default. */
149
+ readonly viewport?: { readonly width: number; readonly height: number } | undefined;
150
+ /**
151
+ * Sent on every request the page makes. `x shot` pins `accept-language` here so a picture is of
152
+ * the locale asked for, never of whatever language the machine's Chrome happens to speak.
153
+ */
154
+ readonly headers?: Readonly<Record<string, string>> | undefined;
148
155
  }
149
156
 
150
157
  export interface ShotSession {
@@ -134,9 +134,15 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
134
134
  await on('Page.enable');
135
135
  await on('Network.enable');
136
136
  await on('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
137
+ // Before the first navigation, like the metrics below: a header set after `goto` would
138
+ // photograph a document negotiated in the machine's own language.
139
+ if (init.headers !== undefined && Object.keys(init.headers).length > 0) {
140
+ await on('Network.setExtraHTTPHeaders', { headers: init.headers });
141
+ }
142
+ const size = init.viewport ?? viewport;
137
143
  await on('Emulation.setDeviceMetricsOverride', {
138
- width: viewport.width,
139
- height: viewport.height,
144
+ width: size.width,
145
+ height: size.height,
140
146
  deviceScaleFactor: 1,
141
147
  mobile: false,
142
148
  });
@@ -0,0 +1,201 @@
1
+ // `x shot --matrix` — every static `site/` route × every locale × light and dark × a phone and a
2
+ // desktop width, into `.x/shot/matrix/`, with an `index.html` contact sheet a reviewer scrolls
3
+ // instead of opening a hundred files. One dev server for the whole run; each cell is an ordinary
4
+ // `runShot`, so a cell's `verdict.json` means exactly what a single shot's does.
5
+
6
+ // why: no Bun native joins a path.
7
+ import { join, relative } from 'node:path';
8
+ import { routeEntries } from '@ultimat3/render';
9
+ import { loadApp } from './app-load';
10
+ import type { ShotRun } from './cmd-shot';
11
+ import type { CommandResult } from './output';
12
+ import { localizedShotPath } from './shot-locale';
13
+ import type { ShotServer } from './shot-server';
14
+ import { SHOT_DIR } from './shot-server';
15
+ import type { ShotArtifacts } from './shot-verdict';
16
+
17
+ export const MATRIX_DIR = join(SHOT_DIR, 'matrix');
18
+ export const MATRIX_INDEX = 'index.html';
19
+
20
+ /** The two themes the boot honours from storage. */
21
+ export const MATRIX_THEMES: readonly ('light' | 'dark')[] = ['light', 'dark'];
22
+
23
+ /** A phone and a desktop, each with the height its class of device is photographed at. */
24
+ export const MATRIX_VIEWPORTS: readonly { readonly width: number; readonly height: number }[] = [
25
+ { width: 390, height: 844 },
26
+ { width: 1440, height: 900 },
27
+ ];
28
+
29
+ export interface MatrixCell {
30
+ /** The declared route, `/precios`. */
31
+ readonly route: string;
32
+ readonly locale: string;
33
+ readonly theme: 'light' | 'dark';
34
+ readonly viewport: { readonly width: number; readonly height: number };
35
+ /** What the browser opens: the route, prefixed for a non-default locale. */
36
+ readonly path: string;
37
+ /** Relative to the matrix directory: `<route-slug>/<locale>/<theme>-<width>`. */
38
+ readonly dir: string;
39
+ }
40
+
41
+ /** A route a matrix can photograph without inventing a parameter: no `:param`, no `*rest`. */
42
+ export const isMatrixRoute = (path: string): boolean => !/[:*[]/.test(path);
43
+
44
+ const slug = (route: string): string => {
45
+ const cleaned = route.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
46
+ return cleaned === '' ? 'root' : cleaned.toLowerCase();
47
+ };
48
+
49
+ /**
50
+ * The app's static `site/` routes, read off the registry `loadApp` fills — the same table the
51
+ * sitemap and the static export are built from, so the matrix photographs what ships.
52
+ */
53
+ export async function matrixRoutes(root: string): Promise<readonly string[]> {
54
+ await loadApp(root);
55
+ return routeEntries()
56
+ .filter((entry) => entry.surface === 'site' && isMatrixRoute(entry.path))
57
+ .map((entry) => entry.path);
58
+ }
59
+
60
+ export interface MatrixPlanInput {
61
+ readonly routes: readonly string[];
62
+ readonly locales: readonly string[];
63
+ readonly defaultLocale: string;
64
+ readonly themes?: readonly ('light' | 'dark')[] | undefined;
65
+ readonly viewports?: readonly { readonly width: number; readonly height: number }[] | undefined;
66
+ }
67
+
68
+ /** Every cell, route-major, in a stable order — the contact sheet reads top to bottom by route. */
69
+ export function planShotMatrix(input: MatrixPlanInput): readonly MatrixCell[] {
70
+ const cells: MatrixCell[] = [];
71
+ for (const route of input.routes.filter(isMatrixRoute)) {
72
+ for (const locale of input.locales) {
73
+ for (const theme of input.themes ?? MATRIX_THEMES) {
74
+ for (const viewport of input.viewports ?? MATRIX_VIEWPORTS) {
75
+ cells.push({
76
+ route,
77
+ locale,
78
+ theme,
79
+ viewport,
80
+ path: localizedShotPath(route, locale, input.defaultLocale),
81
+ dir: join(slug(route), locale, `${theme}-${String(viewport.width)}`),
82
+ });
83
+ }
84
+ }
85
+ }
86
+ }
87
+ return cells;
88
+ }
89
+
90
+ export interface MatrixShot {
91
+ readonly cell: MatrixCell;
92
+ readonly ok: boolean;
93
+ /** Relative to the matrix directory, as the contact sheet links it. */
94
+ readonly image: string;
95
+ }
96
+
97
+ const escapeHtml = (text: string): string =>
98
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
99
+
100
+ /**
101
+ * The contact sheet. System colours only (`Canvas`, `CanvasText`, `Mark`): a generated review page
102
+ * has no design tokens to read, and a literal colour would be the one this repo refuses.
103
+ */
104
+ export function contactSheet(shots: readonly MatrixShot[]): string {
105
+ const routes = [...new Set(shots.map((shot) => shot.cell.route))];
106
+ const sections = routes.map((route) => {
107
+ const figures = shots
108
+ .filter((shot) => shot.cell.route === route)
109
+ .map((shot) => {
110
+ const { locale, theme, viewport, path } = shot.cell;
111
+ const label = `${locale} · ${theme} · ${String(viewport.width)}px${shot.ok ? '' : ' · FAILED'}`;
112
+ return (
113
+ `<figure${shot.ok ? '' : ' class="failed"'}><a href="${escapeHtml(shot.image)}">` +
114
+ `<img src="${escapeHtml(shot.image)}" alt="${escapeHtml(`${path} ${label}`)}" loading="lazy" ` +
115
+ `width="${String(Math.round(viewport.width / 4))}"></a>` +
116
+ `<figcaption>${escapeHtml(label)}</figcaption></figure>`
117
+ );
118
+ })
119
+ .join('');
120
+ return `<section><h2>${escapeHtml(route)}</h2><div class="grid">${figures}</div></section>`;
121
+ });
122
+ return [
123
+ '<!doctype html>',
124
+ '<html lang="en"><head><meta charset="utf-8"><title>x shot --matrix</title>',
125
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
126
+ '<style>',
127
+ ':root{color-scheme:light dark}body{margin:1rem;font:14px/1.4 system-ui,sans-serif;',
128
+ 'background:Canvas;color:CanvasText}',
129
+ '.grid{display:flex;flex-wrap:wrap;gap:1rem;align-items:flex-start}',
130
+ 'figure{margin:0}img{display:block;height:auto;border:1px solid GrayText}',
131
+ '.failed figcaption{background:Mark;color:MarkText}',
132
+ '</style></head><body>',
133
+ `<h1>x shot --matrix — ${String(shots.length)} pictures, ${String(shots.filter((shot) => !shot.ok).length)} failed</h1>`,
134
+ ...sections,
135
+ '</body></html>',
136
+ '',
137
+ ].join('\n');
138
+ }
139
+
140
+ export interface MatrixRun {
141
+ readonly cells: readonly MatrixCell[];
142
+ /** Absolute matrix directory. */
143
+ readonly outDir: string;
144
+ readonly boot: () => Promise<ShotServer>;
145
+ /** `runShot`, handed in so this module never imports the command that imports it. */
146
+ readonly shoot: (run: ShotRun) => Promise<ShotArtifacts>;
147
+ /** Everything a cell shares: the driver, the waits, the hosts. */
148
+ readonly base: Omit<ShotRun, 'route' | 'outDir' | 'boot' | 'colorScheme' | 'viewport'>;
149
+ }
150
+
151
+ /**
152
+ * One server for every cell: booting `x dev` per picture would be a Postgres start per cell, and
153
+ * reusing is the rule `devServerFor` already follows. The cells share it through a boot that
154
+ * never stops it; the run stops it once, after the last picture.
155
+ */
156
+ export async function runShotMatrix(run: MatrixRun): Promise<CommandResult> {
157
+ const server = await run.boot();
158
+ const shared: ShotServer = { ...server, stop: () => Promise.resolve() };
159
+ const shots: MatrixShot[] = [];
160
+ try {
161
+ for (const cell of run.cells) {
162
+ const artifacts = await run.shoot({
163
+ ...run.base,
164
+ route: cell.path,
165
+ outDir: join(run.outDir, cell.dir),
166
+ boot: () => Promise.resolve(shared),
167
+ colorScheme: cell.theme,
168
+ viewport: cell.viewport,
169
+ acceptLanguage: cell.locale,
170
+ });
171
+ shots.push({
172
+ cell,
173
+ ok: artifacts.verdict.ok,
174
+ image: relative(run.outDir, artifacts.image).split('\\').join('/'),
175
+ });
176
+ }
177
+ } finally {
178
+ await server.stop().catch(() => undefined);
179
+ }
180
+ const index = join(run.outDir, MATRIX_INDEX);
181
+ await Bun.write(index, contactSheet(shots));
182
+ const failed = shots.filter((shot) => !shot.ok);
183
+ return {
184
+ ok: failed.length === 0,
185
+ command: 'shot',
186
+ summary: `${String(shots.length)} pictures, ${String(failed.length)} failed — ${index}`,
187
+ lines: failed.map((shot) => `FAILED ${shot.cell.path} (${shot.cell.dir})`),
188
+ data: {
189
+ index,
190
+ cells: shots.map((shot) => ({
191
+ route: shot.cell.route,
192
+ path: shot.cell.path,
193
+ locale: shot.cell.locale,
194
+ theme: shot.cell.theme,
195
+ width: shot.cell.viewport.width,
196
+ image: shot.image,
197
+ ok: shot.ok,
198
+ })),
199
+ },
200
+ };
201
+ }
@@ -7,7 +7,7 @@ export const shotSpec: CommandSpec = {
7
7
  name: 'shot',
8
8
  summary: 'photograph one route, one island in a state it declares, or every island in the app',
9
9
  usage:
10
- 'x shot <route> | --island <name> [--state <id>] | --all-islands [--port 0] [--out <dir>] [--settle 2000] [--json]',
10
+ 'x shot <route> [--locale <l>] [--theme light|dark] | --matrix [<route>] | --island <name> [--state <id>] | --all-islands [--expect-status 404] [--port 0] [--out <dir>] [--settle 2000] [--json]',
11
11
  requiresApp: true,
12
12
  flags: [
13
13
  { name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
@@ -15,6 +15,12 @@ export const shotSpec: CommandSpec = {
15
15
  { name: 'full', type: 'boolean', summary: 'whole page, not the fold', default: true },
16
16
  { name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
17
17
  { name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
18
+ {
19
+ name: 'expect-status',
20
+ type: 'string',
21
+ summary:
22
+ 'the document status this shot is ok with (e.g. 404); absent: any 2xx, else it fails',
23
+ },
18
24
  { name: 'browser', type: 'string', summary: 'Chrome or Chromium binary to launch' },
19
25
  {
20
26
  name: 'cdp-url',
@@ -27,6 +33,20 @@ export const shotSpec: CommandSpec = {
27
33
  type: 'string',
28
34
  summary: "light or dark, stored as the visitor's choice; absent is the app's own default",
29
35
  },
36
+ {
37
+ name: 'locale',
38
+ type: 'string',
39
+ summary:
40
+ "one of the app's locales: sent as Accept-Language, and a /<locale>/ prefix unless it is the default (absent: the default locale, pinned)",
41
+ },
42
+ // A flag and not a second command, for `--island`'s reason below: the matrix is many route
43
+ // shots, each one exactly what `x shot <route> --locale <l> --theme <t>` would take.
44
+ {
45
+ name: 'matrix',
46
+ type: 'boolean',
47
+ summary:
48
+ 'every site route × locale × light/dark × 390/1440 px into .x/shot/matrix/, plus an index.html contact sheet',
49
+ },
30
50
  // A FLAG on `x shot` and never a second command: photographing a route and photographing a
31
51
  // component are one job with two subjects, and a parallel command would be the second path
32
52
  // axiom 1 refuses.
package/src/cmd-shot.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { mkdirSync } from 'node:fs';
9
9
  import { join, resolve } from 'node:path';
10
+ import { renderFixShellArg } from '@ultimat3/core';
10
11
  import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
11
12
  import { requireAppRoot } from './app-root';
12
13
  import { appBrowser } from './browser-launcher';
@@ -22,6 +23,7 @@ import {
22
23
  refuseSweepWithRoute,
23
24
  refuseSweepWithState,
24
25
  } from './cmd-shot-island';
26
+ import { MATRIX_DIR, matrixRoutes, planShotMatrix, runShotMatrix } from './cmd-shot-matrix';
25
27
  import { shotSpec } from './cmd-shot-spec';
26
28
  import type { CliCommand, CommandContext } from './command';
27
29
  import { BadFlagError, MissingPositionalError } from './errors';
@@ -30,6 +32,12 @@ import type { CommandResult } from './output';
30
32
  import type { ParsedArgs } from './parse';
31
33
  import { flagBool, flagString } from './parse';
32
34
  import { shotBrowserChoice } from './shot-browser';
35
+ import {
36
+ acceptLanguageHeaders,
37
+ loadShotLocales,
38
+ localizedShotPath,
39
+ readLocaleFlag,
40
+ } from './shot-locale';
33
41
  import type { BootDevServer, ShotServer } from './shot-server';
34
42
  import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
35
43
  import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
@@ -37,6 +45,7 @@ import { readThemeFlag, themeChoiceExpression } from './shot-theme';
37
45
  import type { IslandCount, ShotArtifacts } from './shot-verdict';
38
46
  import {
39
47
  buildVerdict,
48
+ documentStatus,
40
49
  ISLAND_PROBE,
41
50
  parseIslandProbe,
42
51
  shotLines,
@@ -202,7 +211,19 @@ export interface ShotRun {
202
211
  * and the point of `defaultMode` — and `ui.shot` names one explicitly for exactly that reason.
203
212
  */
204
213
  readonly colorScheme?: ShotColorScheme | undefined;
214
+ /** CSS pixels the page is laid out in; absent is the driver's default. */
215
+ readonly viewport?: { readonly width: number; readonly height: number } | undefined;
216
+ /**
217
+ * Sent as `Accept-Language` on every request. The command always sets it — `--locale`, or the
218
+ * app's default locale — so a picture never depends on the language of the machine's Chrome.
219
+ */
220
+ readonly acceptLanguage?: string | undefined;
205
221
  readonly now?: (() => Date) | undefined;
222
+ /**
223
+ * `--expect-status`: the document status this shot is ok with — `404` to photograph the not-found
224
+ * page on purpose. Absent means any 2xx, and anything else fails the verdict with the status.
225
+ */
226
+ readonly expectStatus?: number | undefined;
206
227
  /**
207
228
  * Something to do with the page AFTER the islands settled and BEFORE the picture — `ui.inspect`
208
229
  * reads the DOM here, on the one navigation the picture already paid for. `settle` re-runs the
@@ -238,6 +259,10 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
238
259
  rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
239
260
  clock: systemShotClock,
240
261
  timeoutMs: options.timeoutMs,
262
+ ...(options.viewport === undefined ? {} : { viewport: options.viewport }),
263
+ ...(options.acceptLanguage === undefined
264
+ ? {}
265
+ : { headers: acceptLanguageHeaders(options.acceptLanguage) }),
241
266
  });
242
267
  const page = session.page;
243
268
  if (options.colorScheme !== undefined) {
@@ -246,6 +271,9 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
246
271
  if (choice !== undefined) await page.prepare(choice);
247
272
  }
248
273
  await page.goto(requestedUrl, { timeout: options.timeoutMs });
274
+ // Read NOW as well as at capture: the network ring is bounded, and a page that fires a few
275
+ // hundred requests while it settles evicts the document entry the verdict needs.
276
+ const landedStatus = documentStatus(page.network(), page.url());
249
277
  if (options.settleMs > 0) await Bun.sleep(options.settleMs);
250
278
  // The probe may legitimately answer nothing — a page that refuses evaluation, a driver with no
251
279
  // JS engine. `null` says so; a `0` would read as "the route renders no islands", which is a
@@ -284,6 +312,8 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
284
312
  network: page.network(),
285
313
  networkDropped: page.networkDropped(),
286
314
  islands,
315
+ landedStatus,
316
+ ...(options.expectStatus === undefined ? {} : { expectStatus: options.expectStatus }),
287
317
  });
288
318
  mkdirSync(options.outDir, { recursive: true });
289
319
  const image = join(options.outDir, SHOT_IMAGE);
@@ -323,6 +353,9 @@ export const shotCommand: CliCommand = {
323
353
  const theme = readThemeFlag(flagString(ctx.args, 'theme'));
324
354
  const positional = ctx.args.positionals[0];
325
355
  const sweep = flagBool(ctx.args, 'all-islands');
356
+ const matrix = flagBool(ctx.args, 'matrix');
357
+ const app = await loadShotLocales(root);
358
+ const locale = readLocaleFlag(flagString(ctx.args, 'locale'), app);
326
359
  // Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
327
360
  // subjects has a belief about which one runs, and half of them would be wrong.
328
361
  if (sweep && island !== undefined && island !== '') refuseSweepWithIsland(island);
@@ -333,8 +366,25 @@ export const shotCommand: CliCommand = {
333
366
  refuseRouteWithIsland(positional, island);
334
367
  }
335
368
  const component = sweep || (island !== undefined && island !== '');
369
+ // The matrix photographs ROUTES; a component beside it is two subjects.
370
+ if (matrix && component) {
371
+ throw new BadFlagError({
372
+ flag: 'matrix',
373
+ command: 'shot',
374
+ reason: 'photographs every site route; --island and --all-islands photograph components',
375
+ fix: 'x shot --matrix --json',
376
+ });
377
+ }
336
378
  // An island is photographed in BOTH themes by the harness, which owns its `data-theme` and
337
379
  // carries no boot script — so a theme asked for beside one is a request nothing could honour.
380
+ if (component && locale !== undefined) {
381
+ throw new BadFlagError({
382
+ flag: 'locale',
383
+ command: 'shot',
384
+ reason: 'photographs a route; an island is photographed in the locale its states declare',
385
+ fix: `x shot / --locale ${renderFixShellArg(locale, '<locale>')} --json`,
386
+ });
387
+ }
338
388
  if (component && theme !== undefined) {
339
389
  throw new BadFlagError({
340
390
  flag: 'theme',
@@ -343,10 +393,16 @@ export const shotCommand: CliCommand = {
343
393
  fix: 'x shot / --theme light --json',
344
394
  });
345
395
  }
346
- const route = component ? '' : readRoute(positional);
396
+ // `--matrix` alone is every site route; a route beside it narrows the matrix to that one.
397
+ const route = component || (matrix && positional === undefined) ? '' : readRoute(positional);
347
398
  const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
348
399
  const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
349
400
  const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
401
+ // Read only when given: its absence means "any 2xx", which no single default number can say.
402
+ const expectStatus =
403
+ flagString(ctx.args, 'expect-status') === undefined
404
+ ? undefined
405
+ : intFlag(ctx.args, 'expect-status', 100, 200, 599);
350
406
  // Which browser this run gets — start one here, or attach to one somebody else is running.
351
407
  // Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
352
408
  // provider session exists to pay for a typo. It also PROBES for an installed Chrome and refuses
@@ -381,16 +437,35 @@ export const shotCommand: CliCommand = {
381
437
  ...(executablePath === undefined ? {} : { executablePath }),
382
438
  ...(cdpUrl === undefined ? {} : { cdpUrl }),
383
439
  });
440
+ const base = {
441
+ driver,
442
+ settleMs,
443
+ timeoutMs,
444
+ fullPage: flagBool(ctx.args, 'full'),
445
+ extraHosts: flagString(ctx.args, 'allow-hosts'),
446
+ ...(expectStatus === undefined ? {} : { expectStatus }),
447
+ };
448
+ if (matrix) {
449
+ // `--locale` and `--theme` narrow the matrix to one value of their axis.
450
+ const cells = planShotMatrix({
451
+ routes: route === '' ? await matrixRoutes(root) : [route],
452
+ locales: locale === undefined ? app.locales : [locale],
453
+ defaultLocale: app.defaultLocale,
454
+ ...(theme === undefined ? {} : { themes: [theme] }),
455
+ });
456
+ const outDir = out === undefined ? join(root, MATRIX_DIR) : resolve(root, out);
457
+ return runShotMatrix({ cells, outDir, boot, shoot: runShot, base });
458
+ }
459
+ const shown = locale ?? app.defaultLocale;
460
+ const path = localizedShotPath(route, shown, app.defaultLocale);
384
461
  return shotResult(
385
462
  await runShot({
386
- route,
387
- outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(route)) : resolve(root, out),
388
- driver,
463
+ ...base,
464
+ route: path,
465
+ outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(path)) : resolve(root, out),
389
466
  boot,
390
- settleMs,
391
- timeoutMs,
392
- fullPage: flagBool(ctx.args, 'full'),
393
- extraHosts: flagString(ctx.args, 'allow-hosts'),
467
+ // Pinned whether or not `--locale` was given: the default locale, never the box's own.
468
+ acceptLanguage: shown,
394
469
  ...(theme === undefined ? {} : { colorScheme: theme }),
395
470
  }),
396
471
  );
@@ -10,7 +10,7 @@ export const testSpec: CommandSpec = {
10
10
  name: 'test',
11
11
  summary:
12
12
  'run one test type — or the whole suite — across N workers, one isolated database per worker',
13
- usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json] [-- <bun test flags>]`,
13
+ usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json] [-- <bun test flags>]`,
14
14
  positionalChoices: TEST_TYPES,
15
15
  // The one command that hands a tail to another tool — `bun test` — and the reason
16
16
  // `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
@@ -20,7 +20,7 @@ export const testSpec: CommandSpec = {
20
20
  {
21
21
  name: 'workers',
22
22
  type: 'string',
23
- summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
23
+ summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to free memory, min ${WORKER_FLOOR}; max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
24
24
  },
25
25
  {
26
26
  name: 'worker',
@@ -28,7 +28,17 @@ export const testSpec: CommandSpec = {
28
28
  summary:
29
29
  'run only shard I of an N-way split of the selection, serially — one CI job\u2019s share',
30
30
  },
31
- { name: 'filter', type: 'string', summary: 'only files whose path contains this substring' },
31
+ {
32
+ name: 'filter',
33
+ type: 'string',
34
+ summary: 'only files whose path contains this substring — or any of several, comma-separated',
35
+ },
36
+ {
37
+ name: 'allow-empty',
38
+ type: 'boolean',
39
+ summary:
40
+ 'a selection that matches no test file exits 0 with a line saying so, instead of X_TEST_NO_FILES',
41
+ },
32
42
  {
33
43
  name: 'sample',
34
44
  type: 'string',