@elench/testkit 0.1.119 → 0.1.121

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.
@@ -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
  }
@@ -167,6 +167,7 @@ function normalizeServiceConfig({
167
167
  skip,
168
168
  runtime,
169
169
  browser,
170
+ ui: config.ui || null,
170
171
  fileMetadataByPath: serviceFileMetadata,
171
172
  local,
172
173
  },
@@ -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: {
@@ -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: {
@@ -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: sortedViolations.length === 0,
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 (Object.prototype.hasOwnProperty.call(enabled, ruleName)) {
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));
@@ -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