@ultimat3/cli 23.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- 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 +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- 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 +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- 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 +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- 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-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- 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 +2 -6
- package/src/island-bundle.ts +57 -79
- 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 +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- 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 +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- 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-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- 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 +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- 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 -33
- 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-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -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 +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -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/naming.ts +28 -0
- 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 +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- 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-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- 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-client.ts +4 -2
- 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-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- 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 +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- 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/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// `package-shape`'s third rule of "test code does not ship": a `src/` module only tests (and
|
|
2
|
+
// fixtures) import is test code under a name `files` cannot see. The tarball excludes `*.test.ts`
|
|
3
|
+
// and `*-fixture.ts`, so a fake, a probe or a `bun:test` helper named anything else rode into
|
|
4
|
+
// every install — 20 of them across eleven packages until 2026-10. The defect is the name.
|
|
5
|
+
|
|
6
|
+
import { ERROR_DOCS_URL, renderFixShellArg } from '@ultimat3/core';
|
|
7
|
+
import type { ClosureHost } from './import-closure';
|
|
8
|
+
import { resolveSpec } from './import-closure';
|
|
9
|
+
import type { Finding } from './output';
|
|
10
|
+
import { FIXTURE_SUFFIX } from './publish-closure';
|
|
11
|
+
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
12
|
+
|
|
13
|
+
/** A published package: its directory under `packages/` and the files its manifest names. */
|
|
14
|
+
export interface ShippedPackage {
|
|
15
|
+
readonly dir: string;
|
|
16
|
+
/** Package-relative (`src/index.ts`), and may be a glob (`src/icons/glyphs/*.ts`). */
|
|
17
|
+
readonly entries: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TestOnlyModule {
|
|
21
|
+
readonly path: string;
|
|
22
|
+
/** Every file that imports or names it — all of them tests, fixtures or test-only modules. */
|
|
23
|
+
readonly importers: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every `src/` file a manifest names anywhere — `exports`, `bin`, the `browser` map, a `scripts`
|
|
28
|
+
* command (`bun run src/icons/build-icons.ts`). Read off the whole document rather than field by
|
|
29
|
+
* field: a module the manifest names is shipped on purpose, whichever field names it.
|
|
30
|
+
*/
|
|
31
|
+
export function entryPatternsOf(manifest: unknown): readonly string[] {
|
|
32
|
+
const text = JSON.stringify(manifest ?? null);
|
|
33
|
+
const found = [...text.matchAll(/(?:\.\/)?(src\/[\w./*-]+?\.tsx?)(?![\w.])/g)];
|
|
34
|
+
return [...new Set(found.map((match) => match[1] ?? ''))].sort();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Type-only imports count: the package ships `.ts` source, so a consumer's `tsc` opens the file a
|
|
38
|
+
// shipped `import type` names. One pattern for `from '.'`, `import('.')` and `import '.'`, relative
|
|
39
|
+
// specifiers only: over-matching an importer only ever clears a module, never flags one.
|
|
40
|
+
const RELATIVE = /(?:\bfrom|\bimport)\s*\(?\s*(['"])(\.\.?\/[^'"]*)\1/g;
|
|
41
|
+
/**
|
|
42
|
+
* A template-literal `import(\`./drivers/${n}\`)`: the static head up to the first `${` names a
|
|
43
|
+
* DIRECTORY prefix, and any module under it may be the one loaded. With no `${` the closing
|
|
44
|
+
* backtick ends it and it is an ordinary specifier.
|
|
45
|
+
*/
|
|
46
|
+
const TEMPLATE = /\bimport\s*\(\s*`(\.\.?\/[^`$]*)(\$\{|`)/g;
|
|
47
|
+
/** A file named in a string — `join(import.meta.dir, 'probe.ts')`, `new URL('./worker.ts', …)`. */
|
|
48
|
+
const NAMED = /['"`/]([\w.-]+\.tsx?)['"`]/g;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the match at `index` sits on a line that IS a comment — `//`, a doc comment's `*`, a
|
|
52
|
+
* `/*` opener. Line-wise and not core's `stripComments`, which is exact and costs ~1.1 s over the
|
|
53
|
+
* repo's 6,000 files (measured 2026-10-05); asked only of a match, never of every line. The lines
|
|
54
|
+
* this skips are where a commented-out or quoted-in-prose import lives, and one it keeps can only
|
|
55
|
+
* clear a module, never flag one.
|
|
56
|
+
*/
|
|
57
|
+
function onCommentLine(text: string, index: number): boolean {
|
|
58
|
+
let at = text.lastIndexOf('\n', index - 1) + 1;
|
|
59
|
+
while (text[at] === ' ' || text[at] === '\t') at += 1;
|
|
60
|
+
const head = text.slice(at, at + 2);
|
|
61
|
+
return head === '//' || head === '/*' || head.startsWith('*');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `from`'s directory joined with a relative `head`, `.` and `..` folded; a trailing `/` kept. */
|
|
65
|
+
function prefixFrom(from: string, head: string): string {
|
|
66
|
+
const parts = from.split('/').slice(0, -1);
|
|
67
|
+
const segments = head.split('/');
|
|
68
|
+
const last = segments.pop() ?? '';
|
|
69
|
+
for (const segment of segments) {
|
|
70
|
+
if (segment === '..') parts.pop();
|
|
71
|
+
else if (segment !== '.' && segment !== '') parts.push(segment);
|
|
72
|
+
}
|
|
73
|
+
return `${parts.join('/')}/${last}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isFixture = (path: string): boolean => path.endsWith(FIXTURE_SUFFIX);
|
|
77
|
+
const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1);
|
|
78
|
+
|
|
79
|
+
const add = (map: Map<string, Set<string>>, key: string, value: string): void => {
|
|
80
|
+
const set = map.get(key);
|
|
81
|
+
if (set === undefined) map.set(key, new Set([value]));
|
|
82
|
+
else set.add(value);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Who imports each file, and which files name each basename in a string. One pass per file. */
|
|
86
|
+
function referrersOf(sources: ReadonlyMap<string, string>): {
|
|
87
|
+
readonly imports: ReadonlyMap<string, ReadonlySet<string>>;
|
|
88
|
+
readonly named: ReadonlyMap<string, ReadonlySet<string>>;
|
|
89
|
+
} {
|
|
90
|
+
const host: ClosureHost = { read: (path) => sources.get(path), alias: () => undefined };
|
|
91
|
+
const imports = new Map<string, Set<string>>();
|
|
92
|
+
const named = new Map<string, Set<string>>();
|
|
93
|
+
const prefixes: { readonly prefix: string; readonly from: string }[] = [];
|
|
94
|
+
for (const [from, text] of sources) {
|
|
95
|
+
for (const match of text.matchAll(RELATIVE)) {
|
|
96
|
+
if (onCommentLine(text, match.index)) continue;
|
|
97
|
+
const target = resolveSpec(host, from, match[2] ?? '');
|
|
98
|
+
if (target !== undefined && target !== from) add(imports, target, from);
|
|
99
|
+
}
|
|
100
|
+
for (const match of text.matchAll(TEMPLATE)) {
|
|
101
|
+
if (onCommentLine(text, match.index)) continue;
|
|
102
|
+
const head = match[1] ?? '';
|
|
103
|
+
if (match[2] === '`') {
|
|
104
|
+
const target = resolveSpec(host, from, head);
|
|
105
|
+
if (target !== undefined && target !== from) add(imports, target, from);
|
|
106
|
+
} else prefixes.push({ prefix: prefixFrom(from, head), from });
|
|
107
|
+
}
|
|
108
|
+
for (const match of text.matchAll(NAMED)) {
|
|
109
|
+
if (!onCommentLine(text, match.index)) add(named, match[1] ?? '', from);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Few computed imports in any repo, so a scan of every path per prefix costs nothing measurable.
|
|
113
|
+
for (const { prefix, from } of prefixes) {
|
|
114
|
+
for (const path of sources.keys()) {
|
|
115
|
+
if (path !== from && path.startsWith(prefix)) add(imports, path, from);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { imports, named };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The shipped `.ts` modules of the given packages whose every importer is a test, a fixture or
|
|
123
|
+
* another such module — a greatest fixed point, so a helper only a fake imports is caught with the
|
|
124
|
+
* fake. A module nobody references is not this rule's question, and an entry point is shipped by
|
|
125
|
+
* definition. `.tsx` is not judged: `FIXTURE_EXCLUSION` matches `-fixture.ts` alone.
|
|
126
|
+
*/
|
|
127
|
+
export function testOnlyModules(
|
|
128
|
+
sources: ReadonlyMap<string, string>,
|
|
129
|
+
packages: readonly ShippedPackage[],
|
|
130
|
+
): readonly TestOnlyModule[] {
|
|
131
|
+
const { imports, named } = referrersOf(sources);
|
|
132
|
+
const entryGlobs = new Map(
|
|
133
|
+
packages.map((pkg) => [
|
|
134
|
+
pkg.dir,
|
|
135
|
+
pkg.entries.map((entry) => new Bun.Glob(`packages/${pkg.dir}/${entry}`)),
|
|
136
|
+
]),
|
|
137
|
+
);
|
|
138
|
+
const referrers = new Map<string, ReadonlySet<string>>();
|
|
139
|
+
for (const path of sources.keys()) {
|
|
140
|
+
const dir = /^packages\/([^/]+)\/src\//.exec(path)?.[1];
|
|
141
|
+
const globs = dir === undefined ? undefined : entryGlobs.get(dir);
|
|
142
|
+
if (globs === undefined || !path.endsWith('.ts') || isGenerated(path)) continue;
|
|
143
|
+
if (isTest(path) || isFixture(path) || globs.some((glob) => glob.match(path))) continue;
|
|
144
|
+
const from = new Set([...(imports.get(path) ?? []), ...(named.get(baseName(path)) ?? [])]);
|
|
145
|
+
from.delete(path);
|
|
146
|
+
if (from.size > 0) referrers.set(path, from);
|
|
147
|
+
}
|
|
148
|
+
const testOnly = new Set(referrers.keys());
|
|
149
|
+
const live = (path: string): boolean => !isTest(path) && !isFixture(path) && !testOnly.has(path);
|
|
150
|
+
for (let changed = true; changed; ) {
|
|
151
|
+
changed = false;
|
|
152
|
+
for (const path of testOnly) {
|
|
153
|
+
if (![...(referrers.get(path) ?? [])].some(live)) continue;
|
|
154
|
+
testOnly.delete(path);
|
|
155
|
+
changed = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return [...testOnly].sort().map((path) => ({
|
|
159
|
+
path,
|
|
160
|
+
importers: [...(referrers.get(path) ?? [])].sort(),
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `X_PACKAGE_TEST_ONLY_SHIPPED`, its fix the registered rename with the real paths in it. */
|
|
165
|
+
export const testOnlyShippedFinding = (module: TestOnlyModule): Finding => {
|
|
166
|
+
const shown = module.importers.slice(0, 3).join(', ');
|
|
167
|
+
const more = module.importers.length > 3 ? ` and ${module.importers.length - 3} more` : '';
|
|
168
|
+
return {
|
|
169
|
+
code: 'X_PACKAGE_TEST_ONLY_SHIPPED',
|
|
170
|
+
cause: `${module.path} is imported only by tests and fixtures (${shown}${more}), and "files" publishes it — test scaffolding in every install`,
|
|
171
|
+
fix: `git mv ${renderFixShellArg(module.path, '<the module above>')} ${renderFixShellArg(module.path.replace(/\.ts$/, FIXTURE_SUFFIX), '<its name>-fixture.ts')} # and update its importers`,
|
|
172
|
+
docs: ERROR_DOCS_URL,
|
|
173
|
+
at: module.path,
|
|
174
|
+
meta: { importers: [...module.importers] },
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The rule over a repo on disk. Every source file is a possible importer — a script reading a
|
|
180
|
+
* package's module by relative path is a shipped use of it — so the walk is the gate's own
|
|
181
|
+
* (`eachSourceFile`), tests included. A repo with no published package reads nothing.
|
|
182
|
+
*/
|
|
183
|
+
export async function checkTestOnlyShipped(
|
|
184
|
+
root: string,
|
|
185
|
+
packages: readonly ShippedPackage[],
|
|
186
|
+
): Promise<readonly Finding[]> {
|
|
187
|
+
if (packages.length === 0) return [];
|
|
188
|
+
const paths: string[] = [];
|
|
189
|
+
for await (const path of eachSourceFile(root)) if (!isGenerated(path)) paths.push(path);
|
|
190
|
+
const texts = await Promise.all(paths.map((path) => Bun.file(`${root}/${path}`).text()));
|
|
191
|
+
const sources = new Map(paths.map((path, index) => [path, texts[index] ?? '']));
|
|
192
|
+
return testOnlyModules(sources, packages).map(testOnlyShippedFinding);
|
|
193
|
+
}
|
package/src/test-passes.ts
CHANGED
|
@@ -24,8 +24,6 @@ export interface PassInput {
|
|
|
24
24
|
readonly workers: number;
|
|
25
25
|
/** The positional, when there was one. A pass over one type keeps it; the split never adds one. */
|
|
26
26
|
readonly type?: TestType;
|
|
27
|
-
/** Set for a `--worker I` rerun, which is one process by construction. */
|
|
28
|
-
readonly shard?: number;
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
const widthFor = (files: readonly TestFile[], workers: number): number =>
|
|
@@ -45,22 +43,9 @@ const widthFor = (files: readonly TestFile[], workers: number): number =>
|
|
|
45
43
|
*
|
|
46
44
|
* Each serial type is its own pass rather than one pass over both, because the pass's `type` is
|
|
47
45
|
* what its failure reproduces with: `x test live --workers 1` selects exactly the files that ran.
|
|
48
|
-
*
|
|
49
|
-
* A `--worker I` rerun is left whole: it is a single `bun test --isolate --shard=i/N` process, so
|
|
50
|
-
* nothing inside it runs beside anything else, and splitting it would make shard i of the rerun a
|
|
51
|
-
* different set of files from shard i of the run it reproduces.
|
|
52
46
|
*/
|
|
53
47
|
export function testPasses(input: PassInput): readonly TestPass[] {
|
|
54
48
|
const serialTypes = new Set<TestType>(SERIAL_TYPES);
|
|
55
|
-
if (input.shard !== undefined) {
|
|
56
|
-
return [
|
|
57
|
-
{
|
|
58
|
-
files: input.files,
|
|
59
|
-
workers: widthFor(input.files, input.workers),
|
|
60
|
-
...(input.type === undefined ? {} : { type: input.type }),
|
|
61
|
-
},
|
|
62
|
-
];
|
|
63
|
-
}
|
|
64
49
|
const shared = input.files.filter((file) => !serialTypes.has(ownerOf(file.path)));
|
|
65
50
|
const passes: TestPass[] = [];
|
|
66
51
|
// Cheapest first, and the widest first: the pool run is most of the corpus and most of the
|
package/src/test-select.ts
CHANGED
|
@@ -6,11 +6,13 @@ import { join } from 'node:path';
|
|
|
6
6
|
// Bun ships no equivalent: `join` builds the host-separator path from the scan root to a hit.
|
|
7
7
|
// Sizing is Bun's own (`Bun.file().size`), so nothing here reaches for `node:fs`.
|
|
8
8
|
import { nearestName, renderFixShellArg } from '@ultimat3/core';
|
|
9
|
+
import { TEST_TYPES } from '@ultimat3/testing';
|
|
9
10
|
import { BadFlagError } from './errors';
|
|
10
11
|
import type { ParsedArgs } from './parse';
|
|
11
12
|
import { flagString } from './parse';
|
|
13
|
+
import { isIgnoredTestPath } from './test-ignores';
|
|
12
14
|
import type { TestType } from './verify-tests';
|
|
13
|
-
import { ownerOf
|
|
15
|
+
import { ownerOf } from './verify-tests';
|
|
14
16
|
|
|
15
17
|
export interface TestFile {
|
|
16
18
|
readonly path: string;
|
|
@@ -27,20 +29,10 @@ const TEST_GLOB = '**/*.test.{ts,tsx}';
|
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* The root `test` script's ignore list, kept identical so `x test` and `bun run test` see one
|
|
30
|
-
* suite
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* `dummy/` and `build/` complete the list `verify-tests.ts` already excluded (`NEVER_A_TEST`). The
|
|
35
|
-
* comment above claimed the two agreed and they did not: `x test unit` discovered 464 files where
|
|
36
|
-
* the gate's `unit` step ran 441, so the gate's own test steps — which now select through this
|
|
37
|
-
* function — would have started running a nested demo app's suite on the framework's gate.
|
|
38
|
-
*
|
|
39
|
-
* Both directories are gated where they belong, by `scripts/reference-app-gate.ts` running
|
|
40
|
-
* `x verify` inside each app — see `NEVER_A_TEST` for why that is the only place they run.
|
|
32
|
+
* suite — `test-ignores.ts` holds it, and the serial steps' argv projects the same policy. `e2e/`
|
|
33
|
+
* is NOT on it: an opt-in suite that the gate runs but `x test` silently drops is a suite nobody
|
|
34
|
+
* runs until CI says so.
|
|
41
35
|
*/
|
|
42
|
-
const IGNORED = ['/dist/', '/build/', '/node_modules/', '/examples/', '/dummy/'];
|
|
43
|
-
|
|
44
36
|
/**
|
|
45
37
|
* File size stands in for duration: cheap to read, and it correlates far better than file count.
|
|
46
38
|
* `type`, when given, narrows to exactly the files verify-tests.ts would run for that suite — one
|
|
@@ -58,7 +50,7 @@ export async function discoverTests(
|
|
|
58
50
|
const files: TestFile[] = [];
|
|
59
51
|
for await (const found of new Bun.Glob(TEST_GLOB).scan({ cwd: root, absolute: false })) {
|
|
60
52
|
const path = found.split('\\').join('/');
|
|
61
|
-
if (
|
|
53
|
+
if (isIgnoredTestPath(path)) continue;
|
|
62
54
|
if (filters !== undefined && !filters.some((part) => path.includes(part))) continue;
|
|
63
55
|
if (type !== undefined && !belongsToType(path, type)) continue;
|
|
64
56
|
files.push({ path, bytes: Bun.file(join(root, path)).size });
|
package/src/test-shards.ts
CHANGED
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
// `@ultimat3/testing`'s preload clears the plugins after each file (`isolated-plugins.ts`). And the
|
|
40
40
|
// per-WORKER database survives untouched: `@ultimat3/testing`'s `workerId` already read
|
|
41
41
|
// `BUN_TEST_WORKER_ID` as its second key, which is exactly what Bun sets, 1..N, one per real
|
|
42
|
-
// process (probed on 1.4.0).
|
|
43
|
-
// still sets, so a single-shard rerun keeps naming its own database.
|
|
42
|
+
// process (probed on 1.4.0).
|
|
44
43
|
|
|
45
44
|
// why: Bun ships no path-join primitive; the timings cache lives under the run's root.
|
|
46
45
|
import { join } from 'node:path';
|
|
47
46
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
47
|
+
import { ISOLATED_ENV } from '@ultimat3/testing';
|
|
48
48
|
import type { AffectedSelection } from './affected';
|
|
49
49
|
import type { Runner } from './exec';
|
|
50
50
|
import { msg } from './messages';
|
|
@@ -52,9 +52,6 @@ import type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
|
52
52
|
import { quoteArg } from './shell-quote';
|
|
53
53
|
import { testEnvOverrides } from './test-dotenv';
|
|
54
54
|
|
|
55
|
-
/** `@ultimat3/testing`'s `ISOLATED_ENV`, restated: `cli → testing` is a runtime edge, kept to fixtures. */
|
|
56
|
-
export const ISOLATED_TEST_ENV = 'ULTIMATE_TEST_ISOLATED';
|
|
57
|
-
|
|
58
55
|
/** Bun's per-file durations, cached under the run's root and refreshed by every parallel run. */
|
|
59
56
|
export const TEST_TIMINGS_FILE = '.x/test-timings.json';
|
|
60
57
|
|
|
@@ -68,24 +65,13 @@ import type { TestType } from './verify-tests';
|
|
|
68
65
|
* The argv for one run. An explicit file list, never a re-glob: discovery already decided which
|
|
69
66
|
* files belong to this type, and a child that globs again can pick up a file the selection removed.
|
|
70
67
|
*
|
|
71
|
-
* `--parallel=N`
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* to remove: measured on this repo, an 8-way split turned 0 failures into 36, every one
|
|
75
|
-
* `X_PERMISSION_UNKNOWN` in `@ultimat3/query` because the `packages/cli` file that had been
|
|
76
|
-
* declaring `feed:read` for it landed elsewhere. Half a dozen registries here are process-global by
|
|
77
|
-
* design — the permission set, the roles, the entity/action/query tables, the error-code titles,
|
|
78
|
-
* the fixture bag — and a serial `bun test` only passes because glob order happens to put every
|
|
79
|
-
* declaring file before every file that reads what it left behind.
|
|
80
|
-
*
|
|
81
|
-
* Bun's shard partition is round-robin over the list it is given, so the sorted list this hands it
|
|
82
|
-
* makes `--shard=2/8` the same 1/8 on CI and on a laptop (probed on 1.4.0).
|
|
68
|
+
* `--parallel=N` over the selection, sorted, so two runs over one corpus hand Bun the same list.
|
|
69
|
+
* One CI job's share of the corpus is `x verify --only unit --shard i/n` (`verify-shard.ts`), never
|
|
70
|
+
* a flag here: `x test --worker I` was a second, 0-based, unhashed split of the same suite.
|
|
83
71
|
*/
|
|
84
72
|
export function testArgs(input: {
|
|
85
73
|
readonly files: readonly string[];
|
|
86
74
|
readonly workers: number;
|
|
87
|
-
/** 0-based, matching `--worker`. Absent runs the whole selection across `workers` processes. */
|
|
88
|
-
readonly shard?: number;
|
|
89
75
|
/**
|
|
90
76
|
* Everything after a bare `--`, handed to `bun test` verbatim and BEFORE the file list, which is
|
|
91
77
|
* where bun reads its flags. `ParsedArgs.passthrough` had no reader anywhere until 2026-09, so
|
|
@@ -114,32 +100,23 @@ export function testArgs(input: {
|
|
|
114
100
|
const isolate =
|
|
115
101
|
extra.includes('--isolate') || (input.isolate === true && !extra.includes('--no-isolate'));
|
|
116
102
|
const bare = extra.filter((arg) => arg !== '--isolate' && arg !== '--no-isolate');
|
|
117
|
-
return
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
]
|
|
129
|
-
: [
|
|
130
|
-
'bun',
|
|
131
|
-
'test',
|
|
132
|
-
...(isolate ? ['--isolate'] : []),
|
|
133
|
-
`--shard=${String(input.shard + 1)}/${String(input.workers)}`,
|
|
134
|
-
...bare,
|
|
135
|
-
...files,
|
|
136
|
-
];
|
|
103
|
+
return [
|
|
104
|
+
'bun',
|
|
105
|
+
'test',
|
|
106
|
+
`--parallel=${String(input.workers)}`,
|
|
107
|
+
...(isolate ? [] : ['--no-isolate']),
|
|
108
|
+
...(input.timings === undefined || bare.some((arg) => arg.startsWith('--timings'))
|
|
109
|
+
? []
|
|
110
|
+
: [`--timings=${input.timings}`, '--update-timings']),
|
|
111
|
+
...bare,
|
|
112
|
+
...files,
|
|
113
|
+
];
|
|
137
114
|
}
|
|
138
115
|
|
|
139
116
|
/**
|
|
140
117
|
* The files an argv selects, flags stripped. `bun test` takes its file list positionally, so this
|
|
141
118
|
* is the inverse of `testArgs` and the one thing a test asserting "what did the child get?" needs
|
|
142
|
-
* — the flag COUNT is not fixed (`--
|
|
119
|
+
* — the flag COUNT is not fixed (`--no-isolate` and the timings pair come and go), and a
|
|
143
120
|
* test slicing a hardcoded prefix length reads a flag as a filename the day that changes.
|
|
144
121
|
*/
|
|
145
122
|
export const filesIn = (command: readonly string[]): readonly string[] =>
|
|
@@ -154,12 +131,9 @@ export interface ReproduceOptions {
|
|
|
154
131
|
readonly sample?: number;
|
|
155
132
|
/**
|
|
156
133
|
* The `--affected` narrowing, when there was one. The input most easily forgotten: `--affected`
|
|
157
|
-
* decides which files exist to run at all, so a rerun without it selects the WHOLE corpus
|
|
158
|
-
* its shard 2 is a different shard 2.
|
|
134
|
+
* decides which files exist to run at all, so a rerun without it selects the WHOLE corpus.
|
|
159
135
|
*/
|
|
160
136
|
readonly affected?: AffectedSelection;
|
|
161
|
-
/** 0-based, when reproducing ONE shard. Absent reproduces the whole selection. */
|
|
162
|
-
readonly shard?: number;
|
|
163
137
|
/** What the caller put after `--`. It reaches `bun test`, so a rerun without it runs differently. */
|
|
164
138
|
readonly passthrough?: readonly string[];
|
|
165
139
|
/** The run was isolated per file; the rerun must be too. */
|
|
@@ -185,7 +159,6 @@ export function reproduceFor(options: ReproduceOptions): string {
|
|
|
185
159
|
...(options.affected?.dirty === true ? ['--dirty'] : []),
|
|
186
160
|
'--workers',
|
|
187
161
|
String(options.workers),
|
|
188
|
-
...(options.shard === undefined ? [] : ['--worker', String(options.shard)]),
|
|
189
162
|
...(options.isolate === true ? ['--isolate'] : []),
|
|
190
163
|
// Last, and after a `--` of its own, because that is where the caller typed it and where the
|
|
191
164
|
// parser will find it again. Quoted for `shell-quote.ts`'s reason: a reproduce line is pasted.
|
|
@@ -200,14 +173,11 @@ export interface RunShardsOptions {
|
|
|
200
173
|
readonly runner: Runner;
|
|
201
174
|
readonly files: readonly TestFile[];
|
|
202
175
|
readonly workers: number;
|
|
203
|
-
/** Run exactly one shard of the same N-way split, not a one-worker run of everything. */
|
|
204
|
-
readonly only?: number;
|
|
205
176
|
readonly filter?: string;
|
|
206
177
|
readonly type?: TestType;
|
|
207
178
|
/**
|
|
208
179
|
* Set when `--sample` narrowed `files`: `kept` is what survived, `total` what discovery found.
|
|
209
|
-
*
|
|
210
|
-
* sample and would otherwise report that shard's size as the corpus.
|
|
180
|
+
* Carried rather than counted from what ran: a pass runs part of the sample, never the corpus.
|
|
211
181
|
*/
|
|
212
182
|
readonly sample?: { readonly kept: number; readonly total: number };
|
|
213
183
|
/** Passed straight to `reproduceFor`: see `ReproduceOptions.affected`. */
|
|
@@ -248,7 +218,6 @@ const planOf = (
|
|
|
248
218
|
...(pass.type === undefined ? {} : { type: pass.type }),
|
|
249
219
|
...(options.sample === undefined ? {} : { sample: options.sample.kept }),
|
|
250
220
|
...(options.affected === undefined ? {} : { affected: options.affected }),
|
|
251
|
-
...(options.only === undefined ? {} : { shard: options.only }),
|
|
252
221
|
...(options.isolate === true ? { isolate: true } : {}),
|
|
253
222
|
...(options.passthrough === undefined || options.passthrough.length === 0
|
|
254
223
|
? {}
|
|
@@ -256,17 +225,12 @@ const planOf = (
|
|
|
256
225
|
});
|
|
257
226
|
|
|
258
227
|
/**
|
|
259
|
-
* `
|
|
260
|
-
*
|
|
261
|
-
* codes already exist and both are already documented — a third would be a new name for a failed
|
|
262
|
-
* `bun test`.
|
|
228
|
+
* `X_TEST_FAILED`, whose `fix:` reruns exactly what failed. `X_TEST_SHARD_FAILED` was the
|
|
229
|
+
* `--worker` rerun's and went with it: the code stays registered (codes are forever) and unthrown.
|
|
263
230
|
*/
|
|
264
231
|
export const failureOf = (code: number, files: number, plan: ReproduceOptions): Finding => ({
|
|
265
|
-
code:
|
|
266
|
-
cause:
|
|
267
|
-
plan.shard === undefined
|
|
268
|
-
? `${plan.type ?? 'test'} run exited ${code} across ${plan.workers} worker(s) (${files} file(s))`
|
|
269
|
-
: `shard ${plan.shard} of ${plan.workers} exited ${code} (${files} file(s))`,
|
|
232
|
+
code: 'X_TEST_FAILED',
|
|
233
|
+
cause: `${plan.type ?? 'test'} run exited ${code} across ${plan.workers} worker(s) (${files} file(s))`,
|
|
270
234
|
fix: reproduceFor(plan),
|
|
271
235
|
docs: ERROR_DOCS_URL,
|
|
272
236
|
});
|
|
@@ -281,19 +245,17 @@ export const failureOf = (code: number, files: number, plan: ReproduceOptions):
|
|
|
281
245
|
* it. And every pass runs even after one fails — the caller asked for a suite, and a report that
|
|
282
246
|
* stops at the first red step hides the rest of the answer.
|
|
283
247
|
*
|
|
284
|
-
* `ULTIMATE_TEST_WORKER`
|
|
285
|
-
*
|
|
286
|
-
* numbers each with `BUN_TEST_WORKER_ID`, which `@ultimat3/testing`'s `workerId` already reads.
|
|
248
|
+
* No `ULTIMATE_TEST_WORKER` here: a `--parallel` run has N processes and Bun numbers each with
|
|
249
|
+
* `BUN_TEST_WORKER_ID`, which `@ultimat3/testing`'s `workerId` already reads.
|
|
287
250
|
*/
|
|
288
251
|
export async function runShards(options: RunShardsOptions): Promise<CommandResult> {
|
|
289
|
-
const only = options.only;
|
|
290
252
|
// Computed ONCE per invocation, never per pass: every pass spawns from the same `root` and the
|
|
291
253
|
// same parent env, so the leaked-key set cannot differ pass to pass.
|
|
292
254
|
const envOverrides: Record<string, string | undefined> = {
|
|
293
255
|
...testEnvOverrides(options.root, options.env ?? Bun.env),
|
|
294
256
|
// Only an isolated run: the preload clears `Bun.plugin` handlers after each file, which in a
|
|
295
257
|
// shared global would leave the next `.tsx` compiling with Bun's classic factory.
|
|
296
|
-
...(isolatedRun(options) ? { [
|
|
258
|
+
...(isolatedRun(options) ? { [ISOLATED_ENV]: '1' } : {}),
|
|
297
259
|
};
|
|
298
260
|
const passthrough = options.passthrough ?? [];
|
|
299
261
|
const watching = passthrough.includes('--watch');
|
|
@@ -302,7 +264,6 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
302
264
|
files: options.files,
|
|
303
265
|
workers: options.workers,
|
|
304
266
|
...(options.type === undefined ? {} : { type: options.type }),
|
|
305
|
-
...(only === undefined ? {} : { shard: only }),
|
|
306
267
|
});
|
|
307
268
|
const started = performance.now();
|
|
308
269
|
const steps: StepResult[] = [];
|
|
@@ -312,53 +273,38 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
312
273
|
let exitCode = 0;
|
|
313
274
|
for (const pass of passes) {
|
|
314
275
|
const files = pass.files.map((file) => file.path);
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
isolatedRun(options) ? BATCH_FILES_PER_WORKER : SHARED_BATCH_FILES_PER_WORKER,
|
|
325
|
-
)
|
|
326
|
-
: [files];
|
|
276
|
+
// `-- --watch` is never split: it never exits, so a second batch would never start. Every
|
|
277
|
+
// other pass is spent in `test-batches.ts`' batches, one `bun test` after another.
|
|
278
|
+
const batches = !watching
|
|
279
|
+
? testBatches(
|
|
280
|
+
files,
|
|
281
|
+
pass.workers,
|
|
282
|
+
isolatedRun(options) ? BATCH_FILES_PER_WORKER : SHARED_BATCH_FILES_PER_WORKER,
|
|
283
|
+
)
|
|
284
|
+
: [files];
|
|
327
285
|
const result = await runBatches({
|
|
328
286
|
runner: options.runner,
|
|
329
287
|
batches,
|
|
330
288
|
stopOnFailure: bailing,
|
|
331
289
|
workers: pass.workers,
|
|
332
|
-
// A
|
|
333
|
-
|
|
334
|
-
...(only === undefined && pass.workers > 1 && options.lease !== undefined
|
|
335
|
-
? { lease: options.lease }
|
|
336
|
-
: {}),
|
|
290
|
+
// A serial pass is one worker: only a default-width parallel pass leases from the pool.
|
|
291
|
+
...(pass.workers > 1 && options.lease !== undefined ? { lease: options.lease } : {}),
|
|
337
292
|
argsFor: (batch, width) =>
|
|
338
293
|
testArgs({
|
|
339
294
|
files: batch,
|
|
340
|
-
workers:
|
|
341
|
-
...(only === undefined ? {} : { shard: only }),
|
|
295
|
+
workers: width,
|
|
342
296
|
...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
|
|
343
297
|
...(options.isolate === undefined ? {} : { isolate: options.isolate }),
|
|
344
298
|
timings: join(options.root, TEST_TIMINGS_FILE),
|
|
345
299
|
}),
|
|
346
300
|
options: {
|
|
347
301
|
cwd: options.root,
|
|
348
|
-
...(Object.keys(envOverrides).length === 0
|
|
349
|
-
? {}
|
|
350
|
-
: {
|
|
351
|
-
env: {
|
|
352
|
-
...envOverrides,
|
|
353
|
-
...(only === undefined ? {} : { ULTIMATE_TEST_WORKER: String(only) }),
|
|
354
|
-
},
|
|
355
|
-
}),
|
|
302
|
+
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
356
303
|
},
|
|
357
304
|
});
|
|
358
305
|
batchCount += batches.length;
|
|
359
306
|
const plan = planOf(options, pass);
|
|
360
|
-
const label =
|
|
361
|
-
only === undefined ? `${pass.workers} worker(s)` : `shard ${only} of ${pass.workers}`;
|
|
307
|
+
const label = `${pass.workers} worker(s)`;
|
|
362
308
|
steps.push({
|
|
363
309
|
name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files${
|
|
364
310
|
batches.length > 1 ? msg('cli.test.batches', { batches: batches.length }) : ''
|
|
@@ -403,7 +349,6 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
403
349
|
durationMs,
|
|
404
350
|
...(options.filter === undefined ? {} : { filter: options.filter }),
|
|
405
351
|
...(sample === undefined ? {} : { sample: { kept: sample.kept, total: sample.total } }),
|
|
406
|
-
...(only === undefined ? {} : { shard: only }),
|
|
407
352
|
// Only when the split made more than one, so a single-pass run's JSON is byte-identical to
|
|
408
353
|
// what it has always been — and a mixed one can never be read as if it were a single run.
|
|
409
354
|
...(spent.length > 1 ? { passes: spent } : {}),
|
package/src/test-workers.ts
CHANGED
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
// How wide a parallel test run goes, decided in one place. `x test --workers`, `x verify
|
|
2
2
|
// --workers` and every parallel step of the gate read this — a second default would split the same
|
|
3
|
-
// suite two different ways, and the `--
|
|
4
|
-
//
|
|
3
|
+
// suite two different ways, and the `--workers N` a failure's reproduction prints would then name
|
|
4
|
+
// a width the gate never ran.
|
|
5
5
|
|
|
6
6
|
// Bun ships no CPU-count or memory primitive: `cpus()` is the fallback when navigator cannot
|
|
7
7
|
// answer, and `totalmem()` is the only reader of the machine's RAM.
|
|
8
8
|
// why: Bun ships no CPU-count or total-memory primitive.
|
|
9
9
|
import { cpus, totalmem } from 'node:os';
|
|
10
|
+
import { UltimateError } from '@ultimat3/core';
|
|
10
11
|
import type { TestType } from '@ultimat3/testing';
|
|
11
|
-
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `ULTIMATE_TEST_MEMORY_BUDGET` or `ULTIMATE_TEST_MAX_WORKERS` set to something that does not
|
|
15
|
+
* parse. Refused rather than ignored: a typo read as "the default" is a run sized for a machine
|
|
16
|
+
* the caller said this is not.
|
|
17
|
+
*/
|
|
18
|
+
export class TestBudgetInvalidError extends UltimateError {
|
|
19
|
+
constructor(name: string, value: string) {
|
|
20
|
+
super({
|
|
21
|
+
code: 'X_TEST_BUDGET_INVALID',
|
|
22
|
+
cause: `${name}=${JSON.stringify(value)} does not parse — ${
|
|
23
|
+
name === 'ULTIMATE_TEST_MAX_WORKERS'
|
|
24
|
+
? 'expected a positive integer'
|
|
25
|
+
: 'expected a size such as 3g, 512m or 4GiB'
|
|
26
|
+
}`,
|
|
27
|
+
fix:
|
|
28
|
+
name === 'ULTIMATE_TEST_MAX_WORKERS'
|
|
29
|
+
? 'ULTIMATE_TEST_MAX_WORKERS=4 x verify'
|
|
30
|
+
: 'ULTIMATE_TEST_MEMORY_BUDGET=3g x verify',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
12
34
|
|
|
13
35
|
/** navigator first: it is the runtime's own answer, and it respects a container's CPU limit. */
|
|
14
36
|
export function availableCpus(): number {
|
|
@@ -219,7 +241,7 @@ export const sharedWorkers = (
|
|
|
219
241
|
* Which types run across worker processes, and why the other two cannot.
|
|
220
242
|
*
|
|
221
243
|
* Parallel is safe when the only thing a test file shares with another file is the database, and
|
|
222
|
-
* the database is per worker by construction (`
|
|
244
|
+
* the database is per worker by construction (`BUN_TEST_WORKER_ID` → one clone of the migrated
|
|
223
245
|
* template, `@ultimat3/testing`'s `acquireWorkerDatabase`). Every other process-global in this
|
|
224
246
|
* framework — the permission set, the roles, the entity/action/query registries, the error-code
|
|
225
247
|
* titles, the fixture bag — is handled by `--isolate` giving each FILE its own module registry.
|