@elench/testkit 0.1.118 → 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/lib/app/doctor.mjs +11 -113
- package/lib/cli/assistant/command-observer.mjs +1 -1
- 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/lint/operation.mjs +12 -0
- package/lib/cli/renderers/doctor/text.mjs +5 -0
- package/lib/cli/renderers/lint/text.mjs +20 -0
- package/lib/config-api/database-steps.mjs +132 -0
- package/lib/config-api/index.d.ts +36 -3
- package/lib/config-api/index.mjs +118 -12
- package/lib/lint/index.mjs +569 -0
- package/lib/runner/template-steps.mjs +8 -0
- 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 +5 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +188 -0
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +1 -0
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +293 -0
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +1 -0
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +25 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { findConfigFile } from "../config/config-loader.mjs";
|
|
5
|
+
import { normalizePath } from "../config/paths.mjs";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_EXCLUDED_DIRS = new Set([
|
|
8
|
+
".git",
|
|
9
|
+
".next",
|
|
10
|
+
".next-testkit",
|
|
11
|
+
".testkit",
|
|
12
|
+
"coverage",
|
|
13
|
+
"dist",
|
|
14
|
+
"node_modules",
|
|
15
|
+
"playwright-report",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const DEFAULT_UI_MAX_LINES = 220;
|
|
19
|
+
const DEFAULT_UI_MAX_TESTS = 8;
|
|
20
|
+
|
|
21
|
+
export async function runLint(options = {}) {
|
|
22
|
+
const productDir = path.resolve(process.cwd(), options.dir || ".");
|
|
23
|
+
const lintOptions = normalizeLintOptions(options);
|
|
24
|
+
const files = collectSourceFiles(productDir);
|
|
25
|
+
const testkitFiles = files.filter((filePath) => isTestkitSuiteFile(filePath));
|
|
26
|
+
const aliasResolver = createAliasResolver(productDir, files);
|
|
27
|
+
const violations = [];
|
|
28
|
+
|
|
29
|
+
if (lintOptions.rules.missingImports) {
|
|
30
|
+
violations.push(...findMissingImports(productDir, files, aliasResolver));
|
|
31
|
+
}
|
|
32
|
+
if (lintOptions.rules.uiRuntimeImports) {
|
|
33
|
+
violations.push(...findPlaywrightRuntimeImportViolations(productDir, testkitFiles));
|
|
34
|
+
}
|
|
35
|
+
if (lintOptions.rules.uiSpecShape) {
|
|
36
|
+
violations.push(...findUiSpecShapeViolations(productDir, testkitFiles, lintOptions.ui));
|
|
37
|
+
}
|
|
38
|
+
if (lintOptions.rules.dalParallelSafety) {
|
|
39
|
+
violations.push(...findDalParallelSafetyViolations(productDir, files, testkitFiles));
|
|
40
|
+
}
|
|
41
|
+
if (lintOptions.rules.legacyHttpAssertions) {
|
|
42
|
+
violations.push(...findLegacyHttpAssertionViolations(productDir, testkitFiles));
|
|
43
|
+
}
|
|
44
|
+
if (lintOptions.rules.legacyDalAssertions) {
|
|
45
|
+
violations.push(...findLegacyDalAssertionViolations(productDir, testkitFiles));
|
|
46
|
+
}
|
|
47
|
+
if (lintOptions.rules.configImports) {
|
|
48
|
+
violations.push(...findConfigImportViolations(productDir));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const sortedViolations = violations.sort(compareViolations);
|
|
52
|
+
return {
|
|
53
|
+
ok: sortedViolations.length === 0,
|
|
54
|
+
productDir,
|
|
55
|
+
summary: {
|
|
56
|
+
files: files.length,
|
|
57
|
+
testkitFiles: testkitFiles.length,
|
|
58
|
+
violations: sortedViolations.length,
|
|
59
|
+
},
|
|
60
|
+
violations: sortedViolations,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeLintConfig(config = {}) {
|
|
65
|
+
const lint = config?.lint || {};
|
|
66
|
+
return normalizeLintOptions(lint);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeLintOptions(options = {}) {
|
|
70
|
+
const rules = options.rules || {};
|
|
71
|
+
const disabled = new Set(Array.isArray(options.disable) ? options.disable : []);
|
|
72
|
+
const enabled = {
|
|
73
|
+
missingImports: true,
|
|
74
|
+
uiRuntimeImports: true,
|
|
75
|
+
uiSpecShape: true,
|
|
76
|
+
dalParallelSafety: true,
|
|
77
|
+
legacyHttpAssertions: true,
|
|
78
|
+
legacyDalAssertions: true,
|
|
79
|
+
configImports: true,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (const [ruleName, value] of Object.entries(rules)) {
|
|
83
|
+
if (Object.prototype.hasOwnProperty.call(enabled, ruleName)) {
|
|
84
|
+
enabled[ruleName] = value !== false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const ruleName of disabled) {
|
|
88
|
+
if (Object.prototype.hasOwnProperty.call(enabled, ruleName)) {
|
|
89
|
+
enabled[ruleName] = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
rules: enabled,
|
|
95
|
+
ui: {
|
|
96
|
+
maxLines: normalizePositiveInteger(options.ui?.maxLines, DEFAULT_UI_MAX_LINES),
|
|
97
|
+
maxTests: normalizePositiveInteger(options.ui?.maxTests, DEFAULT_UI_MAX_TESTS),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizePositiveInteger(value, fallback) {
|
|
103
|
+
if (value == null) return fallback;
|
|
104
|
+
const normalized = Number(value);
|
|
105
|
+
if (!Number.isInteger(normalized) || normalized <= 0) {
|
|
106
|
+
throw new Error("lint numeric limits must be positive integers");
|
|
107
|
+
}
|
|
108
|
+
return normalized;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function collectSourceFiles(rootDir) {
|
|
112
|
+
const out = [];
|
|
113
|
+
walk(rootDir, out);
|
|
114
|
+
return out.sort((left, right) => left.localeCompare(right));
|
|
115
|
+
|
|
116
|
+
function walk(current, entries) {
|
|
117
|
+
if (!fs.existsSync(current)) return;
|
|
118
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
119
|
+
const absolutePath = path.join(current, entry.name);
|
|
120
|
+
if (entry.isSymbolicLink()) continue;
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
if (DEFAULT_EXCLUDED_DIRS.has(entry.name)) continue;
|
|
123
|
+
walk(absolutePath, entries);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (entry.isFile() && isSourceLikeFile(entry.name)) {
|
|
127
|
+
entries.push(absolutePath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isSourceLikeFile(fileName) {
|
|
134
|
+
return /\.(?:[cm]?[jt]sx?|json)$/.test(fileName);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isTypeScriptLikeFile(filePath) {
|
|
138
|
+
return /\.(?:[cm]?tsx?|jsx?)$/.test(filePath);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isTestkitSuiteFile(filePath) {
|
|
142
|
+
return /\.(?:int|e2e|scenario|dal|load|ui)\.testkit\.ts$/.test(filePath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function relative(productDir, absolutePath) {
|
|
146
|
+
return normalizePath(path.relative(productDir, absolutePath));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readSourceFile(filePath) {
|
|
150
|
+
return ts.createSourceFile(
|
|
151
|
+
filePath,
|
|
152
|
+
fs.readFileSync(filePath, "utf8"),
|
|
153
|
+
ts.ScriptTarget.Latest,
|
|
154
|
+
true,
|
|
155
|
+
filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function violationFromSource(ruleId, productDir, filePath, sourceFile, node, message, extra = {}) {
|
|
160
|
+
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
161
|
+
const snippet = node.getText(sourceFile).split(/\r?\n/)[0] || null;
|
|
162
|
+
return {
|
|
163
|
+
ruleId,
|
|
164
|
+
severity: "error",
|
|
165
|
+
file: relative(productDir, filePath),
|
|
166
|
+
line: position.line + 1,
|
|
167
|
+
message,
|
|
168
|
+
...(snippet ? { snippet } : {}),
|
|
169
|
+
...extra,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findMissingImports(productDir, files, aliasResolver) {
|
|
174
|
+
const violations = [];
|
|
175
|
+
for (const filePath of files.filter(isTypeScriptLikeFile)) {
|
|
176
|
+
const sourceFile = readSourceFile(filePath);
|
|
177
|
+
for (const statement of sourceFile.statements) {
|
|
178
|
+
if (!isImportLike(statement)) continue;
|
|
179
|
+
if (!statement.moduleSpecifier) continue;
|
|
180
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
181
|
+
const specifier = statement.moduleSpecifier.text;
|
|
182
|
+
if (!shouldResolveImport(specifier, aliasResolver)) continue;
|
|
183
|
+
if (resolveImport(filePath, specifier, aliasResolver)) continue;
|
|
184
|
+
violations.push(
|
|
185
|
+
violationFromSource(
|
|
186
|
+
"missing-import",
|
|
187
|
+
productDir,
|
|
188
|
+
filePath,
|
|
189
|
+
sourceFile,
|
|
190
|
+
statement,
|
|
191
|
+
`Import "${specifier}" could not be resolved`
|
|
192
|
+
)
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return violations;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isImportLike(statement) {
|
|
200
|
+
return ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function shouldResolveImport(specifier, aliasResolver) {
|
|
204
|
+
if (specifier.startsWith(".")) return true;
|
|
205
|
+
return aliasResolver.mappings.some((mapping) => specifier.startsWith(mapping.prefix));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function createAliasResolver(productDir, files) {
|
|
209
|
+
const mappings = [];
|
|
210
|
+
for (const configPath of files.filter((filePath) => path.basename(filePath) === "tsconfig.json")) {
|
|
211
|
+
const parsed = readJson(configPath);
|
|
212
|
+
const paths = parsed?.compilerOptions?.paths;
|
|
213
|
+
if (!paths || typeof paths !== "object") continue;
|
|
214
|
+
const baseUrl = path.resolve(path.dirname(configPath), parsed.compilerOptions?.baseUrl || ".");
|
|
215
|
+
for (const [alias, targets] of Object.entries(paths)) {
|
|
216
|
+
if (!Array.isArray(targets)) continue;
|
|
217
|
+
const starIndex = alias.indexOf("*");
|
|
218
|
+
const prefix = starIndex >= 0 ? alias.slice(0, starIndex) : alias;
|
|
219
|
+
const suffix = starIndex >= 0 ? alias.slice(starIndex + 1) : "";
|
|
220
|
+
mappings.push({
|
|
221
|
+
prefix,
|
|
222
|
+
suffix,
|
|
223
|
+
hasStar: starIndex >= 0,
|
|
224
|
+
targets: targets.map((target) => ({
|
|
225
|
+
raw: target,
|
|
226
|
+
base: baseUrl,
|
|
227
|
+
})),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { productDir, mappings };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function readJson(filePath) {
|
|
235
|
+
try {
|
|
236
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function resolveImport(importerPath, specifier, aliasResolver) {
|
|
243
|
+
if (specifier.startsWith(".")) {
|
|
244
|
+
return resolveCandidate(path.resolve(path.dirname(importerPath), specifier));
|
|
245
|
+
}
|
|
246
|
+
for (const mapping of aliasResolver.mappings) {
|
|
247
|
+
if (!specifier.startsWith(mapping.prefix) || !specifier.endsWith(mapping.suffix)) continue;
|
|
248
|
+
const matched = mapping.hasStar
|
|
249
|
+
? specifier.slice(mapping.prefix.length, specifier.length - mapping.suffix.length)
|
|
250
|
+
: "";
|
|
251
|
+
for (const target of mapping.targets) {
|
|
252
|
+
const rawTarget = mapping.hasStar ? target.raw.replace("*", matched) : target.raw;
|
|
253
|
+
const resolved = resolveCandidate(path.resolve(target.base, rawTarget));
|
|
254
|
+
if (resolved) return resolved;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function resolveCandidate(candidate) {
|
|
261
|
+
for (const filePath of expandImportCandidates(candidate)) {
|
|
262
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) return filePath;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function expandImportCandidates(candidate) {
|
|
268
|
+
const parsed = path.parse(candidate);
|
|
269
|
+
const candidates = [candidate];
|
|
270
|
+
const extensionAlternates = {
|
|
271
|
+
".js": [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
|
|
272
|
+
".mjs": [".mts", ".mjs"],
|
|
273
|
+
".cjs": [".cts", ".cjs"],
|
|
274
|
+
".jsx": [".tsx", ".jsx"],
|
|
275
|
+
};
|
|
276
|
+
if (extensionAlternates[parsed.ext]) {
|
|
277
|
+
candidates.push(...extensionAlternates[parsed.ext].map((ext) => path.join(parsed.dir, `${parsed.name}${ext}`)));
|
|
278
|
+
} else if (!parsed.ext) {
|
|
279
|
+
candidates.push(
|
|
280
|
+
`${candidate}.ts`,
|
|
281
|
+
`${candidate}.tsx`,
|
|
282
|
+
`${candidate}.mts`,
|
|
283
|
+
`${candidate}.cts`,
|
|
284
|
+
`${candidate}.js`,
|
|
285
|
+
`${candidate}.jsx`,
|
|
286
|
+
`${candidate}.mjs`,
|
|
287
|
+
`${candidate}.cjs`,
|
|
288
|
+
path.join(candidate, "index.ts"),
|
|
289
|
+
path.join(candidate, "index.tsx"),
|
|
290
|
+
path.join(candidate, "index.mts"),
|
|
291
|
+
path.join(candidate, "index.js"),
|
|
292
|
+
path.join(candidate, "index.mjs")
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return [...new Set(candidates)];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function findPlaywrightRuntimeImportViolations(productDir, testkitFiles) {
|
|
299
|
+
const violations = [];
|
|
300
|
+
for (const filePath of testkitFiles.filter((entry) => entry.endsWith(".ui.testkit.ts"))) {
|
|
301
|
+
const sourceFile = readSourceFile(filePath);
|
|
302
|
+
for (const statement of sourceFile.statements) {
|
|
303
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
304
|
+
if (!hasRuntimePlaywrightImport(statement)) continue;
|
|
305
|
+
violations.push(
|
|
306
|
+
violationFromSource(
|
|
307
|
+
"ui-runtime-imports",
|
|
308
|
+
productDir,
|
|
309
|
+
filePath,
|
|
310
|
+
sourceFile,
|
|
311
|
+
statement,
|
|
312
|
+
"UI suites must import runtime helpers from @elench/testkit/ui, not @playwright/test"
|
|
313
|
+
)
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return violations;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function hasRuntimePlaywrightImport(statement) {
|
|
321
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) return false;
|
|
322
|
+
if (statement.moduleSpecifier.text !== "@playwright/test") return false;
|
|
323
|
+
const clause = statement.importClause;
|
|
324
|
+
if (!clause) return true;
|
|
325
|
+
if (clause.isTypeOnly) return false;
|
|
326
|
+
if (clause.name) return true;
|
|
327
|
+
if (!clause.namedBindings) return true;
|
|
328
|
+
if (ts.isNamespaceImport(clause.namedBindings)) return true;
|
|
329
|
+
return clause.namedBindings.elements.some((element) => !element.isTypeOnly);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function findUiSpecShapeViolations(productDir, testkitFiles, options) {
|
|
333
|
+
const violations = [];
|
|
334
|
+
for (const filePath of testkitFiles.filter((entry) => entry.endsWith(".ui.testkit.ts"))) {
|
|
335
|
+
const source = fs.readFileSync(filePath, "utf8");
|
|
336
|
+
const lines = source.split(/\r?\n/).length;
|
|
337
|
+
const tests = countPlaywrightTests(source);
|
|
338
|
+
if (lines > options.maxLines || tests > options.maxTests) {
|
|
339
|
+
violations.push({
|
|
340
|
+
ruleId: "ui-spec-shape",
|
|
341
|
+
severity: "error",
|
|
342
|
+
file: relative(productDir, filePath),
|
|
343
|
+
line: 1,
|
|
344
|
+
message: `UI spec exceeds shape limit (${lines}/${options.maxLines} lines, ${tests}/${options.maxTests} tests)`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return violations;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function countPlaywrightTests(source) {
|
|
352
|
+
return [...source.matchAll(/\btest\s*\(\s*['"`]/g)].length;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function findDalParallelSafetyViolations(productDir, files, testkitFiles) {
|
|
356
|
+
const violations = [];
|
|
357
|
+
const dalFiles = testkitFiles.filter((entry) => entry.endsWith(".dal.testkit.ts"));
|
|
358
|
+
const duplicateFields = new Map(["slug", "email", "authProviderId"].map((field) => [field, new Map()]));
|
|
359
|
+
|
|
360
|
+
for (const filePath of dalFiles) {
|
|
361
|
+
const source = fs.readFileSync(filePath, "utf8");
|
|
362
|
+
if (/\btruncate\s*\(/.test(source) || /\.\s*truncate\s*\(/.test(source)) {
|
|
363
|
+
violations.push({
|
|
364
|
+
ruleId: "dal-parallel-safety",
|
|
365
|
+
severity: "error",
|
|
366
|
+
file: relative(productDir, filePath),
|
|
367
|
+
line: firstMatchingLine(source, /\btruncate\s*\(|\.\s*truncate\s*\(/),
|
|
368
|
+
message: "DAL specs must not truncate shared tables; seed namespaced rows and scope queries instead",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for (const field of duplicateFields.keys()) {
|
|
373
|
+
const matcher = new RegExp(`${field}\\s*:\\s*(['"])([^'"\\\\]*(?:\\\\.[^'"\\\\]*)*)\\1`, "g");
|
|
374
|
+
for (const match of source.matchAll(matcher)) {
|
|
375
|
+
const value = match[2];
|
|
376
|
+
if (!value) continue;
|
|
377
|
+
const occurrences = duplicateFields.get(field).get(value) || [];
|
|
378
|
+
occurrences.push({ filePath, index: match.index || 0 });
|
|
379
|
+
duplicateFields.get(field).set(value, occurrences);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const [field, values] of duplicateFields) {
|
|
385
|
+
for (const [value, occurrences] of values) {
|
|
386
|
+
const uniqueFiles = [...new Set(occurrences.map((entry) => entry.filePath))];
|
|
387
|
+
if (uniqueFiles.length <= 1) continue;
|
|
388
|
+
for (const occurrence of occurrences) {
|
|
389
|
+
violations.push({
|
|
390
|
+
ruleId: "dal-parallel-safety",
|
|
391
|
+
severity: "error",
|
|
392
|
+
file: relative(productDir, occurrence.filePath),
|
|
393
|
+
line: lineForIndex(fs.readFileSync(occurrence.filePath, "utf8"), occurrence.index),
|
|
394
|
+
message: `Duplicate ${field} literal "${value}" appears across DAL specs; use fixture-derived namespaced values`,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (const filePath of files.filter((entry) => isTestkitHelperFile(productDir, entry))) {
|
|
401
|
+
const source = stripComments(fs.readFileSync(filePath, "utf8"));
|
|
402
|
+
if (/ON\s+CONFLICT(?:\s*\([^)]*\))?\s+DO\s+NOTHING/i.test(source)) {
|
|
403
|
+
violations.push({
|
|
404
|
+
ruleId: "dal-parallel-safety",
|
|
405
|
+
severity: "error",
|
|
406
|
+
file: relative(productDir, filePath),
|
|
407
|
+
line: firstMatchingLine(source, /ON\s+CONFLICT(?:\s*\([^)]*\))?\s+DO\s+NOTHING/i),
|
|
408
|
+
message: "Testkit helper seed code must not use ON CONFLICT DO NOTHING; collisions should fail loudly",
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return violations;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isTestkitHelperFile(productDir, filePath) {
|
|
417
|
+
const parts = relative(productDir, filePath).split("/");
|
|
418
|
+
return parts.includes("__testkit__") && parts.includes("helpers") && filePath.endsWith(".ts");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function stripComments(source) {
|
|
422
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function firstMatchingLine(source, pattern) {
|
|
426
|
+
const lines = source.split(/\r?\n/);
|
|
427
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
428
|
+
if (pattern.test(lines[index])) return index + 1;
|
|
429
|
+
}
|
|
430
|
+
return 1;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function lineForIndex(source, index) {
|
|
434
|
+
return source.slice(0, index).split(/\r?\n/).length;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function findLegacyHttpAssertionViolations(productDir, testkitFiles) {
|
|
438
|
+
const violations = [];
|
|
439
|
+
for (const filePath of testkitFiles.filter((entry) => !entry.endsWith(".dal.testkit.ts"))) {
|
|
440
|
+
const sourceFile = readSourceFile(filePath);
|
|
441
|
+
visit(sourceFile, (node) => {
|
|
442
|
+
if (!isLegacyHttpStatusCheck(node, sourceFile)) return;
|
|
443
|
+
violations.push(
|
|
444
|
+
violationFromSource(
|
|
445
|
+
"legacy-http-assertions",
|
|
446
|
+
productDir,
|
|
447
|
+
filePath,
|
|
448
|
+
sourceFile,
|
|
449
|
+
node,
|
|
450
|
+
"HTTP response status assertions must use expectStatus/expectStatusOneOf"
|
|
451
|
+
)
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return violations;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function isLegacyHttpStatusCheck(node, sourceFile) {
|
|
459
|
+
if (!ts.isCallExpression(node)) return false;
|
|
460
|
+
if (node.expression.getText(sourceFile) !== "check") return false;
|
|
461
|
+
if (node.arguments.length !== 2 || !ts.isObjectLiteralExpression(node.arguments[1])) return false;
|
|
462
|
+
const targetText = node.arguments[0]?.getText(sourceFile) || "";
|
|
463
|
+
if (!/^(?:res|[A-Za-z_$][\w$]*Res)$/.test(targetText)) return false;
|
|
464
|
+
|
|
465
|
+
for (const property of node.arguments[1].properties) {
|
|
466
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
467
|
+
const initializer = unwrap(property.initializer);
|
|
468
|
+
if (!ts.isArrowFunction(initializer) && !ts.isFunctionExpression(initializer)) continue;
|
|
469
|
+
if (initializer.parameters.length === 0 || ts.isBlock(initializer.body)) continue;
|
|
470
|
+
const paramName = initializer.parameters[0]?.name.getText(sourceFile);
|
|
471
|
+
if (paramName && hasLegacyStatusPredicate(initializer.body, paramName, sourceFile)) return true;
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function unwrap(expression) {
|
|
477
|
+
while (ts.isParenthesizedExpression(expression)) {
|
|
478
|
+
expression = expression.expression;
|
|
479
|
+
}
|
|
480
|
+
return expression;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function hasLegacyStatusPredicate(expression, paramName, sourceFile) {
|
|
484
|
+
expression = unwrap(expression);
|
|
485
|
+
if (!ts.isBinaryExpression(expression)) return false;
|
|
486
|
+
if (
|
|
487
|
+
expression.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
488
|
+
expression.operatorToken.kind === ts.SyntaxKind.ExclamationEqualsEqualsToken
|
|
489
|
+
) {
|
|
490
|
+
return isParamStatus(expression.left, paramName, sourceFile) || isParamStatus(expression.right, paramName, sourceFile);
|
|
491
|
+
}
|
|
492
|
+
if (expression.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
|
|
493
|
+
return (
|
|
494
|
+
hasLegacyStatusPredicate(expression.left, paramName, sourceFile) ||
|
|
495
|
+
hasLegacyStatusPredicate(expression.right, paramName, sourceFile)
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function isParamStatus(expression, paramName, sourceFile) {
|
|
502
|
+
expression = unwrap(expression);
|
|
503
|
+
return (
|
|
504
|
+
ts.isPropertyAccessExpression(expression) &&
|
|
505
|
+
expression.expression.getText(sourceFile) === paramName &&
|
|
506
|
+
expression.name.getText(sourceFile) === "status"
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function findLegacyDalAssertionViolations(productDir, testkitFiles) {
|
|
511
|
+
const violations = [];
|
|
512
|
+
for (const filePath of testkitFiles.filter((entry) => entry.endsWith(".dal.testkit.ts"))) {
|
|
513
|
+
const sourceFile = readSourceFile(filePath);
|
|
514
|
+
visit(sourceFile, (node) => {
|
|
515
|
+
if (!ts.isCallExpression(node)) return;
|
|
516
|
+
if (node.expression.getText(sourceFile) !== "check") return;
|
|
517
|
+
violations.push(
|
|
518
|
+
violationFromSource(
|
|
519
|
+
"legacy-dal-assertions",
|
|
520
|
+
productDir,
|
|
521
|
+
filePath,
|
|
522
|
+
sourceFile,
|
|
523
|
+
node,
|
|
524
|
+
"DAL assertions must use Testkit expect* helpers instead of raw check(...)"
|
|
525
|
+
)
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return violations;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function findConfigImportViolations(productDir) {
|
|
533
|
+
const configFile = findConfigFile(productDir);
|
|
534
|
+
if (!configFile || !fs.existsSync(configFile)) return [];
|
|
535
|
+
const sourceFile = readSourceFile(configFile);
|
|
536
|
+
const violations = [];
|
|
537
|
+
for (const statement of sourceFile.statements) {
|
|
538
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
539
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
540
|
+
const specifier = statement.moduleSpecifier.text;
|
|
541
|
+
if (!specifier.startsWith(".") && !specifier.startsWith("/")) continue;
|
|
542
|
+
if (!specifier.includes("__testkit__")) continue;
|
|
543
|
+
violations.push(
|
|
544
|
+
violationFromSource(
|
|
545
|
+
"config-imports",
|
|
546
|
+
productDir,
|
|
547
|
+
configFile,
|
|
548
|
+
sourceFile,
|
|
549
|
+
statement,
|
|
550
|
+
"testkit.config.ts must not import repo-local __testkit__ helper modules"
|
|
551
|
+
)
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
return violations;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function visit(node, fn) {
|
|
558
|
+
fn(node);
|
|
559
|
+
ts.forEachChild(node, (child) => visit(child, fn));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function compareViolations(left, right) {
|
|
563
|
+
return (
|
|
564
|
+
left.file.localeCompare(right.file) ||
|
|
565
|
+
(left.line || 0) - (right.line || 0) ||
|
|
566
|
+
left.ruleId.localeCompare(right.ruleId) ||
|
|
567
|
+
left.message.localeCompare(right.message)
|
|
568
|
+
);
|
|
569
|
+
}
|
|
@@ -23,6 +23,12 @@ import {
|
|
|
23
23
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
24
24
|
const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
|
|
25
25
|
const CONFIG_ENTRY = path.join(PACKAGE_ROOT, "lib", "config-api", "index.mjs");
|
|
26
|
+
const CONFIG_DATABASE_STEPS_ENTRY = path.join(
|
|
27
|
+
PACKAGE_ROOT,
|
|
28
|
+
"lib",
|
|
29
|
+
"config-api",
|
|
30
|
+
"database-steps.mjs"
|
|
31
|
+
);
|
|
26
32
|
const CONFIG_NEXT_TSCONFIG_ENTRY = path.join(
|
|
27
33
|
PACKAGE_ROOT,
|
|
28
34
|
"lib",
|
|
@@ -159,6 +165,7 @@ async function runConfiguredStep(config, step, env, resolvedToolchain, options =
|
|
|
159
165
|
const bundledModule = await bundleConfiguredModule(config.productDir, step);
|
|
160
166
|
const { exportName } = parseModuleSpecifier(step.specifier);
|
|
161
167
|
const context = {
|
|
168
|
+
args: step.args ?? {},
|
|
162
169
|
databaseUrl: runtimeEnv.DATABASE_URL || null,
|
|
163
170
|
productDir: config.productDir,
|
|
164
171
|
cwd,
|
|
@@ -267,6 +274,7 @@ function resolvePackageSubpath(specifier) {
|
|
|
267
274
|
const subpath = specifier.slice("@elench/testkit".length);
|
|
268
275
|
if (!subpath) return ROOT_ENTRY;
|
|
269
276
|
if (subpath === "/config") return CONFIG_ENTRY;
|
|
277
|
+
if (subpath === "/config/database-steps") return CONFIG_DATABASE_STEPS_ENTRY;
|
|
270
278
|
if (subpath === "/config/next-runtime-tsconfig") return CONFIG_NEXT_TSCONFIG_ENTRY;
|
|
271
279
|
if (subpath === "/drizzle") return DRIZZLE_ENTRY;
|
|
272
280
|
if (subpath === "/env") return ENV_ENTRY;
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -438,6 +438,41 @@ export declare function expectCondition(
|
|
|
438
438
|
predicate: () => boolean,
|
|
439
439
|
label: string
|
|
440
440
|
): boolean;
|
|
441
|
+
export declare function expectRowCount<T>(
|
|
442
|
+
rows: T[],
|
|
443
|
+
expectedCount: number,
|
|
444
|
+
label?: string | null
|
|
445
|
+
): boolean;
|
|
446
|
+
export declare function expectSingleRow<T>(
|
|
447
|
+
rows: T[],
|
|
448
|
+
label?: string | null
|
|
449
|
+
): T | null;
|
|
450
|
+
export declare function expectNoRows<T>(
|
|
451
|
+
rows: T[],
|
|
452
|
+
label?: string | null
|
|
453
|
+
): boolean;
|
|
454
|
+
export declare function expectField<T extends Record<string, unknown>>(
|
|
455
|
+
row: T,
|
|
456
|
+
field: keyof T | string,
|
|
457
|
+
predicateOrExpected: unknown | ((value: unknown) => boolean),
|
|
458
|
+
label?: string | null
|
|
459
|
+
): boolean;
|
|
460
|
+
export declare function expectTruthyField<T extends Record<string, unknown>>(
|
|
461
|
+
row: T,
|
|
462
|
+
field: keyof T | string,
|
|
463
|
+
label?: string | null
|
|
464
|
+
): boolean;
|
|
465
|
+
export declare function expectType(
|
|
466
|
+
value: unknown,
|
|
467
|
+
typeName: "array" | "bigint" | "boolean" | "function" | "null" | "number" | "object" | "string" | "symbol" | "undefined",
|
|
468
|
+
label?: string | null
|
|
469
|
+
): boolean;
|
|
470
|
+
export declare function captureError(fn: () => unknown): unknown | null;
|
|
471
|
+
export declare function expectError(
|
|
472
|
+
errorOrFn: unknown | (() => unknown) | null,
|
|
473
|
+
predicate?: ((error: unknown) => boolean) | null,
|
|
474
|
+
label?: string | null
|
|
475
|
+
): boolean;
|
|
441
476
|
|
|
442
477
|
export declare function runAuthGateChecks(
|
|
443
478
|
rawReq: RawRequestClient,
|
|
@@ -458,17 +493,25 @@ export declare function runPaginationChecks(
|
|
|
458
493
|
): void;
|
|
459
494
|
|
|
460
495
|
export interface RuntimeExpectNamespace {
|
|
496
|
+
captureError: typeof captureError;
|
|
461
497
|
condition: typeof expectCondition;
|
|
462
498
|
error: {
|
|
499
|
+
captured: typeof expectError;
|
|
463
500
|
message: typeof expectErrorMessage;
|
|
464
501
|
shape: typeof expectErrorShape;
|
|
465
502
|
};
|
|
503
|
+
field: typeof expectField;
|
|
466
504
|
json: typeof expectJson;
|
|
467
505
|
jsonPath: typeof expectJsonPath;
|
|
506
|
+
noRows: typeof expectNoRows;
|
|
468
507
|
notStatus: typeof expectNotStatus;
|
|
469
508
|
response: typeof expectResponse;
|
|
509
|
+
rowCount: typeof expectRowCount;
|
|
510
|
+
singleRow: typeof expectSingleRow;
|
|
470
511
|
status: typeof expectStatus;
|
|
471
512
|
statusOneOf: typeof expectStatusOneOf;
|
|
513
|
+
truthyField: typeof expectTruthyField;
|
|
514
|
+
type: typeof expectType;
|
|
472
515
|
value: typeof expectValue;
|
|
473
516
|
}
|
|
474
517
|
|