@elench/testkit 0.1.150 → 0.1.151
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 +21 -9
- package/lib/cli/assistant/view-model.mjs +1 -1
- package/lib/cli/components/blocks/run-tree.mjs +1 -1
- package/lib/cli/renderers/run/events.mjs +4 -3
- package/lib/cli/renderers/run/failure.mjs +2 -2
- package/lib/cli/renderers/run/inline-detail.mjs +2 -2
- package/lib/cli/renderers/run/interactive.mjs +2 -2
- package/lib/cli/renderers/run/text-reporter.mjs +9 -9
- package/lib/cli/state/run/model.mjs +7 -7
- package/lib/cli/state/run/state.mjs +3 -3
- package/lib/cli/terminal/colors.mjs +1 -1
- package/lib/config/runtime.mjs +130 -0
- package/lib/config-api/index.d.ts +22 -0
- package/lib/database/cleanup.mjs +76 -1
- package/lib/database/index.mjs +6 -0
- package/lib/database/local-postgres.mjs +95 -4
- package/lib/database/naming.mjs +7 -0
- package/lib/database/state-files.mjs +12 -0
- package/lib/docker-compat/matrix.mjs +5 -3
- package/lib/kiln/client.mjs +8 -0
- package/lib/local/kiln-driver.mjs +96 -69
- package/lib/ownership/docker.mjs +67 -1
- package/lib/regressions/github-transport.mjs +178 -4
- package/lib/regressions/github.mjs +52 -16
- package/lib/regressions/index.d.ts +56 -28
- package/lib/regressions/index.mjs +122 -47
- package/lib/regressions/workflow.mjs +266 -0
- package/lib/results/artifacts.mjs +8 -7
- package/lib/runner/formatting.mjs +17 -16
- package/lib/runner/orchestrator.mjs +5 -4
- package/lib/runner/regressions.mjs +175 -33
- package/lib/runner/run-finalization.mjs +34 -4
- 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/node_modules/es-toolkit/CHANGELOG.md +801 -0
- package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
- package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
- package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
- package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
- package/node_modules/esprima/ChangeLog +235 -0
- 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
|
@@ -148,19 +148,28 @@ results without any separate follow-up maintenance command.
|
|
|
148
148
|
- `new regressions`
|
|
149
149
|
- `known regressions`
|
|
150
150
|
- `fixed known regressions`
|
|
151
|
-
- `
|
|
151
|
+
- `case store stale`
|
|
152
152
|
|
|
153
153
|
The default CLI keeps those signals lightweight:
|
|
154
154
|
|
|
155
155
|
- failed files print inline diagnosis immediately under the file line
|
|
156
156
|
- the final summary box reports aggregate regression counts only
|
|
157
157
|
- machine-readable artifacts gain per-file `diagnosis` plus top-level
|
|
158
|
-
`regressions.summary`, `regressions.
|
|
158
|
+
`regressions.summary`, `regressions.caseStore`, `regressions.workflow`, and prepared `regressions.drafts`
|
|
159
159
|
|
|
160
|
-
`
|
|
161
|
-
|
|
160
|
+
`case store stale` is repo hygiene, not a test failure. It means the regression
|
|
161
|
+
case store or linked issue tracker metadata needs attention, for example because a
|
|
162
162
|
linked issue is closed but the regression still reproduces.
|
|
163
163
|
|
|
164
|
+
The canonical durable file is `testkit.regressions.json` with
|
|
165
|
+
`schemaVersion: 2` and a top-level `cases` array. When a configured full run sees
|
|
166
|
+
a new failure, Testkit records an untriaged case automatically. When a known case
|
|
167
|
+
passes, Testkit moves it through `fixed_pending_verification`; after the
|
|
168
|
+
configured stability window it can close the linked GitHub issue and mark the
|
|
169
|
+
case resolved. Open GitHub bug issues with no linked regression case are recorded
|
|
170
|
+
as `coverage_missing` cases so missing regression coverage is visible in normal
|
|
171
|
+
run output instead of becoming a separate manual audit.
|
|
172
|
+
|
|
164
173
|
## Tooling Adapters
|
|
165
174
|
|
|
166
175
|
`testkit` also ships tool-specific config helpers so consumer repos do not need
|
|
@@ -382,7 +391,9 @@ destroyed. `testkit cleanup --resources` cleans stale resources for the current
|
|
|
382
391
|
product; add `--global` to clean labelled resources whose product directory no
|
|
383
392
|
longer exists, and add `--include-legacy` only when you intentionally want to
|
|
384
393
|
remove old unlabelled `testkit_pg_*` containers from pre-ownership Testkit
|
|
385
|
-
versions.
|
|
394
|
+
versions. Host-local Postgres data lives in a Testkit-labelled named Docker
|
|
395
|
+
volume instead of an image-declared anonymous volume, so cleanup can account for
|
|
396
|
+
and remove it safely. Use `--dry-run` first when cleaning globally.
|
|
386
397
|
|
|
387
398
|
`database.template` is the database-side equivalent for reusable template DB
|
|
388
399
|
state. When `database.sourceSchema` is configured, Testkit treats the configured
|
|
@@ -488,7 +499,7 @@ If `regressions.file` is configured, `testkit` enriches
|
|
|
488
499
|
- per-file `diagnosis` metadata (new regression, known regression, fixed known regression)
|
|
489
500
|
- top-level `regressions` summary and prepared draft updates
|
|
490
501
|
|
|
491
|
-
Regression
|
|
502
|
+
Regression case authoring uses this contract:
|
|
492
503
|
|
|
493
504
|
- `summary`
|
|
494
505
|
- concise local statement of the regression slice
|
|
@@ -498,12 +509,12 @@ Regression-catalog entry authoring uses this contract:
|
|
|
498
509
|
- selectors that let testkit automatically recognize the regression in future runs
|
|
499
510
|
|
|
500
511
|
If `regressions.sync` is also configured, `testkit` syncs linked GitHub issues and
|
|
501
|
-
adds top-level regression
|
|
502
|
-
important
|
|
512
|
+
adds top-level regression case health to the run/status artifacts. The most
|
|
513
|
+
important stale-case signal is:
|
|
503
514
|
|
|
504
515
|
- a known regression still fails, but the linked GitHub issue is closed
|
|
505
516
|
|
|
506
|
-
In `mode: "error"`,
|
|
517
|
+
In `mode: "error"`, case-store health can also fail the run for problems such as:
|
|
507
518
|
|
|
508
519
|
- closed issues that still reproduce
|
|
509
520
|
- missing issue refs
|
|
@@ -811,6 +822,7 @@ npm run test:unit
|
|
|
811
822
|
npm run test:integration
|
|
812
823
|
npm run test:system
|
|
813
824
|
npm run test:live:github
|
|
825
|
+
npm run test:live:regression-workflow
|
|
814
826
|
npm run test:live:neon
|
|
815
827
|
npm run test:database-version:compat
|
|
816
828
|
```
|
|
@@ -118,7 +118,7 @@ export function buildWelcomeModel(snapshot, { cwd = process.cwd(), providerLabel
|
|
|
118
118
|
const issues = [
|
|
119
119
|
rowValue("New regressions") ? `${rowValue("New regressions")} new regression${rowValue("New regressions") === "1" ? "" : "s"}` : null,
|
|
120
120
|
rowValue("Known regressions") ? `${rowValue("Known regressions")} known` : null,
|
|
121
|
-
rowValue("
|
|
121
|
+
rowValue("Case store stale") ? `${rowValue("Case store stale")} stale` : null,
|
|
122
122
|
].filter(Boolean);
|
|
123
123
|
|
|
124
124
|
return {
|
|
@@ -120,7 +120,7 @@ function renderTreeLine(snapshot, spinnerFrame, terminalWidth, entry) {
|
|
|
120
120
|
|
|
121
121
|
if (entry.kind === "file" && !entry.collapsed && snapshot.finished) {
|
|
122
122
|
const detailLines = entry.status === "failed"
|
|
123
|
-
? renderFailureDetail(entry, { width: terminalWidth,
|
|
123
|
+
? renderFailureDetail(entry, { width: terminalWidth, regressionCaseStore: snapshot.regressionCaseStore })
|
|
124
124
|
: entry.status === "passed"
|
|
125
125
|
? renderPassedDetail(entry, { width: terminalWidth })
|
|
126
126
|
: [];
|
|
@@ -6,9 +6,10 @@ export function createRunEventsReporter({ stdout = process.stdout, stderr = proc
|
|
|
6
6
|
return {
|
|
7
7
|
outputMode: "events",
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
writeEvent("run.
|
|
11
|
-
configured: Boolean(document
|
|
9
|
+
setRegressionCaseStore(document) {
|
|
10
|
+
writeEvent("run.regression_case_store", {
|
|
11
|
+
configured: Boolean(document),
|
|
12
|
+
caseCount: document?.cases?.length || 0,
|
|
12
13
|
});
|
|
13
14
|
},
|
|
14
15
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildFailurePresentation } from "../../../runner/formatting.mjs";
|
|
2
2
|
import { renderIndentedBlock } from "../../terminal/layout.mjs";
|
|
3
3
|
|
|
4
|
-
export function renderFailureBlock(task, outcome, { width,
|
|
4
|
+
export function renderFailureBlock(task, outcome, { width, regressionCaseStore } = {}) {
|
|
5
5
|
const failureView = buildFailurePresentation(
|
|
6
6
|
{
|
|
7
7
|
service: task.serviceName,
|
|
@@ -11,7 +11,7 @@ export function renderFailureBlock(task, outcome, { width, regressionCatalog } =
|
|
|
11
11
|
failureDetails: Array.isArray(outcome.failureDetails) ? outcome.failureDetails : [],
|
|
12
12
|
suiteError: null,
|
|
13
13
|
},
|
|
14
|
-
|
|
14
|
+
regressionCaseStore
|
|
15
15
|
);
|
|
16
16
|
|
|
17
17
|
const lines = [];
|
|
@@ -3,7 +3,7 @@ import { renderIndentedBlock } from "../../terminal/layout.mjs";
|
|
|
3
3
|
import { dim, green, red } from "../../terminal/colors.mjs";
|
|
4
4
|
import figures from "figures";
|
|
5
5
|
|
|
6
|
-
export function renderFailureDetail(entry, { width,
|
|
6
|
+
export function renderFailureDetail(entry, { width, regressionCaseStore } = {}) {
|
|
7
7
|
const fileSummary = {
|
|
8
8
|
service: entry.serviceName,
|
|
9
9
|
type: normalizeType(entry),
|
|
@@ -13,7 +13,7 @@ export function renderFailureDetail(entry, { width, regressionCatalog } = {}) {
|
|
|
13
13
|
suiteError: null,
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
const failureView = buildFailurePresentation(fileSummary,
|
|
16
|
+
const failureView = buildFailurePresentation(fileSummary, regressionCaseStore);
|
|
17
17
|
const lines = [];
|
|
18
18
|
const indent = " ".repeat(2 + (entry.depth || 0) * 2 + 2);
|
|
19
19
|
|
|
@@ -32,8 +32,8 @@ export function createRunSession({
|
|
|
32
32
|
runState.setTotalFileCount(count);
|
|
33
33
|
},
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
runState.
|
|
35
|
+
setRegressionCaseStore(document) {
|
|
36
|
+
runState.setRegressionCaseStore(document);
|
|
37
37
|
},
|
|
38
38
|
|
|
39
39
|
serviceSkipped(config, reason) {
|
|
@@ -13,15 +13,15 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
|
|
|
13
13
|
const mode = outputMode || "compact";
|
|
14
14
|
let completedCount = 0;
|
|
15
15
|
let totalFileCount = 0;
|
|
16
|
-
let
|
|
16
|
+
let regressionCaseStore = null;
|
|
17
17
|
|
|
18
18
|
return {
|
|
19
19
|
outputMode: mode,
|
|
20
20
|
setTotalFileCount(count) {
|
|
21
21
|
totalFileCount = count;
|
|
22
22
|
},
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
setRegressionCaseStore(document) {
|
|
24
|
+
regressionCaseStore = document;
|
|
25
25
|
},
|
|
26
26
|
writeLine(line = "") {
|
|
27
27
|
stdout.write(`${line}\n`);
|
|
@@ -92,7 +92,7 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
|
|
|
92
92
|
if (status === "FAIL") {
|
|
93
93
|
const detailLines = renderFailureBlock(task, outcome, {
|
|
94
94
|
width: getTerminalWidth(stdout, 100),
|
|
95
|
-
|
|
95
|
+
regressionCaseStore,
|
|
96
96
|
});
|
|
97
97
|
for (const line of detailLines) {
|
|
98
98
|
stdout.write(`${line}\n`);
|
|
@@ -138,13 +138,13 @@ export function createRunReporter({ outputMode = "compact", stdout = process.std
|
|
|
138
138
|
if (summary.fixedKnownRegressions > 0) {
|
|
139
139
|
rows.push(["Fixed known", String(summary.fixedKnownRegressions)]);
|
|
140
140
|
}
|
|
141
|
-
if (summary.
|
|
142
|
-
rows.push(["
|
|
141
|
+
if (summary.staleCases > 0) {
|
|
142
|
+
rows.push(["Case store stale", String(summary.staleCases)]);
|
|
143
143
|
}
|
|
144
|
-
if (summary.
|
|
145
|
-
rows.push(["
|
|
144
|
+
if (summary.syncUnavailable) {
|
|
145
|
+
rows.push(["Case store sync", "Unavailable"]);
|
|
146
146
|
} else if (summary.usedStaleCache) {
|
|
147
|
-
rows.push(["
|
|
147
|
+
rows.push(["Case store sync", "Used stale cache"]);
|
|
148
148
|
}
|
|
149
149
|
const boxed = renderSummaryBox(rows, { stdout });
|
|
150
150
|
stdout.write("\n");
|
|
@@ -32,8 +32,8 @@ export function buildSummaryRows({
|
|
|
32
32
|
if ((regressionSummary?.fixedKnownRegressions || 0) > 0) {
|
|
33
33
|
rows.push(["Fixed known", String(regressionSummary.fixedKnownRegressions)]);
|
|
34
34
|
}
|
|
35
|
-
if ((regressionSummary?.
|
|
36
|
-
rows.push(["
|
|
35
|
+
if ((regressionSummary?.staleCases || 0) > 0) {
|
|
36
|
+
rows.push(["Case store stale", String(regressionSummary.staleCases)]);
|
|
37
37
|
}
|
|
38
38
|
return rows;
|
|
39
39
|
}
|
|
@@ -44,7 +44,7 @@ export function createEmptyRunModel(dataSource = "live", options = {}) {
|
|
|
44
44
|
autoCollapsePassedTreeBranches: options.autoCollapsePassedTreeBranches !== false,
|
|
45
45
|
services: new Map(),
|
|
46
46
|
summaryData: null,
|
|
47
|
-
|
|
47
|
+
regressionCaseStore: null,
|
|
48
48
|
runArtifact: null,
|
|
49
49
|
finished: dataSource === "artifact",
|
|
50
50
|
totalCount: 0,
|
|
@@ -59,7 +59,7 @@ export function resetRunModel(model, dataSource = model.dataSource) {
|
|
|
59
59
|
model.dataSource = dataSource;
|
|
60
60
|
model.services = new Map();
|
|
61
61
|
model.summaryData = null;
|
|
62
|
-
model.
|
|
62
|
+
model.regressionCaseStore = null;
|
|
63
63
|
model.runArtifact = null;
|
|
64
64
|
model.finished = dataSource === "artifact";
|
|
65
65
|
model.totalCount = 0;
|
|
@@ -250,8 +250,8 @@ export function setPhase(model, label) {
|
|
|
250
250
|
model.phase = label;
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
export function
|
|
254
|
-
model.
|
|
253
|
+
export function setRegressionCaseStore(model, document) {
|
|
254
|
+
model.regressionCaseStore = document;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
export function finishModel(model, results, durationMs, regressionReport) {
|
|
@@ -316,7 +316,7 @@ export function buildSnapshot(model) {
|
|
|
316
316
|
phase: model.phase,
|
|
317
317
|
finished: model.finished,
|
|
318
318
|
summaryData: model.summaryData,
|
|
319
|
-
|
|
319
|
+
regressionCaseStore: model.regressionCaseStore,
|
|
320
320
|
runArtifact: model.runArtifact,
|
|
321
321
|
};
|
|
322
322
|
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
resetRunModel,
|
|
15
15
|
revealEntry,
|
|
16
16
|
setPhase,
|
|
17
|
-
|
|
17
|
+
setRegressionCaseStore,
|
|
18
18
|
setTotalFileCount,
|
|
19
19
|
toggleCollapsed,
|
|
20
20
|
} from "./model.mjs";
|
|
@@ -66,8 +66,8 @@ export function createRunState({ dataSource = "live", autoCollapsePassedTreeBran
|
|
|
66
66
|
notify();
|
|
67
67
|
},
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
setRegressionCaseStore(document) {
|
|
70
|
+
setRegressionCaseStore(model, document);
|
|
71
71
|
notify();
|
|
72
72
|
},
|
|
73
73
|
|
|
@@ -82,7 +82,7 @@ export function colorResultLine(line) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export function colorSectionLine(line) {
|
|
85
|
-
if (line === "
|
|
85
|
+
if (line === "Case store issues:" || line === "Diagnosis:") {
|
|
86
86
|
return pc.bold(line);
|
|
87
87
|
}
|
|
88
88
|
if (/^Summary: /.test(line)) return line.replace("Summary:", `${pc.bold("Summary:")}`);
|
package/lib/config/runtime.mjs
CHANGED
|
@@ -32,9 +32,139 @@ export function normalizeRegressionsConfig(value) {
|
|
|
32
32
|
return {
|
|
33
33
|
file,
|
|
34
34
|
sync,
|
|
35
|
+
workflow: normalizeRegressionWorkflowConfig(value.workflow),
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
function normalizeRegressionWorkflowConfig(value) {
|
|
40
|
+
const defaults = {
|
|
41
|
+
mode: "autopilot",
|
|
42
|
+
github: {
|
|
43
|
+
read: true,
|
|
44
|
+
write: false,
|
|
45
|
+
bugLabels: ["bug"],
|
|
46
|
+
issueLabels: ["bug"],
|
|
47
|
+
createIssues: "confirmed",
|
|
48
|
+
reopenIssues: "closed_reproduced",
|
|
49
|
+
closeIssues: "after_stability_window",
|
|
50
|
+
comment: "state_changes",
|
|
51
|
+
},
|
|
52
|
+
evidence: {
|
|
53
|
+
autoRerunThinFailures: true,
|
|
54
|
+
maxAutoReruns: 2,
|
|
55
|
+
requireForIssueCreation: true,
|
|
56
|
+
},
|
|
57
|
+
stability: {
|
|
58
|
+
cleanRunsBeforeClose: 3,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
if (value == null) return defaults;
|
|
62
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
63
|
+
throw new Error("testkit.config.ts regressions.workflow must be an object");
|
|
64
|
+
}
|
|
65
|
+
const mode = normalizeOptionalString(value.mode) || defaults.mode;
|
|
66
|
+
if (!["off", "observe", "autopilot"].includes(mode)) {
|
|
67
|
+
throw new Error('testkit.config.ts regressions.workflow.mode must be one of: off, observe, autopilot');
|
|
68
|
+
}
|
|
69
|
+
const github = normalizeObject(value.github, "testkit.config.ts regressions.workflow.github");
|
|
70
|
+
const evidence = normalizeObject(value.evidence, "testkit.config.ts regressions.workflow.evidence");
|
|
71
|
+
const stability = normalizeObject(value.stability, "testkit.config.ts regressions.workflow.stability");
|
|
72
|
+
return {
|
|
73
|
+
mode,
|
|
74
|
+
github: {
|
|
75
|
+
...defaults.github,
|
|
76
|
+
...normalizeRegressionWorkflowGithub(github),
|
|
77
|
+
},
|
|
78
|
+
evidence: {
|
|
79
|
+
...defaults.evidence,
|
|
80
|
+
...normalizeRegressionWorkflowEvidence(evidence),
|
|
81
|
+
},
|
|
82
|
+
stability: {
|
|
83
|
+
...defaults.stability,
|
|
84
|
+
...normalizeRegressionWorkflowStability(stability),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeRegressionWorkflowGithub(value) {
|
|
90
|
+
if (!value) return {};
|
|
91
|
+
return {
|
|
92
|
+
...(value.read == null ? {} : { read: normalizeBoolean(value.read, "testkit.config.ts regressions.workflow.github.read") }),
|
|
93
|
+
...(value.write == null ? {} : { write: normalizeBoolean(value.write, "testkit.config.ts regressions.workflow.github.write") }),
|
|
94
|
+
...(value.apiBaseUrl == null ? {} : { apiBaseUrl: requireNonEmptyString(value.apiBaseUrl, "testkit.config.ts regressions.workflow.github.apiBaseUrl") }),
|
|
95
|
+
...(value.bugLabels == null ? {} : { bugLabels: normalizeStringArray(value.bugLabels, "testkit.config.ts regressions.workflow.github.bugLabels") }),
|
|
96
|
+
...(value.issueLabels == null ? {} : { issueLabels: normalizeStringArray(value.issueLabels, "testkit.config.ts regressions.workflow.github.issueLabels") }),
|
|
97
|
+
...(value.createIssues == null ? {} : { createIssues: normalizeEnum(value.createIssues, ["never", "confirmed"], "testkit.config.ts regressions.workflow.github.createIssues") }),
|
|
98
|
+
...(value.reopenIssues == null ? {} : { reopenIssues: normalizeEnum(value.reopenIssues, ["never", "closed_reproduced"], "testkit.config.ts regressions.workflow.github.reopenIssues") }),
|
|
99
|
+
...(value.closeIssues == null ? {} : { closeIssues: normalizeEnum(value.closeIssues, ["never", "after_stability_window"], "testkit.config.ts regressions.workflow.github.closeIssues") }),
|
|
100
|
+
...(value.comment == null ? {} : { comment: normalizeEnum(value.comment, ["never", "state_changes"], "testkit.config.ts regressions.workflow.github.comment") }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeRegressionWorkflowEvidence(value) {
|
|
105
|
+
if (!value) return {};
|
|
106
|
+
return {
|
|
107
|
+
...(value.autoRerunThinFailures == null ? {} : { autoRerunThinFailures: normalizeBoolean(value.autoRerunThinFailures, "testkit.config.ts regressions.workflow.evidence.autoRerunThinFailures") }),
|
|
108
|
+
...(value.maxAutoReruns == null ? {} : { maxAutoReruns: normalizeNonNegativeInteger(value.maxAutoReruns, "testkit.config.ts regressions.workflow.evidence.maxAutoReruns") }),
|
|
109
|
+
...(value.requireForIssueCreation == null ? {} : { requireForIssueCreation: normalizeBoolean(value.requireForIssueCreation, "testkit.config.ts regressions.workflow.evidence.requireForIssueCreation") }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeRegressionWorkflowStability(value) {
|
|
114
|
+
if (!value) return {};
|
|
115
|
+
return {
|
|
116
|
+
...(value.cleanRunsBeforeClose == null ? {} : { cleanRunsBeforeClose: normalizePositiveInteger(value.cleanRunsBeforeClose, "testkit.config.ts regressions.workflow.stability.cleanRunsBeforeClose") }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeObject(value, label) {
|
|
121
|
+
if (value == null) return null;
|
|
122
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
123
|
+
throw new Error(`${label} must be an object`);
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeBoolean(value, label) {
|
|
129
|
+
if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeEnum(value, allowed, label) {
|
|
134
|
+
const normalized = requireNonEmptyString(value, label);
|
|
135
|
+
if (!allowed.includes(normalized)) {
|
|
136
|
+
throw new Error(`${label} must be one of: ${allowed.join(", ")}`);
|
|
137
|
+
}
|
|
138
|
+
return normalized;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeStringArray(value, label) {
|
|
142
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
143
|
+
throw new Error(`${label} must be a non-empty array`);
|
|
144
|
+
}
|
|
145
|
+
return value.map((entry, index) => requireNonEmptyString(entry, `${label}[${index}]`));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeNonNegativeInteger(value, label) {
|
|
149
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
150
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizePositiveInteger(value, label) {
|
|
156
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
157
|
+
throw new Error(`${label} must be a positive integer`);
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function requireNonEmptyString(value, label) {
|
|
163
|
+
const normalized = normalizeOptionalString(value);
|
|
164
|
+
if (!normalized) throw new Error(`${label} must be a non-empty string`);
|
|
165
|
+
return normalized;
|
|
166
|
+
}
|
|
167
|
+
|
|
38
168
|
export function inferLocalRuntime(productDir, cwd) {
|
|
39
169
|
const absoluteCwd = resolveServiceCwd(productDir, cwd);
|
|
40
170
|
if (!fs.existsSync(absoluteCwd)) return undefined;
|
|
@@ -586,6 +586,28 @@ export interface TestkitConfig {
|
|
|
586
586
|
regressions?: {
|
|
587
587
|
file?: string;
|
|
588
588
|
sync?: RegressionSyncConfig;
|
|
589
|
+
workflow?: {
|
|
590
|
+
mode?: "off" | "observe" | "autopilot";
|
|
591
|
+
github?: {
|
|
592
|
+
read?: boolean;
|
|
593
|
+
write?: boolean;
|
|
594
|
+
apiBaseUrl?: string;
|
|
595
|
+
bugLabels?: string[];
|
|
596
|
+
issueLabels?: string[];
|
|
597
|
+
createIssues?: "never" | "confirmed";
|
|
598
|
+
reopenIssues?: "never" | "closed_reproduced";
|
|
599
|
+
closeIssues?: "never" | "after_stability_window";
|
|
600
|
+
comment?: "never" | "state_changes";
|
|
601
|
+
};
|
|
602
|
+
evidence?: {
|
|
603
|
+
autoRerunThinFailures?: boolean;
|
|
604
|
+
maxAutoReruns?: number;
|
|
605
|
+
requireForIssueCreation?: boolean;
|
|
606
|
+
};
|
|
607
|
+
stability?: {
|
|
608
|
+
cleanRunsBeforeClose?: number;
|
|
609
|
+
};
|
|
610
|
+
};
|
|
589
611
|
};
|
|
590
612
|
services?: Record<string, ServiceConfig>;
|
|
591
613
|
toolchains?: Record<string, ToolchainConfig>;
|
package/lib/database/cleanup.mjs
CHANGED
|
@@ -7,9 +7,12 @@ import {
|
|
|
7
7
|
TESTKIT_SCOPE_LABEL,
|
|
8
8
|
buildProductIdentity,
|
|
9
9
|
dockerContainerSummary,
|
|
10
|
+
dockerVolumeSummary,
|
|
10
11
|
listLegacyTestkitPostgresContainers,
|
|
11
12
|
listManagedDockerContainers,
|
|
13
|
+
listManagedDockerVolumes,
|
|
12
14
|
removeDockerContainer,
|
|
15
|
+
removeDockerVolume,
|
|
13
16
|
stopDockerContainer,
|
|
14
17
|
} from "../ownership/docker.mjs";
|
|
15
18
|
import { buildContainerName } from "./naming.mjs";
|
|
@@ -22,6 +25,7 @@ import {
|
|
|
22
25
|
export async function cleanupLocalPostgresDockerResources(options = {}) {
|
|
23
26
|
const product = buildProductIdentity(options.productDir || process.cwd());
|
|
24
27
|
const managed = await listManagedDockerContainers();
|
|
28
|
+
const managedVolumes = await listManagedDockerVolumes();
|
|
25
29
|
const targets = [];
|
|
26
30
|
const kept = [];
|
|
27
31
|
const stopped = [];
|
|
@@ -38,6 +42,16 @@ export async function cleanupLocalPostgresDockerResources(options = {}) {
|
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
for (const volume of managedVolumes) {
|
|
46
|
+
if (!isManagedLocalPostgresVolume(volume)) continue;
|
|
47
|
+
const classification = classifyManagedLocalPostgresVolume(volume, product, options);
|
|
48
|
+
if (classification.action === "remove") {
|
|
49
|
+
targets.push({ ...volume, action: "remove", reason: classification.reason, legacy: false, resourceType: "volume" });
|
|
50
|
+
} else if (classification.reason) {
|
|
51
|
+
kept.push({ ...volume, reason: classification.reason, legacy: false, resourceType: "volume" });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
if (options.includeLegacy) {
|
|
42
56
|
const currentLegacyName = buildContainerName(product.dir);
|
|
43
57
|
for (const container of await listLegacyTestkitPostgresContainers()) {
|
|
@@ -51,6 +65,8 @@ export async function cleanupLocalPostgresDockerResources(options = {}) {
|
|
|
51
65
|
if (container.action === "stop") {
|
|
52
66
|
await stopDockerContainer(container.name || container.id);
|
|
53
67
|
stopped.push(container);
|
|
68
|
+
} else if (container.resourceType === "volume") {
|
|
69
|
+
await removeDockerVolume(container.name);
|
|
54
70
|
} else {
|
|
55
71
|
await removeDockerContainer(container.name || container.id);
|
|
56
72
|
}
|
|
@@ -71,7 +87,10 @@ export function formatDatabaseResourceCleanupLine(entry, dryRun = false) {
|
|
|
71
87
|
? isStop ? "Would stop" : "Would remove"
|
|
72
88
|
: isStop ? "Stopped" : "Removed";
|
|
73
89
|
const legacy = entry.legacy ? " legacy" : "";
|
|
74
|
-
|
|
90
|
+
const summary = entry.resourceType === "volume"
|
|
91
|
+
? `volume ${dockerVolumeSummary(entry)}`
|
|
92
|
+
: dockerContainerSummary(entry);
|
|
93
|
+
return `${action}${legacy} database resource ${summary} reason=${entry.reason}`;
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
function classifyManagedLocalPostgresContainer(container, product, options) {
|
|
@@ -112,6 +131,41 @@ function classifyManagedLocalPostgresContainer(container, product, options) {
|
|
|
112
131
|
return { action: "keep", reason: options.global ? "other-product-retained" : "different-product" };
|
|
113
132
|
}
|
|
114
133
|
|
|
134
|
+
function classifyManagedLocalPostgresVolume(volume, product, options) {
|
|
135
|
+
const labels = volume.labels || {};
|
|
136
|
+
const volumeProductId = labels[TESTKIT_PRODUCT_ID_LABEL] || "";
|
|
137
|
+
const volumeProductDir = labels[TESTKIT_PRODUCT_DIR_LABEL] || "";
|
|
138
|
+
const currentProduct = volumeProductId === product.id;
|
|
139
|
+
|
|
140
|
+
if (options.force && currentProduct) {
|
|
141
|
+
return { action: "remove", reason: "destroy-current-product" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!options.global && !currentProduct) {
|
|
145
|
+
return { action: "keep", reason: "different-product" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (currentProduct) {
|
|
149
|
+
if (!hasRemainingLocalArtifacts(product.dir, readStateValue)) {
|
|
150
|
+
return { action: "remove", reason: "current-product-no-local-artifacts" };
|
|
151
|
+
}
|
|
152
|
+
if (!localArtifactsReferenceVolume(product.dir, volume.name)) {
|
|
153
|
+
return { action: "remove", reason: "current-product-unreferenced" };
|
|
154
|
+
}
|
|
155
|
+
return { action: "keep", reason: "current-product-referenced" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (options.global && volumeProductDir && !fs.existsSync(volumeProductDir)) {
|
|
159
|
+
return { action: "remove", reason: "product-dir-missing" };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (options.global && volumeProductDir && !hasRemainingLocalArtifacts(volumeProductDir, readStateValue)) {
|
|
163
|
+
return { action: "remove", reason: "product-has-no-local-artifacts" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { action: "keep", reason: options.global ? "other-product-retained" : "different-product" };
|
|
167
|
+
}
|
|
168
|
+
|
|
115
169
|
function shouldStopIdleLocalPostgresContainer(container, productDir, options) {
|
|
116
170
|
return Boolean(
|
|
117
171
|
options.stopIdle &&
|
|
@@ -130,6 +184,14 @@ function isManagedLocalPostgresContainer(container) {
|
|
|
130
184
|
);
|
|
131
185
|
}
|
|
132
186
|
|
|
187
|
+
function isManagedLocalPostgresVolume(volume) {
|
|
188
|
+
const labels = volume.labels || {};
|
|
189
|
+
return (
|
|
190
|
+
labels[TESTKIT_RESOURCE_KIND_LABEL] === "postgres-volume" &&
|
|
191
|
+
labels[TESTKIT_SCOPE_LABEL] === "local-postgres"
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
133
195
|
function localArtifactsReferenceContainer(productDir, containerName) {
|
|
134
196
|
if (!containerName) return false;
|
|
135
197
|
const root = path.join(productDir, ".testkit");
|
|
@@ -146,6 +208,19 @@ function localArtifactsReferenceContainer(productDir, containerName) {
|
|
|
146
208
|
return referenced;
|
|
147
209
|
}
|
|
148
210
|
|
|
211
|
+
function localArtifactsReferenceVolume(productDir, volumeName) {
|
|
212
|
+
if (!volumeName) return false;
|
|
213
|
+
const root = path.join(productDir, ".testkit");
|
|
214
|
+
let referenced = false;
|
|
215
|
+
visitDirs(root, (dir) => {
|
|
216
|
+
if (referenced) return;
|
|
217
|
+
if (readStateValue(path.join(dir, "volume_name")) === volumeName) {
|
|
218
|
+
referenced = true;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
return referenced;
|
|
222
|
+
}
|
|
223
|
+
|
|
149
224
|
function hasActiveProductRuntime(productDir) {
|
|
150
225
|
return hasActiveRunManifest(productDir) || hasActiveLocalEnvironmentManifest(productDir);
|
|
151
226
|
}
|
package/lib/database/index.mjs
CHANGED
|
@@ -457,6 +457,12 @@ async function ensureRuntimeClone(config, infra, cacheDir, templateFingerprint,
|
|
|
457
457
|
fs.writeFileSync(path.join(config.stateDir, `${backend}_template_database_name`), templateDbName);
|
|
458
458
|
if (backend === "local") {
|
|
459
459
|
fs.writeFileSync(path.join(config.stateDir, "local_container_name"), infra.containerName);
|
|
460
|
+
if (infra.volumeName) {
|
|
461
|
+
fs.writeFileSync(path.join(config.stateDir, "volume_name"), infra.volumeName);
|
|
462
|
+
}
|
|
463
|
+
if (infra.volumeMountPath) {
|
|
464
|
+
fs.writeFileSync(path.join(config.stateDir, "volume_mount_path"), infra.volumeMountPath);
|
|
465
|
+
}
|
|
460
466
|
} else {
|
|
461
467
|
fs.writeFileSync(path.join(config.stateDir, "resource_name"), infra.resourceName);
|
|
462
468
|
writeResourceConnectionState(config.stateDir, infra);
|