@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 +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher-fake.ts +4 -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 +21 -1
- package/src/cmd-shot.ts +83 -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 +7 -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/shot-verdict.ts +65 -2
- 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 +65 -12
- package/src/verify-run.ts +20 -7
- 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.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.
|
|
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.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 {
|
|
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
|
-
|
|
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 {
|
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 [--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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
463
|
+
...base,
|
|
464
|
+
route: path,
|
|
465
|
+
outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(path)) : resolve(root, out),
|
|
389
466
|
boot,
|
|
390
|
-
|
|
391
|
-
|
|
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
|
);
|
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',
|