@ultimat3/cli 13.0.0 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +121 -1
- package/package.json +29 -29
- package/src/cmd-db.ts +11 -3
- package/src/cmd-doctor.ts +2 -2
- package/src/db-generate.ts +53 -3
- package/src/db-ungeneratable.ts +108 -0
- package/src/e2e-dom-fixture.ts +117 -0
- package/src/e2e-driver.ts +78 -0
- package/src/e2e-errors.ts +103 -0
- package/src/e2e-evaluate.ts +156 -0
- package/src/e2e-locator.ts +86 -0
- package/src/e2e-page.ts +124 -0
- package/src/e2e-selection.ts +182 -0
- package/src/error-codes.ts +35 -0
- package/src/index.ts +32 -0
- package/src/mcp-errors.ts +26 -0
- package/src/schema-diff.ts +275 -0
- package/src/schema-drift.ts +146 -0
- package/src/verify-checks.ts +10 -3
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// What an e2e locator SELECTS, as a value, plus the one in-page expression that resolves it.
|
|
2
|
+
// `locator`/`getByRole`/`getByText` are lazy handles, so the selection has to survive as data
|
|
3
|
+
// until something asks a question about it — and only then does it become a string the browser
|
|
4
|
+
// can run.
|
|
5
|
+
|
|
6
|
+
/** Where the driver parks its click target. Removed again as soon as the click has been made. */
|
|
7
|
+
export const MARK_ATTRIBUTE = 'data-x-e2e';
|
|
8
|
+
|
|
9
|
+
/** One selection, exactly as the test spelled it. `first` is `.first()`, applied at resolve time. */
|
|
10
|
+
export type E2eSelection =
|
|
11
|
+
| { readonly kind: 'css'; readonly selector: string; readonly first: boolean }
|
|
12
|
+
| {
|
|
13
|
+
readonly kind: 'role';
|
|
14
|
+
readonly role: string;
|
|
15
|
+
readonly name?: string | undefined;
|
|
16
|
+
readonly level?: number | undefined;
|
|
17
|
+
readonly first: boolean;
|
|
18
|
+
}
|
|
19
|
+
| { readonly kind: 'text'; readonly text: string; readonly first: boolean };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What the page answers for one selection. `count` is how many elements matched AFTER `first`
|
|
23
|
+
* narrowed it, `visible` is about the first match alone, and `marked` says the click target now
|
|
24
|
+
* carries `MARK_ATTRIBUTE` — three facts in one round trip, because a locator that asked twice
|
|
25
|
+
* could be answered about two different renders.
|
|
26
|
+
*/
|
|
27
|
+
export interface E2eResolution {
|
|
28
|
+
readonly count: number;
|
|
29
|
+
readonly visible: boolean;
|
|
30
|
+
readonly marked: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The elements that carry a role IMPLICITLY, so `getByRole('button')` finds a `<button>` that
|
|
35
|
+
* never wrote the attribute. A `Map` and not an object literal: the key is a role a test typed,
|
|
36
|
+
* and a computed read of a `Record` answers `Object.prototype` members — the defect
|
|
37
|
+
* `bun run proto-index` exists for.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately not the whole of WAI-ARIA. A role absent from this table still resolves through
|
|
40
|
+
* its explicit `[role="…"]` attribute, which is why an unknown role is not refused: refusing
|
|
41
|
+
* `role="feed"` because a table in the framework is short would be the framework deciding an app's
|
|
42
|
+
* markup is wrong.
|
|
43
|
+
*/
|
|
44
|
+
const IMPLICIT_ROLE_ELEMENTS = new Map<string, readonly string[]>([
|
|
45
|
+
['banner', ['header']],
|
|
46
|
+
['button', ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]']],
|
|
47
|
+
['checkbox', ['input[type="checkbox"]']],
|
|
48
|
+
['combobox', ['select']],
|
|
49
|
+
['contentinfo', ['footer']],
|
|
50
|
+
['dialog', ['dialog']],
|
|
51
|
+
['form', ['form']],
|
|
52
|
+
['heading', ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']],
|
|
53
|
+
['img', ['img']],
|
|
54
|
+
['link', ['a[href]']],
|
|
55
|
+
['list', ['ul', 'ol']],
|
|
56
|
+
['listitem', ['li']],
|
|
57
|
+
['main', ['main']],
|
|
58
|
+
['navigation', ['nav']],
|
|
59
|
+
['option', ['option']],
|
|
60
|
+
['radio', ['input[type="radio"]']],
|
|
61
|
+
['table', ['table']],
|
|
62
|
+
['textbox', ['input[type="text"]', 'input[type="email"]', 'input[type="search"]', 'textarea']],
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/** Elements whose text is markup rather than page copy — `getByText` must never land on one. */
|
|
66
|
+
const TEXT_SKIP_TAGS = ['SCRIPT', 'STYLE', 'HEAD', 'TITLE', 'META', 'LINK', 'NOSCRIPT'];
|
|
67
|
+
|
|
68
|
+
/** A CSS attribute selector takes a double-quoted string, which is what `JSON.stringify` writes. */
|
|
69
|
+
const roleSelector = (role: string): string =>
|
|
70
|
+
[`[role=${JSON.stringify(role)}]`, ...(IMPLICIT_ROLE_ELEMENTS.get(role) ?? [])].join(',');
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The call a test wrote, rebuilt from the selection. Every refusal below quotes it, because
|
|
74
|
+
* "an e2e locator matched nothing" names no line of the test file and this does.
|
|
75
|
+
*/
|
|
76
|
+
export function selectionCall(selection: E2eSelection): string {
|
|
77
|
+
const tail = selection.first ? '.first()' : '';
|
|
78
|
+
if (selection.kind === 'css') return `page.locator(${JSON.stringify(selection.selector)})${tail}`;
|
|
79
|
+
if (selection.kind === 'text') return `page.getByText(${JSON.stringify(selection.text)})${tail}`;
|
|
80
|
+
const options = [
|
|
81
|
+
...(selection.name === undefined ? [] : [`name: ${JSON.stringify(selection.name)}`]),
|
|
82
|
+
...(selection.level === undefined ? [] : [`level: ${String(selection.level)}`]),
|
|
83
|
+
];
|
|
84
|
+
const role = JSON.stringify(selection.role);
|
|
85
|
+
const suffix = options.length === 0 ? '' : `, { ${options.join(', ')} }`;
|
|
86
|
+
return `page.getByRole(${role}${suffix})${tail}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The candidate list, as a JS expression evaluating to an array of elements.
|
|
91
|
+
*
|
|
92
|
+
* `getByRole` and `getByText` are not CSS and cannot be: a role is implicit in a tag name, an
|
|
93
|
+
* accessible name comes off four different attributes before it comes off the text, and
|
|
94
|
+
* `getByText` has to pick the INNERMOST element that contains the string. So the union selector
|
|
95
|
+
* is only the cheap first pass and every rule after it runs in JS, in the page.
|
|
96
|
+
*/
|
|
97
|
+
function candidateSource(selection: E2eSelection): string {
|
|
98
|
+
if (selection.kind === 'css') return `all(${JSON.stringify(selection.selector)})`;
|
|
99
|
+
if (selection.kind === 'text') {
|
|
100
|
+
const needle = JSON.stringify(selection.text.replace(/\s+/g, ' ').trim().toLowerCase());
|
|
101
|
+
// Innermost FIRST and the skip list second, in that order. Reversed, `<html>` becomes the
|
|
102
|
+
// innermost survivor of a page whose only copy of the string is inside a `<script>` — the
|
|
103
|
+
// ancestor inherits the match its own excluded child made, which is worse than not filtering
|
|
104
|
+
// at all: it reports one match, at the document root, for text no reader can see.
|
|
105
|
+
return `innermost(all('*').filter((el) => norm(el.textContent).toLowerCase().indexOf(${needle}) !== -1)).filter((el) => ${JSON.stringify(TEXT_SKIP_TAGS)}.indexOf(el.tagName) === -1)`;
|
|
106
|
+
}
|
|
107
|
+
const byRole = `all(${JSON.stringify(roleSelector(selection.role))}).filter((el) => { const own = el.getAttribute('role'); return own === null || norm(own).toLowerCase() === ${JSON.stringify(selection.role.toLowerCase())}; })`;
|
|
108
|
+
const byLevel =
|
|
109
|
+
selection.level === undefined
|
|
110
|
+
? byRole
|
|
111
|
+
: `${byRole}.filter((el) => level(el) === ${String(selection.level)})`;
|
|
112
|
+
return selection.name === undefined
|
|
113
|
+
? byLevel
|
|
114
|
+
: `${byLevel}.filter((el) => accName(el).toLowerCase() === ${JSON.stringify(selection.name.replace(/\s+/g, ' ').trim().toLowerCase())})`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The helpers the candidate source above calls, defined once inside the IIFE.
|
|
119
|
+
*
|
|
120
|
+
* `visible` is `display`/`visibility`/`opacity`, which is character for character what
|
|
121
|
+
* `@ultimat3/scraping`'s `snapshotExpression` computes for `ElementSnapshot.visible`. That is a
|
|
122
|
+
* COPY and it is the wrong shape: `ScrapeTarget.query()` already answers a snapshot per element
|
|
123
|
+
* and `ScrapeFrame` does not expose it, so this driver cannot reach the framework's one definition
|
|
124
|
+
* of visible without an edit to `packages/scraping/src/page.ts`. Mirrored deliberately rather than
|
|
125
|
+
* invented, so the two cannot disagree about a page until that edit lands.
|
|
126
|
+
*/
|
|
127
|
+
const HELPERS = `
|
|
128
|
+
const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
|
|
129
|
+
const all = (sel) => Array.prototype.slice.call(document.querySelectorAll(sel));
|
|
130
|
+
const innermost = (found) => found.filter((el) => !found.some((other) => other !== el && el.contains(other)));
|
|
131
|
+
const level = (el) => {
|
|
132
|
+
const aria = el.getAttribute('aria-level');
|
|
133
|
+
if (aria !== null) return Number(aria);
|
|
134
|
+
const tag = el.tagName.toLowerCase();
|
|
135
|
+
return /^h[1-6]$/.test(tag) ? Number(tag.slice(1)) : undefined;
|
|
136
|
+
};
|
|
137
|
+
const accName = (el) => {
|
|
138
|
+
const label = el.getAttribute('aria-label');
|
|
139
|
+
if (label !== null && norm(label) !== '') return norm(label);
|
|
140
|
+
const by = el.getAttribute('aria-labelledby');
|
|
141
|
+
if (by !== null) {
|
|
142
|
+
const parts = norm(by).split(' ').map((id) => document.getElementById(id)).filter((n) => n).map((n) => norm(n.textContent));
|
|
143
|
+
if (norm(parts.join(' ')) !== '') return norm(parts.join(' '));
|
|
144
|
+
}
|
|
145
|
+
const tag = el.tagName.toLowerCase();
|
|
146
|
+
if (tag === 'input') { const v = el.getAttribute('value'); if (v !== null && norm(v) !== '') return norm(v); }
|
|
147
|
+
if (tag === 'img') { const a = el.getAttribute('alt'); if (a !== null) return norm(a); }
|
|
148
|
+
const text = norm(el.textContent);
|
|
149
|
+
if (text !== '') return text;
|
|
150
|
+
const title = el.getAttribute('title');
|
|
151
|
+
return title !== null ? norm(title) : '';
|
|
152
|
+
};`;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Returns JSON TEXT rather than an object, the same bargain `cdp-snapshot.ts` makes: a CDP round
|
|
156
|
+
* trip serialises the answer anyway, and a string has ONE deserialiser — a schema parse — instead
|
|
157
|
+
* of an implicit one inside the browser library plus a cast on this side.
|
|
158
|
+
*/
|
|
159
|
+
export function selectionExpression(selection: E2eSelection, mark?: string): string {
|
|
160
|
+
const marker = mark === undefined ? 'null' : JSON.stringify(mark);
|
|
161
|
+
return `(() => {${HELPERS}
|
|
162
|
+
const found = ${candidateSource(selection)};
|
|
163
|
+
const matches = ${selection.first ? 'found.slice(0, 1)' : 'found'};
|
|
164
|
+
const el = matches[0];
|
|
165
|
+
let visible = false;
|
|
166
|
+
if (el) {
|
|
167
|
+
const style = getComputedStyle(el);
|
|
168
|
+
visible = style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
169
|
+
}
|
|
170
|
+
const mark = ${marker};
|
|
171
|
+
let marked = false;
|
|
172
|
+
if (mark !== null && el) { el.setAttribute(${JSON.stringify(MARK_ATTRIBUTE)}, mark); marked = true; }
|
|
173
|
+
return JSON.stringify({ count: matches.length, visible: visible, marked: marked });
|
|
174
|
+
})()`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The CSS the marked element answers to — what `ScrapePage.click` is handed once one is marked. */
|
|
178
|
+
export const markSelector = (mark: string): string => `[${MARK_ATTRIBUTE}=${JSON.stringify(mark)}]`;
|
|
179
|
+
|
|
180
|
+
/** Undo. Best effort by design: a click that navigated took the whole document with it. */
|
|
181
|
+
export const unmarkExpression = (mark: string): string =>
|
|
182
|
+
`(() => { const el = document.querySelector(${JSON.stringify(markSelector(mark))}); if (el) el.removeAttribute(${JSON.stringify(MARK_ATTRIBUTE)}); return JSON.stringify(true); })()`;
|
package/src/error-codes.ts
CHANGED
|
@@ -92,6 +92,22 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
92
92
|
'X_GENERATE_CONFLICT',
|
|
93
93
|
'X_PORT_IN_USE',
|
|
94
94
|
'X_DB_GEN_FAILED',
|
|
95
|
+
// The two directions of the drift a hash cannot see, and they are two repairs. The `drift` step
|
|
96
|
+
// compared a schema-source HASH to a `.hash` sidecar and never read what the migration recorded,
|
|
97
|
+
// so nine declared CHECK constraints that had never reached any database were green for as long
|
|
98
|
+
// as nobody edited the schema. `UNMIGRATED` means the entities declare it and no migration
|
|
99
|
+
// carries it — the database will never get it. `UNDECLARED` means a migration recorded it and
|
|
100
|
+
// nothing in source declares it any more. One code over both would hand two readers one wrong
|
|
101
|
+
// edit, which is the reason `X_ERROR_FIX_PATH_MISSING` is not `X_ERROR_FIX_INVALID` either.
|
|
102
|
+
'X_DB_SCHEMA_UNMIGRATED',
|
|
103
|
+
'X_DB_SCHEMA_UNDECLARED',
|
|
104
|
+
// The third thing the `drift` step asks, and the one no declaration-based check can answer: SQL
|
|
105
|
+
// in a committed migration that `x db gen` could never have written, which a squash discards in
|
|
106
|
+
// silence because a regenerated sidecar equals the declaration by construction. CLI-owned rather
|
|
107
|
+
// than `@ultimat3/db`'s — that package classifies the statements and deliberately declares no
|
|
108
|
+
// code, because the only remedy available for all of them is a line in the migration file, and
|
|
109
|
+
// where that file lives is this package's fact.
|
|
110
|
+
'X_MIGRATION_UNGENERATABLE',
|
|
95
111
|
'X_DB_MIGRATE_FAILED',
|
|
96
112
|
'X_DB_BRANCH_FAILED',
|
|
97
113
|
'X_DB_STUDIO_FAILED',
|
|
@@ -132,6 +148,16 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
132
148
|
'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
|
|
133
149
|
'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
|
|
134
150
|
'X_SHOT_ISLAND_MISSING',
|
|
151
|
+
// The browser-backed e2e driver — `e2e-driver.ts` and the three modules under it. Owned by the
|
|
152
|
+
// CLI because the ADAPTER is: `@ultimat3/testing` declares `PageLike` and may not import a
|
|
153
|
+
// browser, `@ultimat3/scraping` owns the browser and may not import the harness, and neither
|
|
154
|
+
// package can name a failure that only exists where the two meet.
|
|
155
|
+
'X_E2E_EVALUATE_UNSUPPORTED',
|
|
156
|
+
'X_E2E_EVALUATE_CAPTURED',
|
|
157
|
+
'X_E2E_EVALUATE_THREW',
|
|
158
|
+
'X_E2E_LOCATOR_EMPTY',
|
|
159
|
+
'X_E2E_LOCATOR_AMBIGUOUS',
|
|
160
|
+
'X_E2E_SERVICE_WORKER_ABSENT',
|
|
135
161
|
'X_GH_UNAVAILABLE',
|
|
136
162
|
'X_GH_NOT_AUTHENTICATED',
|
|
137
163
|
'X_GH_COMMAND_FAILED',
|
|
@@ -229,6 +255,9 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
229
255
|
X_GENERATE_CONFLICT: 'a generator would overwrite a file',
|
|
230
256
|
X_PORT_IN_USE: 'the dev port is taken',
|
|
231
257
|
X_DB_GEN_FAILED: 'x db gen failed',
|
|
258
|
+
X_DB_SCHEMA_UNMIGRATED: 'an entity declaration no migration recorded',
|
|
259
|
+
X_DB_SCHEMA_UNDECLARED: 'a migration records schema no entity declares',
|
|
260
|
+
X_MIGRATION_UNGENERATABLE: 'this migration holds SQL no declaration carries and does not say so',
|
|
232
261
|
X_DB_MIGRATE_FAILED: 'x db migrate failed',
|
|
233
262
|
X_DB_BRANCH_FAILED: 'an x db branch step failed',
|
|
234
263
|
X_DB_STUDIO_FAILED: 'x db studio failed',
|
|
@@ -250,6 +279,12 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
250
279
|
X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
|
|
251
280
|
X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
|
|
252
281
|
X_SHOT_ISLAND_MISSING: 'a declared island picture is not on disk',
|
|
282
|
+
X_E2E_EVALUATE_UNSUPPORTED: 'a page.evaluate() closure cannot be sent into the browser',
|
|
283
|
+
X_E2E_EVALUATE_CAPTURED: 'a page.evaluate() closure named a binding the page does not have',
|
|
284
|
+
X_E2E_EVALUATE_THREW: 'an expression an e2e page ran threw inside the browser',
|
|
285
|
+
X_E2E_LOCATOR_EMPTY: 'an e2e locator matched no element',
|
|
286
|
+
X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
|
|
287
|
+
X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
|
|
253
288
|
X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
|
|
254
289
|
X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
|
|
255
290
|
X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
|
package/src/index.ts
CHANGED
|
@@ -113,6 +113,33 @@ export {
|
|
|
113
113
|
schemaHash,
|
|
114
114
|
writeSchemaHash,
|
|
115
115
|
} from './drift';
|
|
116
|
+
// The browser-backed e2e driver. `installE2eDriver` is the ONE entry point an app's test preload
|
|
117
|
+
// calls; everything below it is exported because the adapter's own pieces are what a driver author
|
|
118
|
+
// re-uses, and a deep import into `src/` would make each of them a compatibility promise anyway.
|
|
119
|
+
export type { E2eDriverOptions } from './e2e-driver';
|
|
120
|
+
export { e2eFixtures, installE2eDriver } from './e2e-driver';
|
|
121
|
+
export {
|
|
122
|
+
E2eEvaluateCapturedError,
|
|
123
|
+
E2eEvaluateThrewError,
|
|
124
|
+
E2eEvaluateUnsupportedError,
|
|
125
|
+
E2eLocatorAmbiguousError,
|
|
126
|
+
E2eLocatorEmptyError,
|
|
127
|
+
E2eServiceWorkerAbsentError,
|
|
128
|
+
} from './e2e-errors';
|
|
129
|
+
export type { EvaluablePage } from './e2e-evaluate';
|
|
130
|
+
export { closureSource, evaluateClosure, evaluateExpression } from './e2e-evaluate';
|
|
131
|
+
export type { LocatablePage } from './e2e-locator';
|
|
132
|
+
export { e2eLocator, resetLocatorMarks } from './e2e-locator';
|
|
133
|
+
export type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
|
|
134
|
+
export { DEFAULT_E2E_TIMEOUT_MS, DEFAULT_SERVICE_WORKER_TIMEOUT_MS, e2ePage } from './e2e-page';
|
|
135
|
+
export type { E2eResolution, E2eSelection } from './e2e-selection';
|
|
136
|
+
export {
|
|
137
|
+
MARK_ATTRIBUTE,
|
|
138
|
+
markSelector,
|
|
139
|
+
selectionCall,
|
|
140
|
+
selectionExpression,
|
|
141
|
+
unmarkExpression,
|
|
142
|
+
} from './e2e-selection';
|
|
116
143
|
export type { ErrorCatalog } from './error-catalog';
|
|
117
144
|
export {
|
|
118
145
|
buildErrorCatalog,
|
|
@@ -248,6 +275,11 @@ export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from
|
|
|
248
275
|
export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
|
|
249
276
|
export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
|
|
250
277
|
export { COMMANDS, cliVersion, commandFor, SPECS } from './registry';
|
|
278
|
+
export type { SchemaDifference, SchemaDirection, SchemaPart } from './schema-diff';
|
|
279
|
+
export { diffDeclaredSchema } from './schema-diff';
|
|
280
|
+
// The drift a hash cannot see, and the composition both the gate step and `x doctor` read.
|
|
281
|
+
export type { DeclaredEntities } from './schema-drift';
|
|
282
|
+
export { checkMigrationDrift, checkSnapshotDrift } from './schema-drift';
|
|
251
283
|
export { FrameworkSchemaFailedError } from './schema-errors';
|
|
252
284
|
export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
|
|
253
285
|
export {
|
package/src/mcp-errors.ts
CHANGED
|
@@ -64,6 +64,20 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
64
64
|
'x help shot --json # the cause lists every request the state must answer under routes',
|
|
65
65
|
X_SHOT_ISLAND_MISSING:
|
|
66
66
|
'x help shot --json # every absent picture carries its own named refusal in the run above',
|
|
67
|
+
// The six e2e-driver codes. Every one of them is raised inside a running suite, so the runnable
|
|
68
|
+
// half is the command that re-runs that suite — the cause already names the closure, the locator
|
|
69
|
+
// call or the budget, and no `x` command can edit a test for its author.
|
|
70
|
+
X_E2E_EVALUATE_UNSUPPORTED:
|
|
71
|
+
'x test e2e --json # the cause quotes the closure; page.evaluate takes a zero-parameter arrow',
|
|
72
|
+
X_E2E_EVALUATE_CAPTURED:
|
|
73
|
+
'x test e2e --json # the fix line names the binding to inline into the closure',
|
|
74
|
+
X_E2E_EVALUATE_THREW:
|
|
75
|
+
'x dev --json # then run the expression the cause quotes in the browser console; the throw is the page\u2019s',
|
|
76
|
+
X_E2E_LOCATOR_EMPTY:
|
|
77
|
+
'x test e2e --json # the fix line carries the toBeVisible() assertion to await first',
|
|
78
|
+
X_E2E_LOCATOR_AMBIGUOUS:
|
|
79
|
+
'x test e2e --json # the fix line carries the same call with .first() on it',
|
|
80
|
+
X_E2E_SERVICE_WORKER_ABSENT: 'x build --target static --json',
|
|
67
81
|
X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
|
|
68
82
|
X_GH_NOT_AUTHENTICATED: 'gh auth login',
|
|
69
83
|
X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',
|
|
@@ -142,6 +156,18 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
142
156
|
// so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
|
|
143
157
|
// reachability and drift, and is already this table's answer for X_DB_STUDIO_FAILED.
|
|
144
158
|
X_DB_GEN_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
|
|
159
|
+
// Both directions resolve through the same command, and the CAUSE is what differs: one names a
|
|
160
|
+
// declaration the migrations never carried, the other one they carry and nothing declares. The
|
|
161
|
+
// narrowing is deliberately not a second command — there is only one generator.
|
|
162
|
+
X_DB_SCHEMA_UNMIGRATED:
|
|
163
|
+
'x db gen "add the declaration the cause names" --json # then x db migrate --json',
|
|
164
|
+
X_DB_SCHEMA_UNDECLARED:
|
|
165
|
+
'x db gen "drop the declaration the cause names" --json # or re-declare it on the entity',
|
|
166
|
+
// Not `x db gen`: regenerating is exactly what DISCARDS these statements, so the one command
|
|
167
|
+
// that must not be offered here is the one every other db code answers with. The gate is what
|
|
168
|
+
// reproduces the finding, and the finding names the file and the header line to add.
|
|
169
|
+
X_MIGRATION_UNGENERATABLE:
|
|
170
|
+
'x verify --only drift --json # then add the `-- ungeneratable: <n>` header line the finding names',
|
|
145
171
|
X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
|
|
146
172
|
X_DB_BRANCH_FAILED: 'x db branch ls --json',
|
|
147
173
|
X_DB_STUDIO_FAILED: 'x doctor --json',
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Single responsibility: what two GENERATED schema snapshots disagree about, as data. One side is
|
|
2
|
+
// `snapshotOf(describeEntities())` — what the app declares right now — and the other is the
|
|
3
|
+
// `.snapshot.json` the newest migration wrote down.
|
|
4
|
+
//
|
|
5
|
+
// Not `@ultimat3/db`'s `diffSchema`, and it never can be: that one compares a LIVE catalog to a
|
|
6
|
+
// snapshot, so a predicate, a type name and a default are the server's own rewriting and are
|
|
7
|
+
// deliberately left uncompared. Here both sides come out of `snapshotOf`, which is what makes a
|
|
8
|
+
// check, a default and a column type comparable at all — and those are exactly the three the
|
|
9
|
+
// `drift` gate step could not see.
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CheckDescription,
|
|
13
|
+
ColumnDescription,
|
|
14
|
+
ForeignKeyDescription,
|
|
15
|
+
IndexDescription,
|
|
16
|
+
SchemaDescription,
|
|
17
|
+
TableDescription,
|
|
18
|
+
} from '@ultimat3/db';
|
|
19
|
+
import { indexMethodOf } from '@ultimat3/db';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Which side holds a declaration the other does not, and they are two different repairs.
|
|
23
|
+
* `unmigrated` means the app declares it and no migration carries it, so no database will ever
|
|
24
|
+
* get it. `undeclared` means a migration recorded it and nothing in source declares it any more.
|
|
25
|
+
*/
|
|
26
|
+
export type SchemaDirection = 'unmigrated' | 'undeclared';
|
|
27
|
+
|
|
28
|
+
/** The part of a table a difference is about. Rendered into the cause verbatim. */
|
|
29
|
+
export type SchemaPart = 'table' | 'column' | 'index' | 'foreign key' | 'check';
|
|
30
|
+
|
|
31
|
+
export interface SchemaDifference {
|
|
32
|
+
readonly direction: SchemaDirection;
|
|
33
|
+
readonly part: SchemaPart;
|
|
34
|
+
readonly table: string;
|
|
35
|
+
/** The column, index, constraint or table name. */
|
|
36
|
+
readonly name: string;
|
|
37
|
+
/** One line naming what differs — it reaches a `cause:`, so it never spans lines. */
|
|
38
|
+
readonly detail: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DECLARED_ONLY = 'is declared by the entities and no migration recorded it';
|
|
42
|
+
const RECORDED_ONLY = 'was recorded by the newest migration and no entity declares it';
|
|
43
|
+
|
|
44
|
+
const columns = (names: readonly string[]): string => names.join(', ');
|
|
45
|
+
|
|
46
|
+
/** A default is SQL or nothing at all, and "nothing" has to read as a value in the sentence. */
|
|
47
|
+
const defaultSql = (value: string | null): string => (value === null ? 'no default' : value);
|
|
48
|
+
|
|
49
|
+
function columnDetail(
|
|
50
|
+
declared: ColumnDescription,
|
|
51
|
+
recorded: ColumnDescription,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
if (declared.dataType !== recorded.dataType) {
|
|
54
|
+
return `is declared ${declared.dataType} and was recorded ${recorded.dataType}`;
|
|
55
|
+
}
|
|
56
|
+
if (declared.nullable !== recorded.nullable) {
|
|
57
|
+
return declared.nullable
|
|
58
|
+
? 'is declared nullable and was recorded not null'
|
|
59
|
+
: 'is declared not null and was recorded nullable';
|
|
60
|
+
}
|
|
61
|
+
if (declared.default !== recorded.default) {
|
|
62
|
+
return `is declared ${defaultSql(declared.default)} and was recorded ${defaultSql(recorded.default)}`;
|
|
63
|
+
}
|
|
64
|
+
// Absent and absent agree; absent against an expression is a column the database computes on one
|
|
65
|
+
// side and a writer supplies on the other, which is a `23502` on the first insert.
|
|
66
|
+
if ((declared.generated ?? null) !== (recorded.generated ?? null)) {
|
|
67
|
+
return `is declared generated as ${declared.generated ?? 'nothing'} and was recorded ${
|
|
68
|
+
recorded.generated ?? 'not generated'
|
|
69
|
+
}`;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Absent `using` is `btree` and absent `order` is `asc` — both are Postgres' own defaults and what
|
|
76
|
+
* every index recorded before those fields existed is. Read literally, every app with a sidecar
|
|
77
|
+
* from before them would report a difference on every index it has.
|
|
78
|
+
*/
|
|
79
|
+
function indexDetail(declared: IndexDescription, recorded: IndexDescription): string | undefined {
|
|
80
|
+
if (columns(declared.columns) !== columns(recorded.columns)) {
|
|
81
|
+
return `is declared over (${columns(declared.columns)}) and was recorded over (${columns(recorded.columns)})`;
|
|
82
|
+
}
|
|
83
|
+
if (declared.unique !== recorded.unique) {
|
|
84
|
+
return declared.unique
|
|
85
|
+
? 'is declared unique and was recorded non-unique'
|
|
86
|
+
: 'is declared non-unique and was recorded unique';
|
|
87
|
+
}
|
|
88
|
+
if (indexMethodOf(declared) !== indexMethodOf(recorded)) {
|
|
89
|
+
return `is declared ${indexMethodOf(declared)} and was recorded ${indexMethodOf(recorded)}`;
|
|
90
|
+
}
|
|
91
|
+
if ((declared.order ?? 'asc') !== (recorded.order ?? 'asc')) {
|
|
92
|
+
return `is declared ${declared.order ?? 'asc'} and was recorded ${recorded.order ?? 'asc'}`;
|
|
93
|
+
}
|
|
94
|
+
if (declared.where !== recorded.where) {
|
|
95
|
+
return `is declared ${declared.where === null ? 'over every row' : `where ${declared.where}`} and was recorded ${
|
|
96
|
+
recorded.where === null ? 'over every row' : `where ${recorded.where}`
|
|
97
|
+
}`;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function keyDetail(
|
|
103
|
+
declared: ForeignKeyDescription,
|
|
104
|
+
recorded: ForeignKeyDescription,
|
|
105
|
+
): string | undefined {
|
|
106
|
+
const target = (key: ForeignKeyDescription): string =>
|
|
107
|
+
`(${columns(key.columns)}) -> "${key.referencedTable}" (${columns(key.referencedColumns)})`;
|
|
108
|
+
if (target(declared) !== target(recorded)) {
|
|
109
|
+
return `is declared ${target(declared)} and was recorded ${target(recorded)}`;
|
|
110
|
+
}
|
|
111
|
+
// `null` is "no rule declared", which Postgres records as `no action` — a real answer, and the
|
|
112
|
+
// difference between an orphan row and a cascading delete.
|
|
113
|
+
if (declared.onDelete !== recorded.onDelete) {
|
|
114
|
+
return `is declared on delete ${declared.onDelete ?? 'unset'} and was recorded on delete ${
|
|
115
|
+
recorded.onDelete ?? 'unset'
|
|
116
|
+
}`;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const checkDetail = (declared: CheckDescription, recorded: CheckDescription): string | undefined =>
|
|
122
|
+
declared.expression === recorded.expression
|
|
123
|
+
? undefined
|
|
124
|
+
: `is declared "${declared.expression}" and was recorded "${recorded.expression}"`;
|
|
125
|
+
|
|
126
|
+
interface ListComparison<T> {
|
|
127
|
+
readonly part: SchemaPart;
|
|
128
|
+
readonly table: string;
|
|
129
|
+
readonly declared: readonly T[];
|
|
130
|
+
readonly recorded: readonly T[];
|
|
131
|
+
readonly key: (item: T) => string;
|
|
132
|
+
/** What differs between two items of the same name, or `undefined` when they agree. */
|
|
133
|
+
readonly detail: (declared: T, recorded: T) => string | undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Both directions of one named list, in one pass. A `Map` and not an object literal: a constraint
|
|
138
|
+
* name is data, and a computed read of a `Record<…>` answers an `Object.prototype` member for a
|
|
139
|
+
* table someone called `constructor`.
|
|
140
|
+
*
|
|
141
|
+
* A CHANGED item is reported as `unmigrated`, never as its own direction: the declaration is the
|
|
142
|
+
* intent and a migration that recorded something else is the one that is behind.
|
|
143
|
+
*/
|
|
144
|
+
function compareList<T>(comparison: ListComparison<T>): SchemaDifference[] {
|
|
145
|
+
const { part, table } = comparison;
|
|
146
|
+
const out: SchemaDifference[] = [];
|
|
147
|
+
const recorded = new Map(comparison.recorded.map((item) => [comparison.key(item), item]));
|
|
148
|
+
for (const item of comparison.declared) {
|
|
149
|
+
const name = comparison.key(item);
|
|
150
|
+
const counterpart = recorded.get(name);
|
|
151
|
+
if (counterpart === undefined) {
|
|
152
|
+
out.push({ direction: 'unmigrated', part, table, name, detail: DECLARED_ONLY });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const detail = comparison.detail(item, counterpart);
|
|
156
|
+
if (detail !== undefined) out.push({ direction: 'unmigrated', part, table, name, detail });
|
|
157
|
+
}
|
|
158
|
+
const declared = new Set(comparison.declared.map(comparison.key));
|
|
159
|
+
for (const item of comparison.recorded) {
|
|
160
|
+
const name = comparison.key(item);
|
|
161
|
+
if (declared.has(name)) continue;
|
|
162
|
+
out.push({ direction: 'undeclared', part, table, name, detail: RECORDED_ONLY });
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `checks` is absent — never `[]` — on a table that declares none, deliberately, so a sidecar
|
|
169
|
+
* written before the field existed reads as "nothing recorded" rather than "recorded none". Both
|
|
170
|
+
* sides normalise to empty here for the same reason: a table that never had a constraint must not
|
|
171
|
+
* report one, and a table that has nine of them must.
|
|
172
|
+
*/
|
|
173
|
+
function compareTable(declared: TableDescription, recorded: TableDescription): SchemaDifference[] {
|
|
174
|
+
const table = declared.name;
|
|
175
|
+
const out: SchemaDifference[] = [];
|
|
176
|
+
// Read into locals first, and NOT named `…Key`: `bun run secret-compare` matches an operand
|
|
177
|
+
// whose NAME says it holds a credential, and a row's identity is the opposite of a secret — it
|
|
178
|
+
// is in every URL the app serves.
|
|
179
|
+
const declaredIdentity = columns(declared.primaryKey);
|
|
180
|
+
const recordedIdentity = columns(recorded.primaryKey);
|
|
181
|
+
if (declaredIdentity !== recordedIdentity) {
|
|
182
|
+
out.push({
|
|
183
|
+
direction: 'unmigrated',
|
|
184
|
+
part: 'table',
|
|
185
|
+
table,
|
|
186
|
+
name: table,
|
|
187
|
+
detail: `declares primary key (${columns(declared.primaryKey)}) and the migration recorded (${columns(recorded.primaryKey)})`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
out.push(
|
|
191
|
+
...compareList({
|
|
192
|
+
part: 'column',
|
|
193
|
+
table,
|
|
194
|
+
declared: declared.columns,
|
|
195
|
+
recorded: recorded.columns,
|
|
196
|
+
key: (column) => column.name,
|
|
197
|
+
detail: columnDetail,
|
|
198
|
+
}),
|
|
199
|
+
...compareList({
|
|
200
|
+
part: 'index',
|
|
201
|
+
table,
|
|
202
|
+
declared: declared.indexes,
|
|
203
|
+
recorded: recorded.indexes,
|
|
204
|
+
key: (index) => index.name,
|
|
205
|
+
detail: indexDetail,
|
|
206
|
+
}),
|
|
207
|
+
...compareList({
|
|
208
|
+
part: 'foreign key',
|
|
209
|
+
table,
|
|
210
|
+
declared: declared.foreignKeys,
|
|
211
|
+
recorded: recorded.foreignKeys,
|
|
212
|
+
key: (key) => key.name,
|
|
213
|
+
detail: keyDetail,
|
|
214
|
+
}),
|
|
215
|
+
...compareList({
|
|
216
|
+
part: 'check',
|
|
217
|
+
table,
|
|
218
|
+
declared: declared.checks ?? [],
|
|
219
|
+
recorded: recorded.checks ?? [],
|
|
220
|
+
key: (check) => check.name,
|
|
221
|
+
detail: checkDetail,
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const PART_ORDER: readonly SchemaPart[] = ['table', 'column', 'index', 'foreign key', 'check'];
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Pure and total: the same two snapshots always produce the same ordered report, so a diff of two
|
|
231
|
+
* gate runs on two machines is readable.
|
|
232
|
+
*
|
|
233
|
+
* A table present on only one side is ONE difference and the columns under it are not descended
|
|
234
|
+
* into — the repair is a single statement, and a finding per column would be twelve instructions
|
|
235
|
+
* for it.
|
|
236
|
+
*/
|
|
237
|
+
export function diffDeclaredSchema(
|
|
238
|
+
declared: SchemaDescription,
|
|
239
|
+
recorded: SchemaDescription,
|
|
240
|
+
): readonly SchemaDifference[] {
|
|
241
|
+
const out: SchemaDifference[] = [];
|
|
242
|
+
const byName = new Map(recorded.tables.map((table) => [table.name, table]));
|
|
243
|
+
for (const table of declared.tables) {
|
|
244
|
+
const counterpart = byName.get(table.name);
|
|
245
|
+
if (counterpart === undefined) {
|
|
246
|
+
out.push({
|
|
247
|
+
direction: 'unmigrated',
|
|
248
|
+
part: 'table',
|
|
249
|
+
table: table.name,
|
|
250
|
+
name: table.name,
|
|
251
|
+
detail: DECLARED_ONLY,
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
out.push(...compareTable(table, counterpart));
|
|
256
|
+
}
|
|
257
|
+
const declaredTables = new Set(declared.tables.map((table) => table.name));
|
|
258
|
+
for (const table of recorded.tables) {
|
|
259
|
+
if (declaredTables.has(table.name)) continue;
|
|
260
|
+
out.push({
|
|
261
|
+
direction: 'undeclared',
|
|
262
|
+
part: 'table',
|
|
263
|
+
table: table.name,
|
|
264
|
+
name: table.name,
|
|
265
|
+
detail: RECORDED_ONLY,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
out.sort((a, b) => {
|
|
269
|
+
if (a.table !== b.table) return a.table < b.table ? -1 : 1;
|
|
270
|
+
if (a.part !== b.part) return PART_ORDER.indexOf(a.part) - PART_ORDER.indexOf(b.part);
|
|
271
|
+
if (a.direction !== b.direction) return a.direction < b.direction ? -1 : 1;
|
|
272
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
273
|
+
});
|
|
274
|
+
return out;
|
|
275
|
+
}
|