@elench/testkit 0.1.149 → 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.
Files changed (60) hide show
  1. package/README.md +29 -9
  2. package/lib/cli/assistant/view-model.mjs +1 -1
  3. package/lib/cli/components/blocks/run-tree.mjs +1 -1
  4. package/lib/cli/renderers/run/events.mjs +4 -3
  5. package/lib/cli/renderers/run/failure.mjs +2 -2
  6. package/lib/cli/renderers/run/inline-detail.mjs +2 -2
  7. package/lib/cli/renderers/run/interactive.mjs +2 -2
  8. package/lib/cli/renderers/run/text-reporter.mjs +9 -9
  9. package/lib/cli/state/run/model.mjs +7 -7
  10. package/lib/cli/state/run/state.mjs +3 -3
  11. package/lib/cli/terminal/colors.mjs +1 -1
  12. package/lib/config/database-materialization.mjs +25 -0
  13. package/lib/config/database.mjs +30 -0
  14. package/lib/config/index.mjs +47 -1
  15. package/lib/config/runtime.mjs +130 -0
  16. package/lib/config-api/index.d.ts +28 -0
  17. package/lib/config-api/index.mjs +6 -0
  18. package/lib/database/cleanup.mjs +76 -1
  19. package/lib/database/constants.mjs +3 -0
  20. package/lib/database/index.mjs +6 -0
  21. package/lib/database/local-postgres.mjs +123 -4
  22. package/lib/database/naming.mjs +7 -0
  23. package/lib/database/resource-postgres.mjs +13 -0
  24. package/lib/database/state-files.mjs +17 -0
  25. package/lib/docker-compat/matrix.mjs +5 -3
  26. package/lib/kiln/client.mjs +8 -0
  27. package/lib/local/kiln-driver.mjs +96 -68
  28. package/lib/ownership/docker.mjs +67 -1
  29. package/lib/regressions/github-transport.mjs +178 -4
  30. package/lib/regressions/github.mjs +52 -16
  31. package/lib/regressions/index.d.ts +58 -29
  32. package/lib/regressions/index.mjs +171 -58
  33. package/lib/regressions/workflow.mjs +266 -0
  34. package/lib/results/artifacts.mjs +8 -7
  35. package/lib/runner/formatting.mjs +17 -16
  36. package/lib/runner/orchestrator.mjs +6 -5
  37. package/lib/runner/planning.mjs +40 -0
  38. package/lib/runner/regressions.mjs +183 -33
  39. package/lib/runner/reporting.mjs +1 -1
  40. package/lib/runner/run-finalization.mjs +34 -4
  41. package/lib/runner/runtime-manager.mjs +91 -10
  42. package/lib/runner/scheduler/index.mjs +30 -1
  43. package/lib/runtime/index.d.ts +5 -5
  44. package/lib/runtime-src/k6/http.js +11 -11
  45. package/node_modules/@elench/next-analysis/package.json +1 -1
  46. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  47. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  48. package/node_modules/@elench/ts-analysis/package.json +1 -1
  49. package/node_modules/es-toolkit/CHANGELOG.md +801 -0
  50. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
  51. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
  52. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
  53. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
  54. package/node_modules/esprima/ChangeLog +235 -0
  55. package/package.json +6 -5
  56. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
  57. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
  58. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
  59. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
  60. 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
- - `catalog stale`
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.catalog`, and prepared `regressions.drafts`
158
+ `regressions.summary`, `regressions.caseStore`, `regressions.workflow`, and prepared `regressions.drafts`
159
159
 
160
- `catalog stale` is repo hygiene, not a test failure. It means the regression
161
- catalog or linked issue tracker metadata needs attention, for example because a
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
@@ -271,6 +280,8 @@ export default defineConfig({
271
280
  api: resource.postgres({
272
281
  version: "16",
273
282
  extensions: ["vector"],
283
+ maxConnections: 240,
284
+ runtimeConnections: 20,
274
285
  }),
275
286
  },
276
287
  },
@@ -283,6 +294,7 @@ export default defineConfig({
283
294
  port: 3004,
284
295
  envFiles: [".env.testkit"],
285
296
  database: database.postgres({
297
+ runtimeConnections: 20,
286
298
  sourceSchema: database.schema.fromEnv("PRODUCTION_DATABASE_URL"),
287
299
  template: {
288
300
  inputs: ["db/schema.sql", "scripts/seed.ts"],
@@ -379,7 +391,9 @@ destroyed. `testkit cleanup --resources` cleans stale resources for the current
379
391
  product; add `--global` to clean labelled resources whose product directory no
380
392
  longer exists, and add `--include-legacy` only when you intentionally want to
381
393
  remove old unlabelled `testkit_pg_*` containers from pre-ownership Testkit
382
- versions. Use `--dry-run` first when cleaning globally.
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.
383
397
 
384
398
  `database.template` is the database-side equivalent for reusable template DB
385
399
  state. When `database.sourceSchema` is configured, Testkit treats the configured
@@ -485,7 +499,7 @@ If `regressions.file` is configured, `testkit` enriches
485
499
  - per-file `diagnosis` metadata (new regression, known regression, fixed known regression)
486
500
  - top-level `regressions` summary and prepared draft updates
487
501
 
488
- Regression-catalog entry authoring uses this contract:
502
+ Regression case authoring uses this contract:
489
503
 
490
504
  - `summary`
491
505
  - concise local statement of the regression slice
@@ -495,12 +509,12 @@ Regression-catalog entry authoring uses this contract:
495
509
  - selectors that let testkit automatically recognize the regression in future runs
496
510
 
497
511
  If `regressions.sync` is also configured, `testkit` syncs linked GitHub issues and
498
- adds top-level regression catalog health to the run/status artifacts. The most
499
- important catalog-staleness signal is:
512
+ adds top-level regression case health to the run/status artifacts. The most
513
+ important stale-case signal is:
500
514
 
501
515
  - a known regression still fails, but the linked GitHub issue is closed
502
516
 
503
- In `mode: "error"`, catalog health can also fail the run for problems such as:
517
+ In `mode: "error"`, case-store health can also fail the run for problems such as:
504
518
 
505
519
  - closed issues that still reproduce
506
520
  - missing issue refs
@@ -607,6 +621,11 @@ export default suite;
607
621
 
608
622
  DAL files for the same service database are serialized by default to avoid
609
623
  exhausting Postgres connection limits during highly parallel runs.
624
+ Runtime services also reserve a configurable Postgres connection budget for each
625
+ active runtime slot. Use `database.postgres({ runtimeConnections })` for the
626
+ service pool size, and `resource.postgres({ maxConnections, reservedConnections })`
627
+ or `database.postgres({ maxConnections, reservedConnections })` for the backing
628
+ Postgres capacity.
610
629
 
611
630
  `defineDalFixtures(...)` is the package-owned DAL seeding model. It gives every
612
631
  suite a deterministic `fixtureScope` with:
@@ -803,6 +822,7 @@ npm run test:unit
803
822
  npm run test:integration
804
823
  npm run test:system
805
824
  npm run test:live:github
825
+ npm run test:live:regression-workflow
806
826
  npm run test:live:neon
807
827
  npm run test:database-version:compat
808
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("Catalog stale") ? `${rowValue("Catalog stale")} stale` : null,
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, regressionCatalog: snapshot.regressionCatalog })
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
- setRegressionCatalog(document) {
10
- writeEvent("run.regression_catalog", {
11
- configured: Boolean(document?.configured),
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, regressionCatalog } = {}) {
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
- regressionCatalog
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, regressionCatalog } = {}) {
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, regressionCatalog);
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
- setRegressionCatalog(document) {
36
- runState.setRegressionCatalog(document);
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 regressionCatalog = null;
16
+ let regressionCaseStore = null;
17
17
 
18
18
  return {
19
19
  outputMode: mode,
20
20
  setTotalFileCount(count) {
21
21
  totalFileCount = count;
22
22
  },
23
- setRegressionCatalog(document) {
24
- regressionCatalog = document;
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
- regressionCatalog,
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.catalogStale > 0) {
142
- rows.push(["Catalog stale", String(summary.catalogStale)]);
141
+ if (summary.staleCases > 0) {
142
+ rows.push(["Case store stale", String(summary.staleCases)]);
143
143
  }
144
- if (summary.catalogSyncUnavailable) {
145
- rows.push(["Catalog sync", "Unavailable"]);
144
+ if (summary.syncUnavailable) {
145
+ rows.push(["Case store sync", "Unavailable"]);
146
146
  } else if (summary.usedStaleCache) {
147
- rows.push(["Catalog sync", "Used stale cache"]);
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?.catalogStale || 0) > 0) {
36
- rows.push(["Catalog stale", String(regressionSummary.catalogStale)]);
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
- regressionCatalog: null,
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.regressionCatalog = null;
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 setRegressionCatalog(model, document) {
254
- model.regressionCatalog = document;
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
- regressionCatalog: model.regressionCatalog,
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
- setRegressionCatalog,
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
- setRegressionCatalog(document) {
70
- setRegressionCatalog(model, document);
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 === "Catalog issues:" || line === "Diagnosis:") {
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:")}`);
@@ -1,6 +1,9 @@
1
1
  const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
2
2
  const DEFAULT_LOCAL_USER = "testkit";
3
3
  const DEFAULT_LOCAL_PASSWORD = "testkit";
4
+ const DEFAULT_LOCAL_MAX_CONNECTIONS = 200;
5
+ const DEFAULT_LOCAL_RESERVED_CONNECTIONS = 20;
6
+ const DEFAULT_RUNTIME_CONNECTIONS = 20;
4
7
 
5
8
  export function buildEnvironmentDatabaseMaterialization(environment = {}) {
6
9
  const databases = environment.resources?.databases || {};
@@ -8,6 +11,16 @@ export function buildEnvironmentDatabaseMaterialization(environment = {}) {
8
11
  resourcesByService: Object.fromEntries(
9
12
  Object.keys(databases).map((serviceName) => [serviceName, serviceName])
10
13
  ),
14
+ resourceCapacityByName: Object.fromEntries(
15
+ Object.entries(databases).map(([resourceName, resource]) => [
16
+ resourceName,
17
+ {
18
+ ...(resource.maxConnections ? { maxConnections: Number(resource.maxConnections) } : {}),
19
+ ...(resource.reservedConnections != null ? { reservedConnections: Number(resource.reservedConnections) } : {}),
20
+ ...(resource.runtimeConnections != null ? { runtimeConnections: Number(resource.runtimeConnections) } : {}),
21
+ },
22
+ ])
23
+ ),
11
24
  };
12
25
  }
13
26
 
@@ -24,6 +37,15 @@ export function materializeDatabaseConfig(database, serviceName, materialization
24
37
  provider: "resource",
25
38
  selectedBackend: "resource",
26
39
  resource: resourceName,
40
+ maxConnections: database.maxConnections || materialization.resourceCapacityByName?.[resourceName]?.maxConnections || DEFAULT_LOCAL_MAX_CONNECTIONS,
41
+ reservedConnections:
42
+ database.reservedConnections ??
43
+ materialization.resourceCapacityByName?.[resourceName]?.reservedConnections ??
44
+ DEFAULT_LOCAL_RESERVED_CONNECTIONS,
45
+ runtimeConnections:
46
+ database.runtimeConnections ??
47
+ materialization.resourceCapacityByName?.[resourceName]?.runtimeConnections ??
48
+ DEFAULT_RUNTIME_CONNECTIONS,
27
49
  };
28
50
  }
29
51
 
@@ -34,6 +56,9 @@ export function materializeDatabaseConfig(database, serviceName, materialization
34
56
  image: database.image || DEFAULT_LOCAL_IMAGE,
35
57
  user: database.user || DEFAULT_LOCAL_USER,
36
58
  password: database.password || DEFAULT_LOCAL_PASSWORD,
59
+ maxConnections: database.maxConnections || DEFAULT_LOCAL_MAX_CONNECTIONS,
60
+ reservedConnections: database.reservedConnections ?? DEFAULT_LOCAL_RESERVED_CONNECTIONS,
61
+ runtimeConnections: database.runtimeConnections ?? DEFAULT_RUNTIME_CONNECTIONS,
37
62
  };
38
63
  }
39
64
 
@@ -23,6 +23,18 @@ export function normalizeDatabaseConfig(explicitService, serviceName) {
23
23
  return {
24
24
  ...rawDatabase,
25
25
  binding: normalizeDatabaseBinding(rawDatabase.binding || "per-runtime", `Service "${serviceName}" database.binding`),
26
+ maxConnections: normalizeOptionalPositiveInteger(
27
+ rawDatabase.maxConnections,
28
+ `Service "${serviceName}" database.maxConnections`
29
+ ),
30
+ reservedConnections: normalizeOptionalNonNegativeInteger(
31
+ rawDatabase.reservedConnections,
32
+ `Service "${serviceName}" database.reservedConnections`
33
+ ),
34
+ runtimeConnections: normalizeOptionalPositiveInteger(
35
+ rawDatabase.runtimeConnections,
36
+ `Service "${serviceName}" database.runtimeConnections`
37
+ ),
26
38
  reset: rawDatabase.reset !== false,
27
39
  sourceSchema: normalizeSourceSchemaConfig(rawDatabase.sourceSchema, serviceName),
28
40
  template: normalizeDatabaseTemplateConfig(rawDatabase.template, serviceName),
@@ -30,6 +42,24 @@ export function normalizeDatabaseConfig(explicitService, serviceName) {
30
42
  };
31
43
  }
32
44
 
45
+ function normalizeOptionalPositiveInteger(value, label) {
46
+ if (value == null) return undefined;
47
+ const number = Number(value);
48
+ if (!Number.isInteger(number) || number <= 0) {
49
+ throw new Error(`${label} must be a positive integer`);
50
+ }
51
+ return number;
52
+ }
53
+
54
+ function normalizeOptionalNonNegativeInteger(value, label) {
55
+ if (value == null) return undefined;
56
+ const number = Number(value);
57
+ if (!Number.isInteger(number) || number < 0) {
58
+ throw new Error(`${label} must be a non-negative integer`);
59
+ }
60
+ return number;
61
+ }
62
+
33
63
  export function normalizeDatabaseTemplateConfig(value, serviceName) {
34
64
  if (value == null) {
35
65
  return {
@@ -278,11 +278,57 @@ function normalizeEnvironmentResourceGroup(environmentName, groupName, group = {
278
278
  `Environment "${environmentName}" resources.${groupName}.${name} kind must be ${allowedKinds.map((entry) => `"${entry}"`).join(" or ")}`
279
279
  );
280
280
  }
281
- return [normalizedName, { ...resource, kind }];
281
+ return [
282
+ normalizedName,
283
+ {
284
+ ...resource,
285
+ kind,
286
+ ...(resource.maxConnections != null
287
+ ? {
288
+ maxConnections: normalizePositiveInteger(
289
+ resource.maxConnections,
290
+ `Environment "${environmentName}" resources.${groupName}.${name}.maxConnections`
291
+ ),
292
+ }
293
+ : {}),
294
+ ...(resource.reservedConnections != null
295
+ ? {
296
+ reservedConnections: normalizeNonNegativeInteger(
297
+ resource.reservedConnections,
298
+ `Environment "${environmentName}" resources.${groupName}.${name}.reservedConnections`
299
+ ),
300
+ }
301
+ : {}),
302
+ ...(resource.runtimeConnections != null
303
+ ? {
304
+ runtimeConnections: normalizePositiveInteger(
305
+ resource.runtimeConnections,
306
+ `Environment "${environmentName}" resources.${groupName}.${name}.runtimeConnections`
307
+ ),
308
+ }
309
+ : {}),
310
+ },
311
+ ];
282
312
  })
283
313
  );
284
314
  }
285
315
 
316
+ function normalizePositiveInteger(value, label) {
317
+ const number = Number(value);
318
+ if (!Number.isInteger(number) || number <= 0) {
319
+ throw new Error(`${label} must be a positive integer`);
320
+ }
321
+ return number;
322
+ }
323
+
324
+ function normalizeNonNegativeInteger(value, label) {
325
+ const number = Number(value);
326
+ if (!Number.isInteger(number) || number < 0) {
327
+ throw new Error(`${label} must be a non-negative integer`);
328
+ }
329
+ return number;
330
+ }
331
+
286
332
  function normalizeKilnEnvironmentConfig(name, kiln) {
287
333
  if (!kiln) return null;
288
334
  if (typeof kiln !== "object" || Array.isArray(kiln)) {
@@ -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;
@@ -88,8 +88,11 @@ export type BuildConfig = TscBuildConfig | ScriptBuildConfig | NextBuildConfig |
88
88
  export interface PostgresDatabaseConfig {
89
89
  binding?: "shared" | "per-runtime";
90
90
  image?: string;
91
+ maxConnections?: number;
91
92
  password?: string;
93
+ reservedConnections?: number;
92
94
  reset?: boolean;
95
+ runtimeConnections?: number;
93
96
  sourceSchema?: DatabaseSourceSchemaConfig | null;
94
97
  template?: DatabaseTemplateConfig;
95
98
  user?: string;
@@ -309,8 +312,11 @@ export interface PostgresResourceConfig {
309
312
  database?: string;
310
313
  extensions?: string[];
311
314
  image?: string;
315
+ maxConnections?: number;
312
316
  password?: string;
313
317
  port?: number;
318
+ reservedConnections?: number;
319
+ runtimeConnections?: number;
314
320
  user?: string;
315
321
  version?: string;
316
322
  vm?: KilnVMResourceConfig;
@@ -580,6 +586,28 @@ export interface TestkitConfig {
580
586
  regressions?: {
581
587
  file?: string;
582
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
+ };
583
611
  };
584
612
  services?: Record<string, ServiceConfig>;
585
613
  toolchains?: Record<string, ToolchainConfig>;