@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// none of them, so each emitted TS2307 in any slice a resource had not been run in first.
|
|
5
5
|
|
|
6
6
|
import { stripComments } from '@ultimat3/core';
|
|
7
|
+
import { adminCatalogFiles } from './admin-catalog';
|
|
7
8
|
import type { FeatureTarget } from './entity';
|
|
8
9
|
import { entityFiles } from './entity';
|
|
9
10
|
import type { GeneratedFile, NameSet } from './naming';
|
|
@@ -74,16 +75,24 @@ const ifAbsent = (files: readonly GeneratedFile[]): readonly GeneratedFile[] =>
|
|
|
74
75
|
export function sliceFoundation(
|
|
75
76
|
target: FeatureTarget,
|
|
76
77
|
needs: readonly SliceModule[],
|
|
78
|
+
/** Every locale the planted entity's admin labels ship for. Defaults to `['en']`. */
|
|
79
|
+
locales?: readonly string[],
|
|
77
80
|
): readonly GeneratedFile[] {
|
|
78
81
|
const feature = names(target.feature);
|
|
79
82
|
const dir = `${target.surfaceDir}/${target.feature}`;
|
|
80
|
-
return
|
|
81
|
-
...(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
return [
|
|
84
|
+
...ifAbsent([
|
|
85
|
+
...(needs.includes('entity') ? entityFiles(target.feature, target) : []),
|
|
86
|
+
...(needs.includes('policy') ? policyFiles(target.feature, target) : []),
|
|
87
|
+
...(needs.includes('errors')
|
|
88
|
+
? [{ path: `${dir}/errors.ts`, contents: errorsSource(feature) }]
|
|
89
|
+
: []),
|
|
90
|
+
]),
|
|
91
|
+
// A planted entity joins the typed handle, and an entity in the handle IS an admin screen —
|
|
92
|
+
// so it gets the labels `x g entity` writes for one, or its nav entry renders a missing-key
|
|
93
|
+
// marker. A catalog MERGE, never `if-absent`: a key already there keeps the value it has.
|
|
94
|
+
...(needs.includes('entity') ? adminCatalogFiles(target.feature, target, locales) : []),
|
|
95
|
+
];
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
/** The exported names an `export { a, b as c }` list declares — `c`, never `b`. */
|
|
@@ -117,10 +126,39 @@ export function sliceExports(source: string, name: string): boolean {
|
|
|
117
126
|
const code = stripComments(source);
|
|
118
127
|
// `(?:async\s+)?` before `function`: an `export async function byId` — every repo function
|
|
119
128
|
// `x g entity` scaffolds — matched neither this nor `listedExports`, so a caller checking for
|
|
120
|
-
// `byId`/`
|
|
129
|
+
// `byId`/`list` on a real repo.ts always read `false`. `async` has no meaning before
|
|
121
130
|
// `class`/`const`/`let`/`var`/`enum`, so it is scoped to `function` only.
|
|
122
131
|
const declared = new RegExp(
|
|
123
132
|
`\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|(?:async\\s+)?function|enum)\\s+${name}\\b`,
|
|
124
133
|
);
|
|
125
134
|
return declared.test(code) || listedExports(code).includes(name);
|
|
126
135
|
}
|
|
136
|
+
|
|
137
|
+
/** ` title: text({ max: 200 }),` — one column of an entity, as its name and its builder. */
|
|
138
|
+
const COLUMN_LINE = /^ {4}([A-Za-z_$][\w$]*): ([A-Za-z_$][\w$]*)\(/gm;
|
|
139
|
+
|
|
140
|
+
const columnsOf = (entitySource: string): string =>
|
|
141
|
+
[...stripComments(entitySource).matchAll(COLUMN_LINE)]
|
|
142
|
+
.map((match) => `${match[1] ?? ''}: ${match[2] ?? ''}`)
|
|
143
|
+
.join(', ');
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether a generated TEST may store a row in this slice: its entity declares exactly the columns
|
|
147
|
+
* `x g entity` scaffolds for this feature — read off the entity this generator would write, never
|
|
148
|
+
* a second list — and its `repo.ts`, when one is on disk, still exports `insert`. A slice an author
|
|
149
|
+
* reshaped has a row this generator cannot spell (one more required column is enough), so its
|
|
150
|
+
* tests assert the branches that need none and leave the stored-row case to the author.
|
|
151
|
+
*/
|
|
152
|
+
export function sliceTakesScaffoldRow(
|
|
153
|
+
target: FeatureTarget,
|
|
154
|
+
sliceEntity: string | undefined,
|
|
155
|
+
sliceRepo: string | undefined,
|
|
156
|
+
): boolean {
|
|
157
|
+
if (sliceEntity === undefined) return false;
|
|
158
|
+
const scaffold = entityFiles(target.feature, target).find((file) =>
|
|
159
|
+
file.path.endsWith('/entity.ts'),
|
|
160
|
+
);
|
|
161
|
+
const wanted = columnsOf(String(scaffold?.contents ?? ''));
|
|
162
|
+
if (wanted === '' || columnsOf(sliceEntity) !== wanted) return false;
|
|
163
|
+
return sliceRepo === undefined || sliceExports(sliceRepo, 'insert');
|
|
164
|
+
}
|
package/src/test-counts.ts
CHANGED
|
@@ -5,12 +5,21 @@
|
|
|
5
5
|
import type { ExecResult } from './exec';
|
|
6
6
|
import { execOutput } from './exec';
|
|
7
7
|
import { parseBunTest } from './mcp-test-output';
|
|
8
|
+
// One reader of bun's `N errors` line, shared with MCP `tests.run` — never a second regex.
|
|
9
|
+
import { errorsIn } from './mcp-test-run';
|
|
8
10
|
|
|
9
11
|
export interface TestCounts {
|
|
10
12
|
/** Tests that executed — passed plus failed. A failed test is a test that ran. */
|
|
11
13
|
readonly ran: number;
|
|
12
14
|
/** Tests bun reported as skipped or todo, which is the same thing here: they did not run. */
|
|
13
15
|
readonly skipped: number;
|
|
16
|
+
/**
|
|
17
|
+
* Errors raised OUTSIDE any test — a `describe` body or a module that threw on load, a failed
|
|
18
|
+
* hook — which bun counts on a line of their own (`1 error`) beside `0 fail`. Present only when
|
|
19
|
+
* there were some: the exit code already makes the step red, and without this number the step
|
|
20
|
+
* line read "12 ran, 0 skipped" over a run whose file never got as far as declaring a test.
|
|
21
|
+
*/
|
|
22
|
+
readonly errors?: number;
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
/**
|
|
@@ -26,10 +35,13 @@ export interface TestCounts {
|
|
|
26
35
|
export const countsOf = (results: readonly ExecResult[]): TestCounts => {
|
|
27
36
|
let ran = 0;
|
|
28
37
|
let skipped = 0;
|
|
38
|
+
let errors = 0;
|
|
29
39
|
for (const result of results) {
|
|
30
|
-
const
|
|
40
|
+
const output = execOutput(result);
|
|
41
|
+
const run = parseBunTest(output, result.durationMs);
|
|
31
42
|
ran += run.passed + run.failed;
|
|
32
43
|
skipped += run.skipped;
|
|
44
|
+
errors += errorsIn(output);
|
|
33
45
|
}
|
|
34
|
-
return { ran, skipped };
|
|
46
|
+
return { ran, skipped, ...(errors === 0 ? {} : { errors }) };
|
|
35
47
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The ONE policy for which paths are never a test the gate runs here, projected twice: the path
|
|
2
|
+
// predicate `discoverTests` filters by and the `--path-ignore-patterns` the serial steps pass to
|
|
3
|
+
// `bun test`. A leaf, so `test-select.ts` and `verify-tests.ts` (which import each other) share it.
|
|
4
|
+
|
|
5
|
+
/** Build output and installed packages: never a test, at ANY depth — a workspace's `dist/` too. */
|
|
6
|
+
const IGNORED_ANYWHERE = ['dist', 'node_modules'] as const;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Nested projects with their own gate (`examples/`, `dummy/`, run by
|
|
10
|
+
* `scripts/reference-app-gate.ts`) and the root `build/` — skipped AT THE ROOT only. Matched at any
|
|
11
|
+
* depth they swallowed an app's own slice: `x g resource build` lost 14 of its 16 test files and
|
|
12
|
+
* `x g resource example` its `examples/` page tests, from `x test` and the gate alike.
|
|
13
|
+
*/
|
|
14
|
+
const IGNORED_AT_ROOT = ['build', 'examples', 'dummy'] as const;
|
|
15
|
+
|
|
16
|
+
/** Whether a root-relative POSIX path is excluded from every test selection. */
|
|
17
|
+
export const isIgnoredTestPath = (path: string): boolean =>
|
|
18
|
+
IGNORED_ANYWHERE.some((dir) => `/${path}`.includes(`/${dir}/`)) ||
|
|
19
|
+
IGNORED_AT_ROOT.some((dir) => path.startsWith(`${dir}/`));
|
|
20
|
+
|
|
21
|
+
/** The same policy as `bun test --path-ignore-patterns` globs, relative to the run's root. */
|
|
22
|
+
export const IGNORED_TEST_PATTERNS: readonly string[] = [
|
|
23
|
+
...IGNORED_ANYWHERE.map((dir) => `**/${dir}/**`),
|
|
24
|
+
...IGNORED_AT_ROOT.map((dir) => `${dir}/**`),
|
|
25
|
+
];
|
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
|
@@ -9,6 +9,7 @@ import { nearestName, renderFixShellArg } from '@ultimat3/core';
|
|
|
9
9
|
import { BadFlagError } from './errors';
|
|
10
10
|
import type { ParsedArgs } from './parse';
|
|
11
11
|
import { flagString } from './parse';
|
|
12
|
+
import { isIgnoredTestPath } from './test-ignores';
|
|
12
13
|
import type { TestType } from './verify-tests';
|
|
13
14
|
import { ownerOf, TEST_TYPES } from './verify-tests';
|
|
14
15
|
|
|
@@ -27,20 +28,10 @@ const TEST_GLOB = '**/*.test.{ts,tsx}';
|
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* 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.
|
|
31
|
+
* suite — `test-ignores.ts` holds it, and the serial steps' argv projects the same policy. `e2e/`
|
|
32
|
+
* is NOT on it: an opt-in suite that the gate runs but `x test` silently drops is a suite nobody
|
|
33
|
+
* runs until CI says so.
|
|
41
34
|
*/
|
|
42
|
-
const IGNORED = ['/dist/', '/build/', '/node_modules/', '/examples/', '/dummy/'];
|
|
43
|
-
|
|
44
35
|
/**
|
|
45
36
|
* File size stands in for duration: cheap to read, and it correlates far better than file count.
|
|
46
37
|
* `type`, when given, narrows to exactly the files verify-tests.ts would run for that suite — one
|
|
@@ -58,7 +49,7 @@ export async function discoverTests(
|
|
|
58
49
|
const files: TestFile[] = [];
|
|
59
50
|
for await (const found of new Bun.Glob(TEST_GLOB).scan({ cwd: root, absolute: false })) {
|
|
60
51
|
const path = found.split('\\').join('/');
|
|
61
|
-
if (
|
|
52
|
+
if (isIgnoredTestPath(path)) continue;
|
|
62
53
|
if (filters !== undefined && !filters.some((part) => path.includes(part))) continue;
|
|
63
54
|
if (type !== undefined && !belongsToType(path, type)) continue;
|
|
64
55
|
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,8 +52,8 @@ 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
|
|
56
|
-
export const ISOLATED_TEST_ENV =
|
|
55
|
+
/** `@ultimat3/testing`'s `ISOLATED_ENV` — the one spelling, which that package's preload reads. */
|
|
56
|
+
export const ISOLATED_TEST_ENV = ISOLATED_ENV;
|
|
57
57
|
|
|
58
58
|
/** Bun's per-file durations, cached under the run's root and refreshed by every parallel run. */
|
|
59
59
|
export const TEST_TIMINGS_FILE = '.x/test-timings.json';
|
|
@@ -68,24 +68,13 @@ import type { TestType } from './verify-tests';
|
|
|
68
68
|
* The argv for one run. An explicit file list, never a re-glob: discovery already decided which
|
|
69
69
|
* files belong to this type, and a child that globs again can pick up a file the selection removed.
|
|
70
70
|
*
|
|
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).
|
|
71
|
+
* `--parallel=N` over the selection, sorted, so two runs over one corpus hand Bun the same list.
|
|
72
|
+
* One CI job's share of the corpus is `x verify --only unit --shard i/n` (`verify-shard.ts`), never
|
|
73
|
+
* a flag here: `x test --worker I` was a second, 0-based, unhashed split of the same suite.
|
|
83
74
|
*/
|
|
84
75
|
export function testArgs(input: {
|
|
85
76
|
readonly files: readonly string[];
|
|
86
77
|
readonly workers: number;
|
|
87
|
-
/** 0-based, matching `--worker`. Absent runs the whole selection across `workers` processes. */
|
|
88
|
-
readonly shard?: number;
|
|
89
78
|
/**
|
|
90
79
|
* Everything after a bare `--`, handed to `bun test` verbatim and BEFORE the file list, which is
|
|
91
80
|
* where bun reads its flags. `ParsedArgs.passthrough` had no reader anywhere until 2026-09, so
|
|
@@ -114,32 +103,23 @@ export function testArgs(input: {
|
|
|
114
103
|
const isolate =
|
|
115
104
|
extra.includes('--isolate') || (input.isolate === true && !extra.includes('--no-isolate'));
|
|
116
105
|
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
|
-
];
|
|
106
|
+
return [
|
|
107
|
+
'bun',
|
|
108
|
+
'test',
|
|
109
|
+
`--parallel=${String(input.workers)}`,
|
|
110
|
+
...(isolate ? [] : ['--no-isolate']),
|
|
111
|
+
...(input.timings === undefined || bare.some((arg) => arg.startsWith('--timings'))
|
|
112
|
+
? []
|
|
113
|
+
: [`--timings=${input.timings}`, '--update-timings']),
|
|
114
|
+
...bare,
|
|
115
|
+
...files,
|
|
116
|
+
];
|
|
137
117
|
}
|
|
138
118
|
|
|
139
119
|
/**
|
|
140
120
|
* The files an argv selects, flags stripped. `bun test` takes its file list positionally, so this
|
|
141
121
|
* 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 (`--
|
|
122
|
+
* — the flag COUNT is not fixed (`--no-isolate` and the timings pair come and go), and a
|
|
143
123
|
* test slicing a hardcoded prefix length reads a flag as a filename the day that changes.
|
|
144
124
|
*/
|
|
145
125
|
export const filesIn = (command: readonly string[]): readonly string[] =>
|
|
@@ -154,12 +134,9 @@ export interface ReproduceOptions {
|
|
|
154
134
|
readonly sample?: number;
|
|
155
135
|
/**
|
|
156
136
|
* 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.
|
|
137
|
+
* decides which files exist to run at all, so a rerun without it selects the WHOLE corpus.
|
|
159
138
|
*/
|
|
160
139
|
readonly affected?: AffectedSelection;
|
|
161
|
-
/** 0-based, when reproducing ONE shard. Absent reproduces the whole selection. */
|
|
162
|
-
readonly shard?: number;
|
|
163
140
|
/** What the caller put after `--`. It reaches `bun test`, so a rerun without it runs differently. */
|
|
164
141
|
readonly passthrough?: readonly string[];
|
|
165
142
|
/** The run was isolated per file; the rerun must be too. */
|
|
@@ -185,7 +162,6 @@ export function reproduceFor(options: ReproduceOptions): string {
|
|
|
185
162
|
...(options.affected?.dirty === true ? ['--dirty'] : []),
|
|
186
163
|
'--workers',
|
|
187
164
|
String(options.workers),
|
|
188
|
-
...(options.shard === undefined ? [] : ['--worker', String(options.shard)]),
|
|
189
165
|
...(options.isolate === true ? ['--isolate'] : []),
|
|
190
166
|
// Last, and after a `--` of its own, because that is where the caller typed it and where the
|
|
191
167
|
// parser will find it again. Quoted for `shell-quote.ts`'s reason: a reproduce line is pasted.
|
|
@@ -200,14 +176,11 @@ export interface RunShardsOptions {
|
|
|
200
176
|
readonly runner: Runner;
|
|
201
177
|
readonly files: readonly TestFile[];
|
|
202
178
|
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
179
|
readonly filter?: string;
|
|
206
180
|
readonly type?: TestType;
|
|
207
181
|
/**
|
|
208
182
|
* 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.
|
|
183
|
+
* Carried rather than counted from what ran: a pass runs part of the sample, never the corpus.
|
|
211
184
|
*/
|
|
212
185
|
readonly sample?: { readonly kept: number; readonly total: number };
|
|
213
186
|
/** Passed straight to `reproduceFor`: see `ReproduceOptions.affected`. */
|
|
@@ -248,7 +221,6 @@ const planOf = (
|
|
|
248
221
|
...(pass.type === undefined ? {} : { type: pass.type }),
|
|
249
222
|
...(options.sample === undefined ? {} : { sample: options.sample.kept }),
|
|
250
223
|
...(options.affected === undefined ? {} : { affected: options.affected }),
|
|
251
|
-
...(options.only === undefined ? {} : { shard: options.only }),
|
|
252
224
|
...(options.isolate === true ? { isolate: true } : {}),
|
|
253
225
|
...(options.passthrough === undefined || options.passthrough.length === 0
|
|
254
226
|
? {}
|
|
@@ -256,17 +228,12 @@ const planOf = (
|
|
|
256
228
|
});
|
|
257
229
|
|
|
258
230
|
/**
|
|
259
|
-
* `
|
|
260
|
-
*
|
|
261
|
-
* codes already exist and both are already documented — a third would be a new name for a failed
|
|
262
|
-
* `bun test`.
|
|
231
|
+
* `X_TEST_FAILED`, whose `fix:` reruns exactly what failed. `X_TEST_SHARD_FAILED` was the
|
|
232
|
+
* `--worker` rerun's and went with it: the code stays registered (codes are forever) and unthrown.
|
|
263
233
|
*/
|
|
264
234
|
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))`,
|
|
235
|
+
code: 'X_TEST_FAILED',
|
|
236
|
+
cause: `${plan.type ?? 'test'} run exited ${code} across ${plan.workers} worker(s) (${files} file(s))`,
|
|
270
237
|
fix: reproduceFor(plan),
|
|
271
238
|
docs: ERROR_DOCS_URL,
|
|
272
239
|
});
|
|
@@ -281,12 +248,10 @@ export const failureOf = (code: number, files: number, plan: ReproduceOptions):
|
|
|
281
248
|
* it. And every pass runs even after one fails — the caller asked for a suite, and a report that
|
|
282
249
|
* stops at the first red step hides the rest of the answer.
|
|
283
250
|
*
|
|
284
|
-
* `ULTIMATE_TEST_WORKER`
|
|
285
|
-
*
|
|
286
|
-
* numbers each with `BUN_TEST_WORKER_ID`, which `@ultimat3/testing`'s `workerId` already reads.
|
|
251
|
+
* No `ULTIMATE_TEST_WORKER` here: a `--parallel` run has N processes and Bun numbers each with
|
|
252
|
+
* `BUN_TEST_WORKER_ID`, which `@ultimat3/testing`'s `workerId` already reads.
|
|
287
253
|
*/
|
|
288
254
|
export async function runShards(options: RunShardsOptions): Promise<CommandResult> {
|
|
289
|
-
const only = options.only;
|
|
290
255
|
// Computed ONCE per invocation, never per pass: every pass spawns from the same `root` and the
|
|
291
256
|
// same parent env, so the leaked-key set cannot differ pass to pass.
|
|
292
257
|
const envOverrides: Record<string, string | undefined> = {
|
|
@@ -302,7 +267,6 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
302
267
|
files: options.files,
|
|
303
268
|
workers: options.workers,
|
|
304
269
|
...(options.type === undefined ? {} : { type: options.type }),
|
|
305
|
-
...(only === undefined ? {} : { shard: only }),
|
|
306
270
|
});
|
|
307
271
|
const started = performance.now();
|
|
308
272
|
const steps: StepResult[] = [];
|
|
@@ -312,53 +276,38 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
312
276
|
let exitCode = 0;
|
|
313
277
|
for (const pass of passes) {
|
|
314
278
|
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];
|
|
279
|
+
// `-- --watch` is never split: it never exits, so a second batch would never start. Every
|
|
280
|
+
// other pass is spent in `test-batches.ts`' batches, one `bun test` after another.
|
|
281
|
+
const batches = !watching
|
|
282
|
+
? testBatches(
|
|
283
|
+
files,
|
|
284
|
+
pass.workers,
|
|
285
|
+
isolatedRun(options) ? BATCH_FILES_PER_WORKER : SHARED_BATCH_FILES_PER_WORKER,
|
|
286
|
+
)
|
|
287
|
+
: [files];
|
|
327
288
|
const result = await runBatches({
|
|
328
289
|
runner: options.runner,
|
|
329
290
|
batches,
|
|
330
291
|
stopOnFailure: bailing,
|
|
331
292
|
workers: pass.workers,
|
|
332
|
-
// A
|
|
333
|
-
|
|
334
|
-
...(only === undefined && pass.workers > 1 && options.lease !== undefined
|
|
335
|
-
? { lease: options.lease }
|
|
336
|
-
: {}),
|
|
293
|
+
// A serial pass is one worker: only a default-width parallel pass leases from the pool.
|
|
294
|
+
...(pass.workers > 1 && options.lease !== undefined ? { lease: options.lease } : {}),
|
|
337
295
|
argsFor: (batch, width) =>
|
|
338
296
|
testArgs({
|
|
339
297
|
files: batch,
|
|
340
|
-
workers:
|
|
341
|
-
...(only === undefined ? {} : { shard: only }),
|
|
298
|
+
workers: width,
|
|
342
299
|
...(options.passthrough === undefined ? {} : { passthrough: options.passthrough }),
|
|
343
300
|
...(options.isolate === undefined ? {} : { isolate: options.isolate }),
|
|
344
301
|
timings: join(options.root, TEST_TIMINGS_FILE),
|
|
345
302
|
}),
|
|
346
303
|
options: {
|
|
347
304
|
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
|
-
}),
|
|
305
|
+
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
356
306
|
},
|
|
357
307
|
});
|
|
358
308
|
batchCount += batches.length;
|
|
359
309
|
const plan = planOf(options, pass);
|
|
360
|
-
const label =
|
|
361
|
-
only === undefined ? `${pass.workers} worker(s)` : `shard ${only} of ${pass.workers}`;
|
|
310
|
+
const label = `${pass.workers} worker(s)`;
|
|
362
311
|
steps.push({
|
|
363
312
|
name: `${pass.type === undefined ? label : `${pass.type} · ${label}`} · ${files.length} files${
|
|
364
313
|
batches.length > 1 ? msg('cli.test.batches', { batches: batches.length }) : ''
|
|
@@ -403,7 +352,6 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
403
352
|
durationMs,
|
|
404
353
|
...(options.filter === undefined ? {} : { filter: options.filter }),
|
|
405
354
|
...(sample === undefined ? {} : { sample: { kept: sample.kept, total: sample.total } }),
|
|
406
|
-
...(only === undefined ? {} : { shard: only }),
|
|
407
355
|
// Only when the split made more than one, so a single-pass run's JSON is byte-identical to
|
|
408
356
|
// what it has always been — and a mixed one can never be read as if it were a single run.
|
|
409
357
|
...(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.
|
package/src/theme-boot.ts
CHANGED
|
@@ -1,40 +1,15 @@
|
|
|
1
|
-
// The no-flash theme script, inlined into every document a served or exported process writes,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// scripts the framework exported was wired into a document: an app that wanted to open dark had to
|
|
5
|
-
// write its own boot script AND admit it to the CSP by hand — which every app forgot in one of the
|
|
6
|
-
// two places. Sibling of `app-auth.ts`'s `loadSignInPath` and imports the config for the same
|
|
7
|
-
// reason: a regex over the app's source is the pattern `app-load.ts` refuses.
|
|
1
|
+
// The no-flash theme script, inlined into every document a served or exported process writes, with
|
|
2
|
+
// `theme.defaultMode` (off the one loader, `app-config-load.ts`) as its fallback — and the CSP source
|
|
3
|
+
// that admits it, so no app writes its own boot script and forgets to admit it.
|
|
8
4
|
|
|
9
|
-
// why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
|
|
10
|
-
// necessity `app-auth.ts` records.
|
|
11
|
-
import { join } from 'node:path';
|
|
12
5
|
import type { ThemeMode } from '@ultimat3/core';
|
|
13
6
|
import { cspHashSource } from '@ultimat3/http';
|
|
14
7
|
import { renderHead, themeScript, themeScriptBody } from '@ultimat3/render';
|
|
15
|
-
import {
|
|
16
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
8
|
+
import { loadAppConfig } from './app-config-load';
|
|
17
9
|
|
|
18
|
-
|
|
19
|
-
typeof value === 'object' && value !== null;
|
|
20
|
-
|
|
21
|
-
const isThemeMode = (value: unknown): value is ThemeMode =>
|
|
22
|
-
value === 'light' || value === 'dark' || value === 'system';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* `theme.defaultMode`, or `'system'` — the value `defineConfig` fills in — when the app declares
|
|
26
|
-
* none or the file is absent. Structural, never `instanceof`, for `loadSignInPath`'s reason.
|
|
27
|
-
*/
|
|
10
|
+
/** `theme.defaultMode`, or `'system'` — core's own default — for a root with no config file. */
|
|
28
11
|
export async function loadThemeMode(root: string): Promise<ThemeMode> {
|
|
29
|
-
|
|
30
|
-
if (!(await Bun.file(configPath).exists())) return 'system';
|
|
31
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
32
|
-
const config = module[APP_CONFIG_EXPORT];
|
|
33
|
-
if (!isRecord(config)) return 'system';
|
|
34
|
-
const theme = config['theme'];
|
|
35
|
-
if (!isRecord(theme)) return 'system';
|
|
36
|
-
const mode = theme['defaultMode'];
|
|
37
|
-
return isThemeMode(mode) ? mode : 'system';
|
|
12
|
+
return (await loadAppConfig(root))?.theme.defaultMode ?? 'system';
|
|
38
13
|
}
|
|
39
14
|
|
|
40
15
|
export interface ThemeBoot {
|