@ultimat3/cli 20.2.1 → 22.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 +84 -1582
- package/README.md +40 -2
- package/package.json +31 -31
- package/src/api-registration.ts +124 -0
- package/src/app-artifacts.ts +39 -0
- package/src/app-boundaries.ts +5 -10
- package/src/app-env.ts +2 -2
- package/src/app-load.ts +37 -5
- package/src/app-openapi.ts +27 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-root.ts +1 -1
- package/src/async-pages.ts +32 -0
- package/src/bin.ts +7 -1
- package/src/boundary-findings.ts +36 -0
- package/src/browser-launcher-fake-html.ts +80 -0
- package/src/browser-launcher-fake.ts +165 -0
- package/src/browser-launcher-port.ts +159 -0
- package/src/browser-launcher.ts +26 -133
- package/src/budgets.ts +56 -13
- package/src/build-errors.ts +54 -0
- package/src/cdp-shot-a11y.ts +77 -0
- package/src/cdp-shot-clock.ts +14 -0
- package/src/cdp-shot-driver.ts +150 -0
- package/src/cdp-shot-element.ts +147 -0
- package/src/cdp-shot-errors.ts +62 -0
- package/src/cdp-shot-keys.ts +152 -0
- package/src/cdp-shot-page.ts +230 -0
- package/src/cdp-shot-watch.ts +241 -0
- package/src/cmd-affected-spec.ts +24 -0
- package/src/cmd-affected.ts +3 -19
- package/src/cmd-build-spec.ts +16 -0
- package/src/cmd-build.ts +46 -20
- package/src/cmd-ci-spec.ts +24 -0
- package/src/cmd-ci.ts +17 -21
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db-spec.ts +99 -0
- package/src/cmd-db.ts +15 -94
- package/src/cmd-deploy-helm.ts +136 -0
- package/src/cmd-deploy-spec.ts +40 -0
- package/src/cmd-deploy.ts +117 -42
- package/src/cmd-dev-spec.ts +25 -0
- package/src/cmd-dev.ts +78 -122
- package/src/cmd-docs-spec.ts +16 -0
- package/src/cmd-docs.ts +2 -11
- package/src/cmd-doctor-spec.ts +21 -0
- package/src/cmd-doctor.ts +4 -18
- package/src/cmd-env-spec.ts +18 -0
- package/src/cmd-env.ts +4 -13
- package/src/cmd-errors-spec.ts +23 -0
- package/src/cmd-errors.ts +3 -17
- package/src/cmd-fix-spec.ts +18 -0
- package/src/cmd-fix.ts +4 -13
- package/src/cmd-generate-spec.ts +31 -0
- package/src/cmd-generate.ts +46 -42
- package/src/cmd-i18n-spec.ts +16 -0
- package/src/cmd-i18n.ts +7 -14
- package/src/cmd-jobs-spec.ts +56 -0
- package/src/cmd-jobs.ts +3 -50
- package/src/cmd-manifest-spec.ts +15 -0
- package/src/cmd-manifest.ts +19 -20
- package/src/cmd-mcp-spec.ts +21 -0
- package/src/cmd-mcp.ts +2 -15
- package/src/cmd-new-spec.ts +33 -0
- package/src/cmd-new.ts +38 -33
- package/src/cmd-policy-spec.ts +13 -0
- package/src/cmd-policy.ts +2 -9
- package/src/cmd-pr-spec.ts +39 -0
- package/src/cmd-pr.ts +5 -65
- package/src/cmd-registries-spec.ts +32 -0
- package/src/cmd-registries.ts +4 -25
- package/src/cmd-routes-spec.ts +12 -0
- package/src/cmd-routes.ts +2 -7
- package/src/cmd-secrets-spec.ts +19 -0
- package/src/cmd-secrets.ts +22 -34
- package/src/cmd-shot-island.ts +12 -13
- package/src/cmd-shot-spec.ts +53 -0
- package/src/cmd-shot.ts +11 -60
- package/src/cmd-tasks-spec.ts +21 -0
- package/src/cmd-tasks.ts +2 -16
- package/src/cmd-test-spec.ts +54 -0
- package/src/cmd-test.ts +4 -49
- package/src/cmd-verify-spec.ts +28 -0
- package/src/cmd-verify.ts +3 -23
- package/src/db-seed.ts +4 -2
- package/src/dev-dashboard.ts +4 -4
- package/src/dev-lock.ts +18 -2
- package/src/dev-port.ts +21 -0
- package/src/dev-route-table.ts +119 -0
- package/src/dispatch.ts +12 -5
- package/src/document-styles.ts +1 -1
- package/src/drift.ts +4 -18
- package/src/error-catalog.ts +16 -9
- package/src/error-codes.ts +33 -28
- package/src/error-pages.ts +19 -7
- package/src/error-unthrown.ts +130 -0
- package/src/errors.ts +22 -29
- package/src/favicon.ts +2 -2
- package/src/fix-imports.ts +1 -1
- package/src/fix-scan.ts +2 -9
- package/src/flag-reads.ts +1 -2
- package/src/foreign-text.ts +36 -0
- package/src/framework-schema.ts +5 -6
- package/src/generate-feature.ts +42 -0
- package/src/generate-files.ts +7 -3
- package/src/generate-grants.ts +83 -0
- package/src/generate-kinds.ts +56 -4
- package/src/i18n-index.ts +59 -10
- package/src/icon-assets.ts +1 -1
- package/src/image-prepare.ts +14 -0
- package/src/import-scan.ts +63 -0
- package/src/index.ts +67 -334
- package/src/invocation-flags.ts +26 -0
- package/src/island-bundle.ts +38 -11
- package/src/island-capture.ts +6 -6
- package/src/island-harness-script.ts +6 -2
- package/src/island-realtime.ts +98 -0
- package/src/island-shot.ts +5 -0
- package/src/island-store.ts +131 -0
- package/src/island-verdict.ts +2 -2
- package/src/job-registration.ts +42 -0
- package/src/jobs-driver.ts +2 -2
- package/src/live-routes.ts +82 -42
- package/src/load-findings.ts +51 -0
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +30 -26
- package/src/mcp-host.ts +27 -12
- package/src/mcp-ui-diff.ts +27 -0
- package/src/mcp-ui-inspect.ts +4 -4
- package/src/mcp-ui-interact.ts +21 -15
- package/src/mcp-ui.ts +19 -15
- package/src/measure-database.ts +73 -0
- package/src/measure-paths.ts +74 -0
- package/src/measure-scope.ts +74 -0
- package/src/messages.ts +1 -3
- package/src/metrics-endpoint.ts +1 -1
- package/src/otlp-export.ts +1 -1
- package/src/output.ts +6 -0
- package/src/page-sync.ts +54 -0
- package/src/permission-grants.ts +86 -0
- package/src/prerender-out.ts +25 -0
- package/src/prerender.ts +121 -102
- package/src/pwa-artifacts.ts +3 -3
- package/src/realtime-browser-probe-fixture.ts +2 -2
- package/src/reexport-manifest.ts +2 -1
- package/src/registry.ts +80 -56
- package/src/role-realtime.ts +36 -0
- package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
- package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
- package/src/role-start-types.ts +112 -0
- package/src/{dev-roles.ts → role-start.ts} +42 -115
- package/src/{dev-sync.ts → role-sync.ts} +23 -11
- package/src/root-env.ts +67 -0
- package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
- package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
- package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
- package/src/runtime-jobs.ts +87 -0
- package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
- package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
- package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
- package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
- package/src/runtime-realtime.ts +55 -0
- package/src/{dev-render.ts → runtime-render.ts} +119 -20
- package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
- package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
- package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
- package/src/scaffold-fixture.ts +28 -6
- package/src/scaffold-typecheck.ts +6 -3
- package/src/schema-drift.ts +7 -1
- package/src/script-csp.ts +5 -2
- package/src/secrets-rotation.ts +59 -0
- package/src/serve-boot.ts +192 -0
- package/src/serve-drain.ts +24 -0
- package/src/serve-entry.ts +6 -0
- package/src/serve-env.ts +116 -0
- package/src/serve-types.ts +55 -0
- package/src/serve.ts +44 -338
- package/src/shot-server.ts +2 -2
- package/src/shot-settle.ts +10 -1
- package/src/shot-theme.ts +3 -3
- package/src/shot-verdict.ts +16 -7
- package/src/signal-shred.ts +27 -0
- package/src/solid-loader.ts +26 -2
- package/src/static-report.ts +8 -1
- package/src/sw-artifacts.ts +13 -3
- package/src/sync-url.ts +31 -0
- package/src/templates/action.ts +30 -16
- package/src/templates/entity.ts +12 -7
- package/src/templates/index.ts +1 -1
- package/src/templates/job.ts +10 -7
- package/src/templates/policy.ts +20 -2
- package/src/templates/resource-create.ts +127 -0
- package/src/templates/resource-form-island.ts +76 -30
- package/src/templates/resource.ts +10 -4
- package/src/templates/route.ts +3 -0
- package/src/templates/scaffold-app.ts +4 -1
- package/src/templates/scaffold-auth.ts +3 -1
- package/src/templates/scaffold-container-compose.ts +184 -0
- package/src/templates/scaffold-container.ts +25 -145
- package/src/templates/scaffold-dashboard-example.ts +2 -2
- package/src/templates/scaffold-db-package.ts +16 -3
- package/src/templates/scaffold-demo-org.ts +41 -0
- package/src/templates/scaffold-entries.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm-templates.ts +66 -7
- package/src/templates/scaffold-helm.ts +27 -5
- package/src/templates/scaffold-i18n.ts +15 -10
- package/src/templates/scaffold-repo.ts +13 -10
- package/src/templates/scaffold-roles.ts +38 -10
- package/src/templates/slice-foundation.ts +1 -1
- package/src/templates/wrap.ts +4 -1
- package/src/test-passes.ts +2 -1
- package/src/test-workers.ts +26 -0
- package/src/ts-scan.ts +3 -6
- package/src/tsconfig-references.ts +1 -2
- package/src/verify-checks.ts +41 -39
- package/src/verify-e2e.ts +41 -0
- package/src/verify-run.ts +115 -50
- package/src/verify-step.ts +3 -3
- package/src/verify-tests.ts +22 -30
- package/src/verify-typecheck.ts +28 -0
- package/src/web-binding.ts +2 -2
- package/src/worker-bundle.ts +192 -0
- package/src/workspace-graph.ts +10 -33
- package/src/cdp-browser.ts +0 -100
- package/src/cdp-connection.ts +0 -211
- package/src/cdp-e2e-page.ts +0 -209
- package/src/cdp-errors.ts +0 -56
- package/src/cdp-launch.ts +0 -139
- package/src/e2e-dom-fixture.ts +0 -117
- package/src/e2e-driver.ts +0 -97
- package/src/e2e-errors.ts +0 -103
- package/src/e2e-evaluate.ts +0 -156
- package/src/e2e-locator.ts +0 -86
- package/src/e2e-page.ts +0 -150
- package/src/e2e-selection.ts +0 -182
- package/src/measurement-actor.ts +0 -26
- /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
package/src/live-routes.ts
CHANGED
|
@@ -12,29 +12,35 @@ import { join, posix } from 'node:path';
|
|
|
12
12
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
13
13
|
import type { RouteEntry } from '@ultimat3/render';
|
|
14
14
|
import { ISLAND_EXTENSION, routeEntries } from '@ultimat3/render';
|
|
15
|
+
import { discoverIslands } from './island-bundle';
|
|
15
16
|
import type { Finding } from './output';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
* The exports that only work
|
|
19
|
-
* or
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
19
|
+
* The exports that only work in a booted browser page: each one reads the page's store, its socket
|
|
20
|
+
* or its connection, so a module naming one needs an island to have run it. `hasPageSocket` is
|
|
21
|
+
* deliberately absent — it IS the guard — and so is `installRealtime`, which the island bundle
|
|
22
|
+
* writes for the author (plan 101, slice 14).
|
|
22
23
|
*/
|
|
23
24
|
export const LIVE_HOOKS = [
|
|
24
|
-
'
|
|
25
|
-
'liveHookFor',
|
|
25
|
+
'useQuery',
|
|
26
26
|
'useConnection',
|
|
27
27
|
'useMutation',
|
|
28
28
|
'useMutationQueue',
|
|
29
|
+
'useRecord',
|
|
30
|
+
'useChannel',
|
|
29
31
|
] as const;
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* NOT an escape hatch, `As of 2026-09-22`: `hasPageSocket()` answers false on the server, every
|
|
35
|
+
* time, so a module that guards on it and never runs in a browser renders nothing forever. It was
|
|
36
|
+
* exempted here — "a module that asks has handled the absence" — and that is exactly how
|
|
37
|
+
* `examples/dummy`'s update banner, in a layout no island imports, never showed "A new version is
|
|
38
|
+
* ready.". It is a browser-only read like any hook, so it is reported like one.
|
|
36
39
|
*/
|
|
37
|
-
const GUARD = '
|
|
40
|
+
const GUARD = 'hasPageSocket';
|
|
41
|
+
|
|
42
|
+
/** Everything that only means something in a booted browser page: the hooks, and the guard. */
|
|
43
|
+
const BROWSER_ONLY: readonly string[] = [GUARD, ...LIVE_HOOKS];
|
|
38
44
|
|
|
39
45
|
/** Value imports only: `import type` is erased, so it boots nothing and needs nothing. */
|
|
40
46
|
const REALTIME_IMPORT = /import\s+([^;]*?)from\s*['"]@ultimat3\/realtime(?:\/[\w-]+)?['"]/g;
|
|
@@ -45,18 +51,14 @@ const bindingsOf = (clause: string): readonly string[] =>
|
|
|
45
51
|
.map((entry) => entry.split(/\bas\b/)[0]?.trim() ?? '')
|
|
46
52
|
.filter((name) => name.length > 0 && !name.startsWith('type '));
|
|
47
53
|
|
|
48
|
-
/**
|
|
49
|
-
* Which live hooks one module imports, or `[]` — including for a module that guards, which is a
|
|
50
|
-
* per-FILE verdict on purpose: the guard is written next to the read it protects.
|
|
51
|
-
*/
|
|
54
|
+
/** Which browser-only reads one module imports — the hooks and `hasPageSocket` — or `[]`. */
|
|
52
55
|
export function liveHooksIn(source: string): readonly string[] {
|
|
53
56
|
const hooks: string[] = [];
|
|
54
57
|
for (const match of source.matchAll(REALTIME_IMPORT)) {
|
|
55
58
|
const clause = match[1] ?? '';
|
|
56
59
|
if (clause.trimStart().startsWith('type ')) continue;
|
|
57
60
|
const names = bindingsOf(clause);
|
|
58
|
-
if (names.includes(
|
|
59
|
-
for (const hook of LIVE_HOOKS) if (names.includes(hook)) hooks.push(hook);
|
|
61
|
+
for (const hook of BROWSER_ONLY) if (names.includes(hook)) hooks.push(hook);
|
|
60
62
|
}
|
|
61
63
|
return hooks;
|
|
62
64
|
}
|
|
@@ -88,14 +90,18 @@ export interface LiveReach {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
/**
|
|
91
|
-
* Walk
|
|
93
|
+
* Walk a module's own import graph and answer the first thing `probe` finds in it.
|
|
92
94
|
*
|
|
93
95
|
* Relative specifiers only. A bare one resolves through `node_modules` or a workspace name, and
|
|
94
96
|
* following either would mean guessing which package a name came from — the limit `fix-imports.ts`
|
|
95
97
|
* records for the same walk. So this UNDER-reports rather than over-reports: a finding here is
|
|
96
98
|
* always a real one, which is what lets the rule ship with no pin table.
|
|
97
99
|
*/
|
|
98
|
-
export async function
|
|
100
|
+
export async function firstInGraph<T>(
|
|
101
|
+
root: string,
|
|
102
|
+
file: string,
|
|
103
|
+
probe: (source: string, path: string) => T | undefined,
|
|
104
|
+
): Promise<T | undefined> {
|
|
99
105
|
const seen = new Set<string>();
|
|
100
106
|
const queue = [file];
|
|
101
107
|
while (queue.length > 0) {
|
|
@@ -104,8 +110,8 @@ export async function liveReachOf(root: string, file: string): Promise<LiveReach
|
|
|
104
110
|
seen.add(next);
|
|
105
111
|
const module = await readModule(root, next);
|
|
106
112
|
if (module === undefined) continue;
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
113
|
+
const found = probe(module.source, module.path);
|
|
114
|
+
if (found !== undefined) return found;
|
|
109
115
|
const loader = module.path.endsWith('x') ? 'tsx' : 'ts';
|
|
110
116
|
// Bun's transpiler is the parser, exactly as in `scripts/boundaries.ts`: it erases type-only
|
|
111
117
|
// imports and finds the dynamic ones, which no regex over this source could do.
|
|
@@ -117,6 +123,36 @@ export async function liveReachOf(root: string, file: string): Promise<LiveReach
|
|
|
117
123
|
return undefined;
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
/** Every module in a file's relative import graph, app-root-relative — one walk, no probe. */
|
|
127
|
+
export async function graphModules(root: string, file: string): Promise<ReadonlySet<string>> {
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
await firstInGraph(root, file, (_source, path) => {
|
|
130
|
+
seen.add(path);
|
|
131
|
+
return undefined;
|
|
132
|
+
});
|
|
133
|
+
return seen;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Every module a browser can run: the union of every island's graph in the app. */
|
|
137
|
+
async function islandModules(root: string): Promise<ReadonlySet<string>> {
|
|
138
|
+
const modules = new Set<string>();
|
|
139
|
+
for (const island of await discoverIslands(root)) {
|
|
140
|
+
for (const path of await graphModules(root, island)) modules.add(path);
|
|
141
|
+
}
|
|
142
|
+
return modules;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Each module in the route's graph that reads a browser-only name, with the first name it reads. */
|
|
146
|
+
async function browserOnlyReads(root: string, file: string): Promise<readonly LiveReach[]> {
|
|
147
|
+
const reads: LiveReach[] = [];
|
|
148
|
+
await firstInGraph(root, file, (source, path) => {
|
|
149
|
+
const hook = liveHooksIn(source)[0];
|
|
150
|
+
if (hook !== undefined) reads.push({ at: path, hook });
|
|
151
|
+
return undefined;
|
|
152
|
+
});
|
|
153
|
+
return reads;
|
|
154
|
+
}
|
|
155
|
+
|
|
120
156
|
export interface LiveRouteGap extends LiveReach {
|
|
121
157
|
readonly route: string;
|
|
122
158
|
readonly file: string;
|
|
@@ -132,28 +168,34 @@ const generatorFor = (file: string): string => {
|
|
|
132
168
|
};
|
|
133
169
|
|
|
134
170
|
/**
|
|
135
|
-
* Every
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
171
|
+
* Every browser-only read a route's SERVER graph holds that no browser will ever run. A page's
|
|
172
|
+
* imports are server-rendered — an island is reached by a `src` string, never an import — so a
|
|
173
|
+
* module there runs in a browser only if some island's own graph imports it too. Anything else
|
|
174
|
+
* renders its server state (nothing, or `loading`) forever, at 200. `hydrate: 'never'` boots no
|
|
175
|
+
* island at all, so every read on such a route is reported. One finding per MODULE: a layout every
|
|
176
|
+
* page imports is one mistake, not one per route.
|
|
139
177
|
*/
|
|
140
178
|
export async function liveRouteGaps(
|
|
141
179
|
root: string,
|
|
142
180
|
entries: readonly RouteEntry[],
|
|
143
181
|
): Promise<readonly LiveRouteGap[]> {
|
|
182
|
+
const inBrowser = await islandModules(root);
|
|
183
|
+
const reported = new Set<string>();
|
|
144
184
|
const gaps: LiveRouteGap[] = [];
|
|
145
185
|
for (const entry of entries) {
|
|
146
186
|
if (entry.surface === 'api') continue;
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
187
|
+
const never = entry.config.hydrate === 'never';
|
|
188
|
+
for (const read of await browserOnlyReads(root, entry.file)) {
|
|
189
|
+
if (reported.has(read.at) || (!never && inBrowser.has(read.at))) continue;
|
|
190
|
+
reported.add(read.at);
|
|
191
|
+
gaps.push({
|
|
192
|
+
...read,
|
|
193
|
+
route: entry.path,
|
|
194
|
+
file: entry.file,
|
|
195
|
+
hydrate: entry.config.hydrate,
|
|
196
|
+
islands: entry.islands,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
157
199
|
}
|
|
158
200
|
return gaps;
|
|
159
201
|
}
|
|
@@ -161,14 +203,12 @@ export async function liveRouteGaps(
|
|
|
161
203
|
export const liveRouteFindingFor = (gap: LiveRouteGap): Finding => ({
|
|
162
204
|
code: 'X_LIVE_ROUTE_NO_ISLAND',
|
|
163
205
|
cause:
|
|
164
|
-
`${gap.route} reads ${gap.hook}()
|
|
165
|
-
(gap.
|
|
166
|
-
|
|
167
|
-
: `declares hydrate: 'never' beside ${gap.islands.join(', ')}`) +
|
|
168
|
-
', so no module of this route ever runs in a browser: its rows have nowhere to arrive and the page renders its loading branch forever, at 200',
|
|
206
|
+
`${gap.route} reads ${gap.hook}() in ${gap.at}, which ` +
|
|
207
|
+
(gap.hydrate === 'never' ? `sits on a route declaring hydrate: 'never'` : 'no island imports') +
|
|
208
|
+
' — so it only ever runs on the server, where it answers its server state (nothing, or loading) forever, at 200',
|
|
169
209
|
fix:
|
|
170
|
-
|
|
171
|
-
|
|
210
|
+
`move it into an island: ${generatorFor(gap.file)}, import ${gap.at} from that island's mount(), and declare it with island({ src: './${posix.basename(posix.dirname(gap.file))}${ISLAND_EXTENSION}' }) in ${gap.file}` +
|
|
211
|
+
(gap.hydrate === 'never' ? `, with a hydrate other than 'never'` : ''),
|
|
172
212
|
docs: ERROR_DOCS_URL,
|
|
173
213
|
at: gap.at,
|
|
174
214
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// One cause, one step. A module that will not import was reported by up to four steps at once —
|
|
2
|
+
// `contract-diff`, `budgets`, `policy` and `manifest` all load the app, and each carried the load's
|
|
3
|
+
// findings — so one syntax error read as four red steps and four copies of one line (plan 101
|
|
4
|
+
// slice 11 i). The `manifest` step owns them: it runs in every repo, and "does the projection
|
|
5
|
+
// describe the code?" is the question a module that will not import answers first.
|
|
6
|
+
|
|
7
|
+
import { loadApp } from './app-load';
|
|
8
|
+
import type { Finding } from './output';
|
|
9
|
+
import type { StepOutcome, VerifyStepName } from './verify-step';
|
|
10
|
+
|
|
11
|
+
/** The step that reports the app's load failures. Every other step points at it. */
|
|
12
|
+
export const LOAD_OWNER: VerifyStepName = 'manifest';
|
|
13
|
+
|
|
14
|
+
/** What the app load reports, as the step sees it. */
|
|
15
|
+
export type LoadProbe = (root: string) => Promise<{ readonly findings: readonly Finding[] }>;
|
|
16
|
+
|
|
17
|
+
const keyOf = (finding: Finding): string =>
|
|
18
|
+
[finding.code, finding.at ?? '', finding.cause].join('\u0000');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `findings` without the ones the app load raised, and an `output` line saying where they went.
|
|
22
|
+
* The step's own verdict stands on what is left: a budget the broken module left unmeasured is
|
|
23
|
+
* still this step's finding, only its cause moved.
|
|
24
|
+
*/
|
|
25
|
+
export async function withoutLoadFindings(
|
|
26
|
+
root: string,
|
|
27
|
+
findings: readonly Finding[],
|
|
28
|
+
load: LoadProbe = loadApp,
|
|
29
|
+
): Promise<StepOutcome> {
|
|
30
|
+
const loaded = new Set((await load(root)).findings.map(keyOf));
|
|
31
|
+
const kept = findings.filter((finding) => !loaded.has(keyOf(finding)));
|
|
32
|
+
const moved = findings.length - kept.length;
|
|
33
|
+
return {
|
|
34
|
+
ok: kept.length === 0,
|
|
35
|
+
findings: kept,
|
|
36
|
+
...(moved === 0
|
|
37
|
+
? {}
|
|
38
|
+
: { output: `skipped: ${moved} module-load finding(s) — see ${LOAD_OWNER}` }),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The owner's half: every load finding exactly once, beside what the step found itself. */
|
|
43
|
+
export async function withLoadFindings(
|
|
44
|
+
root: string,
|
|
45
|
+
findings: readonly Finding[],
|
|
46
|
+
load: LoadProbe = loadApp,
|
|
47
|
+
): Promise<readonly Finding[]> {
|
|
48
|
+
const own = (await load(root)).findings;
|
|
49
|
+
const seen = new Set(own.map(keyOf));
|
|
50
|
+
return [...own, ...findings.filter((finding) => !seen.has(keyOf(finding)))];
|
|
51
|
+
}
|
package/src/mcp-db-target.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { tryResolveEnvironment } from '@ultimat3/core';
|
|
|
10
10
|
import { pgliteDataDir } from '@ultimat3/db';
|
|
11
11
|
import type { DatabaseTarget } from '@ultimat3/mcp';
|
|
12
12
|
import { branchNameOf, pgliteBranchName } from './db-branch';
|
|
13
|
-
import type { DevServices, Env } from './
|
|
13
|
+
import type { DevServices, Env } from './runtime-bindings';
|
|
14
14
|
import { safeUrlLabel } from './safe-url-label';
|
|
15
15
|
|
|
16
16
|
/**
|
package/src/mcp-errors.ts
CHANGED
|
@@ -54,7 +54,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
54
54
|
'x verify --json # the package-shape finding carries the dependency line to add',
|
|
55
55
|
X_PACKAGE_DUPLICATED:
|
|
56
56
|
'x i18n check --json # the finding names both copies and the package.json to pin',
|
|
57
|
-
X_SHOT_BROWSER_MISSING: '
|
|
57
|
+
X_SHOT_BROWSER_MISSING: 'export CHROME_PATH=/usr/bin/google-chrome',
|
|
58
58
|
X_UI_SHOT_ROUTE_UNKNOWN:
|
|
59
59
|
'x routes --json # then call the ui.* tool with one of its path values',
|
|
60
60
|
X_UI_SHOT_ROUTE_UNBUDGETED:
|
|
@@ -69,6 +69,12 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
69
69
|
'x routes --json # then resend ui.interact with steps that stay on one of its paths',
|
|
70
70
|
X_UI_INTERACT_STEP_FAILED:
|
|
71
71
|
'x routes --json # then run ui.inspect on the route first and copy a selector it reports with count >= 1',
|
|
72
|
+
X_SHOT_HOST_REFUSED: 'x shot /route --allow-hosts cdn.example.com --json',
|
|
73
|
+
X_SHOT_ELEMENT_MISSING:
|
|
74
|
+
'x routes --json # then run ui.inspect on the route first and copy a selector it reports with count >= 1',
|
|
75
|
+
X_SHOT_ELEMENT_UNREADY:
|
|
76
|
+
'x routes --json # then run ui.inspect on the route first and copy a selector it reports with count >= 1',
|
|
77
|
+
X_SHOT_KEY_INVALID: 'x help shot --json # a chord is modifiers then one key: Meta+K',
|
|
72
78
|
// The three `ui.diff` codes. Every capture it can compare was written by `x shot` or a `ui.*`
|
|
73
79
|
// tool under `.x/shot/`, so the runnable half is the command that writes one there.
|
|
74
80
|
X_UI_DIFF_PATH_OUTSIDE:
|
|
@@ -88,29 +94,6 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
88
94
|
'x help shot --json # the cause lists every request the state must answer under routes',
|
|
89
95
|
X_SHOT_ISLAND_MISSING:
|
|
90
96
|
'x help shot --json # every absent picture carries its own named refusal in the run above',
|
|
91
|
-
// The six e2e-driver codes. Every one of them is raised inside a running suite, so the runnable
|
|
92
|
-
// half is the command that re-runs that suite — the cause already names the closure, the locator
|
|
93
|
-
// call or the budget, and no `x` command can edit a test for its author.
|
|
94
|
-
X_E2E_EVALUATE_UNSUPPORTED:
|
|
95
|
-
'x test e2e --json # the cause quotes the closure; page.evaluate takes a zero-parameter arrow',
|
|
96
|
-
X_E2E_EVALUATE_CAPTURED:
|
|
97
|
-
'x test e2e --json # the fix line names the binding to inline into the closure',
|
|
98
|
-
X_E2E_EVALUATE_THREW:
|
|
99
|
-
'x dev --json # then run the expression the cause quotes in the browser console; the throw is the page\u2019s',
|
|
100
|
-
X_E2E_LOCATOR_EMPTY:
|
|
101
|
-
'x test e2e --json # the fix line carries the toBeVisible() assertion to await first',
|
|
102
|
-
X_E2E_LOCATOR_AMBIGUOUS:
|
|
103
|
-
'x test e2e --json # the fix line carries the same call with .first() on it',
|
|
104
|
-
X_E2E_SERVICE_WORKER_ABSENT: 'x build --target static --json',
|
|
105
|
-
// The four raw-CDP codes. `x doctor` for the missing browser, because that is the command whose
|
|
106
|
-
// whole job is reporting what this machine does not have; the other three are raised inside a
|
|
107
|
-
// running suite, so the runnable half is the command that re-runs it.
|
|
108
|
-
X_CDP_BROWSER_MISSING:
|
|
109
|
-
'x doctor --json # or set CHROME_PATH to a Chrome binary; unset, the browser-backed suite skips',
|
|
110
|
-
X_CDP_LAUNCH_FAILED:
|
|
111
|
-
'x test e2e --json # the cause carries the last lines of the browser\u2019s own stderr',
|
|
112
|
-
X_CDP_CALL_FAILED: 'x test e2e --json # the cause names the DevTools call the browser refused',
|
|
113
|
-
X_CDP_TIMEOUT: 'x test e2e --json # the cause names the call that never answered',
|
|
114
97
|
X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
|
|
115
98
|
X_GH_NOT_AUTHENTICATED: 'gh auth login',
|
|
116
99
|
X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',
|
|
@@ -122,8 +105,11 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
122
105
|
'x errors list --json # register the code in its package src/errors.ts, or move its row under "Reserved codes"',
|
|
123
106
|
X_ERROR_CODE_UNRESOLVED:
|
|
124
107
|
'x verify --json # the finding names the file, the line and the name it could not resolve',
|
|
108
|
+
X_ERROR_CODE_UNTHROWN:
|
|
109
|
+
'x errors explain X_ERROR_CODE_UNTHROWN --json # then mark the row "registered, thrown by nothing since <version>"',
|
|
125
110
|
X_CLI_UNEXPECTED: 'x doctor --json',
|
|
126
|
-
|
|
111
|
+
// `-p .`: an app root has no `references`, so the gate's own step runs this form (#450).
|
|
112
|
+
X_TYPECHECK_FAILED: 'bunx tsc -p . --pretty false',
|
|
127
113
|
X_LINT_FAILED: 'bunx biome check --write .',
|
|
128
114
|
X_TEST_FAILED: 'x test --json # the finding carries the exact bun test invocation that failed',
|
|
129
115
|
// The same two edits `vanishedSuiteFinding` names, verbatim, so both surfaces of this code hand
|
|
@@ -179,6 +165,10 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
179
165
|
X_PORT_INVALID: 'docker run -e PORT=3000 my-app:latest',
|
|
180
166
|
X_RUNTIME_DRIVER_SPLIT: 'x dev --json # the boot names the driver the app installed twice',
|
|
181
167
|
X_GENERATE_CONFLICT: 'x g route posts --force --json',
|
|
168
|
+
X_APP_NAME_EMPTY:
|
|
169
|
+
'x new my-app --json # a name with letters or digits; it becomes the directory',
|
|
170
|
+
X_APP_EMPTY:
|
|
171
|
+
'x doctor --json # then check the app root: no module under apps/ or packages/ loaded',
|
|
182
172
|
X_PORT_IN_USE: 'x dev --port 3001 --json',
|
|
183
173
|
X_DEV_ALREADY_RUNNING:
|
|
184
174
|
'x dev --json # after stopping the x dev that already owns this checkout',
|
|
@@ -218,8 +208,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
218
208
|
// consent and stays in the cause: it is the answer only for a container with a fixed argv.
|
|
219
209
|
X_SEED_ENVIRONMENT:
|
|
220
210
|
'x db seed --dry-run --json # then name the tier: x db seed <name> --tier dev --json',
|
|
211
|
+
// The finding's own `fix:` is the concrete edit (`boundary-findings.ts`): the file and the import.
|
|
221
212
|
X_BOUNDARY_SITE_TO_APP:
|
|
222
|
-
'x verify --json #
|
|
213
|
+
'x verify --only boundaries --json # each finding names the import to delete, and where',
|
|
223
214
|
X_BOUNDARY_SHARED_LEAF:
|
|
224
215
|
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
225
216
|
X_BOUNDARY_APP_TO_API:
|
|
@@ -228,6 +219,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
228
219
|
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
229
220
|
X_BOUNDARY_SERVICE_TO_HTTP:
|
|
230
221
|
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
222
|
+
X_BOUNDARY_SURFACE_IMPORT:
|
|
223
|
+
'x verify --only boundaries --json # then move the imported code into shared/',
|
|
231
224
|
// The app's own guards. All three are reported by the gate and by nothing else, so the runnable
|
|
232
225
|
// half is the gate — the narrowing behind the `#` is the edit, because only the finding knows
|
|
233
226
|
// which file in `guards/` is the one to open.
|
|
@@ -242,6 +235,17 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
242
235
|
// Render's code, thrown here by the bundler half: the cause names the specifier and the file it
|
|
243
236
|
// resolved to, and `x g island` is what puts that file where the page already says it is.
|
|
244
237
|
X_ISLAND_INVALID: 'x routes --json # the cause names the src; then: x g island <name>',
|
|
238
|
+
// The four slice-11 codes: generated code that passed the gate and failed at runtime.
|
|
239
|
+
X_PERMISSION_UNGRANTED:
|
|
240
|
+
'x verify --only policy --json # then add the permission to a role in apps/web/shared/roles.ts',
|
|
241
|
+
X_JOB_UNREGISTERED:
|
|
242
|
+
'x manifest --json # after listing the module under jobs: [...] in apps/web/api/index.ts',
|
|
243
|
+
X_FEATURE_UNKNOWN:
|
|
244
|
+
'x entities --json # then x g resource <feature> for the one the cause names',
|
|
245
|
+
X_BUDGET_PARAMS_UNDECLARED:
|
|
246
|
+
'x build --target static --json # after adding prerender() with one real path to the route',
|
|
247
|
+
X_ROUTE_ASYNC_PAGE:
|
|
248
|
+
'x verify --only budgets --json # after moving each await in Page into export const load',
|
|
245
249
|
};
|
|
246
250
|
|
|
247
251
|
const isCliCode = (code: string): code is CliErrorCode =>
|
package/src/mcp-host.ts
CHANGED
|
@@ -34,12 +34,8 @@ import { loadApp } from './app-load';
|
|
|
34
34
|
import { appManifest, policyFacts } from './app-manifest';
|
|
35
35
|
import { runVerify, VERIFY_STEPS } from './cmd-verify';
|
|
36
36
|
import { declareDevEnvironment } from './dev-environment';
|
|
37
|
-
import type { RunningServices } from './dev-runtime';
|
|
38
|
-
import { startServices } from './dev-runtime';
|
|
39
|
-
import type { DevServices, Env } from './dev-services';
|
|
40
|
-
import { resolveServices } from './dev-services';
|
|
41
37
|
import { loadCodeFixes } from './error-fixes';
|
|
42
|
-
import { CliNotImplementedError } from './errors';
|
|
38
|
+
import { BadFlagError, CliNotImplementedError } from './errors';
|
|
43
39
|
import type { Runner } from './exec';
|
|
44
40
|
import { execOutput } from './exec';
|
|
45
41
|
import { databaseTarget } from './mcp-db-target';
|
|
@@ -48,6 +44,10 @@ import { parseBunTest } from './mcp-test-output';
|
|
|
48
44
|
import { type UiCapabilities, uiCapabilities } from './mcp-ui';
|
|
49
45
|
import { readMigrations } from './migrations';
|
|
50
46
|
import { retryMemo } from './retry-memo';
|
|
47
|
+
import type { DevServices, Env } from './runtime-bindings';
|
|
48
|
+
import { resolveServices } from './runtime-bindings';
|
|
49
|
+
import type { RunningServices } from './runtime-services';
|
|
50
|
+
import { startServices } from './runtime-services';
|
|
51
51
|
import { testEnvOverrides } from './test-dotenv';
|
|
52
52
|
|
|
53
53
|
export interface DevHostInput {
|
|
@@ -180,6 +180,24 @@ export async function readOnlyRows(
|
|
|
180
180
|
return { columns, rows: kept.map((row) => columns.map((column) => row[column])), guards };
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* `bun test`'s argv for a `test.run` filter. A filter is a PATH, and one starting with `-` is a
|
|
185
|
+
* `bun test` flag — `--preload ./x.ts` loads a module into the child — so it is refused rather
|
|
186
|
+
* than forwarded: a filter is a path, and no path this suite has starts with `-`.
|
|
187
|
+
*/
|
|
188
|
+
export function testRunArgv(filter: string | undefined): readonly string[] {
|
|
189
|
+
if (filter === undefined) return ['bun', 'test'];
|
|
190
|
+
if (filter.trimStart().startsWith('-')) {
|
|
191
|
+
throw new BadFlagError({
|
|
192
|
+
flag: 'filter',
|
|
193
|
+
command: 'mcp serve',
|
|
194
|
+
reason: `test.run's filter "${filter}" starts with "-", which bun test would read as its own flag`,
|
|
195
|
+
fix: 'call test.run with a path filter such as packages/cli/src, or with no filter',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return ['bun', 'test', filter];
|
|
199
|
+
}
|
|
200
|
+
|
|
183
201
|
function capabilities(
|
|
184
202
|
input: DevHostInput,
|
|
185
203
|
lazy: LazyServices,
|
|
@@ -249,13 +267,10 @@ function capabilities(
|
|
|
249
267
|
// Same leak the CLI's own `x test`/`x verify` had: `.env.development` auto-loaded into
|
|
250
268
|
// THIS process must not ride along into the `bun test` child the dev MCP server spawns.
|
|
251
269
|
const envOverrides = testEnvOverrides(root, env);
|
|
252
|
-
const result = await runner(
|
|
253
|
-
|
|
254
|
-
{
|
|
255
|
-
|
|
256
|
-
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
257
|
-
},
|
|
258
|
-
);
|
|
270
|
+
const result = await runner(testRunArgv(filter), {
|
|
271
|
+
cwd: root,
|
|
272
|
+
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
273
|
+
});
|
|
259
274
|
return parseBunTest(execOutput(result), result.durationMs);
|
|
260
275
|
},
|
|
261
276
|
|
package/src/mcp-ui-diff.ts
CHANGED
|
@@ -81,6 +81,32 @@ async function readCapture(root: string, relative: string, field: string): Promi
|
|
|
81
81
|
return file.bytes();
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The symlink half for the one path this tool WRITES: `realpath` of the nearest directory that
|
|
86
|
+
* exists above `out`, held under the shot directory's real location — `readCapture`'s rule. The
|
|
87
|
+
* lexical check alone let `.x/shot/<link-to-elsewhere>/diff.png` write wherever the link pointed.
|
|
88
|
+
*/
|
|
89
|
+
async function assertWritable(root: string, path: string): Promise<void> {
|
|
90
|
+
let dir = dirname(path);
|
|
91
|
+
while (!(await exists(dir)) && dirname(dir) !== dir) dir = dirname(dir);
|
|
92
|
+
const real = await realpath(dir);
|
|
93
|
+
const shotDir = await realpath(resolve(root, SHOT_DIR));
|
|
94
|
+
if (!under(real, shotDir)) {
|
|
95
|
+
throw new UltimateError({
|
|
96
|
+
code: 'X_UI_DIFF_PATH_OUTSIDE',
|
|
97
|
+
cause: `out is under a link to ${real}, which is not inside ${shotDir}`,
|
|
98
|
+
fix: SHOT_FIX,
|
|
99
|
+
meta: { field: 'out', path, real, shotDir },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const exists = (dir: string): Promise<boolean> =>
|
|
105
|
+
realpath(dir).then(
|
|
106
|
+
() => true,
|
|
107
|
+
() => false,
|
|
108
|
+
);
|
|
109
|
+
|
|
84
110
|
/**
|
|
85
111
|
* The seam reads 8-bit RGBA and nothing else. A PNG in any other shape — Chrome's RGB when the page
|
|
86
112
|
* is opaque, a palette from an optimiser — goes once through Bun's codecs, which always write the
|
|
@@ -126,6 +152,7 @@ export async function diffShots(deps: DiffDeps, input: UiDiffInput): Promise<UiD
|
|
|
126
152
|
}
|
|
127
153
|
const { width, height } = after;
|
|
128
154
|
const result = diffPixels(before.pixels, after.pixels, width, height, input.threshold);
|
|
155
|
+
await assertWritable(root, diff);
|
|
129
156
|
await Bun.write(diff, encodeImage({ width, height, pixels: result.diffRgba }));
|
|
130
157
|
return {
|
|
131
158
|
ok: true,
|
package/src/mcp-ui-inspect.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import type { UiInspectInput, UiInspectResult, UiInspectSelector } from '@ultimat3/mcp';
|
|
13
13
|
import { UI_INSPECT_LIMITS } from '@ultimat3/mcp';
|
|
14
|
-
import type { AxNode,
|
|
15
|
-
import { DEFAULT_PAGE_TIMEOUT_MS } from '
|
|
14
|
+
import type { AxNode, ShotDriver, ShotPage } from './browser-launcher-port';
|
|
15
|
+
import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
|
|
16
16
|
import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
17
17
|
import type { ShotServer } from './shot-server';
|
|
18
18
|
import type { ShotVerdict } from './shot-verdict';
|
|
@@ -23,7 +23,7 @@ export interface InspectDeps {
|
|
|
23
23
|
readonly root: string;
|
|
24
24
|
/** The memoised scratch server (or the running `x dev`), as `uiCapabilities` hands it out. */
|
|
25
25
|
readonly boot: () => Promise<ShotServer>;
|
|
26
|
-
readonly driver: (viewport: UiInspectInput['viewport']) => Promise<
|
|
26
|
+
readonly driver: (viewport: UiInspectInput['viewport']) => Promise<ShotDriver>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** What the page answered before the parser had a say — `null` selectors when it answered nothing. */
|
|
@@ -45,7 +45,7 @@ export type InspectSpecInput = Pick<
|
|
|
45
45
|
* a page that refuses evaluation is a page with no facts, and the verdict — not a throw here — is
|
|
46
46
|
* what says why. Shared by `ui.inspect` and `ui.interact`, so both read the same facts.
|
|
47
47
|
*/
|
|
48
|
-
export async function readInspect(page:
|
|
48
|
+
export async function readInspect(page: ShotPage, spec: InspectSpecInput): Promise<Seen> {
|
|
49
49
|
const expression = inspectExpression({
|
|
50
50
|
selectors: spec.selectors,
|
|
51
51
|
styles: spec.styles,
|
package/src/mcp-ui-interact.ts
CHANGED
|
@@ -14,8 +14,8 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { isUltimateError, toUltimateError, UltimateError } from '@ultimat3/core';
|
|
15
15
|
import type { UiInteractInput, UiInteractResult, UiInteractStepResult } from '@ultimat3/mcp';
|
|
16
16
|
import { UI_INTERACT_LIMITS } from '@ultimat3/mcp';
|
|
17
|
-
import type {
|
|
18
|
-
import { DEFAULT_PAGE_TIMEOUT_MS } from '
|
|
17
|
+
import type { ShotPage } from './browser-launcher-port';
|
|
18
|
+
import { DEFAULT_PAGE_TIMEOUT_MS } from './cdp-shot-clock';
|
|
19
19
|
import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
20
20
|
import type { InspectDeps, Seen } from './mcp-ui-inspect';
|
|
21
21
|
import { readInspect, selectorsOf } from './mcp-ui-inspect';
|
|
@@ -130,7 +130,7 @@ const describe = (step: InteractStep): string => {
|
|
|
130
130
|
};
|
|
131
131
|
|
|
132
132
|
async function perform(
|
|
133
|
-
page:
|
|
133
|
+
page: ShotPage,
|
|
134
134
|
step: InteractStep,
|
|
135
135
|
sleep: (ms: number) => Promise<void>,
|
|
136
136
|
): Promise<void> {
|
|
@@ -153,19 +153,25 @@ async function perform(
|
|
|
153
153
|
* Before any keystroke: a password field's value would land in the PNG, the verdict's console and
|
|
154
154
|
* an agent's transcript. The read is the driver's own `query`, so the fake answers it offline.
|
|
155
155
|
*/
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
): Promise<void> {
|
|
161
|
-
if (step.kind !== 'type') return;
|
|
162
|
-
|
|
163
|
-
|
|
156
|
+
/** The `type` of the focused element — what a `press` step's key lands in. */
|
|
157
|
+
export const ACTIVE_FIELD_TYPE =
|
|
158
|
+
'(function(){var a=document.activeElement;return a&&a.getAttribute?a.getAttribute("type"):null})()';
|
|
159
|
+
|
|
160
|
+
async function refuseSecretField(page: ShotPage, step: InteractStep, index: number): Promise<void> {
|
|
161
|
+
if (step.kind !== 'type' && step.kind !== 'press') return;
|
|
162
|
+
// A `press` goes to whatever has focus: `focus` a password field, then eleven presses, typed
|
|
163
|
+
// the password the `type` guard refused — so the focused element is asked before each key.
|
|
164
|
+
const secret =
|
|
165
|
+
step.kind === 'type'
|
|
166
|
+
? (await page.query(step.selector))[0]?.attrs['type'] === 'password'
|
|
167
|
+
: (await page.evaluate(ACTIVE_FIELD_TYPE)) === 'password';
|
|
168
|
+
if (!secret) return;
|
|
169
|
+
const target = step.kind === 'type' ? JSON.stringify(step.selector) : 'the focused element';
|
|
164
170
|
throw new UltimateError({
|
|
165
171
|
code: 'X_UI_INTERACT_SECRET_FIELD',
|
|
166
|
-
cause: `step ${index} would type into ${
|
|
172
|
+
cause: `step ${index} would type into ${target}, an <input type="password">`,
|
|
167
173
|
fix: FIX.secret,
|
|
168
|
-
meta: { step: index, selector: step.selector },
|
|
174
|
+
meta: { step: index, ...(step.kind === 'type' ? { selector: step.selector } : {}) },
|
|
169
175
|
});
|
|
170
176
|
}
|
|
171
177
|
|
|
@@ -177,11 +183,11 @@ export interface StepsRun {
|
|
|
177
183
|
|
|
178
184
|
/**
|
|
179
185
|
* The steps in order, each followed by a settle and one poll interval, each checked against the
|
|
180
|
-
* origin. A
|
|
186
|
+
* origin. A driver error is wrapped, never passed through: the agent needs to know WHICH step,
|
|
181
187
|
* and the fix is a selector `ui.inspect` reports rather than whatever the driver's fix names.
|
|
182
188
|
*/
|
|
183
189
|
export async function runSteps(
|
|
184
|
-
page:
|
|
190
|
+
page: ShotPage,
|
|
185
191
|
steps: readonly InteractStep[],
|
|
186
192
|
settle: () => Promise<IslandCount | null>,
|
|
187
193
|
origin: string,
|