@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
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// How many proxies sit in front of this process, read from `TRUSTED_PROXY_HOPS`. Its own module
|
|
2
|
+
// so both the web role (`role-start.ts`) and the sync node (`role-sync.ts`) read it without one
|
|
3
|
+
// importing the other — `role-start` imports `role-sync`, and the reverse edge was a cycle.
|
|
4
|
+
|
|
5
|
+
import { MAX_PROXY_HOPS } from '@ultimat3/http';
|
|
6
|
+
import type { Env } from './runtime-bindings';
|
|
7
|
+
import { TrustedProxyHopsInvalidError } from './trusted-hops-error';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* How many proxies is too many, and the number is **`@ultimat3/http`'s**, not this file's.
|
|
11
|
+
*
|
|
12
|
+
* `defineHttpConfig` screens the same setting — `assertFiniteCount('trustedProxyHops', …,
|
|
13
|
+
* MAX_PROXY_HOPS)` — and this screen used to say 16 where that one says 64, so one setting had two
|
|
14
|
+
* ceilings and a deployment behind 20 hops was accepted by the library and refused by the boot.
|
|
15
|
+
* Widened rather than narrowed: tightening the library would break a shipped public API, while
|
|
16
|
+
* this end only ever refused topologies http already supports.
|
|
17
|
+
*
|
|
18
|
+
* It was a duplicated literal until 2026-08-26, because `@ultimat3/http` did not export the
|
|
19
|
+
* constant. It does now, so this file IMPORTS it — a downward edge, tier 5 to tier 2 — and there is
|
|
20
|
+
* no second number left to drift. `runtime-overrides.test.ts` still probes both screens for the
|
|
21
|
+
* highest count each accepts, and it is kept rather than deleted: it compares BEHAVIOUR, so it also
|
|
22
|
+
* catches the two disagreeing for a reason a shared constant cannot fix, such as one side gaining a
|
|
23
|
+
* range check the other does not have.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How many proxies append to `x-forwarded-for` between the client and this process, or `null`
|
|
28
|
+
* when nothing in front of it is trusted.
|
|
29
|
+
*
|
|
30
|
+
* Read from the environment and not from `app.config.ts`, for the reason `PORT` and `ROLE` are: it
|
|
31
|
+
* is a fact about the DEPLOYMENT — one image runs behind an ingress in one cluster and behind
|
|
32
|
+
* nothing on a laptop — and an app that hardcoded it would be wrong in one of the two. `x dev`
|
|
33
|
+
* sets neither and gets `trustProxy: false`, which is correct: there is no proxy.
|
|
34
|
+
*
|
|
35
|
+
* Without this seam a container behind an ingress reads `ctx.ip` as the ingress's own socket
|
|
36
|
+
* address on every request, so the rate limiter keys the entire fleet's anonymous traffic into ONE
|
|
37
|
+
* bucket and a single scanner 429s every real signup.
|
|
38
|
+
*/
|
|
39
|
+
export function trustedHopsFromEnv(env: Env): number | null {
|
|
40
|
+
const raw = env['TRUSTED_PROXY_HOPS']?.trim();
|
|
41
|
+
if (raw === undefined || raw === '') return null;
|
|
42
|
+
const hops = Number(raw);
|
|
43
|
+
// A malformed count is refused rather than defaulted: reading the header at the wrong index is
|
|
44
|
+
// trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
|
|
45
|
+
if (!/^\d+$/.test(raw) || !Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
|
|
46
|
+
throw new TrustedProxyHopsInvalidError({ value: raw });
|
|
47
|
+
}
|
|
48
|
+
return hops;
|
|
49
|
+
}
|
package/src/unscanned-admin.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// silently, which is how the reference app shipped an admin nobody could open.
|
|
5
5
|
|
|
6
6
|
import { renderFixShellArg, stripComments } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import { describePages } from '@ultimat3/render';
|
|
8
8
|
import { ADMIN_FILE } from './admin-registration';
|
|
9
9
|
import type { Finding } from './output';
|
|
10
10
|
import { hasPathSegment } from './path-segments';
|
|
@@ -17,7 +17,7 @@ const DECLARES = /\bdefineAdmin\(/;
|
|
|
17
17
|
|
|
18
18
|
/** Any admin this process mounted — then every declaration was reached, wherever it sits. */
|
|
19
19
|
export const adminMounted = (): boolean =>
|
|
20
|
-
|
|
20
|
+
describePages().some((route) => route.mount?.by === 'defineAdmin');
|
|
21
21
|
|
|
22
22
|
/** Where the file belongs: its own name under the admin's home, `index.ts` as the declaration. */
|
|
23
23
|
const homeOf = (file: string): string => {
|
package/src/verify-checks.ts
CHANGED
|
@@ -46,7 +46,7 @@ import type { VerifyStep } from './verify-step';
|
|
|
46
46
|
import { fromExec, fromFindings, hostFindings } from './verify-step';
|
|
47
47
|
import { TEST_STEPS } from './verify-tests';
|
|
48
48
|
import { typecheckArgs } from './verify-typecheck';
|
|
49
|
-
import {
|
|
49
|
+
import { checkPackageShape, fileSizeReport, hasWorkspacePackages } from './workspace-checks';
|
|
50
50
|
import { checkWorkspaceDependencies } from './workspace-graph';
|
|
51
51
|
|
|
52
52
|
/** The one file that makes the `roadmap` step answerable, and therefore what `applies` reads. */
|
|
@@ -120,7 +120,17 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
120
120
|
{
|
|
121
121
|
name: 'filesize',
|
|
122
122
|
summary: 'one file, one job',
|
|
123
|
-
|
|
123
|
+
// The band below the ceiling rides as warnings: listed in `--json` on a pass, never a failure.
|
|
124
|
+
run: async (ctx) => {
|
|
125
|
+
const { findings, warnings } = await fileSizeReport(ctx.root);
|
|
126
|
+
return {
|
|
127
|
+
...fromFindings(findings),
|
|
128
|
+
warnings: warnings.map(
|
|
129
|
+
(warning) =>
|
|
130
|
+
`${warning.path}: ${warning.lines} lines, ${warning.ceiling - warning.lines} under the ${warning.ceiling} line ceiling — plan the split`,
|
|
131
|
+
),
|
|
132
|
+
};
|
|
133
|
+
},
|
|
124
134
|
},
|
|
125
135
|
{
|
|
126
136
|
name: 'package-shape',
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { rm } from 'node:fs/promises';
|
|
15
15
|
// why: Bun ships no path-join primitive.
|
|
16
16
|
import { dirname, join } from 'node:path';
|
|
17
|
+
import { ISOLATED_ENV } from '@ultimat3/testing';
|
|
17
18
|
import type { CoverageMap } from './coverage-lcov';
|
|
18
19
|
import { fileCoverageOf, mergeCoverage, parseLcov } from './coverage-lcov';
|
|
19
20
|
import type { ExecResult, Runner } from './exec';
|
|
@@ -21,7 +22,7 @@ import { execOutput } from './exec';
|
|
|
21
22
|
import { msg } from './messages';
|
|
22
23
|
import { countsOf } from './test-counts';
|
|
23
24
|
import { testEnvOverrides } from './test-dotenv';
|
|
24
|
-
import { failureOf
|
|
25
|
+
import { failureOf } from './test-shards';
|
|
25
26
|
import { SLOT_HELD_ENV } from './test-slots';
|
|
26
27
|
import type { StepOutcome } from './verify-step';
|
|
27
28
|
|
|
@@ -113,7 +114,7 @@ export async function runCovered(options: CoveredRunOptions): Promise<CoveredRun
|
|
|
113
114
|
const isolate = options.isolate === true;
|
|
114
115
|
const overrides = {
|
|
115
116
|
...testEnvOverrides(options.root, options.env ?? Bun.env),
|
|
116
|
-
...(isolate ? { [
|
|
117
|
+
...(isolate ? { [ISOLATED_ENV]: '1' } : {}),
|
|
117
118
|
...(lease === undefined ? {} : { [SLOT_HELD_ENV]: '1' }),
|
|
118
119
|
};
|
|
119
120
|
const results = new Array<ExecResult>(slices.length);
|
package/src/verify-deadline.ts
CHANGED
|
@@ -126,15 +126,23 @@ export interface StepGuard {
|
|
|
126
126
|
*
|
|
127
127
|
* A nested gate (a test that runs `x verify`) appends its tag to the one it inherited, so the
|
|
128
128
|
* outer step's kill still finds the inner step's children.
|
|
129
|
+
*
|
|
130
|
+
* Windows reads no process's environment, so there the tag finds nothing: every child's pid is
|
|
131
|
+
* recorded at spawn (`ExecOptions.onSpawn`) and `expire` tree-kills each one (`taskkillTree`).
|
|
129
132
|
*/
|
|
130
133
|
export function guardStep(
|
|
131
134
|
runner: Runner,
|
|
132
135
|
tag: string,
|
|
133
136
|
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
137
|
+
platform: PlatformKill = {},
|
|
134
138
|
): StepGuard {
|
|
135
139
|
let expired = false;
|
|
136
140
|
// Keyed by the promise itself: two batches of one step may run the very same argv.
|
|
137
141
|
const inFlight = new Map<Promise<ExecResult>, readonly string[]>();
|
|
142
|
+
// Every child's pid, recorded the moment it exists and dropped when its run settles (a settled
|
|
143
|
+
// pid may be reused). Windows reads no process's environment, so this is the step's only handle.
|
|
144
|
+
const spawned = new Map<number, readonly string[]>();
|
|
145
|
+
const windows = (platform.platform ?? process.platform) === 'win32';
|
|
138
146
|
const inherited = env[STEP_TAG_ENV];
|
|
139
147
|
const value = inherited === undefined || inherited === '' ? tag : `${inherited} ${tag}`;
|
|
140
148
|
return {
|
|
@@ -151,18 +159,32 @@ export function guardStep(
|
|
|
151
159
|
durationMs: 0,
|
|
152
160
|
};
|
|
153
161
|
}
|
|
154
|
-
|
|
162
|
+
let pid: number | undefined;
|
|
163
|
+
const run = runner(command, {
|
|
164
|
+
...options,
|
|
165
|
+
env: { ...options.env, [STEP_TAG_ENV]: value },
|
|
166
|
+
onSpawn: (spawnedPid) => {
|
|
167
|
+
pid = spawnedPid;
|
|
168
|
+
spawned.set(spawnedPid, command);
|
|
169
|
+
options.onSpawn?.(spawnedPid);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
155
172
|
inFlight.set(run, command);
|
|
156
173
|
try {
|
|
157
174
|
return await run;
|
|
158
175
|
} finally {
|
|
159
176
|
inFlight.delete(run);
|
|
177
|
+
if (pid !== undefined) spawned.delete(pid);
|
|
160
178
|
}
|
|
161
179
|
},
|
|
162
180
|
async expire(): Promise<StepExpiry> {
|
|
163
181
|
expired = true;
|
|
164
182
|
// Before the kill: a killed child settles its runner call, which takes it off the map.
|
|
165
183
|
const waiting = [...inFlight];
|
|
184
|
+
if (windows) {
|
|
185
|
+
const killed = await killTrees(spawned, platform.killTree ?? taskkillTree);
|
|
186
|
+
return { killed, inFlight: await settleAll(waiting) };
|
|
187
|
+
}
|
|
166
188
|
const workers = await killTagged(tag, undefined, isTestWorker);
|
|
167
189
|
if (workers.length > 0) {
|
|
168
190
|
await raceDeadline(Promise.allSettled(waiting.map(([run]) => run)), CRASH_REPORT_MS);
|
|
@@ -171,19 +193,74 @@ export function guardStep(
|
|
|
171
193
|
const killed = [...workers, ...rest.filter((one) => workers.every((w) => w.pid !== one.pid))];
|
|
172
194
|
// After it: the kill closed the child's pipes, so its call now resolves with everything it
|
|
173
195
|
// had printed — for `bun test`, the crash line of each file a worker was holding.
|
|
174
|
-
|
|
175
|
-
waiting.map(async ([run, command]): Promise<InFlightRun> => {
|
|
176
|
-
const raced = await raceDeadline(run.then(execOutput), SETTLE_MS).catch(() => undefined);
|
|
177
|
-
return raced === undefined || raced.timedOut
|
|
178
|
-
? { command }
|
|
179
|
-
: { command, output: raced.value };
|
|
180
|
-
}),
|
|
181
|
-
);
|
|
182
|
-
return { killed, inFlight: settled };
|
|
196
|
+
return { killed, inFlight: await settleAll(waiting) };
|
|
183
197
|
},
|
|
184
198
|
};
|
|
185
199
|
}
|
|
186
200
|
|
|
201
|
+
/** What each child had printed, once the kill closed its pipes — or just its argv past `SETTLE_MS`. */
|
|
202
|
+
function settleAll(
|
|
203
|
+
waiting: readonly (readonly [Promise<ExecResult>, readonly string[]])[],
|
|
204
|
+
): Promise<readonly InFlightRun[]> {
|
|
205
|
+
return Promise.all(
|
|
206
|
+
waiting.map(async ([run, command]): Promise<InFlightRun> => {
|
|
207
|
+
const raced = await raceDeadline(run.then(execOutput), SETTLE_MS).catch(() => undefined);
|
|
208
|
+
return raced === undefined || raced.timedOut ? { command } : { command, output: raced.value };
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Kills `pid` and every process under it; answers whether it did. */
|
|
214
|
+
export type KillTree = (pid: number) => Promise<boolean>;
|
|
215
|
+
|
|
216
|
+
/** The platform a guard kills on, injectable so the Windows path runs on any host's tests. */
|
|
217
|
+
export interface PlatformKill {
|
|
218
|
+
/** `process.platform` unless a test names one. */
|
|
219
|
+
readonly platform?: string;
|
|
220
|
+
/** Windows' tree kill. Absent is `taskkill /T /F /PID`. */
|
|
221
|
+
readonly killTree?: KillTree;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Windows: `taskkill /T /F` — the tree under the pid, forced. POSIX's tag sweep cannot run there
|
|
226
|
+
* (no process exposes its environment) and `process.kill` ends one process, leaving a
|
|
227
|
+
* `bun test --parallel` coordinator's workers behind. Bounded like `ps`, for the same reason.
|
|
228
|
+
*/
|
|
229
|
+
export async function taskkillTree(pid: number): Promise<boolean> {
|
|
230
|
+
try {
|
|
231
|
+
const proc = Bun.spawn(['taskkill', '/T', '/F', '/PID', String(pid)], {
|
|
232
|
+
stdout: 'ignore',
|
|
233
|
+
stderr: 'ignore',
|
|
234
|
+
timeout: PS_TIMEOUT_MS,
|
|
235
|
+
killSignal: 'SIGKILL',
|
|
236
|
+
});
|
|
237
|
+
return (await proc.exited) === 0;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Every recorded child, each with its tree, named by the argv it was started with. A child that
|
|
245
|
+
* already exited is not recorded — its run settled — so what is left is what was still running.
|
|
246
|
+
* A grandchild whose parent already exited is out of reach here: Windows keeps no tag to find it.
|
|
247
|
+
*/
|
|
248
|
+
export async function killTrees(
|
|
249
|
+
spawned: ReadonlyMap<number, readonly string[]>,
|
|
250
|
+
killTree: KillTree,
|
|
251
|
+
): Promise<readonly KilledProcess[]> {
|
|
252
|
+
const killed: KilledProcess[] = [];
|
|
253
|
+
for (const [pid, command] of spawned) {
|
|
254
|
+
if (pid === process.pid || !(await killTree(pid))) continue;
|
|
255
|
+
const line = command.join(' ');
|
|
256
|
+
killed.push({
|
|
257
|
+
pid,
|
|
258
|
+
command: line.length > COMMAND_CHARS ? `${line.slice(0, COMMAND_CHARS)}…` : line,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return killed;
|
|
262
|
+
}
|
|
263
|
+
|
|
187
264
|
/** One process and the environment it was started with, as the platform reports it. */
|
|
188
265
|
export interface ProcessEnviron {
|
|
189
266
|
readonly pid: number;
|
package/src/verify-merge.ts
CHANGED
|
@@ -132,15 +132,41 @@ export function parsePart(file: string, text: string, command: string = GATE_COM
|
|
|
132
132
|
/** A gap finding, with a fix spelled for the entry that raised it. */
|
|
133
133
|
type Gap = (cause: string, at?: string) => Finding;
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The gap kinds a merge can find. `present` is a document that arrived and disagrees with the
|
|
137
|
+
* others; `absent` is one that never arrived at all — GitHub failing to acquire a hosted runner
|
|
138
|
+
* leaves the job `cancelled` with no steps and no upload, and "missing shard 2/3" alone read as a
|
|
139
|
+
* shard that ran and died. The two are fixed differently: the first is debugged, the second re-run.
|
|
140
|
+
*/
|
|
141
|
+
interface Gaps {
|
|
142
|
+
readonly present: Gap;
|
|
143
|
+
readonly absent: Gap;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Said of every absent gap, so it can never be mistaken for a failure the part reported. */
|
|
147
|
+
const NEVER_ARRIVED =
|
|
148
|
+
'its result never arrived — its CI job never ran or never uploaded (cancelled before a step, or no runner acquired); this is not a failure it reported';
|
|
149
|
+
|
|
150
|
+
const gapsFor = (command: string): Gaps => {
|
|
151
|
+
const finding =
|
|
152
|
+
(fix: string): Gap =>
|
|
153
|
+
(cause, at) => ({
|
|
154
|
+
code: 'X_VERIFY_MERGE_INCOMPLETE',
|
|
155
|
+
cause,
|
|
156
|
+
fix,
|
|
157
|
+
docs: ERROR_DOCS_URL,
|
|
158
|
+
...(at === undefined ? {} : { at }),
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
present: finding(
|
|
162
|
+
`${command} merge parts/*.json --json # after every CI job uploaded its part`,
|
|
163
|
+
),
|
|
164
|
+
// The merge is still what proves the gate whole; what it waits on is a re-run, not a fix.
|
|
165
|
+
absent: finding(
|
|
166
|
+
`${command} merge parts/*.json --json # after re-running the job that never reported: gh run rerun <run-id> --failed`,
|
|
167
|
+
),
|
|
168
|
+
};
|
|
169
|
+
};
|
|
144
170
|
|
|
145
171
|
/** The step every part's coverage facts belong to. */
|
|
146
172
|
const COVERED_STEP = 'unit';
|
|
@@ -220,15 +246,16 @@ function mergeStep(
|
|
|
220
246
|
name: string,
|
|
221
247
|
entries: readonly { readonly part: string; readonly step: PartStep }[],
|
|
222
248
|
floor: VerifyFloor | undefined,
|
|
223
|
-
|
|
249
|
+
kinds: Gaps,
|
|
224
250
|
rider: MergeRiders[string] | undefined,
|
|
225
251
|
): StepResult {
|
|
252
|
+
const gap = kinds.present;
|
|
226
253
|
if (entries.length === 0) {
|
|
227
254
|
return {
|
|
228
255
|
name,
|
|
229
256
|
ok: false,
|
|
230
257
|
durationMs: 0,
|
|
231
|
-
findings: [
|
|
258
|
+
findings: [kinds.absent(`step "${name}" is in no part: ${NEVER_ARRIVED}`, name)],
|
|
232
259
|
};
|
|
233
260
|
}
|
|
234
261
|
const sharded = entries.filter((entry) => entry.step.shard !== undefined);
|
|
@@ -268,8 +295,8 @@ function mergeStep(
|
|
|
268
295
|
const doubled = [...seen].filter(([, count]) => count > 1).map(([index]) => index);
|
|
269
296
|
if (missing.length > 0) {
|
|
270
297
|
gaps.push(
|
|
271
|
-
|
|
272
|
-
`step "${name}" is missing shard(s) ${missing.map((i) => `${String(i)}/${String(total)}`).join(', ')}`,
|
|
298
|
+
kinds.absent(
|
|
299
|
+
`step "${name}" is missing shard(s) ${missing.map((i) => `${String(i)}/${String(total)}`).join(', ')}: ${NEVER_ARRIVED}`,
|
|
273
300
|
name,
|
|
274
301
|
),
|
|
275
302
|
);
|
|
@@ -295,6 +322,8 @@ function mergeStep(
|
|
|
295
322
|
.map((step) => step.output as string),
|
|
296
323
|
...(rider?.output === undefined ? [] : [rider.output]),
|
|
297
324
|
].join('\n');
|
|
325
|
+
// Every part's advice, once each and in the order the parts reported it.
|
|
326
|
+
const warnings = [...new Set(steps.flatMap((step) => step.warnings ?? []))];
|
|
298
327
|
const skipped =
|
|
299
328
|
sharded.length > 0 ? nothingRan && !required : steps.every((step) => step.skipped);
|
|
300
329
|
return {
|
|
@@ -309,6 +338,7 @@ function mergeStep(
|
|
|
309
338
|
...(skipped ? { skipped: true } : {}),
|
|
310
339
|
findings,
|
|
311
340
|
...(output.length === 0 ? {} : { output }),
|
|
341
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
312
342
|
...(tests === undefined ? {} : { tests }),
|
|
313
343
|
};
|
|
314
344
|
}
|
|
@@ -320,11 +350,13 @@ export function mergeParts(
|
|
|
320
350
|
declared: readonly string[] = VERIFY_STEP_NAMES,
|
|
321
351
|
options: MergeOptions = {},
|
|
322
352
|
): CommandResult {
|
|
323
|
-
const
|
|
353
|
+
const kinds = gapsFor(options.command ?? GATE_COMMAND);
|
|
324
354
|
const unknown = parts.flatMap((part) =>
|
|
325
355
|
part.steps
|
|
326
356
|
.filter((step) => !declared.includes(step.name))
|
|
327
|
-
.map((step) =>
|
|
357
|
+
.map((step) =>
|
|
358
|
+
kinds.present(`${part.file} reports "${step.name}", which x verify does not run`),
|
|
359
|
+
),
|
|
328
360
|
);
|
|
329
361
|
const steps = declared.map((name) =>
|
|
330
362
|
mergeStep(
|
|
@@ -333,7 +365,7 @@ export function mergeParts(
|
|
|
333
365
|
part.steps.filter((step) => step.name === name).map((step) => ({ part: part.file, step })),
|
|
334
366
|
),
|
|
335
367
|
floor,
|
|
336
|
-
|
|
368
|
+
kinds,
|
|
337
369
|
options.riders !== undefined && Object.hasOwn(options.riders, name)
|
|
338
370
|
? options.riders[name]
|
|
339
371
|
: undefined,
|
package/src/verify-part-step.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface PartStep {
|
|
|
14
14
|
readonly workers?: number;
|
|
15
15
|
readonly tests?: TestCounts;
|
|
16
16
|
readonly output?: string;
|
|
17
|
+
readonly warnings?: readonly string[];
|
|
17
18
|
readonly shard?: {
|
|
18
19
|
readonly index: number;
|
|
19
20
|
readonly total: number;
|
|
@@ -29,7 +30,7 @@ const isCount = (value: unknown): value is number =>
|
|
|
29
30
|
typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
30
31
|
|
|
31
32
|
/** What every finding is: code, cause and fix as strings. Optional fields ride along as written. */
|
|
32
|
-
const isFinding = (value: unknown): value is Finding =>
|
|
33
|
+
export const isFinding = (value: unknown): value is Finding =>
|
|
33
34
|
isRecord(value) &&
|
|
34
35
|
typeof value['code'] === 'string' &&
|
|
35
36
|
typeof value['cause'] === 'string' &&
|
|
@@ -41,6 +42,9 @@ const isTests = (value: unknown): value is TestCounts =>
|
|
|
41
42
|
isCount(value['skipped']) &&
|
|
42
43
|
(value['errors'] === undefined || isCount(value['errors']));
|
|
43
44
|
|
|
45
|
+
const isTextList = (value: unknown): value is readonly string[] =>
|
|
46
|
+
Array.isArray(value) && value.every((line) => typeof line === 'string');
|
|
47
|
+
|
|
44
48
|
const isShard = (value: unknown): value is NonNullable<PartStep['shard']> =>
|
|
45
49
|
isRecord(value) &&
|
|
46
50
|
isCount(value['index']) &&
|
|
@@ -63,7 +67,7 @@ export function readPartStep(raw: unknown): PartStep | string {
|
|
|
63
67
|
if (!Array.isArray(findings) || !findings.every(isFinding)) {
|
|
64
68
|
return `holds step "${name}" whose findings are not each a code, a cause and a fix`;
|
|
65
69
|
}
|
|
66
|
-
const { tests, shard, output, workers, skipped } = raw;
|
|
70
|
+
const { tests, shard, output, workers, skipped, warnings } = raw;
|
|
67
71
|
if (tests !== undefined && !isTests(tests)) {
|
|
68
72
|
return `holds step "${name}" whose tests are not counts`;
|
|
69
73
|
}
|
|
@@ -73,6 +77,9 @@ export function readPartStep(raw: unknown): PartStep | string {
|
|
|
73
77
|
if (output !== undefined && typeof output !== 'string') {
|
|
74
78
|
return `holds step "${name}" whose output is not text`;
|
|
75
79
|
}
|
|
80
|
+
if (warnings !== undefined && !isTextList(warnings)) {
|
|
81
|
+
return `holds step "${name}" whose warnings are not a list of text`;
|
|
82
|
+
}
|
|
76
83
|
if (workers !== undefined && !isCount(workers)) {
|
|
77
84
|
return `holds step "${name}" whose workers is not a count`;
|
|
78
85
|
}
|
|
@@ -85,6 +92,7 @@ export function readPartStep(raw: unknown): PartStep | string {
|
|
|
85
92
|
...(workers === undefined ? {} : { workers }),
|
|
86
93
|
...(tests === undefined ? {} : { tests }),
|
|
87
94
|
...(output === undefined ? {} : { output }),
|
|
95
|
+
...(warnings === undefined ? {} : { warnings }),
|
|
88
96
|
...(shard === undefined ? {} : { shard }),
|
|
89
97
|
};
|
|
90
98
|
}
|
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
// those registered that the worker's process did not hold. Run by hand, it is the same answer.
|
|
4
4
|
|
|
5
5
|
import { scanAppModules } from './app-load';
|
|
6
|
+
import type { RelativeHost } from './posix-path';
|
|
7
|
+
import { posixRelative } from './posix-path';
|
|
6
8
|
import { scanBackground } from './role-load';
|
|
7
9
|
import type { MissingRegistration } from './verify-role-load';
|
|
8
10
|
import { PROBE_MARKER, registeredSince, registrations } from './verify-role-load';
|
|
9
11
|
import { writeLine } from './write-line';
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The app-root-relative POSIX key a scanned module is held under — the form `scanBackground`'s
|
|
15
|
+
* `files` already are. A `${root}/` prefix strip matched nothing on Windows (`D:\…\app\apps\…`),
|
|
16
|
+
* so every module the worker DID import was blamed as missing. `host` is for a test only.
|
|
17
|
+
*/
|
|
18
|
+
export function moduleKey(root: string, absolute: string, host?: RelativeHost): string {
|
|
19
|
+
return posixRelative(root, absolute, host);
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
/**
|
|
12
23
|
* What importing each module the worker load left out registered, by module — `[]` when the whole
|
|
13
24
|
* scan reported a module that would not import. Exported so a test calls it in its own process;
|
|
@@ -31,12 +42,11 @@ export async function probeRoleLoad(root: string): Promise<readonly MissingRegis
|
|
|
31
42
|
|
|
32
43
|
// The whole app, in the scan's own order. `include` is asked before each import, so the look it
|
|
33
44
|
// takes is at what the PREVIOUS module added; a module the worker already imported adds nothing.
|
|
34
|
-
const prefix = root.endsWith('/') ? root : `${root}/`;
|
|
35
45
|
const whole = await scanAppModules(root, {
|
|
36
46
|
track: false,
|
|
37
47
|
include: (absolute) => {
|
|
38
48
|
attribute();
|
|
39
|
-
const file =
|
|
49
|
+
const file = moduleKey(root, absolute);
|
|
40
50
|
current = imported.has(file) ? undefined : file;
|
|
41
51
|
return true;
|
|
42
52
|
},
|
package/src/verify-role-load.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// manifest; this is the build error for everything else a job reads by name.
|
|
6
6
|
|
|
7
7
|
// why: Bun ships no path API; the fix names the import relative to the API index.
|
|
8
|
-
import { dirname, join
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
9
|
import { ERROR_DOCS_URL, registeredServiceNames, singleLine } from '@ultimat3/core';
|
|
10
10
|
import { entityNames } from '@ultimat3/entity';
|
|
11
11
|
import { catalogDeclarationCount } from '@ultimat3/i18n';
|
|
@@ -16,6 +16,8 @@ import { definedStorage } from '@ultimat3/storage';
|
|
|
16
16
|
import { API_INDEX } from './app-root';
|
|
17
17
|
import type { Runner } from './exec';
|
|
18
18
|
import type { Finding } from './output';
|
|
19
|
+
import type { RelativeHost } from './posix-path';
|
|
20
|
+
import { posixRelative } from './posix-path';
|
|
19
21
|
import { quoteArg } from './shell-quote';
|
|
20
22
|
|
|
21
23
|
/** One thing a process registered, as a job would ask for it: by kind and by name. */
|
|
@@ -79,9 +81,12 @@ export function registeredSince(
|
|
|
79
81
|
return after.filter((one) => !had.has(keyOf(one)));
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The import the API index is missing, as it would be written there — POSIX on every host: a `\`
|
|
86
|
+
* pasted from a Windows run is an import no Linux teammate resolves. `host` is for a test only.
|
|
87
|
+
*/
|
|
88
|
+
export function specifierFor(module: string, host?: RelativeHost): string {
|
|
89
|
+
const from = posixRelative(dirname(API_INDEX), module, host).replace(/\.[jt]sx?$/, '');
|
|
85
90
|
return from.startsWith('.') ? from : `./${from}`;
|
|
86
91
|
}
|
|
87
92
|
|
package/src/verify-run.ts
CHANGED
|
@@ -277,6 +277,9 @@ async function runStep(
|
|
|
277
277
|
skipped: nothingRan && !required,
|
|
278
278
|
findings: [...outcome.findings, ...vanished],
|
|
279
279
|
...(outcome.output === undefined ? {} : { output: outcome.output }),
|
|
280
|
+
...(outcome.warnings === undefined || outcome.warnings.length === 0
|
|
281
|
+
? {}
|
|
282
|
+
: { warnings: outcome.warnings }),
|
|
280
283
|
...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
|
|
281
284
|
...(outcome.widthReason === undefined ? {} : { widthReason: outcome.widthReason }),
|
|
282
285
|
...(outcome.shard === undefined ? {} : { shard: outcome.shard }),
|
package/src/verify-step.ts
CHANGED
|
@@ -135,6 +135,8 @@ export interface StepOutcome {
|
|
|
135
135
|
readonly ok: boolean;
|
|
136
136
|
readonly findings: readonly Finding[];
|
|
137
137
|
readonly output?: string;
|
|
138
|
+
/** Advice that does not fail the step; carried on a pass too (`StepResult.warnings`). */
|
|
139
|
+
readonly warnings?: readonly string[];
|
|
138
140
|
/**
|
|
139
141
|
* Processes this step actually used. `1` is a step that ran serially, and a reader has to be
|
|
140
142
|
* able to tell "serial because nothing could isolate it" from "parallel and fast" without
|
package/src/verify-test-run.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
// why: Bun ships no path-join primitive; the timings cache lives under the run's root.
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
+
import { ISOLATED_ENV } from '@ultimat3/testing';
|
|
7
8
|
import type { Runner } from './exec';
|
|
8
9
|
import type { Finding } from './output';
|
|
9
10
|
import { runBatches, testBatches } from './test-batches';
|
|
10
11
|
import { countsOf } from './test-counts';
|
|
11
12
|
import { testEnvOverrides } from './test-dotenv';
|
|
12
13
|
import type { TestFile } from './test-select';
|
|
13
|
-
import { failureOf,
|
|
14
|
+
import { failureOf, TEST_TIMINGS_FILE, testArgs } from './test-shards';
|
|
14
15
|
import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
|
|
15
16
|
import type { StepOutcome } from './verify-step';
|
|
16
17
|
// Type-only, so nothing here evaluates verify-tests.ts and the two files cannot form a cycle.
|
|
@@ -53,7 +54,7 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
|
|
|
53
54
|
const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
|
|
54
55
|
const envOverrides = {
|
|
55
56
|
...testEnvOverrides(options.root, options.env ?? Bun.env),
|
|
56
|
-
...(options.isolate === true ? { [
|
|
57
|
+
...(options.isolate === true ? { [ISOLATED_ENV]: '1' } : {}),
|
|
57
58
|
};
|
|
58
59
|
// Batched exactly as `x test` batches the same selection at the same width, so the gate's
|
|
59
60
|
// `fix:` line — `x test <type> --workers N` — reruns the very processes that failed.
|
package/src/verify-tests.ts
CHANGED
|
@@ -40,7 +40,6 @@ import { runParallel } from './verify-test-run';
|
|
|
40
40
|
* and `@ultimat3/testing` is already a runtime dependency of this package.
|
|
41
41
|
*/
|
|
42
42
|
export type { TestType } from '@ultimat3/testing';
|
|
43
|
-
export { TEST_TYPES } from '@ultimat3/testing';
|
|
44
43
|
|
|
45
44
|
type TypedTest = Exclude<TestType, 'unit'>;
|
|
46
45
|
|
package/src/worker-bundle.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// why: Bun ships no path API; the entry is resolved to a file and named in the cause.
|
|
8
8
|
import { dirname, join, relative } from 'node:path';
|
|
9
9
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
10
|
-
import { applyCacheHeaders,
|
|
10
|
+
import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
|
|
11
11
|
import { FrameworkScriptBuildFailedError, type FrameworkScriptKind } from './errors';
|
|
12
12
|
import { describeBuildError, graphHash, stripDebugId } from './island-identity';
|
|
13
13
|
|
|
@@ -198,7 +198,7 @@ function scriptRoute(
|
|
|
198
198
|
handler: (request: UltimateRequest): Response => {
|
|
199
199
|
const script = source();
|
|
200
200
|
if (script === undefined || script.url !== request.pathname) {
|
|
201
|
-
return
|
|
201
|
+
return jsonResponse(
|
|
202
202
|
{
|
|
203
203
|
ok: false,
|
|
204
204
|
error: {
|