@elench/testkit 0.1.66 → 0.1.68

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.
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  normalizeBrowserServiceConfig,
4
+ normalizeRuntimeConfig,
4
5
  normalizeRuntimePrepareConfig,
5
6
  normalizeTemplateLifecycleStep,
6
7
  normalizeTemplateStepInputs,
@@ -45,6 +46,31 @@ describe("config runtime helpers", () => {
45
46
  });
46
47
  });
47
48
 
49
+ it("folds declarative runtime.build into runtime.prepare", () => {
50
+ expect(
51
+ normalizeRuntimeConfig(
52
+ {
53
+ build: {
54
+ kind: "tsc",
55
+ entry: "src/server.ts",
56
+ },
57
+ },
58
+ "api",
59
+ {}
60
+ )
61
+ ).toMatchObject({
62
+ build: {
63
+ kind: "tsc",
64
+ entry: "src/server.ts",
65
+ tsconfig: "tsconfig.json",
66
+ outDir: "dist",
67
+ },
68
+ prepare: {
69
+ inputs: ["tsconfig.json", "package.json", "src"],
70
+ },
71
+ });
72
+ });
73
+
48
74
  it("rejects malformed lifecycle steps and empty step inputs", () => {
49
75
  expect(() => normalizeTemplateLifecycleStep({ kind: "module" }, "runtime.prepare.steps[0]")).toThrow(
50
76
  /specifier must be a non-empty string/
@@ -19,13 +19,15 @@ describe("coverage graph builder", () => {
19
19
  productDir,
20
20
  "testkit.setup.ts",
21
21
  `
22
- import { defineTestkitSetup, nextService } from "@elench/testkit/setup";
22
+ import { defineTestkitSetup, nextApp } from "@elench/testkit/setup";
23
23
 
24
24
  export default defineTestkitSetup({
25
25
  services: {
26
- web: nextService({
26
+ web: nextApp({
27
27
  cwd: ".",
28
28
  start: "node server.js",
29
+ mode: "start",
30
+ build: null,
29
31
  baseUrl: "http://127.0.0.1:3000",
30
32
  readyUrl: "http://127.0.0.1:3000"
31
33
  })
@@ -0,0 +1,122 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import ts from "typescript";
4
+
5
+ export function loadTestFileMetadataMap(productDir, suitesByService = {}) {
6
+ const metadataByPath = new Map();
7
+
8
+ for (const suites of Object.values(suitesByService || {})) {
9
+ for (const suiteList of Object.values(suites || {})) {
10
+ for (const suite of suiteList || []) {
11
+ for (const filePath of suite.files || []) {
12
+ if (metadataByPath.has(filePath)) continue;
13
+ metadataByPath.set(filePath, readTestFileMetadata(productDir, filePath));
14
+ }
15
+ }
16
+ }
17
+ }
18
+
19
+ return metadataByPath;
20
+ }
21
+
22
+ export function readTestFileMetadata(productDir, filePath) {
23
+ const absolutePath = path.join(productDir, filePath);
24
+ if (!fs.existsSync(absolutePath)) {
25
+ return emptyMetadata();
26
+ }
27
+
28
+ const sourceText = fs.readFileSync(absolutePath, "utf8");
29
+ const sourceFile = ts.createSourceFile(absolutePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
30
+
31
+ for (const statement of sourceFile.statements) {
32
+ if (!ts.isVariableStatement(statement) || !hasExportModifier(statement)) continue;
33
+ for (const declaration of statement.declarationList.declarations) {
34
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "testkit") continue;
35
+ const config = parseMetadataInitializer(declaration.initializer);
36
+ if (config) return config;
37
+ }
38
+ }
39
+
40
+ return emptyMetadata();
41
+ }
42
+
43
+ function parseMetadataInitializer(initializer) {
44
+ if (!initializer) return null;
45
+ if (ts.isCallExpression(initializer)) {
46
+ const callee = getCallIdentifier(initializer.expression);
47
+ if (callee === "defineTestkitFile" && initializer.arguments[0] && ts.isObjectLiteralExpression(initializer.arguments[0])) {
48
+ return parseMetadataObject(initializer.arguments[0]);
49
+ }
50
+ return null;
51
+ }
52
+ if (ts.isObjectLiteralExpression(initializer)) {
53
+ return parseMetadataObject(initializer);
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function parseMetadataObject(node) {
59
+ const metadata = emptyMetadata();
60
+
61
+ for (const property of node.properties) {
62
+ if (!ts.isPropertyAssignment(property)) continue;
63
+ const key = getPropertyName(property.name);
64
+ if (key === "locks") {
65
+ metadata.locks = parseLocks(property.initializer);
66
+ continue;
67
+ }
68
+ if (key === "skip") {
69
+ metadata.skipReason = parseSkipReason(property.initializer);
70
+ }
71
+ }
72
+
73
+ return metadata;
74
+ }
75
+
76
+ function parseLocks(node) {
77
+ if (!ts.isArrayLiteralExpression(node)) return [];
78
+ const values = [];
79
+ for (const entry of node.elements) {
80
+ const value = readStringLiteral(entry);
81
+ if (value) values.push(value);
82
+ }
83
+ return [...new Set(values)].sort();
84
+ }
85
+
86
+ function parseSkipReason(node) {
87
+ const literal = readStringLiteral(node);
88
+ if (literal) return literal;
89
+ if (!ts.isObjectLiteralExpression(node)) return null;
90
+ for (const property of node.properties) {
91
+ if (!ts.isPropertyAssignment(property)) continue;
92
+ if (getPropertyName(property.name) !== "reason") continue;
93
+ return readStringLiteral(property.initializer) || null;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function getCallIdentifier(node) {
99
+ if (ts.isIdentifier(node)) return node.text;
100
+ return null;
101
+ }
102
+
103
+ function getPropertyName(node) {
104
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node)) return node.text;
105
+ return null;
106
+ }
107
+
108
+ function readStringLiteral(node) {
109
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
110
+ return null;
111
+ }
112
+
113
+ function hasExportModifier(node) {
114
+ return (node.modifiers || []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
115
+ }
116
+
117
+ function emptyMetadata() {
118
+ return {
119
+ locks: [],
120
+ skipReason: null,
121
+ };
122
+ }
@@ -0,0 +1,51 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { readTestFileMetadata } from "./file-metadata.mjs";
6
+
7
+ const tempDirs = [];
8
+
9
+ afterEach(() => {
10
+ for (const dir of tempDirs.splice(0)) {
11
+ fs.rmSync(dir, { recursive: true, force: true });
12
+ }
13
+ });
14
+
15
+ describe("test file metadata", () => {
16
+ it("reads exported testkit metadata from object literals and helper calls", () => {
17
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-file-meta-"));
18
+ tempDirs.push(productDir);
19
+ fs.mkdirSync(path.join(productDir, "__testkit__"), { recursive: true });
20
+ fs.writeFileSync(
21
+ path.join(productDir, "__testkit__", "billing.int.testkit.ts"),
22
+ [
23
+ 'import { defineTestkitFile } from "@elench/testkit/setup";',
24
+ 'export const testkit = defineTestkitFile({',
25
+ ' skip: "Billing is stubbed locally",',
26
+ ' locks: ["background-workers", "background-workers"],',
27
+ "});",
28
+ ].join("\n")
29
+ );
30
+
31
+ expect(readTestFileMetadata(productDir, "__testkit__/billing.int.testkit.ts")).toEqual({
32
+ skipReason: "Billing is stubbed locally",
33
+ locks: ["background-workers"],
34
+ });
35
+ });
36
+
37
+ it("returns empty metadata when no export is present", () => {
38
+ const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-file-meta-"));
39
+ tempDirs.push(productDir);
40
+ fs.mkdirSync(path.join(productDir, "__testkit__"), { recursive: true });
41
+ fs.writeFileSync(
42
+ path.join(productDir, "__testkit__", "health.int.testkit.ts"),
43
+ "export default {};\n"
44
+ );
45
+
46
+ expect(readTestFileMetadata(productDir, "__testkit__/health.int.testkit.ts")).toEqual({
47
+ skipReason: null,
48
+ locks: [],
49
+ });
50
+ });
51
+ });
@@ -207,8 +207,16 @@ function buildResolvedSuiteEntries(config, suite, internalType, filters) {
207
207
 
208
208
  for (const filePath of suite.files || []) {
209
209
  if (filters.fileNameSet.size > 0 && !filters.fileNameSet.has(filePath)) continue;
210
- const fileLocks = config.testkit.requirements?.fileLocksByPath?.get(filePath) || [];
211
- const skipReason = config.testkit.skip?.fileReasonByPath?.get(filePath) || suiteSkipReason || null;
210
+ const fileMetadata = config.testkit.fileMetadataByPath?.get(filePath) || { locks: [], skipReason: null };
211
+ const fileLocks = [
212
+ ...(config.testkit.requirements?.fileLocksByPath?.get(filePath) || []),
213
+ ...(fileMetadata.locks || []),
214
+ ];
215
+ const skipReason =
216
+ fileMetadata.skipReason ||
217
+ config.testkit.skip?.fileReasonByPath?.get(filePath) ||
218
+ suiteSkipReason ||
219
+ null;
212
220
  const skipped = Boolean(skipReason);
213
221
  if (filters.runnableOnly && skipped) continue;
214
222
  visibleFiles.push({
@@ -30,14 +30,6 @@ describe("public discovery", () => {
30
30
  start: "node server.js",
31
31
  baseUrl: "http://127.0.0.1:3000",
32
32
  readyUrl: "http://127.0.0.1:3000"
33
- },
34
- requirements: {
35
- files: [
36
- {
37
- path: "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts",
38
- locks: ["route-lock"]
39
- }
40
- ]
41
33
  }
42
34
  },
43
35
  frontend: {
@@ -47,22 +39,30 @@ describe("public discovery", () => {
47
39
  baseUrl: "http://127.0.0.1:3001",
48
40
  readyUrl: "http://127.0.0.1:3001"
49
41
  },
50
- dependsOn: ["api"],
51
- skip: {
52
- files: [
53
- {
54
- path: "frontend/src/app/login/__testkit__/auth.pw.testkit.ts",
55
- reason: "Auth is stubbed locally"
56
- }
57
- ]
58
- }
42
+ dependsOn: ["api"]
59
43
  }
60
44
  }
61
45
  });
62
46
  `
63
47
  );
64
- writeFile(productDir, "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts");
65
- writeFile(productDir, "frontend/src/app/login/__testkit__/auth.pw.testkit.ts");
48
+ writeFile(
49
+ productDir,
50
+ "src/api/routes/__testkit__/agent-configs-auth-gate.int.testkit.ts",
51
+ [
52
+ 'import { defineTestkitFile } from "@elench/testkit/setup";',
53
+ 'export const testkit = defineTestkitFile({ locks: ["route-lock"] });',
54
+ "export {};",
55
+ ].join("\n")
56
+ );
57
+ writeFile(
58
+ productDir,
59
+ "frontend/src/app/login/__testkit__/auth.pw.testkit.ts",
60
+ [
61
+ 'import { defineTestkitFile } from "@elench/testkit/setup";',
62
+ 'export const testkit = defineTestkitFile({ skip: "Auth is stubbed locally" });',
63
+ "export {};",
64
+ ].join("\n")
65
+ );
66
66
 
67
67
  saveHistory(productDir, {
68
68
  version: 1,
@@ -223,14 +223,15 @@ function applySkipRules(config, displayType, suiteName, files, opts = {}) {
223
223
  };
224
224
  }
225
225
  const skip = config.testkit?.skip;
226
- if (!skip) {
226
+ const metadataByPath = config.testkit?.fileMetadataByPath || null;
227
+ if (!skip && !metadataByPath) {
227
228
  return {
228
229
  files,
229
230
  skippedFiles: [],
230
231
  };
231
232
  }
232
233
 
233
- const matchingSuiteRules = skip.suites.filter((rule) =>
234
+ const matchingSuiteRules = (skip?.suites || []).filter((rule) =>
234
235
  matchesSuiteSelectors(displayType, suiteName, [rule.selector])
235
236
  );
236
237
  const suiteReason = matchingSuiteRules[0]?.reason || null;
@@ -239,7 +240,9 @@ function applySkipRules(config, displayType, suiteName, files, opts = {}) {
239
240
 
240
241
  for (const file of files) {
241
242
  const normalizedFile = normalizePathSeparators(file);
242
- const reason = skip.fileReasonByPath.get(normalizedFile) || suiteReason;
243
+ const fileMetadata = config.testkit.fileMetadataByPath?.get(normalizedFile) || null;
244
+ const reason =
245
+ fileMetadata?.skipReason || skip?.fileReasonByPath?.get(normalizedFile) || suiteReason;
243
246
  if (reason) {
244
247
  skippedFiles.push({
245
248
  path: normalizedFile,
@@ -268,9 +271,13 @@ function resolveTaskLocks(config, suite, file) {
268
271
  }
269
272
 
270
273
  const normalizedFile = normalizePathSeparators(file);
274
+ const fileMetadata = config.testkit.fileMetadataByPath?.get(normalizedFile) || null;
271
275
  for (const lockName of config.testkit.requirements?.fileLocksByPath?.get(normalizedFile) || []) {
272
276
  locks.add(lockName);
273
277
  }
278
+ for (const lockName of fileMetadata?.locks || []) {
279
+ locks.add(lockName);
280
+ }
274
281
 
275
282
  return [...locks].sort();
276
283
  }
@@ -138,6 +138,32 @@ describe("runner-planning", () => {
138
138
  ]);
139
139
  });
140
140
 
141
+ it("does not require repo-level skip config when file metadata is present", () => {
142
+ const config = makeConfig("api", {
143
+ suites: {
144
+ integration: [
145
+ {
146
+ name: "health",
147
+ files: ["__testkit__/health/health.int.testkit.ts"],
148
+ },
149
+ ],
150
+ },
151
+ testkit: {
152
+ fileMetadataByPath: new Map([
153
+ ["__testkit__/health/health.int.testkit.ts", { skipReason: null, locks: [] }],
154
+ ]),
155
+ },
156
+ });
157
+
158
+ expect(collectSuites(config, ["int"], [], [])).toEqual([
159
+ expect.objectContaining({
160
+ name: "health",
161
+ files: ["__testkit__/health/health.int.testkit.ts"],
162
+ skippedFiles: [],
163
+ }),
164
+ ]);
165
+ });
166
+
141
167
  it("applies lock requirements to matching suites and files", () => {
142
168
  const api = makeConfig("api", {
143
169
  suites: {
@@ -21,6 +21,12 @@ import {
21
21
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
22
22
  const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
23
23
  const SETUP_ENTRY = path.join(PACKAGE_ROOT, "lib", "setup", "index.mjs");
24
+ const SETUP_NEXT_TSCONFIG_ENTRY = path.join(
25
+ PACKAGE_ROOT,
26
+ "lib",
27
+ "setup",
28
+ "next-runtime-tsconfig.mjs"
29
+ );
24
30
  const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
25
31
  const KNOWN_FAILURES_ENTRY = path.join(PACKAGE_ROOT, "lib", "known-failures", "index.mjs");
26
32
  const MODULE_RUNNER_ENTRY = path.join(
@@ -225,6 +231,7 @@ function resolvePackageSubpath(specifier) {
225
231
  const subpath = specifier.slice("@elench/testkit".length);
226
232
  if (!subpath) return ROOT_ENTRY;
227
233
  if (subpath === "/setup") return SETUP_ENTRY;
234
+ if (subpath === "/setup/next-runtime-tsconfig") return SETUP_NEXT_TSCONFIG_ENTRY;
228
235
  if (subpath === "/runtime") return RUNTIME_ENTRY;
229
236
  if (subpath === "/known-failures") return KNOWN_FAILURES_ENTRY;
230
237
 
@@ -7,7 +7,7 @@ export interface DatabaseTemplateConfig {
7
7
  verify?: TemplateLifecycleStepConfig[];
8
8
  }
9
9
 
10
- export interface SeededDatabaseTemplateOptions {
10
+ export interface DatabaseTemplateOptions {
11
11
  inputs?: string[];
12
12
  schema?: string | TemplateSqlFileStepConfig;
13
13
  migrate?: TemplateLifecycleStepConfig | TemplateLifecycleStepConfig[];
@@ -40,6 +40,38 @@ export type TemplateLifecycleStepConfig =
40
40
  | TemplateSqlFileStepConfig
41
41
  | TemplateModuleStepConfig;
42
42
 
43
+ export interface TscBuildConfig {
44
+ kind: "tsc";
45
+ cwd?: string;
46
+ entry?: string;
47
+ inputs?: string[];
48
+ outDir?: string;
49
+ tsconfig?: string;
50
+ }
51
+
52
+ export interface ScriptBuildConfig {
53
+ kind: "script";
54
+ cwd?: string;
55
+ inputs?: string[];
56
+ script: string;
57
+ }
58
+
59
+ export interface NextBuildConfig {
60
+ kind: "next";
61
+ cwd?: string;
62
+ distDir?: string;
63
+ inputs?: string[];
64
+ tsconfig?: string;
65
+ }
66
+
67
+ export interface StepsBuildConfig {
68
+ kind: "steps";
69
+ inputs?: string[];
70
+ steps?: TemplateLifecycleStepConfig[];
71
+ }
72
+
73
+ export type BuildConfig = TscBuildConfig | ScriptBuildConfig | NextBuildConfig | StepsBuildConfig;
74
+
43
75
  export interface LocalDatabaseConfig {
44
76
  provider: "local";
45
77
  binding?: "shared" | "per-runtime";
@@ -66,6 +98,7 @@ export interface SkipConfig {
66
98
  }
67
99
 
68
100
  export interface RuntimeConfig {
101
+ build?: BuildConfig | null;
69
102
  instances?: number;
70
103
  maxConcurrentTasks?: number;
71
104
  prepare?: {
@@ -144,6 +177,44 @@ export interface ServiceConfig {
144
177
  skip?: SkipConfig;
145
178
  }
146
179
 
180
+ export interface TestkitFileMetadata {
181
+ locks?: string[];
182
+ skip?: string | { reason: string };
183
+ }
184
+
185
+ export interface NodeAppOptions extends Omit<ServiceConfig, "local" | "runtime"> {
186
+ baseUrl?: string;
187
+ build?: BuildConfig | null;
188
+ buildInputs?: string[];
189
+ cwd?: string;
190
+ entry?: string;
191
+ env?: Record<string, string>;
192
+ outDir?: string;
193
+ port: number;
194
+ readyPath?: string;
195
+ readyTimeoutMs?: number;
196
+ readyUrl?: string;
197
+ runtime?: Omit<RuntimeConfig, "build">;
198
+ start?: string;
199
+ toolchain?: string | NodeToolchainConfig;
200
+ tsconfig?: string;
201
+ }
202
+
203
+ export interface NextAppOptions extends Omit<ServiceConfig, "local" | "runtime"> {
204
+ baseUrl?: string;
205
+ build?: BuildConfig | null;
206
+ buildInputs?: string[];
207
+ cwd?: string;
208
+ env?: Record<string, string>;
209
+ mode?: "dev" | "start";
210
+ port: number;
211
+ readyTimeoutMs?: number;
212
+ readyUrl?: string;
213
+ runtime?: Omit<RuntimeConfig, "build">;
214
+ start?: string;
215
+ toolchain?: string | NodeToolchainConfig;
216
+ }
217
+
147
218
  export interface TestkitSetup {
148
219
  discovery?: DiscoveryConfig;
149
220
  execution?: TestkitExecutionConfig;
@@ -166,8 +237,8 @@ export interface TestkitSetup {
166
237
 
167
238
  export declare function defineTestkitSetup<T extends TestkitSetup>(setup: T): T;
168
239
  export declare function defineHttpProfile<T extends HttpSuiteConfig>(profile: T): T;
169
- export declare function service<T extends ServiceConfig>(config: T): T;
170
- export declare function localDatabase(options?: Omit<LocalDatabaseConfig, "provider">): LocalDatabaseConfig;
240
+ export declare function defineTestkitFile<T extends TestkitFileMetadata>(metadata: T): T;
241
+ export declare function postgresDatabase(options?: Omit<LocalDatabaseConfig, "provider">): LocalDatabaseConfig;
171
242
  export declare function commandStep(
172
243
  cmd: string,
173
244
  options?: Omit<TemplateCommandStepConfig, "kind" | "cmd">
@@ -200,24 +271,29 @@ export declare function verifyModule(
200
271
  specifier: string,
201
272
  options?: Omit<TemplateModuleStepConfig, "kind" | "specifier">
202
273
  ): TemplateModuleStepConfig;
203
- export declare function seededDatabaseTemplate(
204
- options?: SeededDatabaseTemplateOptions
205
- ): DatabaseTemplateConfig;
274
+ export declare function templateDatabase(
275
+ options?: DatabaseTemplateOptions & Omit<LocalDatabaseConfig, "provider" | "template">
276
+ ): LocalDatabaseConfig;
277
+ export declare function postgresFixture(
278
+ options?: Omit<LocalDatabaseConfig, "provider"> & {
279
+ discovery?: DiscoveryConfig;
280
+ envFiles?: string[];
281
+ }
282
+ ): ServiceConfig;
283
+ export declare function databaseServiceEnv(
284
+ prefix: string,
285
+ serviceName: string
286
+ ): Record<string, string>;
206
287
  export declare function nodeToolchain(options?: NodeToolchainConfig): NodeToolchainConfig;
207
- export declare function goService(options: ServiceConfig["local"] & {
208
- command?: string;
209
- entrypoint?: string;
210
- envFiles?: string[];
211
- readyPath?: string;
212
- }): ServiceConfig;
213
- export declare function nextService(options: ServiceConfig["local"] & {
214
- envFiles?: string[];
215
- }): ServiceConfig;
216
- export declare function tsxService(options: ServiceConfig["local"] & {
217
- entry?: string;
218
- envFiles?: string[];
219
- readyPath?: string;
220
- }): ServiceConfig;
288
+ export declare function tscBuild(options?: Omit<TscBuildConfig, "kind">): TscBuildConfig;
289
+ export declare function scriptBuild(
290
+ script: string,
291
+ options?: Omit<ScriptBuildConfig, "kind" | "script">
292
+ ): ScriptBuildConfig;
293
+ export declare function stepsBuild(options?: Omit<StepsBuildConfig, "kind">): StepsBuildConfig;
294
+ export declare function nextBuild(options?: Omit<NextBuildConfig, "kind">): NextBuildConfig;
295
+ export declare function nodeApp(options: NodeAppOptions): ServiceConfig;
296
+ export declare function nextApp(options: NextAppOptions): ServiceConfig;
221
297
  export declare function clerkSessionProfile(options?: {
222
298
  apiBase?: string;
223
299
  needsAuth?: boolean;