@elench/testkit 0.1.117 → 0.1.119
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 +27 -12
- package/lib/app/doctor.mjs +11 -113
- package/lib/cli/assistant/command-observer.mjs +1 -1
- package/lib/cli/assistant/context-pack.mjs +31 -11
- package/lib/cli/assistant/state.mjs +2 -0
- package/lib/cli/commands/lint.mjs +37 -0
- package/lib/cli/entrypoint.mjs +1 -0
- package/lib/cli/operations/db/schema/refresh/operation.mjs +4 -2
- package/lib/cli/operations/lint/operation.mjs +12 -0
- package/lib/cli/renderers/db-schema/text.mjs +3 -0
- package/lib/cli/renderers/doctor/text.mjs +5 -0
- package/lib/cli/renderers/lint/text.mjs +20 -0
- package/lib/config/database.mjs +9 -13
- package/lib/config-api/database-steps.mjs +132 -0
- package/lib/config-api/index.d.ts +37 -5
- package/lib/config-api/index.mjs +123 -12
- package/lib/database/fingerprint.mjs +2 -2
- package/lib/database/index.mjs +4 -4
- package/lib/database/schema-source.mjs +107 -14
- package/lib/lint/index.mjs +569 -0
- package/lib/repo/state.mjs +164 -0
- package/lib/runner/metadata.mjs +11 -24
- package/lib/runner/template-steps.mjs +8 -0
- package/lib/runner/template.mjs +0 -3
- package/lib/runtime/index.d.ts +43 -0
- package/lib/runtime/index.mjs +24 -0
- package/lib/runtime-src/k6/http-assertions.js +82 -0
- package/lib/shared/configured-steps.mjs +16 -0
- package/lib/ui/index.d.ts +46 -0
- package/lib/ui/index.mjs +11 -0
- package/lib/ui/sandbox.mjs +115 -0
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -325,13 +325,24 @@ stable build-and-start flows.
|
|
|
325
325
|
|
|
326
326
|
`database.template` is the database-side equivalent for reusable template DB
|
|
327
327
|
state. When `database.sourceSchema` is configured, Testkit treats the configured
|
|
328
|
-
source database as the schema source of truth. A normal `testkit run`
|
|
329
|
-
|
|
330
|
-
applies that cached schema to the local
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
`.testkit/results/schema`.
|
|
328
|
+
source database as the schema source of truth. A normal `testkit run` resolves a
|
|
329
|
+
commit-aware source schema cache under
|
|
330
|
+
`.testkit/db/<service>/source-schemas/`, applies that cached schema to the local
|
|
331
|
+
template DB, runs local template setup, and verifies that the replayed local
|
|
332
|
+
schema still matches the source dump. If local replay differs, Testkit refreshes
|
|
333
|
+
from the source once for the current cache key and retries. If it still differs,
|
|
334
|
+
the run fails with schema diagnostics under `.testkit/results/schema`.
|
|
335
|
+
|
|
336
|
+
Source schema cache keys are derived automatically from repo state:
|
|
337
|
+
|
|
338
|
+
- clean git worktrees use `commits/<sha>`
|
|
339
|
+
- dirty git worktrees use `dirty/<sha>-<fingerprint>`
|
|
340
|
+
- non-git directories use `nogit/<fingerprint>`
|
|
341
|
+
|
|
342
|
+
Branch names and worktree paths are recorded as metadata but do not affect clean
|
|
343
|
+
commit cache keys, so branch renames and clean worktrees at the same commit
|
|
344
|
+
reuse the same source schema. Dirty worktrees are isolated by content
|
|
345
|
+
fingerprint so local experiments cannot overwrite a clean commit baseline.
|
|
335
346
|
|
|
336
347
|
Template setup executes in three explicit phases:
|
|
337
348
|
|
|
@@ -349,11 +360,12 @@ exiting cannot be refreshed at the midpoint.
|
|
|
349
360
|
Source schema refreshes are intentionally single-connection and pooler-safe.
|
|
350
361
|
If a Neon pooled source URL is configured, Testkit rewrites it to the matching
|
|
351
362
|
direct Neon endpoint before running `pg_dump` and records the original/resolved
|
|
352
|
-
host classifications
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
363
|
+
host classifications beside the resolved cache entry. Unknown PgBouncer/pooler
|
|
364
|
+
URLs fail closed; configure a direct source URL for those providers. Concurrent
|
|
365
|
+
refreshes for the same service and cache key are serialized with a cache-local
|
|
366
|
+
lock so multiple Testkit processes do not stampede the source database. Testkit
|
|
367
|
+
also maintains `.testkit/db/<service>/source-schemas/index.json` and prunes old
|
|
368
|
+
inactive cache entries automatically.
|
|
357
369
|
|
|
358
370
|
For most repos, prefer declarative step objects directly inside
|
|
359
371
|
`database.postgres({ template: ... })` and `runtime.prepare.steps`.
|
|
@@ -695,6 +707,8 @@ services that define `database: database.postgres(...)`.
|
|
|
695
707
|
- runtime databases are cloned from templates when binding is `per-runtime`
|
|
696
708
|
- shared databases are reused when binding is `shared`
|
|
697
709
|
- source schema caches are refreshed only from the configured source database
|
|
710
|
+
- clean commits, dirty worktrees, and non-git directories get separate source
|
|
711
|
+
schema cache entries automatically
|
|
698
712
|
- template fingerprints are derived automatically from env files, source schema
|
|
699
713
|
cache, migrate/seed config, and repo contents
|
|
700
714
|
|
|
@@ -711,6 +725,7 @@ npm test
|
|
|
711
725
|
npm run test:unit
|
|
712
726
|
npm run test:integration
|
|
713
727
|
npm run test:system
|
|
728
|
+
npm run test:live:github
|
|
714
729
|
npm run test:live:neon
|
|
715
730
|
npm run test:database-version:compat
|
|
716
731
|
```
|
package/lib/app/doctor.mjs
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
1
|
import path from "path";
|
|
3
|
-
import ts from "typescript";
|
|
4
2
|
import { discoverTests } from "../discovery/index.mjs";
|
|
5
3
|
import { loadConfigContext } from "../config/index.mjs";
|
|
6
4
|
import { runTestkitTypecheck } from "./typecheck.mjs";
|
|
7
|
-
import {
|
|
5
|
+
import { runLint } from "../lint/index.mjs";
|
|
8
6
|
|
|
9
7
|
export async function runDoctor(options = {}) {
|
|
10
8
|
const checks = [];
|
|
11
9
|
const productDir = options.dir ? path.resolve(process.cwd(), options.dir) : process.cwd();
|
|
12
10
|
|
|
13
|
-
await loadConfigContext({ dir: productDir });
|
|
11
|
+
const context = await loadConfigContext({ dir: productDir });
|
|
14
12
|
checks.push({
|
|
15
13
|
code: "config-load",
|
|
16
14
|
level: "pass",
|
|
@@ -29,26 +27,18 @@ export async function runDoctor(options = {}) {
|
|
|
29
27
|
details: discoveryErrors,
|
|
30
28
|
});
|
|
31
29
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
level: playwrightViolations.length === 0 ? "pass" : "fail",
|
|
36
|
-
message:
|
|
37
|
-
playwrightViolations.length === 0
|
|
38
|
-
? "No runtime @playwright/test imports found in testkit UI suites"
|
|
39
|
-
: `Found ${playwrightViolations.length} UI runtime import violation(s); import from @elench/testkit/ui instead`,
|
|
40
|
-
details: playwrightViolations,
|
|
30
|
+
const lint = await runLint({
|
|
31
|
+
dir: productDir,
|
|
32
|
+
...(context.config?.lint || {}),
|
|
41
33
|
});
|
|
42
|
-
|
|
43
|
-
const configImportViolations = findConfigImportViolations(productDir);
|
|
44
34
|
checks.push({
|
|
45
|
-
code: "
|
|
46
|
-
level:
|
|
35
|
+
code: "lint",
|
|
36
|
+
level: lint.ok ? "pass" : "fail",
|
|
47
37
|
message:
|
|
48
|
-
|
|
49
|
-
?
|
|
50
|
-
: `Found ${
|
|
51
|
-
details:
|
|
38
|
+
lint.ok
|
|
39
|
+
? `No Testkit lint violations across ${lint.summary.testkitFiles} suite file(s)`
|
|
40
|
+
: `Found ${lint.summary.violations} Testkit lint violation(s)`,
|
|
41
|
+
details: lint.violations,
|
|
52
42
|
});
|
|
53
43
|
|
|
54
44
|
const hasBrowserOrNextWork = discovery.files.some((entry) => entry.type === "ui");
|
|
@@ -88,95 +78,3 @@ export async function runDoctor(options = {}) {
|
|
|
88
78
|
checks,
|
|
89
79
|
};
|
|
90
80
|
}
|
|
91
|
-
|
|
92
|
-
function findPlaywrightRuntimeImportViolations(productDir) {
|
|
93
|
-
const violations = [];
|
|
94
|
-
for (const absolutePath of collectFiles(productDir)) {
|
|
95
|
-
const sourceText = fs.readFileSync(absolutePath, "utf8");
|
|
96
|
-
const sourceFile = ts.createSourceFile(absolutePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
97
|
-
|
|
98
|
-
for (const statement of sourceFile.statements) {
|
|
99
|
-
if (!ts.isImportDeclaration(statement)) continue;
|
|
100
|
-
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
101
|
-
if (statement.moduleSpecifier.text !== "@playwright/test") continue;
|
|
102
|
-
const clause = statement.importClause;
|
|
103
|
-
if (clause?.isTypeOnly) continue;
|
|
104
|
-
if (!clause) {
|
|
105
|
-
violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (clause.name) {
|
|
109
|
-
violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (!clause.namedBindings) {
|
|
113
|
-
violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
if (ts.isNamespaceImport(clause.namedBindings)) {
|
|
117
|
-
violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (clause.namedBindings.elements.some((entry) => !entry.isTypeOnly)) {
|
|
121
|
-
violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return violations;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function findConfigImportViolations(productDir) {
|
|
129
|
-
const configFile = findConfigFile(productDir);
|
|
130
|
-
if (!configFile || !fs.existsSync(configFile)) return [];
|
|
131
|
-
|
|
132
|
-
const sourceText = fs.readFileSync(configFile, "utf8");
|
|
133
|
-
const sourceFile = ts.createSourceFile(configFile, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
134
|
-
const violations = [];
|
|
135
|
-
|
|
136
|
-
for (const statement of sourceFile.statements) {
|
|
137
|
-
if (!ts.isImportDeclaration(statement)) continue;
|
|
138
|
-
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
139
|
-
const specifier = statement.moduleSpecifier.text;
|
|
140
|
-
if (!isRepoLocalConfigImportViolation(specifier)) continue;
|
|
141
|
-
const position = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile));
|
|
142
|
-
violations.push({
|
|
143
|
-
file: path.relative(productDir, configFile).split(path.sep).join("/"),
|
|
144
|
-
line: position.line + 1,
|
|
145
|
-
specifier,
|
|
146
|
-
snippet: statement.getText(sourceFile),
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return violations;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function collectFiles(rootDir, out = []) {
|
|
154
|
-
if (!fs.existsSync(rootDir)) return out;
|
|
155
|
-
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
|
156
|
-
const absolutePath = path.join(rootDir, entry.name);
|
|
157
|
-
if (entry.isDirectory()) {
|
|
158
|
-
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".testkit") continue;
|
|
159
|
-
collectFiles(absolutePath, out);
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
if (entry.isFile() && (entry.name.endsWith(".ui.testkit.ts") || entry.name.endsWith(".ui.testkit.ts"))) {
|
|
163
|
-
out.push(absolutePath);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return out.sort((left, right) => left.localeCompare(right));
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function isRepoLocalConfigImportViolation(specifier) {
|
|
170
|
-
if (typeof specifier !== "string") return false;
|
|
171
|
-
if (!specifier.startsWith(".") && !specifier.startsWith("/")) return false;
|
|
172
|
-
return specifier.includes("__testkit__");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function relativeViolation(productDir, absolutePath, sourceFile, statement) {
|
|
176
|
-
const position = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile));
|
|
177
|
-
return {
|
|
178
|
-
file: path.relative(productDir, absolutePath).split(path.sep).join("/"),
|
|
179
|
-
line: position.line + 1,
|
|
180
|
-
snippet: statement.getText(sourceFile),
|
|
181
|
-
};
|
|
182
|
-
}
|
|
@@ -3,7 +3,7 @@ import path from "path";
|
|
|
3
3
|
import { isAssistantRunCommand } from "./command-classifier.mjs";
|
|
4
4
|
|
|
5
5
|
const POLL_INTERVAL_MS = 150;
|
|
6
|
-
const OBSERVED_KINDS = new Set(["run", "discover", "status", "doctor", "typecheck"]);
|
|
6
|
+
const OBSERVED_KINDS = new Set(["run", "discover", "status", "doctor", "lint", "typecheck"]);
|
|
7
7
|
|
|
8
8
|
export function createAssistantCommandObserver({
|
|
9
9
|
productDir,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { fileURLToPath
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
4
|
import { readContextContent, buildContextSelection } from "../../results/context.mjs";
|
|
5
5
|
import { assistantSessionPaths, createAssistantSessionId } from "./session-paths.mjs";
|
|
6
6
|
import {
|
|
@@ -88,7 +88,6 @@ export function prepareAssistantContextPack({
|
|
|
88
88
|
);
|
|
89
89
|
if (!fs.existsSync(wrapperPath)) fs.writeFileSync(wrapperPath, buildWrapperScript({
|
|
90
90
|
cliPath: resolveCliPath(),
|
|
91
|
-
classifierUrl: resolveClassifierUrl(),
|
|
92
91
|
sessionId,
|
|
93
92
|
resultDir,
|
|
94
93
|
commandLogPath,
|
|
@@ -165,16 +164,11 @@ function resolveCliPath() {
|
|
|
165
164
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "bin", "testkit.mjs");
|
|
166
165
|
}
|
|
167
166
|
|
|
168
|
-
function
|
|
169
|
-
return pathToFileURL(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "command-classifier.mjs")).href;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function buildWrapperScript({ cliPath, classifierUrl, sessionId, resultDir, commandLogPath } = {}) {
|
|
167
|
+
function buildWrapperScript({ cliPath, sessionId, resultDir, commandLogPath } = {}) {
|
|
173
168
|
return `#!/usr/bin/env node
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
import { classifyAssistantCommandKind } from ${JSON.stringify(classifierUrl)};
|
|
169
|
+
const { spawnSync } = require("child_process");
|
|
170
|
+
const fs = require("fs");
|
|
171
|
+
const path = require("path");
|
|
178
172
|
|
|
179
173
|
const commandId = process.env.${ASSISTANT_COMMAND_ID_ENV} || \`cmd-\${Date.now()}-\${Math.random().toString(36).slice(2, 10)}\`;
|
|
180
174
|
const commandLogPath = process.env.${ASSISTANT_COMMAND_LOG_ENV} || ${JSON.stringify(commandLogPath)};
|
|
@@ -231,6 +225,32 @@ function appendCommandLog(event) {
|
|
|
231
225
|
// Command observation must not affect command execution.
|
|
232
226
|
}
|
|
233
227
|
}
|
|
228
|
+
|
|
229
|
+
function classifyAssistantCommandKind(argv = []) {
|
|
230
|
+
const runShortcuts = new Set(["ui", "e2e", "scenario", "int", "dal", "load", "all"]);
|
|
231
|
+
const valueFlags = new Set([
|
|
232
|
+
"--dir",
|
|
233
|
+
"--service",
|
|
234
|
+
"--type",
|
|
235
|
+
"--suite",
|
|
236
|
+
"--file",
|
|
237
|
+
"--workers",
|
|
238
|
+
"--file-timeout-seconds",
|
|
239
|
+
"--seed",
|
|
240
|
+
"--output-mode",
|
|
241
|
+
]);
|
|
242
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
243
|
+
const value = String(argv[index] || "");
|
|
244
|
+
if (valueFlags.has(value)) {
|
|
245
|
+
index += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!value.startsWith("-")) {
|
|
249
|
+
return runShortcuts.has(value) ? "run" : value;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return "run";
|
|
253
|
+
}
|
|
234
254
|
`;
|
|
235
255
|
}
|
|
236
256
|
|
|
@@ -10,6 +10,7 @@ import { createRunState } from "../state/run/state.mjs";
|
|
|
10
10
|
import { buildContextSelection } from "../../results/context.mjs";
|
|
11
11
|
import { renderDiscoverResult } from "../renderers/discover/text.mjs";
|
|
12
12
|
import { renderDoctorResult } from "../renderers/doctor/text.mjs";
|
|
13
|
+
import { renderLintResult } from "../renderers/lint/text.mjs";
|
|
13
14
|
import { renderStatusResult } from "../renderers/status/text.mjs";
|
|
14
15
|
import { renderTypecheckResult } from "../renderers/typecheck/text.mjs";
|
|
15
16
|
import { isProviderInstalled } from "./providers/index.mjs";
|
|
@@ -1092,6 +1093,7 @@ function renderObservedCommandResult(command) {
|
|
|
1092
1093
|
return normalizeRenderedLines((result.results || []).flatMap((entry) => renderStatusResult(entry)));
|
|
1093
1094
|
}
|
|
1094
1095
|
if (command.kind === "doctor") return normalizeRenderedLines(renderDoctorResult(result));
|
|
1096
|
+
if (command.kind === "lint") return normalizeRenderedLines(renderLintResult(result));
|
|
1095
1097
|
if (command.kind === "typecheck") return normalizeRenderedLines(renderTypecheckResult(result));
|
|
1096
1098
|
return [];
|
|
1097
1099
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Command, Flags } from "@oclif/core";
|
|
2
|
+
import { executeLintOperation } from "../operations/lint/operation.mjs";
|
|
3
|
+
import { renderLintResult } from "../renderers/lint/text.mjs";
|
|
4
|
+
import { withAssistantCommandResult } from "../assistant/command-results.mjs";
|
|
5
|
+
|
|
6
|
+
export default class LintCommand extends Command {
|
|
7
|
+
static summary = "Run built-in Testkit repository hygiene checks";
|
|
8
|
+
|
|
9
|
+
static enableJsonFlag = true;
|
|
10
|
+
|
|
11
|
+
static flags = {
|
|
12
|
+
dir: Flags.string({
|
|
13
|
+
description: "Product directory",
|
|
14
|
+
}),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async run() {
|
|
18
|
+
return withAssistantCommandResult("lint", async () => {
|
|
19
|
+
const { flags } = await this.parse(LintCommand);
|
|
20
|
+
const result = await executeLintOperation(flags);
|
|
21
|
+
|
|
22
|
+
if (!this.jsonEnabled()) {
|
|
23
|
+
for (const line of renderLintResult(result)) {
|
|
24
|
+
this.log(line);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!result.ok) {
|
|
29
|
+
const error = new Error("testkit lint failed");
|
|
30
|
+
error.result = result;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return result;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
package/lib/cli/entrypoint.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { loadManagedConfigs, resolveTargetConfig, collectRequiredConfigs, topologicallySortConfigs } from "../../../../../app/configs.mjs";
|
|
5
5
|
import { resolveProductDir } from "../../../../../config/index.mjs";
|
|
6
6
|
import { prepareDatabaseRuntime } from "../../../../../database/index.mjs";
|
|
7
|
-
import { forceRefreshSourceSchemaCache
|
|
7
|
+
import { forceRefreshSourceSchemaCache } from "../../../../../database/schema-source.mjs";
|
|
8
8
|
import { createRunReporter } from "../../../../renderers/run/text-reporter.mjs";
|
|
9
9
|
import { createRunLogRegistry } from "../../../../../runner/logs.mjs";
|
|
10
10
|
import { createSetupOperationRegistry } from "../../../../../runner/setup-operations.mjs";
|
|
@@ -40,13 +40,15 @@ export async function executeDatabaseSchemaRefreshOperation(options = {}) {
|
|
|
40
40
|
logRegistry,
|
|
41
41
|
setupRegistry,
|
|
42
42
|
});
|
|
43
|
-
const outputPath =
|
|
43
|
+
const outputPath = state.cachePath;
|
|
44
44
|
return {
|
|
45
45
|
ok: true,
|
|
46
46
|
productDir,
|
|
47
47
|
service: target.name,
|
|
48
48
|
outputPath,
|
|
49
49
|
outputLabel: path.relative(productDir, outputPath) || path.basename(outputPath),
|
|
50
|
+
cacheKey: state.cacheKey,
|
|
51
|
+
cacheKind: state.cacheKind,
|
|
50
52
|
envName: state.envName || null,
|
|
51
53
|
sourceUrl: state.refreshInfo?.metadata?.sourceUrl || null,
|
|
52
54
|
reusedExistingRefresh: Boolean(state.refreshInfo?.reusedExistingRefresh),
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { runLint } from "../../../lint/index.mjs";
|
|
2
|
+
import { loadTestkitConfig } from "../../../config/config-loader.mjs";
|
|
3
|
+
import { resolveProductDir } from "../../../config/paths.mjs";
|
|
4
|
+
|
|
5
|
+
export async function executeLintOperation(flags = {}) {
|
|
6
|
+
const productDir = resolveProductDir(process.cwd(), flags.dir);
|
|
7
|
+
const { config } = await loadTestkitConfig(productDir);
|
|
8
|
+
return runLint({
|
|
9
|
+
dir: productDir,
|
|
10
|
+
...(config.lint || {}),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export function renderDatabaseSchemaRefreshResult(result) {
|
|
2
2
|
const lines = [`Refreshed ${result.outputLabel}`];
|
|
3
|
+
if (result.cacheKey) {
|
|
4
|
+
lines.push(`Cache key ${result.cacheKey}`);
|
|
5
|
+
}
|
|
3
6
|
if (result.sourceUrl?.rewritten && result.sourceUrl.originalClassification === "neon-pooler") {
|
|
4
7
|
lines.push("Source schema URL uses Neon pooler; Testkit used the direct endpoint for pg_dump.");
|
|
5
8
|
}
|
|
@@ -2,6 +2,11 @@ export function renderDoctorResult(result) {
|
|
|
2
2
|
const lines = [`testkit doctor ${result.ok ? "passed" : "failed"} for ${result.productDir}`];
|
|
3
3
|
for (const check of result.checks || []) {
|
|
4
4
|
lines.push(`${check.level.toUpperCase()} ${check.code} ${check.message}`);
|
|
5
|
+
for (const detail of (check.details || []).slice(0, 5)) {
|
|
6
|
+
if (detail?.ruleId) {
|
|
7
|
+
lines.push(` ${detail.ruleId} ${detail.file}:${detail.line} ${detail.message}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
5
10
|
}
|
|
6
11
|
return lines;
|
|
7
12
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function renderLintResult(result) {
|
|
2
|
+
const lines = [
|
|
3
|
+
`testkit lint ${result.ok ? "passed" : "failed"} for ${result.productDir}`,
|
|
4
|
+
`Checked ${result.summary.files} source file(s), ${result.summary.testkitFiles} testkit suite file(s).`,
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
if (result.violations.length === 0) {
|
|
8
|
+
return lines;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
lines.push("");
|
|
12
|
+
for (const violation of result.violations) {
|
|
13
|
+
const location = violation.line ? `${violation.file}:${violation.line}` : violation.file;
|
|
14
|
+
lines.push(`${location} ${violation.ruleId}: ${violation.message}`);
|
|
15
|
+
if (violation.snippet) {
|
|
16
|
+
lines.push(` ${violation.snippet}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return lines;
|
|
20
|
+
}
|
package/lib/config/database.mjs
CHANGED
|
@@ -66,8 +66,7 @@ function normalizeSourceSchemaConfig(value, serviceName) {
|
|
|
66
66
|
if (value === undefined) {
|
|
67
67
|
return {
|
|
68
68
|
kind: "auto",
|
|
69
|
-
|
|
70
|
-
refresh: { mode: "always" },
|
|
69
|
+
refresh: { mode: "auto" },
|
|
71
70
|
unavailable: "auto",
|
|
72
71
|
verify: true,
|
|
73
72
|
};
|
|
@@ -79,13 +78,17 @@ function normalizeSourceSchemaConfig(value, serviceName) {
|
|
|
79
78
|
if (kind !== "env") {
|
|
80
79
|
throw new Error(`Service "${serviceName}" database.sourceSchema.kind must be "env"`);
|
|
81
80
|
}
|
|
81
|
+
if (Object.prototype.hasOwnProperty.call(value, "cachePath")) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Service "${serviceName}" database.sourceSchema.cachePath has been removed. Testkit now manages commit-scoped source schema caches automatically.`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
82
86
|
if (typeof value.env !== "string" || value.env.trim().length === 0) {
|
|
83
87
|
throw new Error(`Service "${serviceName}" database.sourceSchema.env must be a non-empty string`);
|
|
84
88
|
}
|
|
85
89
|
return {
|
|
86
90
|
kind,
|
|
87
91
|
env: value.env.trim(),
|
|
88
|
-
cachePath: normalizeOptionalString(value.cachePath, `Service "${serviceName}" database.sourceSchema.cachePath`),
|
|
89
92
|
refresh: normalizeSourceSchemaRefresh(value.refresh, serviceName),
|
|
90
93
|
unavailable: normalizeSourceSchemaUnavailable(value.unavailable, serviceName),
|
|
91
94
|
verify: value.verify !== false,
|
|
@@ -93,7 +96,8 @@ function normalizeSourceSchemaConfig(value, serviceName) {
|
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
function normalizeSourceSchemaRefresh(value, serviceName) {
|
|
96
|
-
if (value == null || value === "
|
|
99
|
+
if (value == null || value === "auto") return { mode: "auto" };
|
|
100
|
+
if (value === "always") return { mode: "always" };
|
|
97
101
|
if (typeof value === "object" && !Array.isArray(value)) {
|
|
98
102
|
const ttlSeconds = value.ttlSeconds;
|
|
99
103
|
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 0) {
|
|
@@ -101,7 +105,7 @@ function normalizeSourceSchemaRefresh(value, serviceName) {
|
|
|
101
105
|
}
|
|
102
106
|
return { mode: "ttl", ttlSeconds };
|
|
103
107
|
}
|
|
104
|
-
throw new Error(`Service "${serviceName}" database.sourceSchema.refresh must be "always" or { ttlSeconds }`);
|
|
108
|
+
throw new Error(`Service "${serviceName}" database.sourceSchema.refresh must be "auto", "always", or { ttlSeconds }`);
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
function normalizeSourceSchemaUnavailable(value, serviceName) {
|
|
@@ -110,11 +114,3 @@ function normalizeSourceSchemaUnavailable(value, serviceName) {
|
|
|
110
114
|
}
|
|
111
115
|
throw new Error(`Service "${serviceName}" database.sourceSchema.unavailable must be "auto", "fail", or "warn-cache"`);
|
|
112
116
|
}
|
|
113
|
-
|
|
114
|
-
function normalizeOptionalString(value, label) {
|
|
115
|
-
if (value == null) return null;
|
|
116
|
-
if (typeof value !== "string" || value.trim().length === 0) {
|
|
117
|
-
throw new Error(`${label} must be a non-empty string`);
|
|
118
|
-
}
|
|
119
|
-
return value.trim();
|
|
120
|
-
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
|
|
3
|
+
export async function verifySeed(context = {}) {
|
|
4
|
+
const args = context.args || {};
|
|
5
|
+
const table = requireIdentifier(args.table, "verifySeed.args.table");
|
|
6
|
+
const where = normalizeOptionalSql(args.where);
|
|
7
|
+
const minRows = normalizeNonNegativeInteger(args.minRows ?? 1, "verifySeed.args.minRows");
|
|
8
|
+
const databaseUrl = requireDatabaseUrl(context);
|
|
9
|
+
const query = `select count(*)::int from ${table}${where ? ` where ${where}` : ""}`;
|
|
10
|
+
const count = Number(await runScalar(databaseUrl, query, context));
|
|
11
|
+
|
|
12
|
+
if (!Number.isInteger(count) || count < minRows) {
|
|
13
|
+
throw new Error(`Expected at least ${minRows} row(s) in ${table}, found ${count || 0}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function materializePostgresBinding(context = {}) {
|
|
18
|
+
const args = context.args || {};
|
|
19
|
+
const table = requireIdentifier(args.table, "materializePostgresBinding.args.table");
|
|
20
|
+
const keyColumn = requireIdentifier(
|
|
21
|
+
args.keyColumn || "slug",
|
|
22
|
+
"materializePostgresBinding.args.keyColumn"
|
|
23
|
+
);
|
|
24
|
+
const key = normalizeRequiredValue(args.key, "materializePostgresBinding.args.key");
|
|
25
|
+
const values = normalizeObject(args.values, "materializePostgresBinding.args.values");
|
|
26
|
+
const databaseUrl = requireDatabaseUrl(context);
|
|
27
|
+
|
|
28
|
+
const columns = Object.keys(values);
|
|
29
|
+
if (columns.length === 0) {
|
|
30
|
+
throw new Error("materializePostgresBinding.args.values must contain at least one column");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const assignments = columns.map((column) => {
|
|
34
|
+
const identifier = requireIdentifier(column, `materializePostgresBinding.args.values.${column}`);
|
|
35
|
+
return `${identifier} = ${toSqlLiteral(values[column])}`;
|
|
36
|
+
});
|
|
37
|
+
const query =
|
|
38
|
+
`with updated as (update ${table} set ${assignments.join(", ")} ` +
|
|
39
|
+
`where ${keyColumn} = ${toSqlLiteral(key)} returning 1) select count(*)::int from updated`;
|
|
40
|
+
const result = await runPsql(databaseUrl, query, context);
|
|
41
|
+
const updated = Number(String(result.stdout || "").trim() || "0");
|
|
42
|
+
if (updated !== 1) {
|
|
43
|
+
throw new Error(`Expected to materialize 1 ${table} row for ${keyColumn}=${key}, updated ${updated || 0}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requireDatabaseUrl(context) {
|
|
48
|
+
const databaseUrl = String(context.databaseUrl || context.env?.DATABASE_URL || "").trim();
|
|
49
|
+
if (!databaseUrl) {
|
|
50
|
+
throw new Error("Database template step requires DATABASE_URL");
|
|
51
|
+
}
|
|
52
|
+
return databaseUrl;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function runScalar(databaseUrl, query, context) {
|
|
56
|
+
const result = await runPsql(databaseUrl, query, context);
|
|
57
|
+
return String(result.stdout || "").trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runPsql(databaseUrl, query, context) {
|
|
61
|
+
const result = await execa(
|
|
62
|
+
"psql",
|
|
63
|
+
[
|
|
64
|
+
databaseUrl,
|
|
65
|
+
"-v",
|
|
66
|
+
"ON_ERROR_STOP=1",
|
|
67
|
+
"-X",
|
|
68
|
+
"-q",
|
|
69
|
+
"-t",
|
|
70
|
+
"-A",
|
|
71
|
+
"-c",
|
|
72
|
+
query,
|
|
73
|
+
],
|
|
74
|
+
{
|
|
75
|
+
cwd: context.cwd || context.productDir || process.cwd(),
|
|
76
|
+
env: context.env || process.env,
|
|
77
|
+
stdout: "pipe",
|
|
78
|
+
stderr: "pipe",
|
|
79
|
+
reject: false,
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
if (result.exitCode !== 0) {
|
|
83
|
+
throw new Error(result.stderr || result.shortMessage || `psql failed with exit code ${result.exitCode}`);
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requireIdentifier(value, label) {
|
|
89
|
+
const normalized = String(value || "").trim();
|
|
90
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(normalized)) {
|
|
91
|
+
throw new Error(`${label} must be a SQL identifier or schema-qualified identifier`);
|
|
92
|
+
}
|
|
93
|
+
return normalized;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeOptionalSql(value) {
|
|
97
|
+
if (value == null || value === "") return "";
|
|
98
|
+
const normalized = String(value).trim();
|
|
99
|
+
if (!normalized || /;|--|\/\*/.test(normalized)) {
|
|
100
|
+
throw new Error("verifySeed.args.where must be a single SQL predicate without comments or semicolons");
|
|
101
|
+
}
|
|
102
|
+
return normalized;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeNonNegativeInteger(value, label) {
|
|
106
|
+
const normalized = Number(value);
|
|
107
|
+
if (!Number.isInteger(normalized) || normalized < 0) {
|
|
108
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
109
|
+
}
|
|
110
|
+
return normalized;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeObject(value, label) {
|
|
114
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
115
|
+
throw new Error(`${label} must be an object`);
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeRequiredValue(value, label) {
|
|
121
|
+
if (value == null || String(value).length === 0) {
|
|
122
|
+
throw new Error(`${label} is required`);
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toSqlLiteral(value) {
|
|
128
|
+
if (value === null) return "null";
|
|
129
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
130
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
131
|
+
return `'${String(value).replaceAll("'", "''")}'`;
|
|
132
|
+
}
|