@elench/testkit 0.1.119 → 0.1.120
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/cli/commands/lint.mjs +4 -0
- package/lib/cli/operations/lint/operation.mjs +8 -0
- package/lib/config/index.mjs +1 -0
- package/lib/config-api/database-steps.mjs +208 -0
- package/lib/config-api/index.d.ts +90 -0
- package/lib/config-api/index.mjs +58 -0
- package/lib/lint/index.mjs +122 -2
- package/lib/runner/template.mjs +13 -0
- package/lib/ui/index.d.ts +72 -0
- package/lib/ui/index.mjs +10 -0
- package/lib/ui/provisioning.mjs +283 -0
- package/lib/ui/sandbox.mjs +135 -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/node_modules/es-toolkit/CHANGELOG.md +801 -0
- package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +1 -0
- package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +3 -0
- package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +4 -0
- package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +4 -0
- package/node_modules/esprima/ChangeLog +235 -0
- package/package.json +5 -5
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
- package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
|
@@ -12,6 +12,10 @@ export default class LintCommand extends Command {
|
|
|
12
12
|
dir: Flags.string({
|
|
13
13
|
description: "Product directory",
|
|
14
14
|
}),
|
|
15
|
+
"testkit-boundary": Flags.boolean({
|
|
16
|
+
description: "Fail when tracked Testkit plumbing exists outside testkit.config.* or __testkit__/ paths",
|
|
17
|
+
default: false,
|
|
18
|
+
}),
|
|
15
19
|
};
|
|
16
20
|
|
|
17
21
|
async run() {
|
|
@@ -5,8 +5,16 @@ import { resolveProductDir } from "../../../config/paths.mjs";
|
|
|
5
5
|
export async function executeLintOperation(flags = {}) {
|
|
6
6
|
const productDir = resolveProductDir(process.cwd(), flags.dir);
|
|
7
7
|
const { config } = await loadTestkitConfig(productDir);
|
|
8
|
+
const cliLint = flags["testkit-boundary"]
|
|
9
|
+
? { rules: { testkitBoundary: true } }
|
|
10
|
+
: {};
|
|
8
11
|
return runLint({
|
|
9
12
|
dir: productDir,
|
|
10
13
|
...(config.lint || {}),
|
|
14
|
+
...cliLint,
|
|
15
|
+
rules: {
|
|
16
|
+
...(config.lint?.rules || {}),
|
|
17
|
+
...(cliLint.rules || {}),
|
|
18
|
+
},
|
|
11
19
|
});
|
|
12
20
|
}
|
package/lib/config/index.mjs
CHANGED
|
@@ -14,6 +14,70 @@ export async function verifySeed(context = {}) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export async function verifyRows(context = {}) {
|
|
18
|
+
const args = context.args || {};
|
|
19
|
+
const checks = Array.isArray(args.checks) ? args.checks : [args];
|
|
20
|
+
const databaseUrl = requireDatabaseUrl(context);
|
|
21
|
+
const failures = [];
|
|
22
|
+
|
|
23
|
+
for (const [index, check] of checks.entries()) {
|
|
24
|
+
const label = `verifyRows.args.checks[${index}]`;
|
|
25
|
+
const table = requireIdentifier(check.table, `${label}.table`);
|
|
26
|
+
const where = normalizeOptionalSql(check.where);
|
|
27
|
+
const minRows = normalizeNonNegativeInteger(check.minRows ?? 1, `${label}.minRows`);
|
|
28
|
+
const severity = check.severity === "warn" ? "warn" : "error";
|
|
29
|
+
const query = `select count(*)::int from ${table}${where ? ` where ${where}` : ""}`;
|
|
30
|
+
const count = Number(await runScalar(databaseUrl, query, context));
|
|
31
|
+
|
|
32
|
+
if (!Number.isInteger(count) || count < minRows) {
|
|
33
|
+
const message = `Expected at least ${minRows} row(s) in ${table}, found ${count || 0}`;
|
|
34
|
+
if (severity === "warn") {
|
|
35
|
+
console.warn(message);
|
|
36
|
+
} else {
|
|
37
|
+
failures.push(message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (failures.length > 0) {
|
|
43
|
+
throw new Error(`Database row verification failed:\n${failures.map((failure) => `- ${failure}`).join("\n")}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runSql(context = {}) {
|
|
48
|
+
const args = context.args || {};
|
|
49
|
+
const statements = normalizeStatementList(args.statements ?? args.statement, "runSql.args.statements");
|
|
50
|
+
const databaseUrl = requireDatabaseUrl(context);
|
|
51
|
+
|
|
52
|
+
for (const statement of statements) {
|
|
53
|
+
await runPsql(databaseUrl, statement, context);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function seedRows(context = {}) {
|
|
58
|
+
const args = context.args || {};
|
|
59
|
+
const table = requireIdentifier(args.table, "seedRows.args.table");
|
|
60
|
+
const rows = normalizeRowList(args.rows, "seedRows.args.rows");
|
|
61
|
+
const databaseUrl = requireDatabaseUrl(context);
|
|
62
|
+
|
|
63
|
+
if (args.truncate === true) {
|
|
64
|
+
await runPsql(databaseUrl, `truncate table ${table}`, context);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (rows.length === 0) return;
|
|
68
|
+
|
|
69
|
+
const columns = normalizeSeedColumns(args.columns, rows);
|
|
70
|
+
const columnSql = columns.map((column) => requireIdentifier(column, `seedRows.args.columns.${column}`));
|
|
71
|
+
const valueGroups = rows.map((row) =>
|
|
72
|
+
`(${columns.map((column) => toSqlExpression(row[column], context)).join(", ")})`
|
|
73
|
+
);
|
|
74
|
+
await runPsql(
|
|
75
|
+
databaseUrl,
|
|
76
|
+
`insert into ${table} (${columnSql.join(", ")}) values ${valueGroups.join(", ")}`,
|
|
77
|
+
context
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
17
81
|
export async function materializePostgresBinding(context = {}) {
|
|
18
82
|
const args = context.args || {};
|
|
19
83
|
const table = requireIdentifier(args.table, "materializePostgresBinding.args.table");
|
|
@@ -44,6 +108,34 @@ export async function materializePostgresBinding(context = {}) {
|
|
|
44
108
|
}
|
|
45
109
|
}
|
|
46
110
|
|
|
111
|
+
export async function updateRows(context = {}) {
|
|
112
|
+
const args = context.args || {};
|
|
113
|
+
const table = requireIdentifier(args.table, "updateRows.args.table");
|
|
114
|
+
const where = normalizeWhereObject(args.where, "updateRows.args.where");
|
|
115
|
+
const values = normalizeObject(args.values ?? args.set, "updateRows.args.values");
|
|
116
|
+
const databaseUrl = requireDatabaseUrl(context);
|
|
117
|
+
const expectRows = args.expectRows == null
|
|
118
|
+
? null
|
|
119
|
+
: normalizeNonNegativeInteger(args.expectRows, "updateRows.args.expectRows");
|
|
120
|
+
|
|
121
|
+
const assignments = Object.keys(values).map((column) => {
|
|
122
|
+
const identifier = requireIdentifier(column, `updateRows.args.values.${column}`);
|
|
123
|
+
return `${identifier} = ${toSqlExpression(values[column], context)}`;
|
|
124
|
+
});
|
|
125
|
+
if (assignments.length === 0) {
|
|
126
|
+
throw new Error("updateRows.args.values must contain at least one column");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const query =
|
|
130
|
+
`with updated as (update ${table} set ${assignments.join(", ")} ` +
|
|
131
|
+
`where ${buildWhereClause(where, context)} returning 1) select count(*)::int from updated`;
|
|
132
|
+
const result = await runPsql(databaseUrl, query, context);
|
|
133
|
+
const updated = Number(String(result.stdout || "").trim() || "0");
|
|
134
|
+
if (expectRows !== null && updated !== expectRows) {
|
|
135
|
+
throw new Error(`Expected to update ${expectRows} ${table} row(s), updated ${updated || 0}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
47
139
|
function requireDatabaseUrl(context) {
|
|
48
140
|
const databaseUrl = String(context.databaseUrl || context.env?.DATABASE_URL || "").trim();
|
|
49
141
|
if (!databaseUrl) {
|
|
@@ -85,6 +177,54 @@ async function runPsql(databaseUrl, query, context) {
|
|
|
85
177
|
return result;
|
|
86
178
|
}
|
|
87
179
|
|
|
180
|
+
function normalizeStatementList(value, label) {
|
|
181
|
+
const values = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
182
|
+
if (values.length === 0) {
|
|
183
|
+
throw new Error(`${label} must contain at least one SQL statement`);
|
|
184
|
+
}
|
|
185
|
+
return values.map((statement, index) => {
|
|
186
|
+
const normalized = String(statement || "").trim();
|
|
187
|
+
if (!normalized || /;\s*\S/.test(normalized)) {
|
|
188
|
+
throw new Error(`${label}[${index}] must be one SQL statement`);
|
|
189
|
+
}
|
|
190
|
+
return normalized;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeRowList(value, label) {
|
|
195
|
+
if (!Array.isArray(value)) {
|
|
196
|
+
throw new Error(`${label} must be an array`);
|
|
197
|
+
}
|
|
198
|
+
return value.map((row, index) => normalizeObject(row, `${label}[${index}]`));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeSeedColumns(value, rows) {
|
|
202
|
+
if (value != null) {
|
|
203
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
204
|
+
throw new Error("seedRows.args.columns must be a non-empty array");
|
|
205
|
+
}
|
|
206
|
+
return value.map((column) => String(column));
|
|
207
|
+
}
|
|
208
|
+
return [...new Set(rows.flatMap((row) => Object.keys(row)))].sort((left, right) => left.localeCompare(right));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeWhereObject(value, label) {
|
|
212
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
213
|
+
throw new Error(`${label} must be an object`);
|
|
214
|
+
}
|
|
215
|
+
const entries = Object.entries(value);
|
|
216
|
+
if (entries.length === 0) {
|
|
217
|
+
throw new Error(`${label} must contain at least one column`);
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildWhereClause(where, context) {
|
|
223
|
+
return Object.entries(where)
|
|
224
|
+
.map(([column, value]) => `${requireIdentifier(column, `where.${column}`)} = ${toSqlExpression(value, context)}`)
|
|
225
|
+
.join(" and ");
|
|
226
|
+
}
|
|
227
|
+
|
|
88
228
|
function requireIdentifier(value, label) {
|
|
89
229
|
const normalized = String(value || "").trim();
|
|
90
230
|
if (!/^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(normalized)) {
|
|
@@ -130,3 +270,71 @@ function toSqlLiteral(value) {
|
|
|
130
270
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
131
271
|
return `'${String(value).replaceAll("'", "''")}'`;
|
|
132
272
|
}
|
|
273
|
+
|
|
274
|
+
function toSqlExpression(value, context) {
|
|
275
|
+
if (!isValueDescriptor(value)) return toSqlLiteral(value);
|
|
276
|
+
switch (value.kind) {
|
|
277
|
+
case "now":
|
|
278
|
+
return "now()";
|
|
279
|
+
case "env":
|
|
280
|
+
return toSqlLiteral(readRequiredEnv(context.env || process.env, value.name));
|
|
281
|
+
case "json":
|
|
282
|
+
return `${toSqlLiteral(JSON.stringify(resolveJsonValue(value.value, context)))}::jsonb`;
|
|
283
|
+
case "postgres-connection-from-env":
|
|
284
|
+
return `${toSqlLiteral(JSON.stringify(buildPostgresConnectionFromEnv(context.env || process.env, value.prefix)))}::jsonb`;
|
|
285
|
+
default:
|
|
286
|
+
throw new Error(`Unsupported database value descriptor "${value.kind}"`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function isValueDescriptor(value) {
|
|
291
|
+
return value && typeof value === "object" && !Array.isArray(value) && typeof value.kind === "string";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function resolveJsonValue(value, context) {
|
|
295
|
+
if (Array.isArray(value)) return value.map((entry) => resolveJsonValue(entry, context));
|
|
296
|
+
if (!value || typeof value !== "object") return value;
|
|
297
|
+
if (isValueDescriptor(value)) {
|
|
298
|
+
if (value.kind === "env") return readRequiredEnv(context.env || process.env, value.name);
|
|
299
|
+
if (value.kind === "postgres-connection-from-env") {
|
|
300
|
+
return buildPostgresConnectionFromEnv(context.env || process.env, value.prefix);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, resolveJsonValue(entry, context)]));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function buildPostgresConnectionFromEnv(env, prefix) {
|
|
307
|
+
const normalizedPrefix = String(prefix || "").trim();
|
|
308
|
+
if (!normalizedPrefix) {
|
|
309
|
+
throw new Error("postgresConnectionFromEnv requires a non-empty prefix");
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
host: readRequiredEnv(env, `${normalizedPrefix}_HOST`),
|
|
313
|
+
port: readRequiredPort(env, `${normalizedPrefix}_PORT`),
|
|
314
|
+
database: readRequiredEnv(env, `${normalizedPrefix}_NAME`),
|
|
315
|
+
username: readRequiredEnv(env, `${normalizedPrefix}_USER`),
|
|
316
|
+
password: readRequiredEnv(env, `${normalizedPrefix}_PASSWORD`),
|
|
317
|
+
ssl: parseBooleanEnv(env[`${normalizedPrefix}_SSL`]),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function readRequiredEnv(env, name) {
|
|
322
|
+
const value = env?.[name];
|
|
323
|
+
if (value == null || String(value).trim().length === 0) {
|
|
324
|
+
throw new Error(`${name} environment variable is required`);
|
|
325
|
+
}
|
|
326
|
+
return String(value).trim();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function readRequiredPort(env, name) {
|
|
330
|
+
const value = Number.parseInt(readRequiredEnv(env, name), 10);
|
|
331
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
332
|
+
throw new Error(`${name} must be a positive integer`);
|
|
333
|
+
}
|
|
334
|
+
return value;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function parseBooleanEnv(value) {
|
|
338
|
+
if (!value) return false;
|
|
339
|
+
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
|
340
|
+
}
|
|
@@ -163,6 +163,81 @@ export interface RegressionSyncConfig {
|
|
|
163
163
|
cacheTtlSeconds?: number;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
export interface TestkitBoundaryLintConfig {
|
|
167
|
+
allowed?: string[];
|
|
168
|
+
severity?: "error" | "warn" | false;
|
|
169
|
+
trackedOnly?: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface TestkitLintConfig {
|
|
173
|
+
disable?: string[];
|
|
174
|
+
rules?: Partial<Record<
|
|
175
|
+
| "missingImports"
|
|
176
|
+
| "uiRuntimeImports"
|
|
177
|
+
| "uiSpecShape"
|
|
178
|
+
| "dalParallelSafety"
|
|
179
|
+
| "legacyHttpAssertions"
|
|
180
|
+
| "legacyDalAssertions"
|
|
181
|
+
| "configImports",
|
|
182
|
+
boolean
|
|
183
|
+
>> & {
|
|
184
|
+
testkitBoundary?: TestkitBoundaryLintConfig | boolean;
|
|
185
|
+
};
|
|
186
|
+
testkitBoundary?: TestkitBoundaryLintConfig | boolean;
|
|
187
|
+
ui?: {
|
|
188
|
+
maxLines?: number;
|
|
189
|
+
maxTests?: number;
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface UiProvisionProfileConfig {
|
|
194
|
+
afterSignupSql?: string | string[];
|
|
195
|
+
email?: string;
|
|
196
|
+
emailDomain?: string;
|
|
197
|
+
loginBody?: Record<string, unknown>;
|
|
198
|
+
loginExpect?: number | number[];
|
|
199
|
+
loginPath?: string;
|
|
200
|
+
name?: string;
|
|
201
|
+
organizationName?: string;
|
|
202
|
+
password?: string;
|
|
203
|
+
sessionPath?: string;
|
|
204
|
+
signup?: false;
|
|
205
|
+
signupBody?: Record<string, unknown>;
|
|
206
|
+
signupExpect?: number | number[];
|
|
207
|
+
signupPath?: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface UiAuthConfig {
|
|
211
|
+
afterSignupSql?: string | string[];
|
|
212
|
+
authenticatedSelector?: string;
|
|
213
|
+
backendBaseUrl?: string;
|
|
214
|
+
browserState?: {
|
|
215
|
+
localStorage?: Record<string, unknown>;
|
|
216
|
+
};
|
|
217
|
+
databaseUrl?: string;
|
|
218
|
+
emailSelector?: string;
|
|
219
|
+
frontendBaseUrl?: string;
|
|
220
|
+
homePath?: string;
|
|
221
|
+
loginBody?: Record<string, unknown>;
|
|
222
|
+
loginExpect?: number | number[];
|
|
223
|
+
loginPagePath?: string;
|
|
224
|
+
loginPath?: string;
|
|
225
|
+
passwordSelector?: string;
|
|
226
|
+
profiles?: Record<string, UiProvisionProfileConfig>;
|
|
227
|
+
sessionPath?: string;
|
|
228
|
+
signup?: false;
|
|
229
|
+
signupBody?: Record<string, unknown>;
|
|
230
|
+
signupExpect?: number | number[];
|
|
231
|
+
signupPath?: string;
|
|
232
|
+
storage?: Record<string, unknown>;
|
|
233
|
+
storagePath?: string;
|
|
234
|
+
submitSelector?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface UiConfig {
|
|
238
|
+
auth?: UiAuthConfig;
|
|
239
|
+
}
|
|
240
|
+
|
|
166
241
|
export interface DiscoveryConfig {
|
|
167
242
|
roots?: string[];
|
|
168
243
|
exclude?: string[];
|
|
@@ -401,6 +476,7 @@ export interface NextAppOptions extends Omit<ServiceConfig, "local" | "runtime"
|
|
|
401
476
|
export interface TestkitConfig {
|
|
402
477
|
discovery?: DiscoveryConfig;
|
|
403
478
|
execution?: TestkitExecutionConfig;
|
|
479
|
+
lint?: TestkitLintConfig;
|
|
404
480
|
profiles?: {
|
|
405
481
|
http?: Record<string, HttpSuiteConfig<any>>;
|
|
406
482
|
};
|
|
@@ -416,6 +492,7 @@ export interface TestkitConfig {
|
|
|
416
492
|
timeoutMs?: number;
|
|
417
493
|
tokenEnv?: string;
|
|
418
494
|
};
|
|
495
|
+
ui?: UiConfig;
|
|
419
496
|
}
|
|
420
497
|
|
|
421
498
|
export interface NodeNextPresetOptions {
|
|
@@ -438,8 +515,18 @@ export declare const database: {
|
|
|
438
515
|
};
|
|
439
516
|
steps: {
|
|
440
517
|
materializePostgresBinding(options?: unknown): TemplateModuleStepConfig;
|
|
518
|
+
runSql(options?: unknown): TemplateModuleStepConfig;
|
|
519
|
+
seedRows(options?: unknown): TemplateModuleStepConfig;
|
|
520
|
+
updateRows(options?: unknown): TemplateModuleStepConfig;
|
|
521
|
+
verifyRows(options?: unknown): TemplateModuleStepConfig;
|
|
441
522
|
verifySeed(options?: unknown): TemplateModuleStepConfig;
|
|
442
523
|
};
|
|
524
|
+
values: {
|
|
525
|
+
env(name: string): unknown;
|
|
526
|
+
json(value: unknown): unknown;
|
|
527
|
+
now(): unknown;
|
|
528
|
+
postgresConnectionFromEnv(prefix: string): unknown;
|
|
529
|
+
};
|
|
443
530
|
postgres(
|
|
444
531
|
options?: Omit<LocalDatabaseConfig, "provider" | "template"> & {
|
|
445
532
|
inputs?: string[];
|
|
@@ -472,6 +559,9 @@ export declare const presets: {
|
|
|
472
559
|
export declare const toolchain: {
|
|
473
560
|
node(options?: NodeToolchainConfig): NodeToolchainConfig;
|
|
474
561
|
};
|
|
562
|
+
export declare const ui: {
|
|
563
|
+
auth(options?: UiAuthConfig): UiConfig;
|
|
564
|
+
};
|
|
475
565
|
export declare const auth: {
|
|
476
566
|
fixture(options: { contract: JsonSessionContract; topology: AuthTopology }): AuthFixture;
|
|
477
567
|
contracts: {
|
package/lib/config-api/index.mjs
CHANGED
|
@@ -109,10 +109,45 @@ function verifySeedStep(options = {}) {
|
|
|
109
109
|
return moduleStep(databaseStepTarget("verifySeed"), { args: options });
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function verifyRowsStep(options = {}) {
|
|
113
|
+
return moduleStep(databaseStepTarget("verifyRows"), { args: options });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runSqlStep(options = {}) {
|
|
117
|
+
return moduleStep(databaseStepTarget("runSql"), { args: options });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function seedRowsStep(options = {}) {
|
|
121
|
+
return moduleStep(databaseStepTarget("seedRows"), { args: options });
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
function materializePostgresBindingStep(options = {}) {
|
|
113
125
|
return moduleStep(databaseStepTarget("materializePostgresBinding"), { args: options });
|
|
114
126
|
}
|
|
115
127
|
|
|
128
|
+
function updateRowsStep(options = {}) {
|
|
129
|
+
return moduleStep(databaseStepTarget("updateRows"), { args: options });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function nowValue() {
|
|
133
|
+
return { kind: "now" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function envValue(name) {
|
|
137
|
+
return { kind: "env", name: normalizeDatabaseEnvToken(name, "database.values.env(...) name", false) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function jsonValue(value) {
|
|
141
|
+
return { kind: "json", value };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function postgresConnectionFromEnvValue(prefix) {
|
|
145
|
+
return {
|
|
146
|
+
kind: "postgres-connection-from-env",
|
|
147
|
+
prefix: normalizeDatabaseEnvToken(prefix, "database.values.postgresConnectionFromEnv(...) prefix", false),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
116
151
|
function postgresFixture(options = {}) {
|
|
117
152
|
const { discovery, envFiles, template, inputs, migrate, seed, verify, ...databaseOptions } = options;
|
|
118
153
|
for (const legacyKey of ["schema"]) {
|
|
@@ -155,6 +190,15 @@ function nodeToolchain(options = {}) {
|
|
|
155
190
|
};
|
|
156
191
|
}
|
|
157
192
|
|
|
193
|
+
function uiAuth(options = {}) {
|
|
194
|
+
return {
|
|
195
|
+
auth: {
|
|
196
|
+
...options,
|
|
197
|
+
...(options.profiles ? { profiles: { ...options.profiles } } : {}),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
158
202
|
function tscBuild(options = {}) {
|
|
159
203
|
return {
|
|
160
204
|
kind: "tsc",
|
|
@@ -326,8 +370,18 @@ export const database = {
|
|
|
326
370
|
},
|
|
327
371
|
steps: {
|
|
328
372
|
materializePostgresBinding: materializePostgresBindingStep,
|
|
373
|
+
runSql: runSqlStep,
|
|
374
|
+
seedRows: seedRowsStep,
|
|
375
|
+
updateRows: updateRowsStep,
|
|
376
|
+
verifyRows: verifyRowsStep,
|
|
329
377
|
verifySeed: verifySeedStep,
|
|
330
378
|
},
|
|
379
|
+
values: {
|
|
380
|
+
env: envValue,
|
|
381
|
+
json: jsonValue,
|
|
382
|
+
now: nowValue,
|
|
383
|
+
postgresConnectionFromEnv: postgresConnectionFromEnvValue,
|
|
384
|
+
},
|
|
331
385
|
postgres(options = {}) {
|
|
332
386
|
const { template, sourceSchema, inputs, migrate, seed, verify, ...databaseOptions } = options;
|
|
333
387
|
for (const legacyKey of ["schema"]) {
|
|
@@ -386,6 +440,10 @@ export const toolchain = {
|
|
|
386
440
|
node: nodeToolchain,
|
|
387
441
|
};
|
|
388
442
|
|
|
443
|
+
export const ui = {
|
|
444
|
+
auth: uiAuth,
|
|
445
|
+
};
|
|
446
|
+
|
|
389
447
|
export const auth = {
|
|
390
448
|
fixture: createAuthFixture,
|
|
391
449
|
contracts: {
|
package/lib/lint/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import ts from "typescript";
|
|
4
|
+
import { execa } from "execa";
|
|
4
5
|
import { findConfigFile } from "../config/config-loader.mjs";
|
|
5
6
|
import { normalizePath } from "../config/paths.mjs";
|
|
6
7
|
|
|
@@ -17,6 +18,15 @@ const DEFAULT_EXCLUDED_DIRS = new Set([
|
|
|
17
18
|
|
|
18
19
|
const DEFAULT_UI_MAX_LINES = 220;
|
|
19
20
|
const DEFAULT_UI_MAX_TESTS = 8;
|
|
21
|
+
const DEFAULT_TESTKIT_BOUNDARY_ALLOWED = [
|
|
22
|
+
"testkit.config.ts",
|
|
23
|
+
"testkit.config.mts",
|
|
24
|
+
"testkit.config.mjs",
|
|
25
|
+
"testkit.config.js",
|
|
26
|
+
"testkit.regressions.json",
|
|
27
|
+
"testkit.status.json",
|
|
28
|
+
"**/__testkit__/**",
|
|
29
|
+
];
|
|
20
30
|
|
|
21
31
|
export async function runLint(options = {}) {
|
|
22
32
|
const productDir = path.resolve(process.cwd(), options.dir || ".");
|
|
@@ -47,14 +57,19 @@ export async function runLint(options = {}) {
|
|
|
47
57
|
if (lintOptions.rules.configImports) {
|
|
48
58
|
violations.push(...findConfigImportViolations(productDir));
|
|
49
59
|
}
|
|
60
|
+
if (lintOptions.rules.testkitBoundary) {
|
|
61
|
+
violations.push(...await findTestkitBoundaryViolations(productDir, lintOptions.testkitBoundary));
|
|
62
|
+
}
|
|
50
63
|
|
|
51
64
|
const sortedViolations = violations.sort(compareViolations);
|
|
65
|
+
const errors = sortedViolations.filter((entry) => entry.severity !== "warn").length;
|
|
52
66
|
return {
|
|
53
|
-
ok:
|
|
67
|
+
ok: errors === 0,
|
|
54
68
|
productDir,
|
|
55
69
|
summary: {
|
|
56
70
|
files: files.length,
|
|
57
71
|
testkitFiles: testkitFiles.length,
|
|
72
|
+
errors,
|
|
58
73
|
violations: sortedViolations.length,
|
|
59
74
|
},
|
|
60
75
|
violations: sortedViolations,
|
|
@@ -77,10 +92,14 @@ function normalizeLintOptions(options = {}) {
|
|
|
77
92
|
legacyHttpAssertions: true,
|
|
78
93
|
legacyDalAssertions: true,
|
|
79
94
|
configImports: true,
|
|
95
|
+
testkitBoundary: false,
|
|
80
96
|
};
|
|
97
|
+
const boundaryConfig = normalizeBoundaryConfig(options.testkitBoundary ?? rules.testkitBoundary);
|
|
81
98
|
|
|
82
99
|
for (const [ruleName, value] of Object.entries(rules)) {
|
|
83
|
-
if (
|
|
100
|
+
if (ruleName === "testkitBoundary") {
|
|
101
|
+
enabled.testkitBoundary = value !== false && value != null;
|
|
102
|
+
} else if (Object.prototype.hasOwnProperty.call(enabled, ruleName)) {
|
|
84
103
|
enabled[ruleName] = value !== false;
|
|
85
104
|
}
|
|
86
105
|
}
|
|
@@ -96,6 +115,34 @@ function normalizeLintOptions(options = {}) {
|
|
|
96
115
|
maxLines: normalizePositiveInteger(options.ui?.maxLines, DEFAULT_UI_MAX_LINES),
|
|
97
116
|
maxTests: normalizePositiveInteger(options.ui?.maxTests, DEFAULT_UI_MAX_TESTS),
|
|
98
117
|
},
|
|
118
|
+
testkitBoundary: boundaryConfig,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeBoundaryConfig(value) {
|
|
123
|
+
if (value === false || value == null) {
|
|
124
|
+
return {
|
|
125
|
+
allowed: DEFAULT_TESTKIT_BOUNDARY_ALLOWED,
|
|
126
|
+
severity: "error",
|
|
127
|
+
trackedOnly: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (value === true) {
|
|
131
|
+
return {
|
|
132
|
+
allowed: DEFAULT_TESTKIT_BOUNDARY_ALLOWED,
|
|
133
|
+
severity: "error",
|
|
134
|
+
trackedOnly: true,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
138
|
+
throw new Error("lint testkitBoundary config must be an object, true, or false");
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
allowed: Array.isArray(value.allowed) && value.allowed.length > 0
|
|
142
|
+
? value.allowed.map((entry) => normalizePath(String(entry)))
|
|
143
|
+
: DEFAULT_TESTKIT_BOUNDARY_ALLOWED,
|
|
144
|
+
severity: value.severity === "warn" ? "warn" : "error",
|
|
145
|
+
trackedOnly: value.trackedOnly !== false,
|
|
99
146
|
};
|
|
100
147
|
}
|
|
101
148
|
|
|
@@ -554,6 +601,79 @@ function findConfigImportViolations(productDir) {
|
|
|
554
601
|
return violations;
|
|
555
602
|
}
|
|
556
603
|
|
|
604
|
+
async function findTestkitBoundaryViolations(productDir, options) {
|
|
605
|
+
const candidates = options.trackedOnly
|
|
606
|
+
? await collectTrackedFiles(productDir)
|
|
607
|
+
: collectAllFilePaths(productDir);
|
|
608
|
+
const violations = [];
|
|
609
|
+
for (const file of candidates) {
|
|
610
|
+
if (!hasTestkitPathSegment(file)) continue;
|
|
611
|
+
if (isAllowedTestkitBoundaryPath(file, options.allowed)) continue;
|
|
612
|
+
violations.push({
|
|
613
|
+
ruleId: "testkit-boundary",
|
|
614
|
+
severity: options.severity,
|
|
615
|
+
file,
|
|
616
|
+
line: 1,
|
|
617
|
+
message:
|
|
618
|
+
"Tracked Testkit plumbing must live in testkit.config.* or __testkit__/ suites, or move into @elench/testkit",
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return violations;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function collectTrackedFiles(productDir) {
|
|
625
|
+
const result = await execa("git", ["ls-files", "-z"], {
|
|
626
|
+
cwd: productDir,
|
|
627
|
+
reject: false,
|
|
628
|
+
stdout: "pipe",
|
|
629
|
+
stderr: "pipe",
|
|
630
|
+
});
|
|
631
|
+
if (result.exitCode !== 0) {
|
|
632
|
+
return collectAllFilePaths(productDir);
|
|
633
|
+
}
|
|
634
|
+
return String(result.stdout || "")
|
|
635
|
+
.split("\0")
|
|
636
|
+
.map((entry) => normalizePath(entry))
|
|
637
|
+
.filter(Boolean)
|
|
638
|
+
.sort((left, right) => left.localeCompare(right));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function collectAllFilePaths(productDir) {
|
|
642
|
+
return collectSourceFiles(productDir)
|
|
643
|
+
.map((filePath) => relative(productDir, filePath))
|
|
644
|
+
.sort((left, right) => left.localeCompare(right));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function hasTestkitPathSegment(file) {
|
|
648
|
+
return normalizePath(file)
|
|
649
|
+
.split("/")
|
|
650
|
+
.some((part) => part.toLowerCase().includes("testkit"));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function isAllowedTestkitBoundaryPath(file, patterns) {
|
|
654
|
+
return patterns.some((pattern) => matchBoundaryPattern(file, pattern));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function matchBoundaryPattern(file, pattern) {
|
|
658
|
+
const normalizedFile = normalizePath(file);
|
|
659
|
+
const normalizedPattern = normalizePath(pattern);
|
|
660
|
+
if (normalizedPattern === normalizedFile) return true;
|
|
661
|
+
if (normalizedPattern === "**/__testkit__/**") {
|
|
662
|
+
return normalizedFile.split("/").includes("__testkit__");
|
|
663
|
+
}
|
|
664
|
+
if (normalizedPattern.startsWith("**/") && normalizedPattern.endsWith("/**")) {
|
|
665
|
+
const segment = normalizedPattern.slice(3, -3);
|
|
666
|
+
return normalizedFile.split("/").includes(segment);
|
|
667
|
+
}
|
|
668
|
+
if (normalizedPattern.endsWith("/**")) {
|
|
669
|
+
return normalizedFile.startsWith(normalizedPattern.slice(0, -3));
|
|
670
|
+
}
|
|
671
|
+
if (normalizedPattern.startsWith("**/")) {
|
|
672
|
+
return normalizedFile.endsWith(normalizedPattern.slice(3));
|
|
673
|
+
}
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
|
|
557
677
|
function visit(node, fn) {
|
|
558
678
|
fn(node);
|
|
559
679
|
ts.forEachChild(node, (child) => visit(child, fn));
|
package/lib/runner/template.mjs
CHANGED
|
@@ -251,6 +251,7 @@ function buildExecutionEnvWithContext(config, lease, extraEnv, processEnv, optio
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
export function buildPlaywrightEnv(config, baseUrl, lease, processEnv = process.env) {
|
|
254
|
+
const templateContext = buildTemplateContext(config, lease);
|
|
254
255
|
return buildTaskExecutionEnv(
|
|
255
256
|
config,
|
|
256
257
|
lease,
|
|
@@ -260,6 +261,9 @@ export function buildPlaywrightEnv(config, baseUrl, lease, processEnv = process.
|
|
|
260
261
|
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS:
|
|
261
262
|
processEnv.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS || "1",
|
|
262
263
|
TESTKIT_MANAGED_SERVERS: "1",
|
|
264
|
+
...(config.testkit?.ui
|
|
265
|
+
? { TESTKIT_UI_CONFIG_JSON: JSON.stringify(finalizeSerializable(config.testkit.ui, templateContext)) }
|
|
266
|
+
: {}),
|
|
263
267
|
},
|
|
264
268
|
processEnv
|
|
265
269
|
);
|
|
@@ -300,6 +304,15 @@ export function finalizeString(value, context) {
|
|
|
300
304
|
return resolved;
|
|
301
305
|
}
|
|
302
306
|
|
|
307
|
+
function finalizeSerializable(value, context) {
|
|
308
|
+
if (typeof value === "string") return finalizeString(value, context);
|
|
309
|
+
if (Array.isArray(value)) return value.map((entry) => finalizeSerializable(entry, context));
|
|
310
|
+
if (!value || typeof value !== "object") return value;
|
|
311
|
+
return Object.fromEntries(
|
|
312
|
+
Object.entries(value).map(([key, entry]) => [key, finalizeSerializable(entry, context)])
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
303
316
|
export function resolveTemplateString(value, context) {
|
|
304
317
|
if (typeof value !== "string") return value;
|
|
305
318
|
|