@ultimat3/cli 22.2.2 → 22.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.2.2",
3
+ "version": "22.3.0",
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.0",
42
+ "@ultimat3/admin": "22.3.0",
43
+ "@ultimat3/ai": "22.3.0",
44
+ "@ultimat3/auth": "22.3.0",
45
+ "@ultimat3/cache": "22.3.0",
46
+ "@ultimat3/core": "22.3.0",
47
+ "@ultimat3/db": "22.3.0",
48
+ "@ultimat3/entity": "22.3.0",
49
+ "@ultimat3/flags": "22.3.0",
50
+ "@ultimat3/http": "22.3.0",
51
+ "@ultimat3/i18n": "22.3.0",
52
+ "@ultimat3/jobs": "22.3.0",
53
+ "@ultimat3/mail": "22.3.0",
54
+ "@ultimat3/manifest": "22.3.0",
55
+ "@ultimat3/mcp": "22.3.0",
56
+ "@ultimat3/money": "22.3.0",
57
+ "@ultimat3/notify": "22.3.0",
58
+ "@ultimat3/policy": "22.3.0",
59
+ "@ultimat3/pwa": "22.3.0",
60
+ "@ultimat3/query": "22.3.0",
61
+ "@ultimat3/realtime": "22.3.0",
62
+ "@ultimat3/render": "22.3.0",
63
+ "@ultimat3/schema": "22.3.0",
64
+ "@ultimat3/seo": "22.3.0",
65
+ "@ultimat3/storage": "22.3.0",
66
+ "@ultimat3/testing": "22.3.0",
67
+ "@ultimat3/time": "22.3.0",
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
 
@@ -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 [--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)' },
@@ -27,6 +27,20 @@ export const shotSpec: CommandSpec = {
27
27
  type: 'string',
28
28
  summary: "light or dark, stored as the visitor's choice; absent is the app's own default",
29
29
  },
30
+ {
31
+ name: 'locale',
32
+ type: 'string',
33
+ summary:
34
+ "one of the app's locales: sent as Accept-Language, and a /<locale>/ prefix unless it is the default (absent: the default locale, pinned)",
35
+ },
36
+ // A flag and not a second command, for `--island`'s reason below: the matrix is many route
37
+ // shots, each one exactly what `x shot <route> --locale <l> --theme <t>` would take.
38
+ {
39
+ name: 'matrix',
40
+ type: 'boolean',
41
+ summary:
42
+ 'every site route × locale × light/dark × 390/1440 px into .x/shot/matrix/, plus an index.html contact sheet',
43
+ },
30
44
  // A FLAG on `x shot` and never a second command: photographing a route and photographing a
31
45
  // component are one job with two subjects, and a parallel command would be the second path
32
46
  // 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';
@@ -202,6 +210,13 @@ export interface ShotRun {
202
210
  * and the point of `defaultMode` — and `ui.shot` names one explicitly for exactly that reason.
203
211
  */
204
212
  readonly colorScheme?: ShotColorScheme | undefined;
213
+ /** CSS pixels the page is laid out in; absent is the driver's default. */
214
+ readonly viewport?: { readonly width: number; readonly height: number } | undefined;
215
+ /**
216
+ * Sent as `Accept-Language` on every request. The command always sets it — `--locale`, or the
217
+ * app's default locale — so a picture never depends on the language of the machine's Chrome.
218
+ */
219
+ readonly acceptLanguage?: string | undefined;
205
220
  readonly now?: (() => Date) | undefined;
206
221
  /**
207
222
  * Something to do with the page AFTER the islands settled and BEFORE the picture — `ui.inspect`
@@ -238,6 +253,10 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
238
253
  rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
239
254
  clock: systemShotClock,
240
255
  timeoutMs: options.timeoutMs,
256
+ ...(options.viewport === undefined ? {} : { viewport: options.viewport }),
257
+ ...(options.acceptLanguage === undefined
258
+ ? {}
259
+ : { headers: acceptLanguageHeaders(options.acceptLanguage) }),
241
260
  });
242
261
  const page = session.page;
243
262
  if (options.colorScheme !== undefined) {
@@ -323,6 +342,9 @@ export const shotCommand: CliCommand = {
323
342
  const theme = readThemeFlag(flagString(ctx.args, 'theme'));
324
343
  const positional = ctx.args.positionals[0];
325
344
  const sweep = flagBool(ctx.args, 'all-islands');
345
+ const matrix = flagBool(ctx.args, 'matrix');
346
+ const app = await loadShotLocales(root);
347
+ const locale = readLocaleFlag(flagString(ctx.args, 'locale'), app);
326
348
  // Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
327
349
  // subjects has a belief about which one runs, and half of them would be wrong.
328
350
  if (sweep && island !== undefined && island !== '') refuseSweepWithIsland(island);
@@ -333,8 +355,25 @@ export const shotCommand: CliCommand = {
333
355
  refuseRouteWithIsland(positional, island);
334
356
  }
335
357
  const component = sweep || (island !== undefined && island !== '');
358
+ // The matrix photographs ROUTES; a component beside it is two subjects.
359
+ if (matrix && component) {
360
+ throw new BadFlagError({
361
+ flag: 'matrix',
362
+ command: 'shot',
363
+ reason: 'photographs every site route; --island and --all-islands photograph components',
364
+ fix: 'x shot --matrix --json',
365
+ });
366
+ }
336
367
  // An island is photographed in BOTH themes by the harness, which owns its `data-theme` and
337
368
  // carries no boot script — so a theme asked for beside one is a request nothing could honour.
369
+ if (component && locale !== undefined) {
370
+ throw new BadFlagError({
371
+ flag: 'locale',
372
+ command: 'shot',
373
+ reason: 'photographs a route; an island is photographed in the locale its states declare',
374
+ fix: `x shot / --locale ${renderFixShellArg(locale, '<locale>')} --json`,
375
+ });
376
+ }
338
377
  if (component && theme !== undefined) {
339
378
  throw new BadFlagError({
340
379
  flag: 'theme',
@@ -343,7 +382,8 @@ export const shotCommand: CliCommand = {
343
382
  fix: 'x shot / --theme light --json',
344
383
  });
345
384
  }
346
- const route = component ? '' : readRoute(positional);
385
+ // `--matrix` alone is every site route; a route beside it narrows the matrix to that one.
386
+ const route = component || (matrix && positional === undefined) ? '' : readRoute(positional);
347
387
  const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
348
388
  const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
349
389
  const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
@@ -381,16 +421,34 @@ export const shotCommand: CliCommand = {
381
421
  ...(executablePath === undefined ? {} : { executablePath }),
382
422
  ...(cdpUrl === undefined ? {} : { cdpUrl }),
383
423
  });
424
+ const base = {
425
+ driver,
426
+ settleMs,
427
+ timeoutMs,
428
+ fullPage: flagBool(ctx.args, 'full'),
429
+ extraHosts: flagString(ctx.args, 'allow-hosts'),
430
+ };
431
+ if (matrix) {
432
+ // `--locale` and `--theme` narrow the matrix to one value of their axis.
433
+ const cells = planShotMatrix({
434
+ routes: route === '' ? await matrixRoutes(root) : [route],
435
+ locales: locale === undefined ? app.locales : [locale],
436
+ defaultLocale: app.defaultLocale,
437
+ ...(theme === undefined ? {} : { themes: [theme] }),
438
+ });
439
+ const outDir = out === undefined ? join(root, MATRIX_DIR) : resolve(root, out);
440
+ return runShotMatrix({ cells, outDir, boot, shoot: runShot, base });
441
+ }
442
+ const shown = locale ?? app.defaultLocale;
443
+ const path = localizedShotPath(route, shown, app.defaultLocale);
384
444
  return shotResult(
385
445
  await runShot({
386
- route,
387
- outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(route)) : resolve(root, out),
388
- driver,
446
+ ...base,
447
+ route: path,
448
+ outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(path)) : resolve(root, out),
389
449
  boot,
390
- settleMs,
391
- timeoutMs,
392
- fullPage: flagBool(ctx.args, 'full'),
393
- extraHosts: flagString(ctx.args, 'allow-hosts'),
450
+ // Pinned whether or not `--locale` was given: the default locale, never the box's own.
451
+ acceptLanguage: shown,
394
452
  ...(theme === undefined ? {} : { colorScheme: theme }),
395
453
  }),
396
454
  );
@@ -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',
package/src/cmd-test.ts CHANGED
@@ -17,7 +17,14 @@ import type { CommandResult, JsonValue } from './output';
17
17
  import type { ParsedArgs } from './parse';
18
18
  import { flagBool, flagString } from './parse';
19
19
  import { quoteArg } from './shell-quote';
20
- import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
20
+ import {
21
+ discoverTests,
22
+ missingSelection,
23
+ readFilters,
24
+ readSample,
25
+ readType,
26
+ sampleFiles,
27
+ } from './test-select';
21
28
  import { runShards } from './test-shards';
22
29
  import { defaultWorkers, SERIAL_TYPES, WORKER_CEILING } from './test-workers';
23
30
  import type { TestType } from './verify-tests';
@@ -100,16 +107,37 @@ const withScope = (result: CommandResult, scope: AffectedScope): CommandResult =
100
107
  data: { ...asObject(result.data), affected: affectedScopeJson(scope) },
101
108
  });
102
109
 
110
+ /** The selection that matched nothing, as one phrase for `cli.test.empty`. */
111
+ const describeSelection = (missing: { type?: string; filter?: string }): string =>
112
+ [
113
+ missing.type === undefined ? undefined : `type ${missing.type}`,
114
+ missing.filter === undefined ? undefined : `"${missing.filter}"`,
115
+ ]
116
+ .filter((part) => part !== undefined)
117
+ .join(' and ') || 'the selection';
118
+
103
119
  export const testCommand: CliCommand = {
104
120
  spec: testSpec,
105
121
  async run(ctx: CommandContext): Promise<CommandResult> {
106
122
  const type = readOnlyType(ctx.args.positionals);
107
123
  const filter = flagString(ctx.args, 'filter');
124
+ const filters = readFilters(filter);
108
125
  const sample = readSample(ctx.args);
109
126
  const scope = await readAffectedScope(ctx);
110
- const discovered = await discoverTests(ctx.cwd, filter, type);
127
+ const discovered = await discoverTests(ctx.cwd, filters, type);
111
128
  if (discovered.length === 0) {
112
- throw new NoTestFilesError({ root: ctx.cwd, ...missingSelection(type, filter) });
129
+ const missing = missingSelection(type, filter);
130
+ // `--allow-empty` is for a caller that COMPUTED the selection — a scoped runner handing over
131
+ // the paths a diff touched, some of which hold no test yet. Green, spawning nothing, and
132
+ // never read as "the suite passed": the line names the selection that matched nothing and
133
+ // `data.files` is 0. Without the flag an empty selection stays X_TEST_NO_FILES, because a
134
+ // typo'd `--filter` typed by hand is exactly the run that must not pass.
135
+ if (flagBool(ctx.args, 'allow-empty')) {
136
+ return ok('test', msg('cli.test.empty', { selection: describeSelection(missing) }), {
137
+ data: { ...missing, files: 0, empty: true },
138
+ });
139
+ }
140
+ throw new NoTestFilesError({ root: ctx.cwd, ...missing });
113
141
  }
114
142
  const selected =
115
143
  scope === undefined
@@ -7,22 +7,22 @@ import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-worke
7
7
  export const verifySpec: CommandSpec = {
8
8
  name: 'verify',
9
9
  summary: 'the gate: typecheck, lint, boundaries, all tests, drift, contract, budgets',
10
- usage: 'x verify [--only <step>] [--workers N] [--json]',
10
+ usage: 'x verify [--only <step>[,<step>…]] [--workers N] [--json]',
11
11
  requiresApp: true,
12
12
  // Two flags, and only one of them narrows. `--workers` changes how wide the test steps
13
- // spread, never which steps run. `--only` runs one step and says so in both renderers —
13
+ // spread, never which steps run. `--only` runs the steps it names and says so in both renderers —
14
14
  // never silently, which is the whole of what makes it safe to have.
15
15
  flags: [
16
16
  {
17
17
  name: 'workers',
18
18
  type: 'string',
19
- summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
19
+ summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs rounded up, held to free memory; min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
20
20
  },
21
21
  {
22
22
  name: 'only',
23
23
  type: 'string',
24
24
  summary:
25
- 'run ONE step by name — an iteration loop, NOT A GATE RUN; the gate is this command with no flag',
25
+ 'run the named step(s), comma-separated, in one process — an iteration loop, NOT A GATE RUN; the gate is this command with no flag',
26
26
  },
27
27
  ],
28
28
  };