@ultimat3/cli 20.1.2 → 20.1.3
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/package.json +29 -29
- package/src/browser-launcher.ts +11 -4
- package/src/cdp-launch.ts +14 -5
- package/src/cmd-dev.ts +4 -0
- package/src/cmd-generate.ts +24 -1
- package/src/cmd-test.ts +1 -0
- package/src/cmd-verify.ts +1 -0
- package/src/dev-environment.ts +42 -0
- package/src/exec.ts +33 -2
- package/src/generate-files.ts +24 -2
- package/src/island-capture.ts +1 -0
- package/src/island-harness-script.ts +54 -5
- package/src/island-verdict.ts +11 -0
- package/src/mcp-host.ts +6 -1
- package/src/scaffold-fixture.ts +42 -0
- package/src/templates/index.ts +2 -1
- package/src/templates/job.ts +167 -7
- package/src/templates/scaffold-auth.ts +13 -4
- package/src/templates/slice-foundation.ts +5 -1
- package/src/test-dotenv.ts +125 -0
- package/src/test-shards.ts +21 -1
- package/src/verify-step.ts +7 -0
- package/src/verify-test-run.ts +8 -1
- package/src/verify-tests.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "20.1.
|
|
3
|
+
"version": "20.1.3",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,34 +37,34 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "20.1.
|
|
41
|
-
"@ultimat3/admin": "20.1.
|
|
42
|
-
"@ultimat3/ai": "20.1.
|
|
43
|
-
"@ultimat3/auth": "20.1.
|
|
44
|
-
"@ultimat3/cache": "20.1.
|
|
45
|
-
"@ultimat3/core": "20.1.
|
|
46
|
-
"@ultimat3/db": "20.1.
|
|
47
|
-
"@ultimat3/entity": "20.1.
|
|
48
|
-
"@ultimat3/flags": "20.1.
|
|
49
|
-
"@ultimat3/http": "20.1.
|
|
50
|
-
"@ultimat3/i18n": "20.1.
|
|
51
|
-
"@ultimat3/jobs": "20.1.
|
|
52
|
-
"@ultimat3/mail": "20.1.
|
|
53
|
-
"@ultimat3/manifest": "20.1.
|
|
54
|
-
"@ultimat3/mcp": "20.1.
|
|
55
|
-
"@ultimat3/money": "20.1.
|
|
56
|
-
"@ultimat3/notify": "20.1.
|
|
57
|
-
"@ultimat3/policy": "20.1.
|
|
58
|
-
"@ultimat3/pwa": "20.1.
|
|
59
|
-
"@ultimat3/query": "20.1.
|
|
60
|
-
"@ultimat3/realtime": "20.1.
|
|
61
|
-
"@ultimat3/render": "20.1.
|
|
62
|
-
"@ultimat3/schema": "20.1.
|
|
63
|
-
"@ultimat3/scraping": "20.1.
|
|
64
|
-
"@ultimat3/seo": "20.1.
|
|
65
|
-
"@ultimat3/storage": "20.1.
|
|
66
|
-
"@ultimat3/testing": "20.1.
|
|
67
|
-
"@ultimat3/time": "20.1.
|
|
40
|
+
"@ultimat3/action": "20.1.3",
|
|
41
|
+
"@ultimat3/admin": "20.1.3",
|
|
42
|
+
"@ultimat3/ai": "20.1.3",
|
|
43
|
+
"@ultimat3/auth": "20.1.3",
|
|
44
|
+
"@ultimat3/cache": "20.1.3",
|
|
45
|
+
"@ultimat3/core": "20.1.3",
|
|
46
|
+
"@ultimat3/db": "20.1.3",
|
|
47
|
+
"@ultimat3/entity": "20.1.3",
|
|
48
|
+
"@ultimat3/flags": "20.1.3",
|
|
49
|
+
"@ultimat3/http": "20.1.3",
|
|
50
|
+
"@ultimat3/i18n": "20.1.3",
|
|
51
|
+
"@ultimat3/jobs": "20.1.3",
|
|
52
|
+
"@ultimat3/mail": "20.1.3",
|
|
53
|
+
"@ultimat3/manifest": "20.1.3",
|
|
54
|
+
"@ultimat3/mcp": "20.1.3",
|
|
55
|
+
"@ultimat3/money": "20.1.3",
|
|
56
|
+
"@ultimat3/notify": "20.1.3",
|
|
57
|
+
"@ultimat3/policy": "20.1.3",
|
|
58
|
+
"@ultimat3/pwa": "20.1.3",
|
|
59
|
+
"@ultimat3/query": "20.1.3",
|
|
60
|
+
"@ultimat3/realtime": "20.1.3",
|
|
61
|
+
"@ultimat3/render": "20.1.3",
|
|
62
|
+
"@ultimat3/schema": "20.1.3",
|
|
63
|
+
"@ultimat3/scraping": "20.1.3",
|
|
64
|
+
"@ultimat3/seo": "20.1.3",
|
|
65
|
+
"@ultimat3/storage": "20.1.3",
|
|
66
|
+
"@ultimat3/testing": "20.1.3",
|
|
67
|
+
"@ultimat3/time": "20.1.3",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/browser-launcher.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { existsSync } from 'node:fs';
|
|
|
13
13
|
import { UltimateError } from '@ultimat3/core';
|
|
14
14
|
import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
|
|
15
15
|
import { localBrowser, remoteBrowser } from '@ultimat3/scraping';
|
|
16
|
-
import { CHROME_CANDIDATES } from './cdp-launch';
|
|
16
|
+
import { CHROME_CANDIDATES, CONTAINER_CHROME_ARGS } from './cdp-launch';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* The one library this works against. Playwright is not an alternative and is not a flag:
|
|
@@ -231,8 +231,15 @@ export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriv
|
|
|
231
231
|
...(options.executablePath === undefined ? {} : { executablePath: options.executablePath }),
|
|
232
232
|
// `LocalBrowserOptions.options` is passed through to `launch()` untouched, which is the seam
|
|
233
233
|
// that lets the CLI size a browser without `@ultimat3/scraping` naming a puppeteer type.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
//
|
|
235
|
+
// `args` carries `CONTAINER_CHROME_ARGS` on EVERY local launch, viewport or not — the same
|
|
236
|
+
// `--no-sandbox` / `--disable-dev-shm-usage` `cdp-launch.ts`'s e2e driver already needed for
|
|
237
|
+
// this container, read from the one export rather than restated. Before this, `x shot` was the
|
|
238
|
+
// only browser-launching command in the tree with neither, so a box where the e2e gate ran
|
|
239
|
+
// green could not run `x shot` at all — Chrome exits "No usable sandbox".
|
|
240
|
+
options: {
|
|
241
|
+
args: [...CONTAINER_CHROME_ARGS],
|
|
242
|
+
...(options.viewport === undefined ? {} : { defaultViewport: { ...options.viewport } }),
|
|
243
|
+
},
|
|
237
244
|
});
|
|
238
245
|
}
|
package/src/cdp-launch.ts
CHANGED
|
@@ -36,6 +36,19 @@ export async function findChrome(
|
|
|
36
36
|
return undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The two flags a CONTAINER needs, regardless of which process launches Chrome: the sandbox needs
|
|
41
|
+
* privileges CI (and an Ubuntu 23.10+ host with AppArmor's unprivileged-user-namespace restriction
|
|
42
|
+
* — Chrome exits "No usable sandbox" there with neither) does not grant, and `/dev/shm` is 64 MB in
|
|
43
|
+
* a default container, which crashes the renderer on any real page.
|
|
44
|
+
*
|
|
45
|
+
* Exported so `browser-launcher.ts`'s `appBrowser` — a DIFFERENT launch path, `puppeteer-core`'s
|
|
46
|
+
* own `launch()` rather than the `Bun.spawn` below — passes the SAME two, rather than a second
|
|
47
|
+
* list that agrees today and drifts the next time either changes. `x shot` had neither before this
|
|
48
|
+
* export existed, so a box where `x verify`'s e2e gate ran green could not run `x shot` at all.
|
|
49
|
+
*/
|
|
50
|
+
export const CONTAINER_CHROME_ARGS: readonly string[] = ['--no-sandbox', '--disable-dev-shm-usage'];
|
|
51
|
+
|
|
39
52
|
/**
|
|
40
53
|
* The flags, and every one of them earns its line.
|
|
41
54
|
*
|
|
@@ -43,16 +56,12 @@ export async function findChrome(
|
|
|
43
56
|
* asks the OS for a free port, so two suites on one machine never collide — the port is read back
|
|
44
57
|
* off stderr, which is the only place Chrome states the one it took. A throwaway `--user-data-dir`
|
|
45
58
|
* because a run sharing a profile with a real browser inherits its cookies and locks its files.
|
|
46
|
-
* `--no-sandbox` and `--disable-dev-shm-usage` are the two a container needs: the sandbox needs
|
|
47
|
-
* privileges CI does not grant, and `/dev/shm` is 64 MB in a default container, which crashes the
|
|
48
|
-
* renderer on any real page.
|
|
49
59
|
*/
|
|
50
60
|
const flags = (profileDir: string): readonly string[] => [
|
|
51
61
|
'--headless=new',
|
|
52
62
|
'--remote-debugging-port=0',
|
|
53
63
|
`--user-data-dir=${profileDir}`,
|
|
54
|
-
|
|
55
|
-
'--disable-dev-shm-usage',
|
|
64
|
+
...CONTAINER_CHROME_ARGS,
|
|
56
65
|
'--disable-gpu',
|
|
57
66
|
// Nothing here should reach the network on its own account, and a first-run bubble or an update
|
|
58
67
|
// check is a page load the test did not ask for.
|
package/src/cmd-dev.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { CliCommand, CommandContext } from './command';
|
|
|
24
24
|
import { assetRoutes } from './dev-assets';
|
|
25
25
|
import type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
26
26
|
import { devDashboardRoutes, devPanels } from './dev-dashboard';
|
|
27
|
+
import { declareDevEnvironment } from './dev-environment';
|
|
27
28
|
import { liveFeedLabel } from './dev-live-feed';
|
|
28
29
|
import { clearLock, preflight, writeLock } from './dev-lock';
|
|
29
30
|
import { createStatementLedger } from './dev-n-plus-one';
|
|
@@ -374,6 +375,9 @@ export const devCommand: CliCommand = {
|
|
|
374
375
|
},
|
|
375
376
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
376
377
|
const root = requireAppRoot('dev', ctx.cwd).dir;
|
|
378
|
+
// BEFORE `startDev` imports a single app module — see `dev-environment.ts` for why this must
|
|
379
|
+
// run this early, and why it mutates the real `process.env` rather than `startDev`'s `env`.
|
|
380
|
+
declareDevEnvironment(ctx.env);
|
|
377
381
|
// Validated, not `parseInt`'d: `x dev --port abc` handed `NaN` to `Bun.serve`, which binds an
|
|
378
382
|
// arbitrary port — a dev server reachable at an address nothing printed.
|
|
379
383
|
const port = intFlagOr(
|
package/src/cmd-generate.ts
CHANGED
|
@@ -72,12 +72,19 @@ export const generateCommand: CliCommand = {
|
|
|
72
72
|
// imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
|
|
73
73
|
const catalogModule = await resolveCatalogModule(root);
|
|
74
74
|
// Read for the same reason: which errors the slice declares is written on THIS app's disk.
|
|
75
|
-
const
|
|
75
|
+
const slice = sliceDir(surface, featureFlag ?? name);
|
|
76
|
+
const sliceErrors = await readSliceErrors(root, kind, slice);
|
|
77
|
+
// Same reason again: whether `job`/`task` may assume the tenant-scoped shape is a fact about
|
|
78
|
+
// THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
|
|
79
|
+
const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
|
|
80
|
+
const sliceRepo = await readSliceFile(root, kind, slice, 'repo.ts');
|
|
76
81
|
const files = generate({
|
|
77
82
|
kind,
|
|
78
83
|
name,
|
|
79
84
|
...(featureFlag === undefined ? {} : { feature: featureFlag }),
|
|
80
85
|
...(sliceErrors === undefined ? {} : { sliceErrors }),
|
|
86
|
+
...(sliceEntity === undefined ? {} : { sliceEntity }),
|
|
87
|
+
...(sliceRepo === undefined ? {} : { sliceRepo }),
|
|
81
88
|
...(at === undefined ? {} : { at }),
|
|
82
89
|
...(permission === undefined ? {} : { permission }),
|
|
83
90
|
surface,
|
|
@@ -157,3 +164,19 @@ async function readSliceErrors(
|
|
|
157
164
|
const file = containedPath(root, `${slice}/errors.ts`);
|
|
158
165
|
return existsSync(file) ? await Bun.file(file).text() : undefined;
|
|
159
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `job` and `task` only: the slice's `entity.ts`/`repo.ts` as they stand on disk, absent when the
|
|
170
|
+
* generator's kind is neither or the file does not exist yet. `readSliceErrors`'s reason —
|
|
171
|
+
* whichever generator reads it decides on THIS app's disk, not on a default the template assumes.
|
|
172
|
+
*/
|
|
173
|
+
async function readSliceFile(
|
|
174
|
+
root: string,
|
|
175
|
+
kind: Generator,
|
|
176
|
+
slice: string,
|
|
177
|
+
name: 'entity.ts' | 'repo.ts',
|
|
178
|
+
): Promise<string | undefined> {
|
|
179
|
+
if (kind !== 'job' && kind !== 'task') return undefined;
|
|
180
|
+
const file = containedPath(root, `${slice}/${name}`);
|
|
181
|
+
return existsSync(file) ? await Bun.file(file).text() : undefined;
|
|
182
|
+
}
|
package/src/cmd-test.ts
CHANGED
package/src/cmd-verify.ts
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Single responsibility: whether `x dev` must declare `ULTIMATE_ENV` for the app it is about to
|
|
2
|
+
// boot, and the one-liner side effect that declares it. Split out of `cmd-dev.ts` (which the
|
|
3
|
+
// filesize gate holds to ~500 lines) rather than folded into it — this is one decision with one
|
|
4
|
+
// consumer, and keeping it separate is what lets a test pin the decision without paying for a
|
|
5
|
+
// whole app boot.
|
|
6
|
+
|
|
7
|
+
import { ENVIRONMENT_KEY } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether `x dev` must declare `ULTIMATE_ENV` for the app it is about to boot: true only when
|
|
11
|
+
* NEITHER key `@ultimat3/core`'s `resolveEnvironment` reads is set to a real value. Empty-string
|
|
12
|
+
* matches that reader's own rule (`packages/core/src/environment.ts`'s `readEnvironment`):
|
|
13
|
+
* `ULTIMATE_ENV=''` is treated as unset, so "already set" here means non-empty, exactly as there.
|
|
14
|
+
*
|
|
15
|
+
* `NODE_ENV=ci` (or any other non-`Environment` value) counts as "already set" and is left alone
|
|
16
|
+
* even though `resolveEnvironment` would still fall through it to `DEFAULT_ENVIRONMENT` — an
|
|
17
|
+
* operator who set SOMETHING gets no override from this process, only a process that named
|
|
18
|
+
* NEITHER key does.
|
|
19
|
+
*/
|
|
20
|
+
export function needsDevEnvironmentDeclaration(
|
|
21
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
22
|
+
): boolean {
|
|
23
|
+
const declared = env[ENVIRONMENT_KEY];
|
|
24
|
+
const nodeEnv = env['NODE_ENV'];
|
|
25
|
+
return (declared === undefined || declared === '') && (nodeEnv === undefined || nodeEnv === '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The one-liner side effect, split from the decision so a test can pin either without paying for
|
|
30
|
+
* a whole app boot: `process.env[ENVIRONMENT_KEY] = 'development'`, and only when
|
|
31
|
+
* `needsDevEnvironmentDeclaration` says neither key was set.
|
|
32
|
+
*
|
|
33
|
+
* Mutates the real `process.env`, not a copy: `cmd-dev.ts`'s `run` passes `ctx.env`, which IS
|
|
34
|
+
* `Bun.env` (probed on 1.4.2 — `Bun.env === process.env`), and it is `process.env` that
|
|
35
|
+
* `resolveEnvironment`'s default reader (no explicit `env` passed) and every app module loaded
|
|
36
|
+
* in-process actually consult. The call has to land before `startDev` imports a single app
|
|
37
|
+
* module — see `cmd-dev.ts`'s `run` for why that means the top of the command, not inside
|
|
38
|
+
* `startDev` itself, which stays a pure function of the `env` it is handed.
|
|
39
|
+
*/
|
|
40
|
+
export function declareDevEnvironment(env: Readonly<Record<string, string | undefined>>): void {
|
|
41
|
+
if (needsDevEnvironmentDeclaration(env)) process.env[ENVIRONMENT_KEY] = 'development';
|
|
42
|
+
}
|
package/src/exec.ts
CHANGED
|
@@ -21,7 +21,16 @@ export interface ExecResult {
|
|
|
21
21
|
|
|
22
22
|
export interface ExecOptions {
|
|
23
23
|
readonly cwd: string;
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Overlaid onto `Bun.env`, key by key: a string sets/overrides it for the child, and
|
|
26
|
+
* `undefined` UNSETS it — the child does not inherit it at all, even though the parent has it.
|
|
27
|
+
* The delete case exists for one caller (`test-dotenv.ts`'s `testEnvOverrides`, spent by
|
|
28
|
+
* `test-shards.ts`/`verify-tests.ts`/`verify-test-run.ts`/`mcp-host.ts`): a `bun test` child
|
|
29
|
+
* must not inherit a key that reached the parent only through Bun auto-loading
|
|
30
|
+
* `.env.development`. A merge that only ever adds or overrides (`{ ...Bun.env, ...env }`) cannot
|
|
31
|
+
* express that — `Bun.env` is always the base, so a key just absent from `env` survives from it.
|
|
32
|
+
*/
|
|
33
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
25
34
|
readonly stdin?: string;
|
|
26
35
|
}
|
|
27
36
|
|
|
@@ -48,12 +57,34 @@ const now = (): number => performance.now();
|
|
|
48
57
|
*
|
|
49
58
|
* The return type is inferred so this stays one statement of `Bun.spawn`'s own shape.
|
|
50
59
|
*/
|
|
60
|
+
/**
|
|
61
|
+
* `Bun.env` overlaid with `overrides`, an `undefined` value deleting the key rather than setting
|
|
62
|
+
* it to the string `"undefined"` — see `ExecOptions.env`'s own comment for why a delete has to be
|
|
63
|
+
* expressible here at all.
|
|
64
|
+
*/
|
|
65
|
+
function mergedEnv(
|
|
66
|
+
overrides: Readonly<Record<string, string | undefined>>,
|
|
67
|
+
): Record<string, string> {
|
|
68
|
+
// A `Map`, not an object indexed by `key`: the keys are DATA (environment variable names), and
|
|
69
|
+
// a plain-object table read or deleted by a computed key is the `Object.prototype` hazard
|
|
70
|
+
// `scripts/proto-index.ts` ratchets. `Object.fromEntries` builds the record once, at the end.
|
|
71
|
+
const merged = new Map<string, string>();
|
|
72
|
+
for (const [key, value] of Object.entries(Bun.env)) {
|
|
73
|
+
if (value !== undefined) merged.set(key, value);
|
|
74
|
+
}
|
|
75
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
76
|
+
if (value === undefined) merged.delete(key);
|
|
77
|
+
else merged.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
return Object.fromEntries(merged);
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
function spawnOrRefuse(command: readonly string[], options: ExecOptions) {
|
|
52
83
|
const [head = '', ...rest] = command;
|
|
53
84
|
try {
|
|
54
85
|
return Bun.spawn([head, ...rest], {
|
|
55
86
|
cwd: options.cwd,
|
|
56
|
-
env: options.env === undefined ? Bun.env :
|
|
87
|
+
env: options.env === undefined ? Bun.env : mergedEnv(options.env),
|
|
57
88
|
stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
|
|
58
89
|
stdout: 'pipe',
|
|
59
90
|
stderr: 'pipe',
|
package/src/generate-files.ts
CHANGED
|
@@ -55,6 +55,16 @@ export interface GenerateOptions {
|
|
|
55
55
|
* import of a class the app never declared. Read at `sliceDir(surface, feature)/errors.ts`.
|
|
56
56
|
*/
|
|
57
57
|
readonly sliceErrors?: string;
|
|
58
|
+
/**
|
|
59
|
+
* `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when the feature has no
|
|
60
|
+
* entity yet. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
|
|
61
|
+
* tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
|
|
62
|
+
* `repo.byId`/`repo.listByOrg` calls into a feature whose entity names no tenant column. Read at
|
|
63
|
+
* `sliceDir(surface, feature)/entity.ts`.
|
|
64
|
+
*/
|
|
65
|
+
readonly sliceEntity?: string;
|
|
66
|
+
/** `job` and `task`: the slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
|
|
67
|
+
readonly sliceRepo?: string;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
|
|
@@ -110,9 +120,21 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
110
120
|
case 'query':
|
|
111
121
|
return dedupe(queryFiles(options.name, { ...target, live: options.live === true }));
|
|
112
122
|
case 'job':
|
|
113
|
-
return dedupe(
|
|
123
|
+
return dedupe(
|
|
124
|
+
jobFiles(options.name, {
|
|
125
|
+
...target,
|
|
126
|
+
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
127
|
+
...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
114
130
|
case 'task':
|
|
115
|
-
return dedupe(
|
|
131
|
+
return dedupe(
|
|
132
|
+
taskFiles(options.name, {
|
|
133
|
+
...target,
|
|
134
|
+
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
135
|
+
...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
116
138
|
case 'island':
|
|
117
139
|
return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
|
|
118
140
|
// No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
|
package/src/island-capture.ts
CHANGED
|
@@ -267,6 +267,7 @@ export async function captureIslandState(
|
|
|
267
267
|
box: seen?.box ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
268
268
|
mounted: seen?.mounted === true,
|
|
269
269
|
unstubbed: seen?.unstubbed ?? [],
|
|
270
|
+
sockets: seen?.sockets ?? [],
|
|
270
271
|
console: page.console(),
|
|
271
272
|
pageErrors: page.pageErrors(),
|
|
272
273
|
overflow: seen?.overflow ?? { x: false, y: false },
|
|
@@ -65,10 +65,54 @@ var method=(init&&init.method)||(typeof input==='object'&&input&&input.method)||
|
|
|
65
65
|
var path=pathOf(url);var k=method.toUpperCase()+' '+path;
|
|
66
66
|
var respond=stubFor(method,path);bump();
|
|
67
67
|
return answer(respond,k).then(function(r){bump();return r},function(e){bump();throw e})};
|
|
68
|
-
// A socket
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
// A socket or an event stream needs no stub, because a SNAPSHOT of an island does not need a
|
|
69
|
+
// LIVE one: every state's fixture already rides \`props\`/\`routes\`, so the component's own
|
|
70
|
+
// realtime layer has nothing to tell it that the fetch/XHR seal does not already say. Refusing
|
|
71
|
+
// the construction outright — as this did until the defect that made every live island
|
|
72
|
+
// unphotographable — fails a component for opening a channel whose data this harness was never
|
|
73
|
+
// asked to carry, in EVERY state, because \`mount()\` dials it unconditionally.
|
|
74
|
+
//
|
|
75
|
+
// So the stand-in is INERT rather than refused: no real dial, no network, no message and no error
|
|
76
|
+
// ever delivered. \`close()\`/\`send()\` are no-ops. Recorded on \`W.sockets\`, never on
|
|
77
|
+
// \`W.unstubbed\` — a real unanswered fetch still fails the run (the refusal above is unchanged),
|
|
78
|
+
// but a socket a component merely opened and heard nothing from is not the same fact as a request
|
|
79
|
+
// nobody stubbed, and \`stateShotOk\` must not conflate the two; neither field is hidden from the
|
|
80
|
+
// verdict, both ride \`--json\` plainly, so this is not a candidate for \`ISLAND_BLIND_SPOTS\` —
|
|
81
|
+
// that list is for a fact a PICTURE cannot show, and a socket's inertness is a fact this JSON
|
|
82
|
+
// already states.
|
|
83
|
+
//
|
|
84
|
+
// \`readyState\` DOES leave CONNECTING, on a zero-delay timer, once: a component whose \`mount()\`
|
|
85
|
+
// awaits the socket's own \`open\` before it renders anything — never true of the reference app's
|
|
86
|
+
// \`LiveClient\`, whose \`connect()\` registers callbacks and returns, but not a fact this harness
|
|
87
|
+
// may assume of every app — would otherwise hang the mount forever, trading one impossible
|
|
88
|
+
// \`X_SHOT_ISLAND_UNSTUBBED_REQUEST\` fix for an unreachable \`--settle\` deadline, the same defect
|
|
89
|
+
// under a different name. So \`onopen\`/an \`'open'\` listener fires exactly once; \`onmessage\` and
|
|
90
|
+
// \`onerror\` never do, because this is a channel that opened and then heard nothing, not one that
|
|
91
|
+
// received data no fixture could have supplied.
|
|
92
|
+
function inertSocket(kind,url){
|
|
93
|
+
W.sockets.push(kind+' '+url);
|
|
94
|
+
var listeners={};
|
|
95
|
+
var self={
|
|
96
|
+
readyState:0,url:String(url),
|
|
97
|
+
addEventListener:function(type,fn){(listeners[type]=listeners[type]||[]).push(fn)},
|
|
98
|
+
removeEventListener:function(type,fn){var l=listeners[type];if(!l)return;
|
|
99
|
+
var i=l.indexOf(fn);if(i>=0)l.splice(i,1)},
|
|
100
|
+
dispatchEvent:function(){return true},
|
|
101
|
+
send:function(){},
|
|
102
|
+
close:function(){},
|
|
103
|
+
onopen:null,onmessage:null,onerror:null,onclose:null};
|
|
104
|
+
setTimeout(function(){
|
|
105
|
+
self.readyState=1;
|
|
106
|
+
var ev={type:'open',target:self};
|
|
107
|
+
if(typeof self.onopen==='function')self.onopen(ev);
|
|
108
|
+
var handlers=listeners['open'];
|
|
109
|
+
if(handlers)for(var i=0;i<handlers.length;i+=1)handlers[i](ev)},0);
|
|
110
|
+
return self}
|
|
111
|
+
window.WebSocket=function(url){return inertSocket('WS',url)};
|
|
112
|
+
window.WebSocket.CONNECTING=0;window.WebSocket.OPEN=1;
|
|
113
|
+
window.WebSocket.CLOSING=2;window.WebSocket.CLOSED=3;
|
|
114
|
+
window.EventSource=function(url){return inertSocket('SSE',url)};
|
|
115
|
+
window.EventSource.CONNECTING=0;window.EventSource.OPEN=1;window.EventSource.CLOSED=2;
|
|
72
116
|
var RealXHR=window.XMLHttpRequest;
|
|
73
117
|
window.XMLHttpRequest=function(){var xhr=new RealXHR();var open=xhr.open;
|
|
74
118
|
xhr.open=function(method,url){W.unstubbed.push(String(method).toUpperCase()+' '+pathOf(url));
|
|
@@ -117,7 +161,7 @@ requestAnimationFrame(tick)}
|
|
|
117
161
|
/** The whole prelude, in the one order that works: state, seal, clock, then the readiness watch. */
|
|
118
162
|
export function harnessScript(options: HarnessScriptOptions): string {
|
|
119
163
|
return [
|
|
120
|
-
`window.${HARNESS_GLOBAL}={harness:true,activity:0,ready:false,unstubbed:[]};`,
|
|
164
|
+
`window.${HARNESS_GLOBAL}={harness:true,activity:0,ready:false,unstubbed:[],sockets:[]};`,
|
|
121
165
|
sealScript(options.stubs),
|
|
122
166
|
clockScript(options.now, options.timeZone),
|
|
123
167
|
readyScript(),
|
|
@@ -138,6 +182,11 @@ export const readinessProbe = (selector: string): string =>
|
|
|
138
182
|
'var r=box?box.getBoundingClientRect():{width:0,height:0,x:0,y:0};' +
|
|
139
183
|
'return{harness:W.harness===true,ready:W.ready===true,' +
|
|
140
184
|
'unstubbed:(W.unstubbed||[]).slice(),' +
|
|
185
|
+
// Recorded beside `unstubbed`, never merged into it: a socket a component opened and never
|
|
186
|
+
// heard back from is not the same fact as a request nobody stubbed, and `stateShotOk` reads
|
|
187
|
+
// neither — a component may legitimately hold an open, silent channel in a state that is
|
|
188
|
+
// otherwise clean.
|
|
189
|
+
'sockets:(W.sockets||[]).slice(),' +
|
|
141
190
|
'attached:host!==null&&document.body.contains(host),' +
|
|
142
191
|
'mounted:host!==null&&host.hasAttribute("data-x-mounted"),' +
|
|
143
192
|
'failed:host&&host.hasAttribute("data-x-failed")?host.getAttribute("data-x-failed"):null,' +
|
package/src/island-verdict.ts
CHANGED
|
@@ -54,6 +54,13 @@ export interface IslandReadiness {
|
|
|
54
54
|
readonly harness: boolean;
|
|
55
55
|
readonly ready: boolean;
|
|
56
56
|
readonly unstubbed: readonly string[];
|
|
57
|
+
/**
|
|
58
|
+
* `"WS <url>"` / `"SSE <url>"` for every socket a component constructed — recorded, never
|
|
59
|
+
* gating. The harness's stand-in is inert (constructs, never opens, `close()` is a no-op), so
|
|
60
|
+
* a component dialing `@ultimat3/realtime`'s `LiveClient.connect()` does not fail the state it
|
|
61
|
+
* is mounted in; this is the fact a picture cannot carry about that.
|
|
62
|
+
*/
|
|
63
|
+
readonly sockets: readonly string[];
|
|
57
64
|
readonly attached: boolean;
|
|
58
65
|
readonly mounted: boolean;
|
|
59
66
|
readonly failed: string | null;
|
|
@@ -86,6 +93,7 @@ const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
|
|
|
86
93
|
harness: t.boolean,
|
|
87
94
|
ready: t.boolean,
|
|
88
95
|
unstubbed: t.array(t.string),
|
|
96
|
+
sockets: t.array(t.string),
|
|
89
97
|
attached: t.boolean,
|
|
90
98
|
mounted: t.boolean,
|
|
91
99
|
failed: t.nullable(t.string),
|
|
@@ -115,6 +123,8 @@ export interface IslandStateShot {
|
|
|
115
123
|
readonly box: IslandBox;
|
|
116
124
|
readonly mounted: boolean;
|
|
117
125
|
readonly unstubbed: readonly string[];
|
|
126
|
+
/** See `IslandReadiness.sockets` — recorded, and read by neither `stateShotOk` nor the gate. */
|
|
127
|
+
readonly sockets: readonly string[];
|
|
118
128
|
readonly console: readonly ConsoleLine[];
|
|
119
129
|
readonly pageErrors: readonly PageError[];
|
|
120
130
|
/**
|
|
@@ -184,6 +194,7 @@ const shotJson = (shot: IslandStateShot): JsonValue => ({
|
|
|
184
194
|
warnings: stateShotWarnings(shot).length,
|
|
185
195
|
overflow: { x: shot.overflow.x, y: shot.overflow.y },
|
|
186
196
|
unstubbed: [...shot.unstubbed],
|
|
197
|
+
sockets: [...shot.sockets],
|
|
187
198
|
console: shot.console.map((line) => ({ level: line.level, text: line.text, at: line.at })),
|
|
188
199
|
pageErrors: shot.pageErrors.map((error) => ({
|
|
189
200
|
message: error.message,
|
package/src/mcp-host.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { explainErrorCode } from './mcp-errors';
|
|
|
46
46
|
import { parseBunTest } from './mcp-test-output';
|
|
47
47
|
import { readMigrations } from './migrations';
|
|
48
48
|
import { retryMemo } from './retry-memo';
|
|
49
|
+
import { testEnvOverrides } from './test-dotenv';
|
|
49
50
|
|
|
50
51
|
export interface DevHostInput {
|
|
51
52
|
readonly root: string;
|
|
@@ -178,7 +179,7 @@ export async function readOnlyRows(
|
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities {
|
|
181
|
-
const { root, runner } = input;
|
|
182
|
+
const { root, runner, env } = input;
|
|
182
183
|
// Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
|
|
183
184
|
// loop — resolve the role once per process and reuse the answer, `null` included. A FAILED
|
|
184
185
|
// resolution is not an answer: `??=` kept the rejection, so a statement timeout on the DDL
|
|
@@ -239,10 +240,14 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
239
240
|
|
|
240
241
|
// `bun test <filter>` matches on the test path, the same rule `x test`'s `discoverTests` uses.
|
|
241
242
|
async runTests(filter: string | undefined) {
|
|
243
|
+
// Same leak the CLI's own `x test`/`x verify` had: `.env.development` auto-loaded into
|
|
244
|
+
// THIS process must not ride along into the `bun test` child the dev MCP server spawns.
|
|
245
|
+
const envOverrides = testEnvOverrides(root, env);
|
|
242
246
|
const result = await runner(
|
|
243
247
|
filter === undefined ? ['bun', 'test'] : ['bun', 'test', filter],
|
|
244
248
|
{
|
|
245
249
|
cwd: root,
|
|
250
|
+
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
246
251
|
},
|
|
247
252
|
);
|
|
248
253
|
return parseBunTest(execOutput(result), result.durationMs);
|
package/src/scaffold-fixture.ts
CHANGED
|
@@ -21,6 +21,31 @@ export const HANDWRITTEN_ERRORS = `import { UltimateError } from '@ultimat3/core
|
|
|
21
21
|
export class LedgerClosedError extends UltimateError {}
|
|
22
22
|
`;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A feature slice whose entity is NOT tenant-scoped — the shape `x g entity`'s own comment
|
|
26
|
+
* describes for a single-tenant app, and never a shape the generator writes itself. The
|
|
27
|
+
* generator's INPUT only, the same way `HANDWRITTEN_ERRORS` above is: `x g job`/`x g task` read
|
|
28
|
+
* this from a real `entity.ts`, and a fixture that never exercised it compiled only the shape
|
|
29
|
+
* that never failed.
|
|
30
|
+
*/
|
|
31
|
+
export const HANDWRITTEN_ENTITY_NO_TENANT = `import { entity, text, uuid } from '@ultimat3/entity';
|
|
32
|
+
|
|
33
|
+
export const shortLink = entity('short_links', {
|
|
34
|
+
columns: { id: uuid().primaryKey(), url: text({ max: 2000 }) },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type ShortLink = typeof shortLink.$row;
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/** The paired `repo.ts`: no `byId`, no `listByOrg` — nothing this slice's job may call. */
|
|
41
|
+
export const HANDWRITTEN_REPO_NO_TENANT = `import { db, sql } from '@ultimat3/db';
|
|
42
|
+
import type { ShortLink } from './entity';
|
|
43
|
+
|
|
44
|
+
export async function list(limit = 50): Promise<readonly ShortLink[]> {
|
|
45
|
+
return db().query<ShortLink>(sql\`select * from short_links order by url limit \${limit}\`);
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
|
|
24
49
|
/**
|
|
25
50
|
* One realistic invocation of every generator, on top of `x new --example`. Names differ from
|
|
26
51
|
* their feature on purpose: `x g query invoice --feature invoice` would collide with the entity
|
|
@@ -47,6 +72,23 @@ export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
|
|
|
47
72
|
{ kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
|
|
48
73
|
{ kind: 'backfill', name: 'reindex-invoices', feature: 'invoice' },
|
|
49
74
|
{ kind: 'task', name: 'nightly-sweep', feature: 'invoice' },
|
|
75
|
+
// The other shape both templates have: a feature whose entity names no tenant column, so the
|
|
76
|
+
// job/task must not assume one — compiled here beside the tenant-scoped pair above, exactly as
|
|
77
|
+
// `ping-invoice`/`touch-invoice` compile the action's other shape beside `send-invoice`.
|
|
78
|
+
{
|
|
79
|
+
kind: 'job',
|
|
80
|
+
name: 'purge-orphans',
|
|
81
|
+
feature: 'short-link',
|
|
82
|
+
sliceEntity: HANDWRITTEN_ENTITY_NO_TENANT,
|
|
83
|
+
sliceRepo: HANDWRITTEN_REPO_NO_TENANT,
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
kind: 'task',
|
|
87
|
+
name: 'nightly-purge',
|
|
88
|
+
feature: 'short-link',
|
|
89
|
+
sliceEntity: HANDWRITTEN_ENTITY_NO_TENANT,
|
|
90
|
+
sliceRepo: HANDWRITTEN_REPO_NO_TENANT,
|
|
91
|
+
},
|
|
50
92
|
{ kind: 'route', name: 'pricing', surface: 'site' },
|
|
51
93
|
{ kind: 'route', name: 'billing', surface: 'app' },
|
|
52
94
|
// `--at`, pointed at the `site/` route above: an island's whole reason to exist is a 0kb page
|
package/src/templates/index.ts
CHANGED
|
@@ -12,7 +12,8 @@ export { entityFiles } from './entity';
|
|
|
12
12
|
export { guardCode, guardFiles } from './guard';
|
|
13
13
|
export type { IslandOptions } from './island';
|
|
14
14
|
export { islandFiles } from './island';
|
|
15
|
-
export {
|
|
15
|
+
export type { JobOptions } from './job';
|
|
16
|
+
export { isTenantScopedSlice, jobFiles, taskFiles } from './job';
|
|
16
17
|
export { CATALOG_ROOT, catalogPath, DEFAULT_LOCALES, resolveLocales } from './locales';
|
|
17
18
|
// All three members of the `GeneratedFile` union, not two: the barrel exported the union and the
|
|
18
19
|
// foundation variant only, so a consumer could hold a `GeneratedFile` and had no name to narrow it
|
package/src/templates/job.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// both; the generated test pins them through a real driver, because a key that is not stable is a
|
|
4
4
|
// job that runs twice and a tenant that is not declared is a job that reads the wrong org's rows.
|
|
5
5
|
|
|
6
|
+
import { stripComments } from '../ts-scan';
|
|
6
7
|
import type { FeatureTarget } from './entity';
|
|
7
8
|
import type { GeneratedFile, NameSet } from './naming';
|
|
8
9
|
import { names } from './naming';
|
|
9
|
-
import { sliceFoundation } from './slice-foundation';
|
|
10
|
+
import { sliceExports, sliceFoundation } from './slice-foundation';
|
|
10
11
|
|
|
11
12
|
const jobSource = (
|
|
12
13
|
name: NameSet,
|
|
@@ -39,6 +40,42 @@ export const ${name.camel} = job({
|
|
|
39
40
|
});
|
|
40
41
|
`;
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The other shape: this feature's own `entity.ts` names no tenant column (or names `'none'`), or
|
|
45
|
+
* its `repo.ts` does not export the `byId`/`listByOrg` pair the tenant-scoped body above calls —
|
|
46
|
+
* checked by `isTenantScopedSlice` against what is actually on the app's disk, never assumed. The
|
|
47
|
+
* body imports neither `../entity` nor `../repo`, so it compiles whether this feature has an
|
|
48
|
+
* entity yet or has one with no tenant, and `tenant: 'none'` is stated rather than defaulted so a
|
|
49
|
+
* reviewer sees the decision instead of an absence.
|
|
50
|
+
*/
|
|
51
|
+
const neutralJobSource = (
|
|
52
|
+
name: NameSet,
|
|
53
|
+
): string => `// ${name.camel}: multi-step durable work with no tenant behind it. Each step is retried
|
|
54
|
+
// independently and its result is stored under its name — step names are stable identifiers, not
|
|
55
|
+
// labels. \`t\` comes from @ultimat3/jobs, not @ultimat3/schema: a job file imports one package.
|
|
56
|
+
|
|
57
|
+
import { job, t } from '@ultimat3/jobs';
|
|
58
|
+
|
|
59
|
+
export const ${name.camel} = job({
|
|
60
|
+
input: t.object({ id: t.uuid }),
|
|
61
|
+
// This feature has no tenant column to derive an org from — either it has no entity yet, or its
|
|
62
|
+
// entity names none. \`tenant: 'none'\` STRIPS the org from the run rather than leaving one
|
|
63
|
+
// behind, so a tenant-scoped read added later fails closed with X_TENANCY_ACTOR_ORG_REQUIRED
|
|
64
|
+
// instead of reading whichever org the enqueuer happened to hold. Once this feature's entity
|
|
65
|
+
// declares \`tenant: 'orgId'\` and its repo exports \`byId\`/\`listByOrg\`, the next \`x g job\` in
|
|
66
|
+
// this slice scaffolds the tenant-scoped shape above instead.
|
|
67
|
+
tenant: 'none',
|
|
68
|
+
idempotencyKey: ({ id }) => \`${name.kebab}:\${id}\`,
|
|
69
|
+
retry: { attempts: 5, backoff: 'exponential' },
|
|
70
|
+
async run({ step }) {
|
|
71
|
+
await step.run('process', async () => {
|
|
72
|
+
// TODO: this job's own work.
|
|
73
|
+
});
|
|
74
|
+
return { processed: true };
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
`;
|
|
78
|
+
|
|
42
79
|
const taskSource = (
|
|
43
80
|
name: NameSet,
|
|
44
81
|
jobName: NameSet,
|
|
@@ -65,6 +102,25 @@ export const ${name.camel} = task({
|
|
|
65
102
|
});
|
|
66
103
|
`;
|
|
67
104
|
|
|
105
|
+
/** The task's other shape: enqueues the neutral job above, so its payload carries no `orgId`. */
|
|
106
|
+
const neutralTaskSource = (
|
|
107
|
+
name: NameSet,
|
|
108
|
+
jobName: NameSet,
|
|
109
|
+
): string => `// ${name.camel}: a scheduled trigger. Tasks only enqueue jobs — the work itself is durable and
|
|
110
|
+
// retryable, and the schedule carries an explicit IANA time zone.
|
|
111
|
+
|
|
112
|
+
import { task } from '@ultimat3/jobs';
|
|
113
|
+
import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
|
|
114
|
+
|
|
115
|
+
export const ${name.camel} = task({
|
|
116
|
+
cron: '0 3 * * *',
|
|
117
|
+
tz: 'UTC',
|
|
118
|
+
// No org in the payload: the job this enqueues declares \`tenant: 'none'\`, because this
|
|
119
|
+
// feature's entity names no tenant column (or has none yet).
|
|
120
|
+
enqueue: () => [[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]],
|
|
121
|
+
});
|
|
122
|
+
`;
|
|
123
|
+
|
|
68
124
|
const jobTest = (
|
|
69
125
|
name: NameSet,
|
|
70
126
|
): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
|
|
@@ -121,6 +177,63 @@ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
|
121
177
|
});
|
|
122
178
|
`;
|
|
123
179
|
|
|
180
|
+
/** The job test's other shape: no `orgId` anywhere, and a `tenantFor` that reads `undefined`. */
|
|
181
|
+
const neutralJobTest = (
|
|
182
|
+
name: NameSet,
|
|
183
|
+
): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
|
|
184
|
+
// are the framework's, so what this pins is that THIS job's steps run and are idempotent.
|
|
185
|
+
import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
|
|
186
|
+
import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
|
|
187
|
+
import { ${name.camel} } from './${name.kebab}';
|
|
188
|
+
|
|
189
|
+
const id = '00000000-0000-4000-8000-000000000001';
|
|
190
|
+
const input = { id };
|
|
191
|
+
// The key this job owes, spelled once. Named rather than inlined so the assertion below carries
|
|
192
|
+
// the job's own name and still fits the formatter width the app's \`lint\` step enforces.
|
|
193
|
+
const expectedKey = \`${name.kebab}:\${id}\`;
|
|
194
|
+
|
|
195
|
+
// The driver is process-global, so it is installed and released around this file rather than
|
|
196
|
+
// left behind for whichever test happens to run next.
|
|
197
|
+
beforeAll(() => {
|
|
198
|
+
setJobDriver(createMemoryDriver());
|
|
199
|
+
});
|
|
200
|
+
afterAll(resetJobDriver);
|
|
201
|
+
|
|
202
|
+
jobTest('${name.camel} declares a key and a retry policy', () => {
|
|
203
|
+
expect(${name.camel}.kind).toBe('job');
|
|
204
|
+
expect(${name.camel}.idempotencyKeyFor(input)).toBe(expectedKey);
|
|
205
|
+
expect(${name.camel}.retry.attempts).toBeGreaterThan(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
jobTest('${name.camel} derives the same key for the same input', () => {
|
|
209
|
+
const key = ${name.camel}.idempotencyKeyFor(input);
|
|
210
|
+
expect(${name.camel}.idempotencyKeyFor(input)).toBe(key);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
jobTest('${name.camel} declares no tenant', () => {
|
|
214
|
+
// This feature's entity names no tenant column (or has none yet) — \`tenant: 'none'\` is the
|
|
215
|
+
// declaration for that, and it strips one rather than inheriting the worker's, which is what
|
|
216
|
+
// stands between a read added here later and X_TENANCY_ACTOR_ORG_REQUIRED, or worse, another
|
|
217
|
+
// org's rows if this ever gains one.
|
|
218
|
+
expect(${name.camel}.tenantFor(input)).toBeUndefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
jobTest('${name.camel} projects itself into the manifest', () => {
|
|
222
|
+
const described = ${name.camel}.describe();
|
|
223
|
+
expect(described.queue).toBe('default');
|
|
224
|
+
expect(described.retry.attempts).toBe(5);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
|
|
228
|
+
// The whole point of the key: an at-least-once caller may enqueue twice and the work still
|
|
229
|
+
// happens once. \`.enqueue()\` is the one queue path — a job is never run inline.
|
|
230
|
+
const first = await ${name.camel}.enqueue(input);
|
|
231
|
+
expect(first.deduped).toBe(false);
|
|
232
|
+
const again = await ${name.camel}.enqueue(input);
|
|
233
|
+
expect(again.deduped).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
`;
|
|
236
|
+
|
|
124
237
|
const taskTest = (
|
|
125
238
|
name: NameSet,
|
|
126
239
|
jobName: NameSet,
|
|
@@ -163,28 +276,75 @@ jobTest('${name.camel} fires its declared entries', async () => {
|
|
|
163
276
|
});
|
|
164
277
|
`;
|
|
165
278
|
|
|
166
|
-
export
|
|
279
|
+
export interface JobOptions extends FeatureTarget {
|
|
280
|
+
/**
|
|
281
|
+
* This feature's own `entity.ts` as it stands on disk, or absent when the feature has none yet.
|
|
282
|
+
* Supplied by `run` for the same reason `ActionOptions.sliceErrors` is: whether the slice is
|
|
283
|
+
* tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
|
|
284
|
+
* `repo.byId`/`repo.listByOrg` calls into a feature whose repo never declared them —
|
|
285
|
+
* `x g task purgeOrphans --feature links` on a `links` slice with no `orgId` produced files that
|
|
286
|
+
* did not compile.
|
|
287
|
+
*/
|
|
288
|
+
readonly sliceEntity?: string;
|
|
289
|
+
/** This feature's own `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
|
|
290
|
+
readonly sliceRepo?: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Whether `x g job`/`x g task` may assume the tenant-scoped shape: an `entity.ts` this feature
|
|
295
|
+
* does not have yet is about to be scaffolded fresh by `sliceFoundation` below, tenant-scoped by
|
|
296
|
+
* default — so absent counts as scoped. One that exists is trusted over that default: it declares
|
|
297
|
+
* a real, non-`'none'` `tenant`, AND its `repo.ts` actually exports the `byId`/`listByOrg` pair the
|
|
298
|
+
* tenant-scoped body calls. Both have to hold — an entity that still names `tenant: 'orgId'` after
|
|
299
|
+
* an author trimmed `listByOrg` out of `repo.ts` (or never generated one) is not a slice this job
|
|
300
|
+
* can read through either.
|
|
301
|
+
*/
|
|
302
|
+
export function isTenantScopedSlice(
|
|
303
|
+
sliceEntity: string | undefined,
|
|
304
|
+
sliceRepo: string | undefined,
|
|
305
|
+
): boolean {
|
|
306
|
+
if (sliceEntity === undefined) return true;
|
|
307
|
+
const declaresTenant = /\btenant\s*:\s*'(?!none')[^']+'/.test(stripComments(sliceEntity));
|
|
308
|
+
if (!declaresTenant) return false;
|
|
309
|
+
if (sliceRepo === undefined) return true;
|
|
310
|
+
return sliceExports(sliceRepo, 'byId') && sliceExports(sliceRepo, 'listByOrg');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function jobFiles(rawName: string, target: JobOptions): readonly GeneratedFile[] {
|
|
167
314
|
const name = names(rawName);
|
|
168
315
|
const dir = `${target.surfaceDir}/${target.feature}/jobs`;
|
|
316
|
+
const scoped = isTenantScopedSlice(target.sliceEntity, target.sliceRepo);
|
|
169
317
|
return [
|
|
170
318
|
// The job's steps read through `../repo`, which carries `../entity` for its row type. No
|
|
171
319
|
// policy: a job has no request behind it and evaluates none, so a generated one would be a
|
|
172
320
|
// file nobody asked for. `x g task` inherits this by composing `jobFiles` below.
|
|
173
|
-
|
|
174
|
-
|
|
321
|
+
// Only for the tenant-scoped shape: the neutral job below reads neither module, and a slice
|
|
322
|
+
// this feature does not own the tenancy of is not this generator's to scaffold an entity into.
|
|
323
|
+
...(scoped ? sliceFoundation(target, ['entity']) : []),
|
|
324
|
+
{
|
|
325
|
+
path: `${dir}/${name.kebab}.ts`,
|
|
326
|
+
contents: scoped ? jobSource(name) : neutralJobSource(name),
|
|
327
|
+
},
|
|
175
328
|
// `.job.test.ts`, because the gate types a test by its FILENAME: a `jobTest` in a plain
|
|
176
329
|
// `<name>.test.ts` runs under `unit`, and `x test job` answers X_TEST_NO_FILES in an app that
|
|
177
330
|
// is full of them. Same lesson `x g route` already carries for `page.e2e.test.ts`.
|
|
178
|
-
{
|
|
331
|
+
{
|
|
332
|
+
path: `${dir}/${name.kebab}.job.test.ts`,
|
|
333
|
+
contents: scoped ? jobTest(name) : neutralJobTest(name),
|
|
334
|
+
},
|
|
179
335
|
];
|
|
180
336
|
}
|
|
181
337
|
|
|
182
|
-
export function taskFiles(rawName: string, target:
|
|
338
|
+
export function taskFiles(rawName: string, target: JobOptions): readonly GeneratedFile[] {
|
|
183
339
|
const name = names(rawName);
|
|
184
340
|
const jobName = names(`${rawName}-job`);
|
|
185
341
|
const dir = `${target.surfaceDir}/${target.feature}/tasks`;
|
|
342
|
+
const scoped = isTenantScopedSlice(target.sliceEntity, target.sliceRepo);
|
|
186
343
|
return [
|
|
187
|
-
{
|
|
344
|
+
{
|
|
345
|
+
path: `${dir}/${name.kebab}.ts`,
|
|
346
|
+
contents: scoped ? taskSource(name, jobName) : neutralTaskSource(name, jobName),
|
|
347
|
+
},
|
|
188
348
|
// A task's test is a `jobTest` too — it drives a queue — so it takes the same suffix.
|
|
189
349
|
{ path: `${dir}/${name.kebab}.job.test.ts`, contents: taskTest(name, jobName) },
|
|
190
350
|
...jobFiles(`${rawName}-job`, target),
|
|
@@ -23,9 +23,13 @@ const devActor = (
|
|
|
23
23
|
// \`X_CONFIG_INVALID\` — which is what a scaffolded app did on its very first \`x dev\`.
|
|
24
24
|
//
|
|
25
25
|
// DEVELOPMENT ONLY, and the guard is the point: a viewer that followed this to staging would sign
|
|
26
|
-
// every visitor in as an admin.
|
|
27
|
-
//
|
|
28
|
-
//
|
|
26
|
+
// every visitor in as an admin. FAILS CLOSED (\`fallback: 'production'\`) rather than trusting
|
|
27
|
+
// \`tryResolveEnvironment\`'s own default: that default is \`development\`, so a process that named
|
|
28
|
+
// NEITHER \`ULTIMATE_ENV\` nor \`NODE_ENV\` would otherwise read as development too — indistinguishable
|
|
29
|
+
// from the one this file exists to allow. \`x dev\` declares \`ULTIMATE_ENV=development\` for exactly
|
|
30
|
+
// this reason (whenever neither key is already set), so a bare \`x dev\` still installs this viewer;
|
|
31
|
+
// \`bun test\` sets \`NODE_ENV=test\`, so it does not install there either — a fixture mints its own
|
|
32
|
+
// actor, and a second one arriving from a cookie would decide which actor a test is about.
|
|
29
33
|
//
|
|
30
34
|
// REPLACE IT with the real thing: resolve a session cookie to a row, and return that actor.
|
|
31
35
|
// Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
|
|
@@ -78,7 +82,7 @@ export const devActorFor = (role: DevRole): Actor => ({
|
|
|
78
82
|
export function installDevAuthenticator(
|
|
79
83
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
80
84
|
): boolean {
|
|
81
|
-
if (tryResolveEnvironment({ env }) !== 'development') return false;
|
|
85
|
+
if (tryResolveEnvironment({ env, fallback: 'production' }) !== 'development') return false;
|
|
82
86
|
configureAuthenticator((request) => devActorFor(devRoleFrom(request.header('cookie'))));
|
|
83
87
|
logger.warn('every request is answered as a development viewer', {
|
|
84
88
|
role: DEFAULT_DEV_ROLE,
|
|
@@ -133,6 +137,11 @@ unitTest('it installs in development and in no other environment', () => {
|
|
|
133
137
|
|
|
134
138
|
expect(installDevAuthenticator({ ULTIMATE_ENV: 'production' })).toBe(false);
|
|
135
139
|
expect(installDevAuthenticator({ ULTIMATE_ENV: 'staging' })).toBe(false);
|
|
140
|
+
// FAILS CLOSED: a process naming NEITHER key is production here, never the default-development
|
|
141
|
+
// a bare \`tryResolveEnvironment({ env })\` would answer. \`x dev\` is what makes a real \`x dev\`
|
|
142
|
+
// still install this viewer — it declares \`ULTIMATE_ENV=development\` before this module loads,
|
|
143
|
+
// for exactly the process this call simulates having none of.
|
|
144
|
+
expect(installDevAuthenticator({})).toBe(false);
|
|
136
145
|
expect(configuredAuthenticator()).toBeUndefined();
|
|
137
146
|
|
|
138
147
|
expect(installDevAuthenticator({ ULTIMATE_ENV: 'development' })).toBe(true);
|
|
@@ -115,8 +115,12 @@ const listedExports = (code: string): readonly string[] =>
|
|
|
115
115
|
*/
|
|
116
116
|
export function sliceExports(source: string, name: string): boolean {
|
|
117
117
|
const code = stripComments(source);
|
|
118
|
+
// `(?:async\s+)?` before `function`: an `export async function byId` — every repo function
|
|
119
|
+
// `x g entity` scaffolds — matched neither this nor `listedExports`, so a caller checking for
|
|
120
|
+
// `byId`/`listByOrg` on a real repo.ts always read `false`. `async` has no meaning before
|
|
121
|
+
// `class`/`const`/`let`/`var`/`enum`, so it is scoped to `function` only.
|
|
118
122
|
const declared = new RegExp(
|
|
119
|
-
`\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|function|enum)\\s+${name}\\b`,
|
|
123
|
+
`\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|(?:async\\s+)?function|enum)\\s+${name}\\b`,
|
|
120
124
|
);
|
|
121
125
|
return declared.test(code) || listedExports(code).includes(name);
|
|
122
126
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Single responsibility: which env keys reached THIS `x` process only because Bun auto-loaded
|
|
2
|
+
// `.env.development` / `.env.development.local` at startup — so a `bun test` child this process
|
|
3
|
+
// spawns (`test-shards.ts`, `verify-tests.ts`, `verify-test-run.ts`, `mcp-host.ts`'s `runTests`)
|
|
4
|
+
// does not inherit a key a bare `bun test` would never have seen.
|
|
5
|
+
//
|
|
6
|
+
// PROBED ON BUN 1.4.2, and the probe decided the rule: an ambient env var SHADOWS a dotenv file's
|
|
7
|
+
// own value for the same key (`FOO=dev bun test` with a fixture `.env.test` declaring `FOO=test`
|
|
8
|
+
// still read `FOO=dev` inside the test). So a leaked key is never made safe by a legitimate test
|
|
9
|
+
// file (`.env`, `.env.test`, …) also declaring it — if we left it in place, the leaked value would
|
|
10
|
+
// go on shadowing that file's own value exactly as it shadows `.env.test` above. The only question
|
|
11
|
+
// worth asking is the one `devOnlyLeakedKeys` answers: does this process's CURRENT value match
|
|
12
|
+
// what `.env.development`/`.env.development.local` would have set? If yes, delete it and let the
|
|
13
|
+
// child's own dotenv load (or absence of one) answer instead. If the current value differs, this
|
|
14
|
+
// process's env holds something dotenv did not put there — a real export, CI, `.env.local` — and
|
|
15
|
+
// deleting it is not this function's call to make.
|
|
16
|
+
//
|
|
17
|
+
// KNOWN LIMITATION, stated rather than hidden: a real ambient value that happens to COINCIDE with
|
|
18
|
+
// the dev file's value is indistinguishable from a leak from here — there is no pre-dotenv
|
|
19
|
+
// snapshot to compare against. This function resolves that ambiguity toward closing the leak.
|
|
20
|
+
//
|
|
21
|
+
// A SECOND PARSER, not a call to `env-example.ts`'s `parseEnvKeys`: that function is deliberately
|
|
22
|
+
// keys-only ("half of them are placeholders" — it feeds `.env.example` rendering, where a secret's
|
|
23
|
+
// value must never appear), and widening it to return values would change what a template
|
|
24
|
+
// generator ships. This one exists to COMPARE values, a different job with a different file.
|
|
25
|
+
|
|
26
|
+
import { readFileSync } from 'node:fs'; // why: Bun ships no synchronous file read with a graceful-missing return.
|
|
27
|
+
// why: Bun exposes no path-join primitive.
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
/** Bun's own grammar, matched against `env-example.ts`'s `ENV_KEY_RE` (kept separate: see header). */
|
|
31
|
+
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One dotenv file's key/value pairs. A `Map`, not an object literal: a key of `constructor` or
|
|
35
|
+
* `__proto__` passes `ENV_KEY_RE` and reads back as a function on `Object.prototype`, the exact
|
|
36
|
+
* defect this repo's `verify-tests.ts` header names thirteen instances of.
|
|
37
|
+
*
|
|
38
|
+
* Quoting: a value opening with `"` or `'` runs to its closing quote (or end of value if the
|
|
39
|
+
* dotenv file never closes it), `#` included — Bun does not stop a quoted value at an internal
|
|
40
|
+
* `#`. An unquoted value stops at the first `#`, which is where an inline comment starts.
|
|
41
|
+
*/
|
|
42
|
+
export function parseDotenvValues(text: string): ReadonlyMap<string, string> {
|
|
43
|
+
const values = new Map<string, string>();
|
|
44
|
+
for (const raw of text.split('\n')) {
|
|
45
|
+
const line = raw.trim().replace(/^export\s+/, '');
|
|
46
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
47
|
+
const separator = line.indexOf('=');
|
|
48
|
+
if (separator <= 0) continue;
|
|
49
|
+
const key = line.slice(0, separator).trim();
|
|
50
|
+
if (!ENV_KEY_RE.test(key)) continue;
|
|
51
|
+
const rest = line.slice(separator + 1).trim();
|
|
52
|
+
const quote = rest.startsWith('"') || rest.startsWith("'") ? rest[0] : undefined;
|
|
53
|
+
if (quote === undefined) {
|
|
54
|
+
const hash = rest.indexOf('#');
|
|
55
|
+
values.set(key, (hash >= 0 ? rest.slice(0, hash) : rest).trim());
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const closing = rest.indexOf(quote, 1);
|
|
59
|
+
values.set(key, closing >= 0 ? rest.slice(1, closing) : rest.slice(1));
|
|
60
|
+
}
|
|
61
|
+
return values;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface DevOnlyLeakInput {
|
|
65
|
+
/** `.env.development`'s text, or `''` when the file does not exist. */
|
|
66
|
+
readonly devText: string;
|
|
67
|
+
/** `.env.development.local`'s text, or `''` — wins over `devText` for a shared key, Bun's own precedence. */
|
|
68
|
+
readonly devLocalText: string;
|
|
69
|
+
/** This process's environment, as `exec.ts` would spawn a child with it (before any override). */
|
|
70
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The pure decision, no filesystem: every key `.env.development`/`.env.development.local` would
|
|
75
|
+
* set, where `env`'s CURRENT value for that key is exactly what the file would have set. See this
|
|
76
|
+
* file's header for why a legitimate test file declaring the same key does not exempt it.
|
|
77
|
+
*/
|
|
78
|
+
export function devOnlyLeakedKeys(input: DevOnlyLeakInput): readonly string[] {
|
|
79
|
+
const merged = new Map(parseDotenvValues(input.devText));
|
|
80
|
+
for (const [key, value] of parseDotenvValues(input.devLocalText)) merged.set(key, value);
|
|
81
|
+
const leaked: string[] = [];
|
|
82
|
+
for (const [key, devValue] of merged) {
|
|
83
|
+
// `Object.hasOwn` FIRST: `key` is data (a name out of a dotenv file), and `env['constructor']`
|
|
84
|
+
// on a plain object answers `Object.prototype`'s member, not `undefined` — the rule
|
|
85
|
+
// `scripts/proto-index.ts` ratchets, with its own sanctioned repair.
|
|
86
|
+
if (Object.hasOwn(input.env, key) && input.env[key] === devValue) leaked.push(key);
|
|
87
|
+
}
|
|
88
|
+
return leaked;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A missing dotenv file is the common case (no `.env.development.local` in most checkouts). */
|
|
92
|
+
function readIfPresent(path: string): string {
|
|
93
|
+
try {
|
|
94
|
+
return readFileSync(path, 'utf8');
|
|
95
|
+
} catch {
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The env override a `bun test` child spawned FROM `root` should get: every dev-only leaked key
|
|
102
|
+
* deleted. `exec.ts`'s `ExecOptions.env` reads an `undefined` value as "unset for this child,
|
|
103
|
+
* even though the parent has it" — see that file for why the merge could not otherwise express
|
|
104
|
+
* a deletion.
|
|
105
|
+
*
|
|
106
|
+
* `root` is the app/repo root the caller already resolved — NOT necessarily `process.cwd()`, which
|
|
107
|
+
* is what Bun actually auto-loaded `.env.development` relative to at this process's own startup.
|
|
108
|
+
* The two agree for every command that boots from the repo/app root, which is every one of them
|
|
109
|
+
* today; a future command invoked from a subdirectory would fail SAFE here (the file this reads
|
|
110
|
+
* would differ from the one Bun loaded, values would not match, and nothing gets stripped) rather
|
|
111
|
+
* than stripping the wrong key.
|
|
112
|
+
*/
|
|
113
|
+
export function testEnvOverrides(
|
|
114
|
+
root: string,
|
|
115
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
116
|
+
): Readonly<Record<string, string | undefined>> {
|
|
117
|
+
const leaked = devOnlyLeakedKeys({
|
|
118
|
+
devText: readIfPresent(join(root, '.env.development')),
|
|
119
|
+
devLocalText: readIfPresent(join(root, '.env.development.local')),
|
|
120
|
+
env,
|
|
121
|
+
});
|
|
122
|
+
const overrides: Record<string, string | undefined> = {};
|
|
123
|
+
for (const key of leaked) overrides[key] = undefined;
|
|
124
|
+
return overrides;
|
|
125
|
+
}
|
package/src/test-shards.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { execOutput } from './exec';
|
|
|
40
40
|
import { msg } from './messages';
|
|
41
41
|
import type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
42
42
|
import { quoteArg } from './shell-quote';
|
|
43
|
+
import { testEnvOverrides } from './test-dotenv';
|
|
43
44
|
import { testPasses } from './test-passes';
|
|
44
45
|
import type { TestFile } from './test-select';
|
|
45
46
|
import type { TestType } from './verify-tests';
|
|
@@ -165,6 +166,13 @@ export interface RunShardsOptions {
|
|
|
165
166
|
readonly affected?: AffectedSelection;
|
|
166
167
|
/** Everything after the caller's `--`, forwarded to every pass and printed in the reproduce. */
|
|
167
168
|
readonly passthrough?: readonly string[];
|
|
169
|
+
/**
|
|
170
|
+
* This process's own environment, as `exec.ts` would otherwise hand it whole to the spawned
|
|
171
|
+
* child. Optional and defaulted to `Bun.env`: every real caller's is `Bun.env` already (`x
|
|
172
|
+
* test`/`x verify` read `CommandContext.env`, itself `Bun.env`), so the default is not a
|
|
173
|
+
* fallback so much as a seam this file's own tests use to hand it a fixture instead.
|
|
174
|
+
*/
|
|
175
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
168
176
|
}
|
|
169
177
|
|
|
170
178
|
/**
|
|
@@ -219,6 +227,11 @@ export const failureOf = (code: number, files: number, plan: ReproduceOptions):
|
|
|
219
227
|
*/
|
|
220
228
|
export async function runShards(options: RunShardsOptions): Promise<CommandResult> {
|
|
221
229
|
const only = options.only;
|
|
230
|
+
// Computed ONCE per invocation, never per pass: every pass spawns from the same `root` and the
|
|
231
|
+
// same parent env, so the leaked-key set cannot differ pass to pass.
|
|
232
|
+
const envOverrides: Record<string, string | undefined> = {
|
|
233
|
+
...testEnvOverrides(options.root, options.env ?? Bun.env),
|
|
234
|
+
};
|
|
222
235
|
const passes = testPasses({
|
|
223
236
|
files: options.files,
|
|
224
237
|
workers: options.workers,
|
|
@@ -241,7 +254,14 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
|
|
|
241
254
|
}),
|
|
242
255
|
{
|
|
243
256
|
cwd: options.root,
|
|
244
|
-
...(
|
|
257
|
+
...(Object.keys(envOverrides).length === 0 && only === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: {
|
|
260
|
+
env: {
|
|
261
|
+
...envOverrides,
|
|
262
|
+
...(only === undefined ? {} : { ULTIMATE_TEST_WORKER: String(only) }),
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
245
265
|
},
|
|
246
266
|
);
|
|
247
267
|
const plan = planOf(options, pass);
|
package/src/verify-step.ts
CHANGED
|
@@ -65,6 +65,13 @@ export type HostCheck = (root: string) => Promise<readonly Finding[]>;
|
|
|
65
65
|
export interface VerifyContext {
|
|
66
66
|
readonly root: string;
|
|
67
67
|
readonly runner: Runner;
|
|
68
|
+
/**
|
|
69
|
+
* This process's own environment. Optional and defaulted to `Bun.env` at every reader
|
|
70
|
+
* (`verify-tests.ts`'s `runSerial`/`runType`): every real caller's IS `Bun.env` already
|
|
71
|
+
* (`cmd-verify.ts` passes `ctx.env`, itself `Bun.env`), so the default only matters to a test
|
|
72
|
+
* that constructs a `VerifyContext` fixture and never mentions `env`.
|
|
73
|
+
*/
|
|
74
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
68
75
|
readonly hostChecks?: Partial<Record<VerifyStepName, HostCheck>>;
|
|
69
76
|
/**
|
|
70
77
|
* How wide the parallel test steps go. Absent means `defaultWorkers()` — a knob, never a
|
package/src/verify-test-run.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Runner } from './exec';
|
|
|
6
6
|
import { execOutput } from './exec';
|
|
7
7
|
import type { Finding } from './output';
|
|
8
8
|
import { countsOf } from './test-counts';
|
|
9
|
+
import { testEnvOverrides } from './test-dotenv';
|
|
9
10
|
import type { TestFile } from './test-select';
|
|
10
11
|
import { failureOf, testArgs } from './test-shards';
|
|
11
12
|
import type { StepOutcome } from './verify-step';
|
|
@@ -20,6 +21,8 @@ export interface ParallelRunOptions {
|
|
|
20
21
|
readonly workers: number;
|
|
21
22
|
/** Carried into the `fix:` so a failure reproduces as `x test <type> --workers N`. */
|
|
22
23
|
readonly type: TestType;
|
|
24
|
+
/** This process's own environment. Optional and defaulted to `Bun.env` — see `VerifyContext.env`. */
|
|
25
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
/**
|
|
@@ -38,7 +41,11 @@ export interface ParallelRunOptions {
|
|
|
38
41
|
export async function runParallel(options: ParallelRunOptions): Promise<StepOutcome> {
|
|
39
42
|
const files = options.files.map((file) => file.path);
|
|
40
43
|
const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
|
|
41
|
-
const
|
|
44
|
+
const envOverrides = testEnvOverrides(options.root, options.env ?? Bun.env);
|
|
45
|
+
const result = await options.runner(testArgs({ files, workers }), {
|
|
46
|
+
cwd: options.root,
|
|
47
|
+
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
48
|
+
});
|
|
42
49
|
// `failureOf` is `x test`'s own, imported rather than restated: the two paths report the SAME
|
|
43
50
|
// failed `bun test`, so a second literal here is two `cause:` strings and two `fix:` lines free
|
|
44
51
|
// to drift — and the one that drifts is the gate's, which is the one an agent reads first.
|
package/src/verify-tests.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { TEST_TYPES } from '@ultimat3/testing';
|
|
|
15
15
|
import { checkEvalBaselines, checkEvalCoverage, checkEvalRecording } from './app-evals';
|
|
16
16
|
import { APP_CONFIG_FILE } from './app-root';
|
|
17
17
|
import { countsOf } from './test-counts';
|
|
18
|
+
import { testEnvOverrides } from './test-dotenv';
|
|
18
19
|
import type { TestFile } from './test-select';
|
|
19
20
|
import { discoverTests } from './test-select';
|
|
20
21
|
import { defaultWorkers } from './test-workers';
|
|
@@ -201,7 +202,11 @@ export const resetTestDiscovery = (): void => discovered.clear();
|
|
|
201
202
|
|
|
202
203
|
const runSerial = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
|
|
203
204
|
const command = testStepCommand(type);
|
|
204
|
-
const
|
|
205
|
+
const envOverrides = testEnvOverrides(ctx.root, ctx.env ?? Bun.env);
|
|
206
|
+
const result = await ctx.runner(command, {
|
|
207
|
+
cwd: ctx.root,
|
|
208
|
+
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
209
|
+
});
|
|
205
210
|
return {
|
|
206
211
|
...fromExec(result, {
|
|
207
212
|
code: 'X_TEST_FAILED',
|
|
@@ -223,6 +228,7 @@ const runType = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome>
|
|
|
223
228
|
files,
|
|
224
229
|
workers: ctx.workers ?? defaultWorkers(),
|
|
225
230
|
type,
|
|
231
|
+
...(ctx.env === undefined ? {} : { env: ctx.env }),
|
|
226
232
|
});
|
|
227
233
|
};
|
|
228
234
|
|