@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 +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher-port.ts +7 -0
- package/src/cdp-shot-driver.ts +8 -2
- package/src/cmd-shot-matrix.ts +201 -0
- package/src/cmd-shot-spec.ts +15 -1
- package/src/cmd-shot.ts +66 -8
- package/src/cmd-test-spec.ts +13 -3
- package/src/cmd-test.ts +31 -3
- package/src/cmd-verify-spec.ts +4 -4
- package/src/cmd-verify.ts +31 -16
- package/src/dev-route-table.ts +6 -1
- package/src/error-contract.ts +5 -0
- package/src/index.ts +2 -0
- package/src/messages.ts +1 -0
- package/src/prerender-locales.ts +34 -0
- package/src/prerender.ts +67 -11
- package/src/runtime-assets.ts +10 -2
- package/src/runtime-render.ts +41 -19
- package/src/seo-routes.ts +11 -9
- package/src/serve-boot.ts +6 -1
- package/src/shot-locale.ts +67 -0
- package/src/site-asset-routes.ts +106 -0
- package/src/site-assets.ts +177 -0
- package/src/site-config.ts +79 -0
- package/src/site-seo.ts +31 -2
- package/src/templates/scaffold-entries.ts +6 -4
- package/src/test-select.ts +30 -3
- package/src/test-workers.ts +52 -12
- package/src/verify-run.ts +13 -6
- package/src/verify-step.ts +10 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.
|
|
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.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
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 {
|
|
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 {
|
package/src/cdp-shot-driver.ts
CHANGED
|
@@ -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:
|
|
139
|
-
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|
package/src/cmd-shot-spec.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
446
|
+
...base,
|
|
447
|
+
route: path,
|
|
448
|
+
outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(path)) : resolve(root, out),
|
|
389
449
|
boot,
|
|
390
|
-
|
|
391
|
-
|
|
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
|
);
|
package/src/cmd-test-spec.ts
CHANGED
|
@@ -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
|
|
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}
|
|
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
|
-
{
|
|
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 {
|
|
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,
|
|
127
|
+
const discovered = await discoverTests(ctx.cwd, filters, type);
|
|
111
128
|
if (discovered.length === 0) {
|
|
112
|
-
|
|
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
|
package/src/cmd-verify-spec.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
};
|