@ultimat3/cli 24.0.0 → 25.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 +15 -8
- package/README.md +30 -5
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +3 -1
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +3 -3
- package/src/app-env.ts +21 -14
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +2 -2
- package/src/app-mcp.ts +25 -27
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +2 -8
- package/src/app-permissions-site.ts +31 -7
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +115 -121
- package/src/cdp-shot-driver.ts +17 -0
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth.ts +2 -2
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +89 -9
- package/src/cmd-db-backfill.ts +1 -1
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +5 -2
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -357
- package/src/cmd-doctor.ts +16 -66
- package/src/cmd-env.ts +29 -14
- package/src/cmd-generate.ts +12 -2
- package/src/cmd-i18n.ts +11 -8
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +18 -137
- package/src/cmd-mcp.ts +25 -7
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +9 -0
- package/src/cmd-routes.ts +3 -3
- package/src/cmd-shot-matrix.ts +21 -4
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +35 -39
- package/src/cmd-test.ts +1 -1
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +19 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +1 -1
- package/src/dev-dashboard.ts +5 -1
- package/src/dev-lock.ts +96 -24
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +14 -7
- package/src/doctor-auth.ts +4 -4
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +2 -2
- package/src/drift.ts +29 -6
- package/src/error-codes.ts +6 -0
- package/src/error-page-csp.ts +14 -15
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema.ts +13 -2
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-kinds.ts +3 -17
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +50 -1
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +21 -8
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +1 -3
- package/src/island-bundle.ts +54 -64
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +32 -16
- package/src/local-cli-handoff.ts +90 -0
- package/src/mcp-errors.ts +8 -1
- package/src/mcp-host.ts +6 -5
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +27 -16
- package/src/metrics-endpoint.ts +3 -2
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +18 -0
- package/src/page-speculation.ts +14 -11
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +63 -98
- package/src/pwa-artifacts.ts +8 -1
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/retry-memo.ts +1 -1
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +110 -31
- package/src/role-start.ts +64 -85
- package/src/role-sync.ts +65 -6
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +15 -2
- package/src/runtime-bindings.ts +23 -3
- package/src/runtime-cache.ts +8 -8
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +2 -2
- package/src/runtime-jobs.ts +78 -18
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +2 -2
- package/src/runtime-overrides.ts +3 -3
- package/src/runtime-purge.ts +1 -1
- package/src/runtime-queue.ts +12 -12
- package/src/runtime-realtime.ts +5 -3
- package/src/runtime-render.ts +77 -82
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +33 -13
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +3 -3
- package/src/schema-diff.ts +1 -1
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +2 -2
- package/src/serve-boot.ts +22 -11
- package/src/serve-drain.ts +3 -1
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +19 -8
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -15
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-seo.ts +6 -4
- package/src/source-files.ts +13 -3
- package/src/static-report.ts +5 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +1 -1
- package/src/templates/action.ts +4 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +23 -8
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +2 -1
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container.ts +12 -6
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-templates.ts +74 -18
- package/src/templates/scaffold-helm.ts +31 -4
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +18 -15
- package/src/templates/scaffold-shell.ts +17 -2
- package/src/test-only-shipped.ts +193 -0
- package/src/test-select.ts +2 -1
- package/src/test-shards.ts +1 -4
- package/src/theme-boot.ts +2 -2
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/verify-checks.ts +12 -2
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +87 -10
- package/src/verify-merge.ts +48 -16
- package/src/verify-part-step.ts +10 -2
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +3 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +0 -1
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/jobs-drain.ts +0 -230
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
package/src/workspace-checks.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { ERROR_DOCS_URL, renderCauseValue } from '@ultimat3/core';
|
|
16
16
|
import type { Finding } from './output';
|
|
17
|
+
import { pathSegments } from './path-segments';
|
|
18
|
+
import { toPosix } from './posix-path';
|
|
17
19
|
import {
|
|
18
20
|
entryFilesOf,
|
|
19
21
|
FIXTURE_SUFFIX,
|
|
@@ -23,6 +25,8 @@ import {
|
|
|
23
25
|
} from './publish-closure';
|
|
24
26
|
import { isReExportManifest } from './reexport-manifest';
|
|
25
27
|
import { eachSourceFile, isGenerated } from './source-files';
|
|
28
|
+
import type { ShippedPackage } from './test-only-shipped';
|
|
29
|
+
import { checkTestOnlyShipped, entryPatternsOf } from './test-only-shipped';
|
|
26
30
|
import { checkRootReferences } from './tsconfig-references';
|
|
27
31
|
|
|
28
32
|
export const LINE_CEILING = 500;
|
|
@@ -53,15 +57,41 @@ export const countLines = (text: string): number =>
|
|
|
53
57
|
* statement of logic in it is not, and only reading tells them apart.
|
|
54
58
|
*/
|
|
55
59
|
export async function checkFileSizes(root: string): Promise<readonly Finding[]> {
|
|
60
|
+
return (await fileSizeReport(root)).findings;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where a split gets planned rather than forced. Seven files once sat at exactly 500 lines,
|
|
65
|
+
* trimmed to the ceiling instead of split: a band that lists a file at 450 makes the split a
|
|
66
|
+
* decision taken with room to spare. Never a finding — a warning that fails the step is a ceiling.
|
|
67
|
+
*/
|
|
68
|
+
export const WARN_BAND = 450;
|
|
69
|
+
|
|
70
|
+
/** A file in the band: at or over `WARN_BAND`, at or under the ceiling. */
|
|
71
|
+
export interface FileSizeWarning {
|
|
72
|
+
readonly path: string;
|
|
73
|
+
readonly lines: number;
|
|
74
|
+
readonly ceiling: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FileSizeReport {
|
|
78
|
+
readonly findings: readonly Finding[];
|
|
79
|
+
readonly warnings: readonly FileSizeWarning[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The ceiling's findings and the band's warnings, from one read of every file. */
|
|
83
|
+
export async function fileSizeReport(root: string): Promise<FileSizeReport> {
|
|
56
84
|
const findings: Finding[] = [];
|
|
85
|
+
const warnings: FileSizeWarning[] = [];
|
|
57
86
|
for await (const path of eachSourceFile(root)) {
|
|
58
87
|
if (isGenerated(path)) continue;
|
|
59
88
|
const source = await Bun.file(join(root, path)).text();
|
|
60
89
|
const lines = countLines(source);
|
|
61
|
-
if (lines
|
|
62
|
-
findings.push(tooLongFinding(path, lines));
|
|
90
|
+
if (lines < WARN_BAND || isReExportManifest(source)) continue;
|
|
91
|
+
if (lines > LINE_CEILING) findings.push(tooLongFinding(path, lines));
|
|
92
|
+
else warnings.push({ path, lines, ceiling: LINE_CEILING });
|
|
63
93
|
}
|
|
64
|
-
return findings;
|
|
94
|
+
return { findings, warnings };
|
|
65
95
|
}
|
|
66
96
|
|
|
67
97
|
export const missingFileFinding = (dir: string, file: string, scaffolder: boolean): Finding => ({
|
|
@@ -307,13 +337,20 @@ export function frameworkDepsOf(manifest: unknown): ManifestFacts['frameworkDeps
|
|
|
307
337
|
});
|
|
308
338
|
}
|
|
309
339
|
|
|
340
|
+
/**
|
|
341
|
+
* `packages/<dir>/package.json` → `<dir>`, from a glob answer in the host's separator. Split on `/`
|
|
342
|
+
* alone, Windows' `packages\db\package.json` named no directory, so an app's `package-shape` found
|
|
343
|
+
* no workspace and its floor reported `X_VERIFY_SUITE_VANISHED` on a scaffold fresh from `x new`.
|
|
344
|
+
*/
|
|
345
|
+
export const workspaceDirOf = (globbed: string): string | undefined => pathSegments(globbed)[1];
|
|
346
|
+
|
|
310
347
|
export async function workspacePackages(root: string): Promise<readonly string[]> {
|
|
311
348
|
const dirs: string[] = [];
|
|
312
349
|
for await (const path of new Bun.Glob('packages/*/package.json').scan({
|
|
313
350
|
cwd: root,
|
|
314
351
|
absolute: false,
|
|
315
352
|
})) {
|
|
316
|
-
const dir = path
|
|
353
|
+
const dir = workspaceDirOf(path);
|
|
317
354
|
if (dir !== undefined) dirs.push(dir);
|
|
318
355
|
}
|
|
319
356
|
return dirs.sort();
|
|
@@ -345,7 +382,10 @@ async function fixtureReach(root: string, dir: string, manifest: unknown): Promi
|
|
|
345
382
|
// consumer's, so no `*.test.ts` is read.
|
|
346
383
|
const sources = new Map<string, string>();
|
|
347
384
|
for await (const path of new Bun.Glob('src/**/*.{ts,tsx}').scan({ cwd: base, absolute: false })) {
|
|
348
|
-
|
|
385
|
+
// Keyed POSIX: `entryFilesOf` and every import the walk resolves are, on every host.
|
|
386
|
+
if (!/\.test\.tsx?$/.test(path)) {
|
|
387
|
+
sources.set(toPosix(path), readFileSync(join(base, path), 'utf8'));
|
|
388
|
+
}
|
|
349
389
|
}
|
|
350
390
|
if (!mayReachFixture(sources, entries)) return [];
|
|
351
391
|
return fixturesReachedFrom({ read: (path) => sources.get(path) }, entries).map((fixture) =>
|
|
@@ -361,6 +401,7 @@ export async function checkPackageShape(
|
|
|
361
401
|
const scaffolder = existsSync(join(root, 'scripts', 'new-package.ts'));
|
|
362
402
|
const findings: Finding[] = [];
|
|
363
403
|
const facts: ManifestFacts[] = [];
|
|
404
|
+
const shipped: ShippedPackage[] = [];
|
|
364
405
|
const allowlisted = new Set(ARTIFACT_ALLOWLIST);
|
|
365
406
|
for (const dir of await workspacePackages(root)) {
|
|
366
407
|
for (const file of PACKAGE_FILES) {
|
|
@@ -373,7 +414,7 @@ export async function checkPackageShape(
|
|
|
373
414
|
cwd: join(root, 'packages', dir),
|
|
374
415
|
absolute: false,
|
|
375
416
|
})) {
|
|
376
|
-
if (!allowlisted.has(join('packages', dir, path))) artifacts.push(path);
|
|
417
|
+
if (!allowlisted.has(toPosix(join('packages', dir, path)))) artifacts.push(path);
|
|
377
418
|
}
|
|
378
419
|
if (artifacts.length > 0) {
|
|
379
420
|
findings.push(buildArtifactsFinding(dir, artifacts.length));
|
|
@@ -389,7 +430,10 @@ export async function checkPackageShape(
|
|
|
389
430
|
findings.push(badVersionFinding(dir, version));
|
|
390
431
|
continue;
|
|
391
432
|
}
|
|
392
|
-
if (record.private !== true)
|
|
433
|
+
if (record.private !== true) {
|
|
434
|
+
findings.push(...(await fixtureReach(root, dir, manifest)));
|
|
435
|
+
shipped.push({ dir, entries: entryPatternsOf(manifest) });
|
|
436
|
+
}
|
|
393
437
|
facts.push({
|
|
394
438
|
dir,
|
|
395
439
|
name: typeof record.name === 'string' ? record.name : `@ultimat3/${dir}`,
|
|
@@ -403,6 +447,8 @@ export async function checkPackageShape(
|
|
|
403
447
|
...findings,
|
|
404
448
|
...checkLockstep(facts, options.release),
|
|
405
449
|
...checkPublishShape(root, facts),
|
|
450
|
+
// The other way test code ships: a module only tests import, under a name `files` keeps.
|
|
451
|
+
...(await checkTestOnlyShipped(root, shipped)),
|
|
406
452
|
// Nothing enforced that a workspace joins the root build graph, and `scripts/new-package.ts`
|
|
407
453
|
// never added one — so a package could ship, be imported, and be typechecked by nothing. It
|
|
408
454
|
// rides on this step because it is this step's own question: what does a workspace owe the
|
package/src/workspace-graph.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// for `tsc` and is invisible to bun, to `--filter` ordering and to any change-detection tool.
|
|
5
5
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
+
import { ERROR_DOCS_URL, maskLiterals } from '@ultimat3/core';
|
|
8
8
|
import { scanAllImports } from './import-scan';
|
|
9
9
|
import type { Finding } from './output';
|
|
10
10
|
import { eachSourceFile, isGenerated, isTest, isVendored } from './source-files';
|
|
@@ -148,10 +148,11 @@ export function packageOfSpecifier(specifier: string): string | undefined {
|
|
|
148
148
|
* included, because a type edge still needs its manifest line. The transpiler is the parser, so a
|
|
149
149
|
* generator's template literal (even one nested in a `${…}`, #493) is a string and never an import,
|
|
150
150
|
* and `from<Row>('posts')` is a call. A file it cannot parse throws rather than reading as empty.
|
|
151
|
+
* The mask is what reads a type-position `typeof import('x')`, which the transpiler erases whole.
|
|
151
152
|
*/
|
|
152
153
|
export function importedPackages(source: string, path = 'source.ts'): readonly string[] {
|
|
153
154
|
const packages = new Set<string>();
|
|
154
|
-
for (const specifier of scanAllImports({ path, source })) {
|
|
155
|
+
for (const specifier of scanAllImports({ path, source }, maskLiterals(source))) {
|
|
155
156
|
const name = packageOfSpecifier(specifier);
|
|
156
157
|
if (name !== undefined) packages.add(name);
|
|
157
158
|
}
|
package/src/write-line.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { writeSync } from 'node:fs';
|
|
8
8
|
// The one import beyond `node:fs`, and it costs nothing here: `create-ultimate` reaches this
|
|
9
9
|
// module through `@ultimat3/cli`'s barrel, which has already evaluated core.
|
|
10
|
-
import { stringField } from '@ultimat3/core';
|
|
10
|
+
import { backoffDelay, stringField } from '@ultimat3/core';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Write to stdout and be certain it arrived, even if the next statement exits the process.
|
|
@@ -22,22 +22,97 @@ import { stringField } from '@ultimat3/core';
|
|
|
22
22
|
*
|
|
23
23
|
* `EAGAIN` is "the pipe is full right now", not a failure. CI hands the process a NON-BLOCKING
|
|
24
24
|
* stdout, where `writeSync` throws rather than blocking — so the loop that fixed the truncation
|
|
25
|
-
* took the whole command down on a runner, emitting nothing at all.
|
|
26
|
-
* microseconds
|
|
25
|
+
* took the whole command down on a runner, emitting nothing at all. A draining reader clears it in
|
|
26
|
+
* microseconds, so it is retried — but BOUNDED: a reader that never drains (a stopped `| less`, a
|
|
27
|
+
* wedged parent) turned the unbounded retry into a command that never exits, spinning a core.
|
|
28
|
+
* `EAGAIN_ATTEMPTS` consecutive stalls, each waiting longer (capped), then the line is dropped and
|
|
29
|
+
* counted (`droppedLineCount()`): one lost line beats a process that cannot finish. After a drop
|
|
30
|
+
* the fd is DEGRADED: each later line gets one attempt and no sleep, or a never-draining reader
|
|
31
|
+
* would cost ~1.5 s per line forever. Any byte arriving clears it.
|
|
27
32
|
*/
|
|
28
33
|
function writeTo(fd: 1 | 2, line: string): void {
|
|
29
|
-
|
|
34
|
+
writeAll(fd, Buffer.from(`${line}\n`));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Consecutive stalls one write may absorb before its line is dropped. Progress resets it. */
|
|
38
|
+
export const EAGAIN_ATTEMPTS = 50;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Core's one curve, exponential and capped: 1, 2, 4 … 32 ms — under 1.5 s for a reader that never
|
|
42
|
+
* drains at all. No jitter: one process waiting on its own pipe has no herd to decorrelate.
|
|
43
|
+
*/
|
|
44
|
+
const backoffMs = (stall: number): number => backoffDelay({ attempt: stall, base: 1, max: 32 });
|
|
45
|
+
|
|
46
|
+
/** `writeSync`'s shape, so a test can hand in a pipe that is full forever. */
|
|
47
|
+
export type WriteSync = (fd: number, buffer: Uint8Array, offset: number, length: number) => number;
|
|
48
|
+
|
|
49
|
+
let dropped = 0;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Per fd, because fd 1 and fd 2 are two readers. `degraded`: the last line was dropped, so the next
|
|
53
|
+
* gets one attempt. `torn`: it was dropped HALF-written, so the next line that arrives opens with
|
|
54
|
+
* the newline the torn one never got — otherwise the two glue into one unparseable line.
|
|
55
|
+
*/
|
|
56
|
+
const pipes = new Map<number, { degraded: boolean; torn: boolean }>();
|
|
57
|
+
|
|
58
|
+
const pipeOf = (fd: number): { degraded: boolean; torn: boolean } => {
|
|
59
|
+
let pipe = pipes.get(fd);
|
|
60
|
+
if (pipe === undefined) {
|
|
61
|
+
pipe = { degraded: false, torn: false };
|
|
62
|
+
pipes.set(fd, pipe);
|
|
63
|
+
}
|
|
64
|
+
return pipe;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const NEWLINE = 0x0a;
|
|
68
|
+
|
|
69
|
+
/** Lines this process dropped to a reader that never drained. */
|
|
70
|
+
export const droppedLineCount = (): number => dropped;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write every byte of `buffer` to `fd`, or drop it after `EAGAIN_ATTEMPTS` stalls in a row — one
|
|
74
|
+
* stall while the fd is degraded. Returns whether it all arrived. A write of zero bytes is a stall
|
|
75
|
+
* too, so no path spins.
|
|
76
|
+
*/
|
|
77
|
+
export function writeAll(
|
|
78
|
+
fd: number,
|
|
79
|
+
buffer: Uint8Array,
|
|
80
|
+
write: WriteSync = writeSync,
|
|
81
|
+
sleep: (ms: number) => void = Bun.sleepSync,
|
|
82
|
+
): boolean {
|
|
83
|
+
const pipe = pipeOf(fd);
|
|
84
|
+
const prefix = pipe.torn ? 1 : 0;
|
|
85
|
+
const bytes = prefix === 0 ? buffer : Buffer.concat([Uint8Array.of(NEWLINE), buffer]);
|
|
30
86
|
let written = 0;
|
|
31
|
-
|
|
87
|
+
let stalls = 0;
|
|
88
|
+
while (written < bytes.length) {
|
|
89
|
+
let progress = 0;
|
|
32
90
|
try {
|
|
33
|
-
|
|
91
|
+
progress = write(fd, bytes, written, bytes.length - written);
|
|
34
92
|
} catch (cause) {
|
|
35
93
|
// `stringField`, never a cast plus a property read — the rule `metrics-endpoint.ts` states
|
|
36
94
|
// and `caught-value-reads.test.ts` enforces. Here it is also the difference between
|
|
37
|
-
// rethrowing and
|
|
95
|
+
// rethrowing and retrying: a `code` that cannot be read must not read as `EAGAIN`.
|
|
38
96
|
if (stringField(cause, 'code') !== 'EAGAIN') throw cause;
|
|
39
97
|
}
|
|
98
|
+
if (progress > 0) {
|
|
99
|
+
written += progress;
|
|
100
|
+
stalls = 0;
|
|
101
|
+
pipe.degraded = false; // The reader drained: it gets the full budget again.
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
stalls += 1;
|
|
105
|
+
if (stalls >= (pipe.degraded ? 1 : EAGAIN_ATTEMPTS)) {
|
|
106
|
+
dropped += 1;
|
|
107
|
+
pipe.degraded = true;
|
|
108
|
+
// Nothing written leaves a torn line torn; only the prefix written closes it cleanly.
|
|
109
|
+
if (written > 0) pipe.torn = written > prefix;
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
sleep(backoffMs(stalls));
|
|
40
113
|
}
|
|
114
|
+
pipe.torn = false;
|
|
115
|
+
return true;
|
|
41
116
|
}
|
|
42
117
|
|
|
43
118
|
export function writeLine(line: string): void {
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
// CSS selectors over an HTML string, on Bun's own `HTMLRewriter`: the `query()` the offline
|
|
2
|
-
// `fakeShotDriver` answers from markup. The selector subset is lol-html's — type, `#id`, `.class`,
|
|
3
|
-
// `[attr]`, `[attr=value]`, descendant and child combinators. No box and no hit-target on any
|
|
4
|
-
// answer: this has no layout engine, and a fabricated box would make a covered button clickable.
|
|
5
|
-
import type { ElementSnapshot } from './browser-launcher-port';
|
|
6
|
-
|
|
7
|
-
/** Elements with no end tag: `onEndTag` never fires for them, so they close on open. */
|
|
8
|
-
const VOID_TAGS = new Set([
|
|
9
|
-
'area',
|
|
10
|
-
'base',
|
|
11
|
-
'br',
|
|
12
|
-
'col',
|
|
13
|
-
'embed',
|
|
14
|
-
'hr',
|
|
15
|
-
'img',
|
|
16
|
-
'input',
|
|
17
|
-
'link',
|
|
18
|
-
'meta',
|
|
19
|
-
'source',
|
|
20
|
-
'track',
|
|
21
|
-
'wbr',
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
const HIDDEN_STYLE = /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*(?:;|$)/i;
|
|
25
|
-
|
|
26
|
-
const visible = (tag: string, attrs: ReadonlyMap<string, string>): boolean =>
|
|
27
|
-
!attrs.has('hidden') &&
|
|
28
|
-
!(tag === 'input' && attrs.get('type')?.toLowerCase() === 'hidden') &&
|
|
29
|
-
!HIDDEN_STYLE.test(attrs.get('style') ?? '') &&
|
|
30
|
-
attrs.get('aria-hidden') !== 'true';
|
|
31
|
-
|
|
32
|
-
interface Open {
|
|
33
|
-
readonly tag: string;
|
|
34
|
-
readonly attrs: Map<string, string>;
|
|
35
|
-
readonly parts: string[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const finish = (open: Open): ElementSnapshot => ({
|
|
39
|
-
tag: open.tag,
|
|
40
|
-
attrs: Object.fromEntries(open.attrs),
|
|
41
|
-
text: open.parts.join('').replaceAll(/\s+/g, ' ').trim(),
|
|
42
|
-
value: open.attrs.get('value') ?? '',
|
|
43
|
-
visible: visible(open.tag, open.attrs),
|
|
44
|
-
enabled: !open.attrs.has('disabled') && open.attrs.get('aria-disabled') !== 'true',
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
/** Every element matching `selector`, as the markup states it. A selector lol-html refuses throws. */
|
|
48
|
-
export async function queryHtml(
|
|
49
|
-
html: string,
|
|
50
|
-
selector: string,
|
|
51
|
-
): Promise<readonly ElementSnapshot[]> {
|
|
52
|
-
const done: ElementSnapshot[] = [];
|
|
53
|
-
const open: Open[] = [];
|
|
54
|
-
const rewriter = new HTMLRewriter().on(selector, {
|
|
55
|
-
element(element): void {
|
|
56
|
-
const tag = element.tagName.toLowerCase();
|
|
57
|
-
const attrs = new Map<string, string>();
|
|
58
|
-
for (const [name, value] of element.attributes) attrs.set(name.toLowerCase(), value);
|
|
59
|
-
const record: Open = { tag, attrs, parts: [] };
|
|
60
|
-
if (VOID_TAGS.has(tag)) {
|
|
61
|
-
done.push(finish(record));
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
open.push(record);
|
|
65
|
-
element.onEndTag(() => {
|
|
66
|
-
const index = open.lastIndexOf(record);
|
|
67
|
-
if (index !== -1) open.splice(index, 1);
|
|
68
|
-
done.push(finish(record));
|
|
69
|
-
});
|
|
70
|
-
},
|
|
71
|
-
text(chunk): void {
|
|
72
|
-
// Every OPEN match: `div, span` matches both, and both contain the text.
|
|
73
|
-
for (const record of open) record.parts.push(chunk.text);
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
await rewriter.transform(new Response(html)).text();
|
|
77
|
-
// Never closed is malformed markup; the element still matched, so it is still reported.
|
|
78
|
-
for (const record of open) done.push(finish(record));
|
|
79
|
-
return done;
|
|
80
|
-
}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
// `fakeShotDriver`: a `ShotDriver` over recorded pages, so every `x shot` and `ui.*` test runs with
|
|
2
|
-
// no Chrome. It parses markup and runs none of it — `query()` is answered from the HTML,
|
|
3
|
-
// `evaluate()` from a recorded table, and a click follows `data-goto` or an `<a href>`. What it
|
|
4
|
-
// cannot know it refuses by name rather than inventing: no layout box, and no accessibility tree.
|
|
5
|
-
import { hostDecision, notImplemented } from '@ultimat3/core';
|
|
6
|
-
import { CdpCallFailedError } from '@ultimat3/testing';
|
|
7
|
-
import { queryHtml } from './browser-launcher-fake-html';
|
|
8
|
-
import type {
|
|
9
|
-
CaptureClip,
|
|
10
|
-
ElementSnapshot,
|
|
11
|
-
NetworkEntry,
|
|
12
|
-
ShotColorScheme,
|
|
13
|
-
ShotDriver,
|
|
14
|
-
ShotPage,
|
|
15
|
-
ShotSession,
|
|
16
|
-
ShotSessionInit,
|
|
17
|
-
} from './browser-launcher-port';
|
|
18
|
-
import { awaitReady } from './cdp-shot-element';
|
|
19
|
-
import { ShotHostRefusedError } from './cdp-shot-errors';
|
|
20
|
-
import { parseKeyChord } from './cdp-shot-keys';
|
|
21
|
-
|
|
22
|
-
export const FAKE_SHOT_DRIVER = 'fake';
|
|
23
|
-
|
|
24
|
-
/** One recorded page: its url, its markup, and `expression -> JSON text` for `evaluate()`. */
|
|
25
|
-
export interface FakeShotPage {
|
|
26
|
-
readonly url: string;
|
|
27
|
-
readonly html: string;
|
|
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;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** A PNG signature, and nothing behind it: deterministic bytes, never a render. */
|
|
34
|
-
export const FAKE_PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Different deterministic bytes for every framing fact that was set — one per rectangle, one per
|
|
38
|
-
* colour preference — so a caller that dropped the clip or the scheme fails a test here, which is
|
|
39
|
-
* the only place a framing knob can be proved without a browser (issue #338).
|
|
40
|
-
*/
|
|
41
|
-
export const framedPng = (clip: CaptureClip | undefined, scheme: ShotColorScheme): Uint8Array => {
|
|
42
|
-
const notes =
|
|
43
|
-
(clip === undefined
|
|
44
|
-
? ''
|
|
45
|
-
: ` clip ${String(clip.x)},${String(clip.y)},${String(clip.width)},${String(clip.height)}`) +
|
|
46
|
-
(scheme === 'no-preference' ? '' : ` scheme ${scheme}`);
|
|
47
|
-
if (notes === '') return FAKE_PNG;
|
|
48
|
-
const suffix = new TextEncoder().encode(notes);
|
|
49
|
-
const out = new Uint8Array(FAKE_PNG.length + suffix.length);
|
|
50
|
-
out.set(FAKE_PNG);
|
|
51
|
-
out.set(suffix, FAKE_PNG.length);
|
|
52
|
-
return out;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const normalise = (url: string): string => {
|
|
56
|
-
try {
|
|
57
|
-
return new URL(url).toString();
|
|
58
|
-
} catch {
|
|
59
|
-
return url;
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
function fakePage(byUrl: ReadonlyMap<string, FakeShotPage>, init: ShotSessionInit): ShotPage {
|
|
64
|
-
let current: FakeShotPage | undefined;
|
|
65
|
-
let scheme: ShotColorScheme = 'no-preference';
|
|
66
|
-
const network: NetworkEntry[] = [];
|
|
67
|
-
const url = (): string => current?.url ?? 'about:blank';
|
|
68
|
-
|
|
69
|
-
const navigate = (to: string): void => {
|
|
70
|
-
const page = byUrl.get(normalise(to));
|
|
71
|
-
if (page === undefined) {
|
|
72
|
-
throw new CdpCallFailedError({
|
|
73
|
-
method: `Page.navigate to ${to}`,
|
|
74
|
-
detail: 'fakeShotDriver() has no page recorded at that url',
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
current = page;
|
|
78
|
-
network.push({
|
|
79
|
-
method: 'GET',
|
|
80
|
-
url: page.url,
|
|
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,
|
|
83
|
-
resourceType: 'document',
|
|
84
|
-
at: init.clock.now().getTime(),
|
|
85
|
-
});
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const query = (selector: string): Promise<readonly ElementSnapshot[]> =>
|
|
89
|
-
queryHtml(current?.html ?? '', selector);
|
|
90
|
-
|
|
91
|
-
const ready = (selector: string, options?: Parameters<ShotPage['waitFor']>[1]) =>
|
|
92
|
-
awaitReady({
|
|
93
|
-
selector,
|
|
94
|
-
state: options?.state ?? 'actionable',
|
|
95
|
-
timeoutMs: options?.timeout ?? init.timeoutMs,
|
|
96
|
-
clock: init.clock,
|
|
97
|
-
url,
|
|
98
|
-
snapshot: async () => (await query(selector))[0],
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
url,
|
|
103
|
-
async goto(to) {
|
|
104
|
-
if (!hostDecision(to, init.rules.allowHosts).allowed) {
|
|
105
|
-
throw new ShotHostRefusedError({ url: to, allowHosts: init.rules.allowHosts });
|
|
106
|
-
}
|
|
107
|
-
navigate(to);
|
|
108
|
-
},
|
|
109
|
-
waitFor: (selector, options) => ready(selector, options),
|
|
110
|
-
async click(selector, options) {
|
|
111
|
-
const target = await ready(selector, options);
|
|
112
|
-
// What the browser would do with the click, as far as markup can say: follow the link.
|
|
113
|
-
const next =
|
|
114
|
-
target.attrs['data-goto'] ?? (target.tag === 'a' ? target.attrs['href'] : undefined);
|
|
115
|
-
if (next !== undefined) navigate(new URL(next, url()).toString());
|
|
116
|
-
},
|
|
117
|
-
async type(selector, _text, options) {
|
|
118
|
-
await ready(selector, options);
|
|
119
|
-
},
|
|
120
|
-
async focus(selector, options) {
|
|
121
|
-
await ready(selector, options);
|
|
122
|
-
},
|
|
123
|
-
async press(chord) {
|
|
124
|
-
parseKeyChord(chord);
|
|
125
|
-
},
|
|
126
|
-
async accessibility() {
|
|
127
|
-
return notImplemented(
|
|
128
|
-
'accessibility() on fakeShotDriver()',
|
|
129
|
-
'assert on the markup with page.query(selector) — a role read off the HTML would hide the element a screen reader cannot name, which is the finding this read exists for',
|
|
130
|
-
);
|
|
131
|
-
},
|
|
132
|
-
query,
|
|
133
|
-
async evaluate(expression) {
|
|
134
|
-
const table = current?.evaluate ?? {};
|
|
135
|
-
const recorded = Object.hasOwn(table, expression) ? table[expression] : undefined;
|
|
136
|
-
if (recorded === undefined) {
|
|
137
|
-
throw new CdpCallFailedError({
|
|
138
|
-
method: 'Runtime.evaluate',
|
|
139
|
-
detail: 'fakeShotDriver() has no answer recorded for that expression on this page',
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
return JSON.parse(recorded) as unknown;
|
|
143
|
-
},
|
|
144
|
-
async screenshot(options) {
|
|
145
|
-
return framedPng(options?.clip, scheme);
|
|
146
|
-
},
|
|
147
|
-
async colorScheme(next) {
|
|
148
|
-
scheme = next;
|
|
149
|
-
},
|
|
150
|
-
async prepare() {},
|
|
151
|
-
console: () => [],
|
|
152
|
-
pageErrors: () => [],
|
|
153
|
-
pageErrorsDropped: () => 0,
|
|
154
|
-
network: () => [...network],
|
|
155
|
-
networkDropped: () => 0,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** A driver whose every session reads from `pages`. An unrecorded url is refused, never fetched. */
|
|
160
|
-
export function fakeShotDriver(pages: readonly FakeShotPage[]): ShotDriver {
|
|
161
|
-
const byUrl = new Map(pages.map((page) => [normalise(page.url), page]));
|
|
162
|
-
return {
|
|
163
|
-
name: FAKE_SHOT_DRIVER,
|
|
164
|
-
async open(init): Promise<ShotSession> {
|
|
165
|
-
return { page: fakePage(byUrl, init), close: async () => undefined };
|
|
166
|
-
},
|
|
167
|
-
};
|
|
168
|
-
}
|