@elench/testkit 0.1.134 → 0.1.136
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/README.md +38 -0
- package/lib/cli/commands/local/down.mjs +37 -0
- package/lib/cli/commands/local/env.mjs +31 -0
- package/lib/cli/commands/local/logs.mjs +35 -0
- package/lib/cli/commands/local/shell.mjs +49 -0
- package/lib/cli/commands/local/status.mjs +34 -0
- package/lib/cli/commands/local/up.mjs +39 -0
- package/lib/cli/entrypoint.mjs +12 -4
- package/lib/cli/renderers/status/text.mjs +14 -0
- package/lib/config/index.mjs +117 -0
- package/lib/config/validation.mjs +9 -0
- package/lib/config-api/database-steps.mjs +1 -1
- package/lib/config-api/index.d.ts +22 -0
- package/lib/config-api/index.mjs +14 -0
- package/lib/database/fingerprint.mjs +13 -33
- package/lib/database/index.mjs +27 -12
- package/lib/database/schema-source.mjs +61 -6
- package/lib/env/index.d.ts +1 -0
- package/lib/env/index.mjs +5 -1
- package/lib/local/lifecycle.mjs +287 -0
- package/lib/local/orchestrator.mjs +314 -0
- package/lib/repo/fingerprint-policy.mjs +145 -0
- package/lib/repo/state.mjs +46 -44
- package/lib/runner/maintenance.mjs +23 -0
- package/lib/runner/processes.mjs +45 -6
- package/lib/runner/readiness.mjs +12 -1
- package/lib/runner/runtime-preparation.mjs +10 -5
- package/lib/runner/services.mjs +24 -18
- package/lib/runner/status-model.mjs +27 -0
- package/lib/runner/template.mjs +6 -1
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +6 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
package/README.md
CHANGED
|
@@ -62,6 +62,13 @@ npx @elench/testkit status
|
|
|
62
62
|
npx @elench/testkit destroy
|
|
63
63
|
npx @elench/testkit cleanup
|
|
64
64
|
|
|
65
|
+
# Local production environment
|
|
66
|
+
npx @elench/testkit local up
|
|
67
|
+
npx @elench/testkit local status
|
|
68
|
+
npx @elench/testkit local env --service frontend
|
|
69
|
+
npx @elench/testkit local logs --service frontend
|
|
70
|
+
npx @elench/testkit local down
|
|
71
|
+
|
|
65
72
|
# Inspect the latest run artifact through the assistant
|
|
66
73
|
npx @elench/testkit assistant --message '/inspect "__testkit__/health/health.int.testkit.ts"'
|
|
67
74
|
npx @elench/testkit assistant --message '/artifacts "__testkit__/health/health.int.testkit.ts"'
|
|
@@ -222,6 +229,7 @@ import {
|
|
|
222
229
|
database,
|
|
223
230
|
defineConfig,
|
|
224
231
|
defineFile,
|
|
232
|
+
environment,
|
|
225
233
|
toolchain,
|
|
226
234
|
} from "@elench/testkit/config";
|
|
227
235
|
|
|
@@ -238,6 +246,9 @@ export default defineConfig({
|
|
|
238
246
|
cacheTtlSeconds: 900,
|
|
239
247
|
},
|
|
240
248
|
},
|
|
249
|
+
fingerprints: {
|
|
250
|
+
exclude: ["next-env.d.ts"],
|
|
251
|
+
},
|
|
241
252
|
toolchains: {
|
|
242
253
|
frontendNode: toolchain.node({
|
|
243
254
|
cwd: "frontend",
|
|
@@ -245,6 +256,12 @@ export default defineConfig({
|
|
|
245
256
|
install: "download",
|
|
246
257
|
}),
|
|
247
258
|
},
|
|
259
|
+
environments: {
|
|
260
|
+
local: environment.local({
|
|
261
|
+
target: "frontend",
|
|
262
|
+
data: "reuse",
|
|
263
|
+
}),
|
|
264
|
+
},
|
|
248
265
|
services: {
|
|
249
266
|
api: app.node({
|
|
250
267
|
cwd: ".",
|
|
@@ -314,6 +331,7 @@ for:
|
|
|
314
331
|
- named HTTP suite profiles
|
|
315
332
|
- automatic regression classification for new vs known failures
|
|
316
333
|
- optional GitHub-backed regression issue sync
|
|
334
|
+
- repo-level fingerprint include/exclude policy
|
|
317
335
|
- repo-declared suite/file skip policies with explicit reasons
|
|
318
336
|
- telemetry upload configuration
|
|
319
337
|
|
|
@@ -323,6 +341,18 @@ inputs, and writes cache state under the service runtime directory. This is the
|
|
|
323
341
|
right way to move expensive browser targets from `next dev` / watch mode to
|
|
324
342
|
stable build-and-start flows.
|
|
325
343
|
|
|
344
|
+
`testkit local` starts the same service graph as a persistent local production
|
|
345
|
+
environment instead of a test run. It provisions local databases, runs template
|
|
346
|
+
setup, runs `runtime.prepare`, starts dependent services, and records state
|
|
347
|
+
under `.testkit/environments/<name>/` rather than `.testkit/_runs`. Local
|
|
348
|
+
environment processes receive `TESTKIT_ACTIVE=1`, `TESTKIT_MODE=local`, and
|
|
349
|
+
`TESTKIT_LOCAL_ENV=<name>`. Use `data: "reuse"` for fast restarts against the
|
|
350
|
+
existing local runtime database, `data: "reset"` to refresh runtime databases
|
|
351
|
+
from their templates on each launch, or `--rebuild` to destroy and recreate the
|
|
352
|
+
environment state. Testkit only supports local environments here; it does not
|
|
353
|
+
copy production data and it refuses managed runtime database URLs that are not
|
|
354
|
+
loopback PostgreSQL URLs.
|
|
355
|
+
|
|
326
356
|
`database.template` is the database-side equivalent for reusable template DB
|
|
327
357
|
state. When `database.sourceSchema` is configured, Testkit treats the configured
|
|
328
358
|
source database as the schema source of truth. A normal `testkit run` resolves a
|
|
@@ -339,6 +369,14 @@ Source schema cache keys are derived automatically from repo state:
|
|
|
339
369
|
- dirty git worktrees use `dirty/<sha>-<fingerprint>`
|
|
340
370
|
- non-git directories use `nogit/<fingerprint>`
|
|
341
371
|
|
|
372
|
+
Dirty worktree fingerprints use Git's own ignore engine for untracked files, so
|
|
373
|
+
normal `.gitignore` rules are respected. Testkit-owned outputs are always
|
|
374
|
+
excluded from fingerprints: `.testkit/`, `.next-testkit/`, and
|
|
375
|
+
`testkit.status.json`. `testkit.config.ts` is normal repo configuration and is
|
|
376
|
+
not excluded automatically. For user-managed generated files, add repo-level
|
|
377
|
+
`fingerprints.exclude` patterns; `fingerprints.include` can opt specific paths
|
|
378
|
+
back in below an excluded directory.
|
|
379
|
+
|
|
342
380
|
Branch names and worktree paths are recorded as metadata but do not affect clean
|
|
343
381
|
commit cache keys, so branch renames and clean worktrees at the same commit
|
|
344
382
|
reuse the same source schema. Dirty worktrees are isolated by content
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Args, Command, Flags } from "@oclif/core";
|
|
2
|
+
import { sharedFlags } from "../../command-flags.mjs";
|
|
3
|
+
import { localDown } from "../../../local/orchestrator.mjs";
|
|
4
|
+
import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
|
|
5
|
+
|
|
6
|
+
export default class LocalDownCommand extends Command {
|
|
7
|
+
static summary = "Stop a local production environment";
|
|
8
|
+
|
|
9
|
+
static enableJsonFlag = true;
|
|
10
|
+
|
|
11
|
+
static args = {
|
|
12
|
+
name: Args.string({ required: false, description: "Environment name" }),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
static flags = {
|
|
16
|
+
...sharedFlags,
|
|
17
|
+
"destroy-state": Flags.boolean({
|
|
18
|
+
description: "Remove environment runtime state and databases after stopping",
|
|
19
|
+
default: false,
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async run() {
|
|
24
|
+
return withAssistantCommandResult("local down", async () => {
|
|
25
|
+
const { args, flags } = await this.parse(LocalDownCommand);
|
|
26
|
+
const result = await localDown({
|
|
27
|
+
dir: flags.dir,
|
|
28
|
+
name: args.name || "local",
|
|
29
|
+
destroyState: flags["destroy-state"],
|
|
30
|
+
});
|
|
31
|
+
if (!this.jsonEnabled()) {
|
|
32
|
+
for (const line of result.lines || []) this.log(line);
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, result };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Args, Command } from "@oclif/core";
|
|
2
|
+
import { sharedFlags } from "../../command-flags.mjs";
|
|
3
|
+
import { localEnv } from "../../../local/orchestrator.mjs";
|
|
4
|
+
import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
|
|
5
|
+
|
|
6
|
+
export default class LocalEnvCommand extends Command {
|
|
7
|
+
static summary = "Print resolved environment variables for a local service";
|
|
8
|
+
|
|
9
|
+
static enableJsonFlag = true;
|
|
10
|
+
|
|
11
|
+
static args = {
|
|
12
|
+
name: Args.string({ required: false, description: "Environment name" }),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
static flags = sharedFlags;
|
|
16
|
+
|
|
17
|
+
async run() {
|
|
18
|
+
return withAssistantCommandResult("local env", async () => {
|
|
19
|
+
const { args, flags } = await this.parse(LocalEnvCommand);
|
|
20
|
+
const result = await localEnv({
|
|
21
|
+
dir: flags.dir,
|
|
22
|
+
name: args.name || "local",
|
|
23
|
+
service: flags.service,
|
|
24
|
+
});
|
|
25
|
+
if (!this.jsonEnabled()) {
|
|
26
|
+
for (const line of result.lines || []) this.log(line);
|
|
27
|
+
}
|
|
28
|
+
return { ok: true, result };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Args, Command, Flags } from "@oclif/core";
|
|
2
|
+
import { sharedFlags } from "../../command-flags.mjs";
|
|
3
|
+
import { localLogs } from "../../../local/orchestrator.mjs";
|
|
4
|
+
import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
|
|
5
|
+
|
|
6
|
+
export default class LocalLogsCommand extends Command {
|
|
7
|
+
static summary = "Show local production environment logs";
|
|
8
|
+
|
|
9
|
+
static enableJsonFlag = true;
|
|
10
|
+
|
|
11
|
+
static args = {
|
|
12
|
+
name: Args.string({ required: false, description: "Environment name" }),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
static flags = {
|
|
16
|
+
...sharedFlags,
|
|
17
|
+
lines: Flags.integer({ description: "Number of lines to show", default: 200 }),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async run() {
|
|
21
|
+
return withAssistantCommandResult("local logs", async () => {
|
|
22
|
+
const { args, flags } = await this.parse(LocalLogsCommand);
|
|
23
|
+
const result = await localLogs({
|
|
24
|
+
dir: flags.dir,
|
|
25
|
+
name: args.name || "local",
|
|
26
|
+
service: flags.service,
|
|
27
|
+
lines: flags.lines,
|
|
28
|
+
});
|
|
29
|
+
if (!this.jsonEnabled()) {
|
|
30
|
+
for (const line of result.lines || []) this.log(line);
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, result };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Command } from "@oclif/core";
|
|
2
|
+
import { sharedFlags } from "../../command-flags.mjs";
|
|
3
|
+
import { localShell } from "../../../local/orchestrator.mjs";
|
|
4
|
+
import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
|
|
5
|
+
|
|
6
|
+
export default class LocalShellCommand extends Command {
|
|
7
|
+
static summary = "Run a command with a local service environment";
|
|
8
|
+
|
|
9
|
+
static strict = false;
|
|
10
|
+
|
|
11
|
+
static enableJsonFlag = true;
|
|
12
|
+
|
|
13
|
+
static flags = sharedFlags;
|
|
14
|
+
|
|
15
|
+
async run() {
|
|
16
|
+
return withAssistantCommandResult("local shell", async () => {
|
|
17
|
+
const { flags } = await this.parse(LocalShellCommand);
|
|
18
|
+
const separatorIndex = this.argv.indexOf("--");
|
|
19
|
+
const beforeSeparator = separatorIndex >= 0 ? this.argv.slice(0, separatorIndex) : this.argv;
|
|
20
|
+
const command = separatorIndex >= 0 ? this.argv.slice(separatorIndex + 1) : [];
|
|
21
|
+
const result = await localShell({
|
|
22
|
+
dir: flags.dir,
|
|
23
|
+
name: extractEnvironmentName(beforeSeparator) || "local",
|
|
24
|
+
service: flags.service,
|
|
25
|
+
command,
|
|
26
|
+
});
|
|
27
|
+
if (!this.jsonEnabled()) {
|
|
28
|
+
if (result.stdout) this.log(result.stdout.trimEnd());
|
|
29
|
+
if (result.stderr) this.error(result.stderr.trimEnd(), { exit: false });
|
|
30
|
+
}
|
|
31
|
+
this.exit(result.code);
|
|
32
|
+
return { ok: result.code === 0, result };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function extractEnvironmentName(args) {
|
|
38
|
+
const valueFlags = new Set(["--dir", "--service"]);
|
|
39
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
40
|
+
const value = args[index];
|
|
41
|
+
if (valueFlags.has(value)) {
|
|
42
|
+
index += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (value.startsWith("-")) continue;
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Args, Command } from "@oclif/core";
|
|
2
|
+
import { sharedFlags } from "../../command-flags.mjs";
|
|
3
|
+
import { localStatus } from "../../../local/orchestrator.mjs";
|
|
4
|
+
import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
|
|
5
|
+
|
|
6
|
+
export default class LocalStatusCommand extends Command {
|
|
7
|
+
static summary = "Show local production environments";
|
|
8
|
+
|
|
9
|
+
static enableJsonFlag = true;
|
|
10
|
+
|
|
11
|
+
static args = {
|
|
12
|
+
name: Args.string({ required: false, description: "Environment name" }),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
static flags = sharedFlags;
|
|
16
|
+
|
|
17
|
+
async run() {
|
|
18
|
+
return withAssistantCommandResult("local status", async () => {
|
|
19
|
+
const { args, flags } = await this.parse(LocalStatusCommand);
|
|
20
|
+
const result = await localStatus({ dir: flags.dir, name: args.name || null });
|
|
21
|
+
if (!this.jsonEnabled()) {
|
|
22
|
+
if (result.environments) {
|
|
23
|
+
if (result.environments.length === 0) this.log("Local Environments: none");
|
|
24
|
+
for (const environment of result.environments) {
|
|
25
|
+
for (const line of environment.lines || []) this.log(line);
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
for (const line of result.lines || []) this.log(line);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, result };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Args, Command, Flags } from "@oclif/core";
|
|
2
|
+
import { sharedFlags } from "../../command-flags.mjs";
|
|
3
|
+
import { localUp } from "../../../local/orchestrator.mjs";
|
|
4
|
+
import { withAssistantCommandResult } from "../../assistant/command-results.mjs";
|
|
5
|
+
|
|
6
|
+
export default class LocalUpCommand extends Command {
|
|
7
|
+
static summary = "Start a local production environment";
|
|
8
|
+
|
|
9
|
+
static enableJsonFlag = true;
|
|
10
|
+
|
|
11
|
+
static args = {
|
|
12
|
+
name: Args.string({ required: false, description: "Environment name" }),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
static flags = {
|
|
16
|
+
...sharedFlags,
|
|
17
|
+
reset: Flags.boolean({ description: "Reset runtime databases from their templates", default: false }),
|
|
18
|
+
rebuild: Flags.boolean({ description: "Destroy and rebuild environment runtime state", default: false }),
|
|
19
|
+
"port-offset": Flags.integer({ description: "Add an offset to configured local ports" }),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async run() {
|
|
23
|
+
return withAssistantCommandResult("local up", async () => {
|
|
24
|
+
const { args, flags } = await this.parse(LocalUpCommand);
|
|
25
|
+
const result = await localUp({
|
|
26
|
+
dir: flags.dir,
|
|
27
|
+
name: args.name || "local",
|
|
28
|
+
service: flags.service,
|
|
29
|
+
reset: flags.reset,
|
|
30
|
+
rebuild: flags.rebuild,
|
|
31
|
+
portOffset: flags["port-offset"],
|
|
32
|
+
});
|
|
33
|
+
if (!this.jsonEnabled()) {
|
|
34
|
+
for (const line of result.lines || []) this.log(line);
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, result };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
package/lib/cli/entrypoint.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export function normalizeCliArgs(argv) {
|
|
|
14
14
|
"typecheck",
|
|
15
15
|
"doctor",
|
|
16
16
|
"lint",
|
|
17
|
+
"local",
|
|
17
18
|
"browser",
|
|
18
19
|
"db",
|
|
19
20
|
"guard",
|
|
@@ -43,9 +44,12 @@ export function normalizeCliArgs(argv) {
|
|
|
43
44
|
"--prompt",
|
|
44
45
|
"--command",
|
|
45
46
|
"--use",
|
|
47
|
+
"--port-offset",
|
|
48
|
+
"--lines",
|
|
46
49
|
]);
|
|
47
50
|
const positionals = findPositionals(argv, valueFlags);
|
|
48
51
|
const firstPositional = positionals[0] || null;
|
|
52
|
+
const explicitTopLevelCommand = topLevelCommands.has(firstPositional?.value);
|
|
49
53
|
const forcedInteractiveAssistant = process.env.TESTKIT_FORCE_INTERACTIVE_ASSISTANT === "1";
|
|
50
54
|
const interactiveTty = process.stdout.isTTY || forcedInteractiveAssistant;
|
|
51
55
|
const assistantDefaultDisabled = process.env.TESTKIT_NO_ASSISTANT_DEFAULT === "1";
|
|
@@ -69,10 +73,11 @@ export function normalizeCliArgs(argv) {
|
|
|
69
73
|
].includes(value)
|
|
70
74
|
);
|
|
71
75
|
const shouldPrefixRun =
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
!explicitTopLevelCommand &&
|
|
77
|
+
((!firstPositional && !interactiveTty) ||
|
|
78
|
+
runTypeShortcuts.has(firstPositional?.value) ||
|
|
79
|
+
runFlagPresent ||
|
|
80
|
+
!topLevelCommands.has(firstPositional?.value));
|
|
76
81
|
|
|
77
82
|
if (!firstPositional && interactiveTty && !runFlagPresent && !assistantDefaultDisabled) {
|
|
78
83
|
return ["assistant", ...argv];
|
|
@@ -132,6 +137,9 @@ function reorderCommandArgs(args, positionals) {
|
|
|
132
137
|
if (positionals[0]?.value === "browser" && positionals[1]) {
|
|
133
138
|
commandTokens.push(positionals[1]);
|
|
134
139
|
}
|
|
140
|
+
if (positionals[0]?.value === "local" && positionals[1]) {
|
|
141
|
+
commandTokens.push(positionals[1]);
|
|
142
|
+
}
|
|
135
143
|
const commandIndexes = new Set(commandTokens.map((token) => token.index));
|
|
136
144
|
return [
|
|
137
145
|
...commandTokens.map((token) => token.value),
|
|
@@ -24,6 +24,20 @@ export function renderStatusResult(result) {
|
|
|
24
24
|
lines.push(` ... ${(result.runs.runs.length - 5)} more run${result.runs.runs.length - 5 === 1 ? "" : "s"}`);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
lines.push("", "Local Environments");
|
|
28
|
+
if ((result.localEnvironments?.environments || []).length === 0) {
|
|
29
|
+
lines.push(" none");
|
|
30
|
+
} else {
|
|
31
|
+
lines.push(` active: ${result.localEnvironments?.active || 0}`);
|
|
32
|
+
for (const environment of result.localEnvironments.environments.slice(0, 5)) {
|
|
33
|
+
const ports = environment.ports?.length ? ` ports=${environment.ports.join(",")}` : "";
|
|
34
|
+
lines.push(` ${environment.name}: ${environment.status} target=${environment.target || "unknown"}${ports}`);
|
|
35
|
+
}
|
|
36
|
+
if (result.localEnvironments.environments.length > 5) {
|
|
37
|
+
lines.push(` ... ${result.localEnvironments.environments.length - 5} more environment${result.localEnvironments.environments.length - 5 === 1 ? "" : "s"}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
lines.push("", "Runtime Graphs");
|
|
28
42
|
if ((result.runtimeGraphs || []).length === 0) {
|
|
29
43
|
lines.push(" none");
|
package/lib/config/index.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { loadTestkitConfig } from "./config-loader.mjs";
|
|
|
5
5
|
import { normalizeToolchainRegistry } from "../toolchains/index.mjs";
|
|
6
6
|
import { loadTestFileMetadataMap } from "../discovery/file-metadata.mjs";
|
|
7
7
|
import { mergeDiscoveryConfigs } from "../discovery/path-policy.mjs";
|
|
8
|
+
import { normalizeFingerprintPolicy } from "../repo/fingerprint-policy.mjs";
|
|
8
9
|
import { normalizeDatabaseConfig } from "./database.mjs";
|
|
9
10
|
import { normalizeRepoDiscoveryConfig, normalizeServiceDiscoveryConfig } from "./discovery-config.mjs";
|
|
10
11
|
import { inferEnvFiles, loadServiceEnv, parseDotenv } from "./env.mjs";
|
|
@@ -32,6 +33,8 @@ export async function loadConfigContext(opts = {}) {
|
|
|
32
33
|
const execution = normalizeRepoExecution(config.execution);
|
|
33
34
|
const regressions = normalizeRegressionsConfig(config.regressions);
|
|
34
35
|
const toolchains = normalizeToolchainRegistry(config.toolchains);
|
|
36
|
+
const environments = normalizeEnvironmentRegistry(config.environments);
|
|
37
|
+
const fingerprints = normalizeFingerprintPolicy(config.fingerprints);
|
|
35
38
|
const discoveryConfig = normalizeRepoDiscoveryConfig(config.discovery);
|
|
36
39
|
const explicitServices = config.services || {};
|
|
37
40
|
const discovery = discoverProject(productDir, explicitServices, {
|
|
@@ -56,6 +59,8 @@ export async function loadConfigContext(opts = {}) {
|
|
|
56
59
|
discovery: discoveryConfig,
|
|
57
60
|
regressions,
|
|
58
61
|
toolchains,
|
|
62
|
+
environments,
|
|
63
|
+
fingerprints,
|
|
59
64
|
explicitService: explicitServices[name] || {},
|
|
60
65
|
discoveredService: discovery.services[name] || null,
|
|
61
66
|
suites: discovery.suitesByService[name] || {},
|
|
@@ -70,9 +75,11 @@ export async function loadConfigContext(opts = {}) {
|
|
|
70
75
|
config,
|
|
71
76
|
configFile,
|
|
72
77
|
execution,
|
|
78
|
+
environments,
|
|
73
79
|
discovery: discoveryConfig,
|
|
74
80
|
regressions,
|
|
75
81
|
toolchains,
|
|
82
|
+
fingerprints,
|
|
76
83
|
explicitServices,
|
|
77
84
|
discovery,
|
|
78
85
|
configs,
|
|
@@ -104,6 +111,8 @@ function normalizeServiceConfig({
|
|
|
104
111
|
discovery,
|
|
105
112
|
regressions,
|
|
106
113
|
toolchains,
|
|
114
|
+
environments,
|
|
115
|
+
fingerprints,
|
|
107
116
|
explicitService,
|
|
108
117
|
discoveredService,
|
|
109
118
|
suites,
|
|
@@ -168,12 +177,120 @@ function normalizeServiceConfig({
|
|
|
168
177
|
runtime,
|
|
169
178
|
browser,
|
|
170
179
|
ui: config.ui || null,
|
|
180
|
+
environments,
|
|
181
|
+
fingerprints,
|
|
171
182
|
fileMetadataByPath: serviceFileMetadata,
|
|
172
183
|
local,
|
|
173
184
|
},
|
|
174
185
|
};
|
|
175
186
|
}
|
|
176
187
|
|
|
188
|
+
function normalizeEnvironmentRegistry(environments = {}) {
|
|
189
|
+
if (!environments || typeof environments !== "object" || Array.isArray(environments)) {
|
|
190
|
+
throw new Error("testkit.config.ts environments must be an object");
|
|
191
|
+
}
|
|
192
|
+
return Object.fromEntries(
|
|
193
|
+
Object.entries(environments).map(([name, environment]) => [
|
|
194
|
+
normalizeEnvironmentName(name),
|
|
195
|
+
normalizeEnvironmentConfig(name, environment),
|
|
196
|
+
])
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function normalizeEnvironmentName(name) {
|
|
201
|
+
const normalized = String(name || "").trim();
|
|
202
|
+
if (!/^[A-Za-z0-9_-]+$/.test(normalized)) {
|
|
203
|
+
throw new Error(`Environment name "${name}" must contain only letters, numbers, underscores, or dashes`);
|
|
204
|
+
}
|
|
205
|
+
return normalized;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeEnvironmentConfig(name, environment) {
|
|
209
|
+
if (!environment || typeof environment !== "object" || Array.isArray(environment)) {
|
|
210
|
+
throw new Error(`Environment "${name}" must be an object`);
|
|
211
|
+
}
|
|
212
|
+
if ((environment.kind || "local") !== "local") {
|
|
213
|
+
throw new Error(`Environment "${name}" uses unsupported kind "${environment.kind}"`);
|
|
214
|
+
}
|
|
215
|
+
const target = String(environment.target || "").trim();
|
|
216
|
+
if (!target) {
|
|
217
|
+
throw new Error(`Environment "${name}" requires target`);
|
|
218
|
+
}
|
|
219
|
+
const data = environment.data || "reuse";
|
|
220
|
+
if (!["reuse", "reset", "rebuild"].includes(data)) {
|
|
221
|
+
throw new Error(`Environment "${name}" data must be one of: reuse, reset, rebuild`);
|
|
222
|
+
}
|
|
223
|
+
const portOffset = environment.portOffset == null ? 0 : Number(environment.portOffset);
|
|
224
|
+
if (!Number.isInteger(portOffset) || portOffset < 0) {
|
|
225
|
+
throw new Error(`Environment "${name}" portOffset must be a non-negative integer`);
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
kind: "local",
|
|
229
|
+
target,
|
|
230
|
+
data,
|
|
231
|
+
portOffset,
|
|
232
|
+
env: normalizeEnvironmentEnv(environment.env),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function normalizeEnvironmentEnv(env) {
|
|
237
|
+
if (!env) return {};
|
|
238
|
+
if (typeof env !== "object" || Array.isArray(env)) {
|
|
239
|
+
throw new Error("Environment env must be an object");
|
|
240
|
+
}
|
|
241
|
+
const hasPresetShape =
|
|
242
|
+
Object.prototype.hasOwnProperty.call(env, "values") ||
|
|
243
|
+
Object.prototype.hasOwnProperty.call(env, "databases");
|
|
244
|
+
if (!hasPresetShape) {
|
|
245
|
+
return Object.fromEntries(
|
|
246
|
+
Object.entries(env).map(([key, value]) => [key, String(value)])
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const allowedKeys = new Set(["values", "databases"]);
|
|
250
|
+
const unexpectedKeys = Object.keys(env).filter((key) => !allowedKeys.has(key));
|
|
251
|
+
if (unexpectedKeys.length > 0) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Environment env only supports "values" and "databases". Received unexpected key(s): ${unexpectedKeys.join(", ")}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
const values = env.values && typeof env.values === "object" && !Array.isArray(env.values) ? env.values : {};
|
|
257
|
+
const databases =
|
|
258
|
+
env.databases && typeof env.databases === "object" && !Array.isArray(env.databases) ? env.databases : {};
|
|
259
|
+
return {
|
|
260
|
+
...expandDatabaseBindings(databases),
|
|
261
|
+
...Object.fromEntries(Object.entries(values).map(([key, value]) => [key, String(value)])),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function expandDatabaseBindings(bindings) {
|
|
266
|
+
const env = {};
|
|
267
|
+
for (const [name, binding] of Object.entries(bindings || {})) {
|
|
268
|
+
if (!binding || typeof binding !== "object" || Array.isArray(binding)) {
|
|
269
|
+
throw new Error(`env.databases.${name} must be an object`);
|
|
270
|
+
}
|
|
271
|
+
const prefix = normalizeDatabaseEnvToken(binding.prefix, `env.databases.${name}.prefix`);
|
|
272
|
+
const serviceName = normalizeDatabaseEnvToken(binding.service, `env.databases.${name}.service`, false);
|
|
273
|
+
env[`${prefix}_DATABASE_HOST`] = `{dbHost:${serviceName}}`;
|
|
274
|
+
env[`${prefix}_DATABASE_PORT`] = `{dbPort:${serviceName}}`;
|
|
275
|
+
env[`${prefix}_DATABASE_NAME`] = `{dbName:${serviceName}}`;
|
|
276
|
+
env[`${prefix}_DATABASE_USER`] = `{dbUser:${serviceName}}`;
|
|
277
|
+
env[`${prefix}_DATABASE_PASSWORD`] = `{dbPassword:${serviceName}}`;
|
|
278
|
+
env[`${prefix}_DATABASE_SSL`] = "0";
|
|
279
|
+
}
|
|
280
|
+
return env;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function normalizeDatabaseEnvToken(value, label, sanitize = true) {
|
|
284
|
+
const raw = String(value || "").trim();
|
|
285
|
+
const normalized = sanitize
|
|
286
|
+
? raw.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
|
|
287
|
+
: raw;
|
|
288
|
+
if (!normalized) {
|
|
289
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
290
|
+
}
|
|
291
|
+
return normalized;
|
|
292
|
+
}
|
|
293
|
+
|
|
177
294
|
function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
|
|
178
295
|
if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
|
|
179
296
|
if (explicitService.local === false) {
|
|
@@ -3,6 +3,15 @@ import { validateConfiguredCollection } from "../shared/configured-steps.mjs";
|
|
|
3
3
|
|
|
4
4
|
export function validateConfigCoverage(configs) {
|
|
5
5
|
const names = new Set(configs.map((config) => config.name));
|
|
6
|
+
const environments = configs[0]?.testkit?.environments || {};
|
|
7
|
+
for (const [environmentName, environment] of Object.entries(environments)) {
|
|
8
|
+
if (!names.has(environment.target)) {
|
|
9
|
+
const available = [...names].sort().join(", ");
|
|
10
|
+
throw new Error(
|
|
11
|
+
`Environment "${environmentName}" targets unknown service "${environment.target}". Available: ${available}`
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
6
15
|
for (const config of configs) {
|
|
7
16
|
for (const depName of config.testkit.dependsOn || []) {
|
|
8
17
|
if (!names.has(depName)) {
|
|
@@ -96,7 +96,7 @@ export async function materializePostgresBinding(context = {}) {
|
|
|
96
96
|
|
|
97
97
|
const assignments = columns.map((column) => {
|
|
98
98
|
const identifier = requireIdentifier(column, `materializePostgresBinding.args.values.${column}`);
|
|
99
|
-
return `${identifier} = ${
|
|
99
|
+
return `${identifier} = ${toSqlExpression(values[column], context)}`;
|
|
100
100
|
});
|
|
101
101
|
const query =
|
|
102
102
|
`with updated as (update ${table} set ${assignments.join(", ")} ` +
|
|
@@ -153,6 +153,11 @@ export interface TestkitExecutionConfig {
|
|
|
153
153
|
fileTimeoutSeconds?: number;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
export interface FingerprintConfig {
|
|
157
|
+
exclude?: string[];
|
|
158
|
+
include?: string[];
|
|
159
|
+
}
|
|
160
|
+
|
|
156
161
|
export interface BrowserServiceConfig {
|
|
157
162
|
origins?: string[];
|
|
158
163
|
}
|
|
@@ -277,6 +282,18 @@ export interface PresetEnvConfig {
|
|
|
277
282
|
databases?: Record<string, DatabaseBindingEnvConfig>;
|
|
278
283
|
}
|
|
279
284
|
|
|
285
|
+
export interface LocalEnvironmentConfig {
|
|
286
|
+
kind: "local";
|
|
287
|
+
data?: "reuse" | "reset" | "rebuild";
|
|
288
|
+
env?: Record<string, string>;
|
|
289
|
+
portOffset?: number;
|
|
290
|
+
target: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface LocalEnvironmentOptions extends Omit<LocalEnvironmentConfig, "kind" | "env"> {
|
|
294
|
+
env?: PresetEnvConfig;
|
|
295
|
+
}
|
|
296
|
+
|
|
280
297
|
export interface TestkitFileMetadata {
|
|
281
298
|
locks?: string[];
|
|
282
299
|
skip?: string | { reason: string };
|
|
@@ -477,6 +494,8 @@ export interface NextAppOptions extends Omit<ServiceConfig, "local" | "runtime"
|
|
|
477
494
|
export interface TestkitConfig {
|
|
478
495
|
discovery?: DiscoveryConfig;
|
|
479
496
|
execution?: TestkitExecutionConfig;
|
|
497
|
+
environments?: Record<string, LocalEnvironmentConfig>;
|
|
498
|
+
fingerprints?: FingerprintConfig;
|
|
480
499
|
lint?: TestkitLintConfig;
|
|
481
500
|
profiles?: {
|
|
482
501
|
http?: Record<string, HttpSuiteConfig<any>>;
|
|
@@ -563,6 +582,9 @@ export declare const toolchain: {
|
|
|
563
582
|
export declare const ui: {
|
|
564
583
|
auth(options?: UiAuthConfig): UiConfig;
|
|
565
584
|
};
|
|
585
|
+
export declare const environment: {
|
|
586
|
+
local(options: LocalEnvironmentOptions): LocalEnvironmentConfig;
|
|
587
|
+
};
|
|
566
588
|
export declare const auth: {
|
|
567
589
|
fixture(options: { contract: JsonSessionContract; topology: AuthTopology }): AuthFixture;
|
|
568
590
|
contracts: {
|
package/lib/config-api/index.mjs
CHANGED
|
@@ -444,6 +444,20 @@ export const ui = {
|
|
|
444
444
|
auth: uiAuth,
|
|
445
445
|
};
|
|
446
446
|
|
|
447
|
+
function localEnvironment(options = {}) {
|
|
448
|
+
const { env, ...rest } = options;
|
|
449
|
+
return {
|
|
450
|
+
kind: "local",
|
|
451
|
+
data: "reuse",
|
|
452
|
+
...rest,
|
|
453
|
+
...(env !== undefined ? { env: normalizePresetEnv(env) } : {}),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export const environment = {
|
|
458
|
+
local: localEnvironment,
|
|
459
|
+
};
|
|
460
|
+
|
|
447
461
|
export const auth = {
|
|
448
462
|
fixture: createAuthFixture,
|
|
449
463
|
contracts: {
|