@elench/testkit 0.1.66 → 0.1.67

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: {
@@ -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,30 @@ 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 StepsBuildConfig {
60
+ kind: "steps";
61
+ inputs?: string[];
62
+ steps?: TemplateLifecycleStepConfig[];
63
+ }
64
+
65
+ export type BuildConfig = TscBuildConfig | ScriptBuildConfig | StepsBuildConfig;
66
+
43
67
  export interface LocalDatabaseConfig {
44
68
  provider: "local";
45
69
  binding?: "shared" | "per-runtime";
@@ -66,6 +90,7 @@ export interface SkipConfig {
66
90
  }
67
91
 
68
92
  export interface RuntimeConfig {
93
+ build?: BuildConfig | null;
69
94
  instances?: number;
70
95
  maxConcurrentTasks?: number;
71
96
  prepare?: {
@@ -126,7 +151,6 @@ export interface ServiceConfig {
126
151
  databaseFrom?: string;
127
152
  dependsOn?: string[];
128
153
  discovery?: DiscoveryConfig;
129
- env?: Record<string, string>;
130
154
  envFile?: string;
131
155
  envFiles?: string[];
132
156
  browser?: BrowserServiceConfig;
@@ -144,6 +168,44 @@ export interface ServiceConfig {
144
168
  skip?: SkipConfig;
145
169
  }
146
170
 
171
+ export interface TestkitFileMetadata {
172
+ locks?: string[];
173
+ skip?: string | { reason: string };
174
+ }
175
+
176
+ export interface NodeAppOptions extends Omit<ServiceConfig, "local" | "runtime"> {
177
+ baseUrl?: string;
178
+ build?: BuildConfig | null;
179
+ buildInputs?: string[];
180
+ cwd?: string;
181
+ entry?: string;
182
+ env?: Record<string, string>;
183
+ outDir?: string;
184
+ port: number;
185
+ readyPath?: string;
186
+ readyTimeoutMs?: number;
187
+ readyUrl?: string;
188
+ runtime?: Omit<RuntimeConfig, "build">;
189
+ start?: string;
190
+ toolchain?: string | NodeToolchainConfig;
191
+ tsconfig?: string;
192
+ }
193
+
194
+ export interface NextAppOptions extends Omit<ServiceConfig, "local" | "runtime"> {
195
+ baseUrl?: string;
196
+ build?: BuildConfig | null;
197
+ buildInputs?: string[];
198
+ cwd?: string;
199
+ env?: Record<string, string>;
200
+ mode?: "dev" | "start";
201
+ port: number;
202
+ readyTimeoutMs?: number;
203
+ readyUrl?: string;
204
+ runtime?: Omit<RuntimeConfig, "build">;
205
+ start?: string;
206
+ toolchain?: string | NodeToolchainConfig;
207
+ }
208
+
147
209
  export interface TestkitSetup {
148
210
  discovery?: DiscoveryConfig;
149
211
  execution?: TestkitExecutionConfig;
@@ -166,8 +228,8 @@ export interface TestkitSetup {
166
228
 
167
229
  export declare function defineTestkitSetup<T extends TestkitSetup>(setup: T): T;
168
230
  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;
231
+ export declare function defineTestkitFile<T extends TestkitFileMetadata>(metadata: T): T;
232
+ export declare function postgresDatabase(options?: Omit<LocalDatabaseConfig, "provider">): LocalDatabaseConfig;
171
233
  export declare function commandStep(
172
234
  cmd: string,
173
235
  options?: Omit<TemplateCommandStepConfig, "kind" | "cmd">
@@ -200,24 +262,29 @@ export declare function verifyModule(
200
262
  specifier: string,
201
263
  options?: Omit<TemplateModuleStepConfig, "kind" | "specifier">
202
264
  ): TemplateModuleStepConfig;
203
- export declare function seededDatabaseTemplate(
204
- options?: SeededDatabaseTemplateOptions
205
- ): DatabaseTemplateConfig;
265
+ export declare function templateDatabase(
266
+ options?: DatabaseTemplateOptions & Omit<LocalDatabaseConfig, "provider" | "template">
267
+ ): LocalDatabaseConfig;
268
+ export declare function postgresFixture(
269
+ options?: Omit<LocalDatabaseConfig, "provider"> & {
270
+ discovery?: DiscoveryConfig;
271
+ envFiles?: string[];
272
+ }
273
+ ): ServiceConfig;
274
+ export declare function databaseServiceEnv(
275
+ prefix: string,
276
+ serviceName: string
277
+ ): Record<string, string>;
206
278
  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;
279
+ export declare function tscBuild(options?: Omit<TscBuildConfig, "kind">): TscBuildConfig;
280
+ export declare function scriptBuild(
281
+ script: string,
282
+ options?: Omit<ScriptBuildConfig, "kind" | "script">
283
+ ): ScriptBuildConfig;
284
+ export declare function stepsBuild(options?: Omit<StepsBuildConfig, "kind">): StepsBuildConfig;
285
+ export declare function nextBuild(options?: Omit<ScriptBuildConfig, "kind" | "script">): ScriptBuildConfig;
286
+ export declare function nodeApp(options: NodeAppOptions): ServiceConfig;
287
+ export declare function nextApp(options: NextAppOptions): ServiceConfig;
221
288
  export declare function clerkSessionProfile(options?: {
222
289
  apiBase?: string;
223
290
  needsAuth?: boolean;