@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.
package/README.md CHANGED
@@ -87,16 +87,13 @@ Create `testkit.setup.ts` at repo root:
87
87
 
88
88
  ```ts
89
89
  import {
90
- commandStep,
91
90
  defineTestkitSetup,
92
- localDatabase,
93
- nextService,
91
+ defineTestkitFile,
92
+ nextApp,
94
93
  nodeToolchain,
95
- schemaSql,
94
+ nodeApp,
96
95
  seedCommand,
97
- seededDatabaseTemplate,
98
- service,
99
- tsxService,
96
+ templateDatabase,
100
97
  verifyModule,
101
98
  } from "@elench/testkit/setup";
102
99
 
@@ -121,77 +118,53 @@ export default defineTestkitSetup({
121
118
  }),
122
119
  },
123
120
  services: {
124
- api: service({
125
- ...tsxService({
126
- cwd: ".",
127
- entry: "src/index.ts",
128
- port: 3004,
129
- readyPath: "/health",
130
- }),
121
+ api: nodeApp({
122
+ cwd: ".",
123
+ entry: "src/index.ts",
124
+ port: 3004,
131
125
  envFiles: [".env.testkit"],
132
- database: localDatabase({
133
- template: seededDatabaseTemplate({
134
- inputs: ["db/schema.sql", "scripts/seed.ts"],
135
- schema: schemaSql("db/schema.sql"),
136
- seed: seedCommand("npm run db:seed"),
137
- verify: verifyModule("src/testkit/verify-seed.ts#verifySeed"),
138
- }),
126
+ database: templateDatabase({
127
+ inputs: ["db/schema.sql", "scripts/seed.ts"],
128
+ schema: "db/schema.sql",
129
+ seed: seedCommand("npm run db:seed"),
130
+ verify: verifyModule("src/testkit/verify-seed.ts#verifySeed"),
139
131
  }),
140
132
  runtime: {
141
133
  instances: 1,
142
134
  maxConcurrentTasks: 4,
143
135
  },
144
- requirements: {
145
- files: [
146
- {
147
- path: "src/api/routes/__testkit__/crawls-worker-concurrency.int.testkit.ts",
148
- locks: ["global-worker-loop"],
149
- },
150
- ],
151
- },
152
136
  }),
153
- frontend: service({
154
- ...nextService({
155
- cwd: "frontend",
156
- port: 3000,
157
- start: "./node_modules/.bin/next start --port {port}",
158
- env: {
159
- NEXT_DIST_DIR: "{prepareDir}/dist",
160
- NEXT_PUBLIC_API_URL: "{baseUrl:api}",
161
- },
162
- }),
137
+ frontend: nextApp({
138
+ cwd: "frontend",
139
+ mode: "start",
140
+ port: 3000,
163
141
  dependsOn: ["api"],
164
142
  envFiles: ["frontend/.env.testkit"],
143
+ env: {
144
+ NEXT_DIST_DIR: "{prepareDir}/dist",
145
+ NEXT_PUBLIC_API_URL: "{baseUrl:api}",
146
+ },
165
147
  runtime: {
166
148
  instances: 1,
167
149
  maxConcurrentTasks: 2,
168
150
  toolchain: "frontendNode",
169
- prepare: {
170
- inputs: ["frontend/src", "frontend/public", "frontend/package.json"],
171
- steps: [commandStep("npm run build", { cwd: "frontend" })],
172
- },
173
- },
174
- }),
175
- billing: service({
176
- skip: {
177
- files: [
178
- {
179
- path: "__testkit__/invoices/invoices.int.testkit.ts",
180
- reason: "Billing is still stubbed locally",
181
- },
182
- ],
183
- suites: [
184
- {
185
- selector: "pw:lifecycle",
186
- reason: "End-to-end billing lifecycle is not implemented yet",
187
- },
188
- ],
189
151
  },
190
152
  }),
191
153
  },
192
154
  });
193
155
  ```
194
156
 
157
+ File-local execution metadata now lives next to the test when possible:
158
+
159
+ ```ts
160
+ import { defineTestkitFile } from "@elench/testkit/setup";
161
+
162
+ export const testkit = defineTestkitFile({
163
+ skip: "Billing is still stubbed locally",
164
+ locks: ["global-worker-loop"],
165
+ });
166
+ ```
167
+
195
168
  `testkit.setup.ts` is optional for simple repos, but it is the primary escape hatch
196
169
  for:
197
170
 
@@ -232,7 +205,7 @@ For most repos, prefer the intent-focused helpers:
232
205
  - `seedModule(...)`
233
206
  - `verifyCommand(...)`
234
207
  - `verifyModule(...)`
235
- - `seededDatabaseTemplate(...)`
208
+ - `templateDatabase(...)`
236
209
 
237
210
  Use raw `commandStep(...)`, `sqlFileStep(...)`, and `moduleStep(...)` arrays when
238
211
  you need lower-level control over the exact stage layout.
@@ -264,7 +237,9 @@ toolchains: {
264
237
  }),
265
238
  },
266
239
  services: {
267
- frontend: service({
240
+ frontend: nextApp({
241
+ cwd: "frontend",
242
+ port: 3000,
268
243
  runtime: {
269
244
  toolchain: "frontendNode",
270
245
  },
@@ -473,7 +448,7 @@ Git metadata.
473
448
  ## Local Databases
474
449
 
475
450
  `@elench/testkit` provisions Docker-managed local Postgres automatically for
476
- services that define `database: localDatabase(...)`.
451
+ services that define `database: postgresDatabase(...)` or `database: templateDatabase(...)`.
477
452
 
478
453
  - template databases are cached
479
454
  - runtime databases are cloned from templates when binding is `per-runtime`
@@ -0,0 +1,139 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import ts from "typescript";
4
+ import { discoverTests } from "../discovery/index.mjs";
5
+ import { loadConfigContext } from "../config/index.mjs";
6
+ import { runTestkitTypecheck } from "./typecheck.mjs";
7
+
8
+ export async function runDoctor(options = {}) {
9
+ const checks = [];
10
+ const productDir = options.dir ? path.resolve(process.cwd(), options.dir) : process.cwd();
11
+
12
+ await loadConfigContext({ dir: productDir });
13
+ checks.push({
14
+ code: "config-load",
15
+ level: "pass",
16
+ message: "Loaded testkit setup and service config",
17
+ });
18
+
19
+ const discovery = await discoverTests({ dir: productDir, diagnostics: "report" });
20
+ const discoveryErrors = discovery.diagnostics.filter((entry) => entry.severity === "error");
21
+ checks.push({
22
+ code: "discovery",
23
+ level: discoveryErrors.length === 0 ? "pass" : "fail",
24
+ message:
25
+ discoveryErrors.length === 0
26
+ ? `Discovered ${discovery.files.length} testkit files without diagnostics`
27
+ : `Discovery reported ${discoveryErrors.length} error diagnostics`,
28
+ details: discoveryErrors,
29
+ });
30
+
31
+ const playwrightViolations = findPlaywrightRuntimeImportViolations(productDir);
32
+ checks.push({
33
+ code: "playwright-runtime-imports",
34
+ level: playwrightViolations.length === 0 ? "pass" : "fail",
35
+ message:
36
+ playwrightViolations.length === 0
37
+ ? "No runtime @playwright/test imports found in testkit Playwright suites"
38
+ : `Found ${playwrightViolations.length} Playwright runtime import violation(s)`,
39
+ details: playwrightViolations,
40
+ });
41
+
42
+ const hasBrowserOrNextWork = discovery.files.some((entry) => entry.selectionType === "pw");
43
+ if (hasBrowserOrNextWork) {
44
+ const nodeCount = discovery.coverageGraph?.nodes?.length || 0;
45
+ checks.push({
46
+ code: "coverage-graph",
47
+ level: nodeCount > 0 ? "pass" : "warning",
48
+ message:
49
+ nodeCount > 0
50
+ ? `Coverage graph contains ${nodeCount} node(s)`
51
+ : "Coverage graph is empty even though browser-facing suites were discovered",
52
+ });
53
+ }
54
+
55
+ if (options.typecheck !== false) {
56
+ try {
57
+ const typecheck = await runTestkitTypecheck({ dir: productDir });
58
+ checks.push({
59
+ code: "typecheck",
60
+ level: "pass",
61
+ message: `Typechecked ${typecheck.results.length} generated program(s)`,
62
+ });
63
+ } catch (error) {
64
+ checks.push({
65
+ code: "typecheck",
66
+ level: "fail",
67
+ message: error?.result?.stderr || error?.result?.stdout || error.message,
68
+ });
69
+ }
70
+ }
71
+
72
+ const failed = checks.some((check) => check.level === "fail");
73
+ return {
74
+ ok: !failed,
75
+ productDir,
76
+ checks,
77
+ };
78
+ }
79
+
80
+ function findPlaywrightRuntimeImportViolations(productDir) {
81
+ const violations = [];
82
+ for (const absolutePath of collectFiles(productDir)) {
83
+ const sourceText = fs.readFileSync(absolutePath, "utf8");
84
+ const sourceFile = ts.createSourceFile(absolutePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
85
+
86
+ for (const statement of sourceFile.statements) {
87
+ if (!ts.isImportDeclaration(statement)) continue;
88
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
89
+ if (statement.moduleSpecifier.text !== "@playwright/test") continue;
90
+ const clause = statement.importClause;
91
+ if (clause?.isTypeOnly) continue;
92
+ if (!clause) {
93
+ violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
94
+ continue;
95
+ }
96
+ if (clause.name) {
97
+ violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
98
+ continue;
99
+ }
100
+ if (!clause.namedBindings) {
101
+ violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
102
+ continue;
103
+ }
104
+ if (ts.isNamespaceImport(clause.namedBindings)) {
105
+ violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
106
+ continue;
107
+ }
108
+ if (clause.namedBindings.elements.some((entry) => !entry.isTypeOnly)) {
109
+ violations.push(relativeViolation(productDir, absolutePath, sourceFile, statement));
110
+ }
111
+ }
112
+ }
113
+ return violations;
114
+ }
115
+
116
+ function collectFiles(rootDir, out = []) {
117
+ if (!fs.existsSync(rootDir)) return out;
118
+ for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
119
+ const absolutePath = path.join(rootDir, entry.name);
120
+ if (entry.isDirectory()) {
121
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".testkit") continue;
122
+ collectFiles(absolutePath, out);
123
+ continue;
124
+ }
125
+ if (entry.isFile() && entry.name.endsWith(".pw.testkit.ts")) {
126
+ out.push(absolutePath);
127
+ }
128
+ }
129
+ return out.sort((left, right) => left.localeCompare(right));
130
+ }
131
+
132
+ function relativeViolation(productDir, absolutePath, sourceFile, statement) {
133
+ const position = sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile));
134
+ return {
135
+ file: path.relative(productDir, absolutePath).split(path.sep).join("/"),
136
+ line: position.line + 1,
137
+ snippet: statement.getText(sourceFile),
138
+ };
139
+ }
@@ -0,0 +1,203 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { spawn } from "child_process";
4
+ import { fileURLToPath } from "url";
5
+ import { loadConfigContext } from "../config/index.mjs";
6
+ import { detectNextApp } from "../config/runtime.mjs";
7
+
8
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
9
+
10
+ export async function runTestkitTypecheck(options = {}) {
11
+ const context = await loadConfigContext({ dir: options.dir });
12
+ const productDir = context.productDir;
13
+ const tempDir = path.join(productDir, ".testkit", "_typecheck");
14
+ fs.mkdirSync(tempDir, { recursive: true });
15
+
16
+ const checks = [];
17
+ const rootTsconfig = writeRootTypecheckConfig({
18
+ productDir,
19
+ setupFile: context.setupFile,
20
+ outputDir: tempDir,
21
+ });
22
+ checks.push({
23
+ label: "repo",
24
+ cwd: productDir,
25
+ tsconfigPath: rootTsconfig,
26
+ });
27
+
28
+ for (const config of context.configs) {
29
+ const cwd = config.testkit.local?.cwd;
30
+ if (!cwd) continue;
31
+ const absoluteCwd = path.resolve(productDir, cwd);
32
+ if (!detectNextApp(absoluteCwd)) continue;
33
+
34
+ const serviceTsconfig = writeNextServiceTypecheckConfig({
35
+ productDir,
36
+ cwd,
37
+ outputDir: tempDir,
38
+ serviceName: config.name,
39
+ });
40
+ checks.push({
41
+ label: config.name,
42
+ cwd: productDir,
43
+ tsconfigPath: serviceTsconfig,
44
+ });
45
+ }
46
+
47
+ const results = [];
48
+ for (const check of checks) {
49
+ const result = await runTscCheck(check);
50
+ results.push(result);
51
+ if (!result.ok) {
52
+ const error = new Error(`testkit typecheck failed for ${check.label}`);
53
+ error.result = result;
54
+ error.results = results;
55
+ throw error;
56
+ }
57
+ }
58
+
59
+ return {
60
+ ok: true,
61
+ productDir,
62
+ results,
63
+ };
64
+ }
65
+
66
+ function writeRootTypecheckConfig({ productDir, setupFile, outputDir }) {
67
+ const tsconfigPath = path.join(outputDir, "repo.tsconfig.json");
68
+ const extendsPath = findExistingTsconfig(productDir) || null;
69
+ const config = {
70
+ ...(extendsPath ? { extends: relativeJsonPath(tsconfigPath, extendsPath) } : {}),
71
+ compilerOptions: {
72
+ baseUrl: ".",
73
+ module: "ESNext",
74
+ moduleResolution: "Bundler",
75
+ noEmit: true,
76
+ paths: packageTypePaths(tsconfigPath),
77
+ rootDir: relativeJsonPath(tsconfigPath, productDir),
78
+ target: "ES2022",
79
+ },
80
+ include: [
81
+ setupFile ? relativeJsonPath(tsconfigPath, setupFile) : "testkit.setup.ts",
82
+ "**/*.int.testkit.ts",
83
+ "**/*.e2e.testkit.ts",
84
+ "**/*.scenario.testkit.ts",
85
+ "**/*.dal.testkit.ts",
86
+ "**/*.load.testkit.ts",
87
+ "**/__testkit__/helpers/**/*.ts",
88
+ ],
89
+ exclude: ["node_modules", "dist", ".testkit", "**/*.pw.testkit.ts"],
90
+ };
91
+ fs.writeFileSync(tsconfigPath, `${JSON.stringify(config, null, 2)}\n`);
92
+ return tsconfigPath;
93
+ }
94
+
95
+ function writeNextServiceTypecheckConfig({ productDir, cwd, outputDir, serviceName }) {
96
+ const serviceDir = path.resolve(productDir, cwd);
97
+ const tsconfigPath = path.join(outputDir, `${serviceName}.tsconfig.json`);
98
+ const extendsPath =
99
+ findExistingTsconfig(serviceDir) ||
100
+ findExistingTsconfig(productDir) ||
101
+ null;
102
+ const config = {
103
+ ...(extendsPath ? { extends: relativeJsonPath(tsconfigPath, extendsPath) } : {}),
104
+ compilerOptions: {
105
+ baseUrl: ".",
106
+ module: "ESNext",
107
+ moduleResolution: "Bundler",
108
+ noEmit: true,
109
+ paths: packageTypePaths(tsconfigPath),
110
+ target: "ES2022",
111
+ },
112
+ include: [
113
+ serviceExists(serviceDir, "next-env.d.ts") ? relativeJsonPath(tsconfigPath, path.join(serviceDir, "next-env.d.ts")) : undefined,
114
+ relativeGlob(tsconfigPath, serviceDir, "src/**/*.pw.testkit.ts"),
115
+ relativeGlob(tsconfigPath, serviceDir, "tests/**/*.ts"),
116
+ relativeGlob(tsconfigPath, serviceDir, ".next/types/**/*.ts"),
117
+ relativeGlob(tsconfigPath, serviceDir, ".next/dev/types/**/*.ts"),
118
+ relativeGlob(tsconfigPath, serviceDir, ".next-testkit/**/dist/types/**/*.ts"),
119
+ relativeGlob(tsconfigPath, serviceDir, ".next-testkit/**/dist/dev/types/**/*.ts"),
120
+ ].filter(Boolean),
121
+ exclude: ["node_modules", ".next/cache", ".next/dev"],
122
+ };
123
+ fs.writeFileSync(tsconfigPath, `${JSON.stringify(config, null, 2)}\n`);
124
+ return tsconfigPath;
125
+ }
126
+
127
+ function runTscCheck({ label, cwd, tsconfigPath }) {
128
+ const binary = resolveTscBinary(cwd);
129
+ return new Promise((resolve, reject) => {
130
+ const child = spawn(binary, ["--noEmit", "-p", tsconfigPath], {
131
+ cwd,
132
+ env: process.env,
133
+ stdio: ["ignore", "pipe", "pipe"],
134
+ });
135
+ let stdout = "";
136
+ let stderr = "";
137
+ child.stdout.on("data", (chunk) => {
138
+ stdout += String(chunk);
139
+ });
140
+ child.stderr.on("data", (chunk) => {
141
+ stderr += String(chunk);
142
+ });
143
+ child.on("error", reject);
144
+ child.on("close", (code) => {
145
+ resolve({
146
+ label,
147
+ tsconfigPath,
148
+ ok: code === 0,
149
+ exitCode: code,
150
+ stdout,
151
+ stderr,
152
+ });
153
+ });
154
+ });
155
+ }
156
+
157
+ function resolveTscBinary(productDir) {
158
+ let current = productDir;
159
+ const binaryName = process.platform === "win32" ? "tsc.cmd" : "tsc";
160
+
161
+ while (true) {
162
+ const candidate = path.join(current, "node_modules", ".bin", binaryName);
163
+ if (fs.existsSync(candidate)) {
164
+ return candidate;
165
+ }
166
+ const parent = path.dirname(current);
167
+ if (parent === current) break;
168
+ current = parent;
169
+ }
170
+ const packageCandidate = path.join(PACKAGE_ROOT, "node_modules", ".bin", binaryName);
171
+ if (fs.existsSync(packageCandidate)) {
172
+ return packageCandidate;
173
+ }
174
+ throw new Error(`TypeScript compiler not found from ${productDir} upward`);
175
+ }
176
+
177
+ function findExistingTsconfig(rootDir) {
178
+ for (const candidate of ["tsconfig.json", "tsconfig.build.json"]) {
179
+ const absolute = path.join(rootDir, candidate);
180
+ if (fs.existsSync(absolute)) return absolute;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function relativeJsonPath(fromFile, targetPath) {
186
+ return path.relative(path.dirname(fromFile), targetPath).split(path.sep).join("/");
187
+ }
188
+
189
+ function relativeGlob(fromFile, rootDir, pattern) {
190
+ return path.join(relativeJsonPath(fromFile, rootDir), pattern).split(path.sep).join("/");
191
+ }
192
+
193
+ function serviceExists(rootDir, relativePath) {
194
+ return fs.existsSync(path.join(rootDir, relativePath));
195
+ }
196
+
197
+ function packageTypePaths(tsconfigPath) {
198
+ return {
199
+ "@elench/testkit": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "index.d.ts"))],
200
+ "@elench/testkit/setup": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "setup", "index.d.ts"))],
201
+ "@elench/testkit/runtime": [relativeJsonPath(tsconfigPath, path.join(PACKAGE_ROOT, "lib", "runtime", "index.d.ts"))],
202
+ };
203
+ }
@@ -0,0 +1,39 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { runDoctor } from "../../app/doctor.mjs";
3
+
4
+ export default class DoctorCommand extends Command {
5
+ static summary = "Run built-in setup, discovery, and hygiene checks";
6
+
7
+ static enableJsonFlag = true;
8
+
9
+ static flags = {
10
+ dir: Flags.string({
11
+ description: "Product directory",
12
+ }),
13
+ typecheck: Flags.boolean({
14
+ description: "Include generated TypeScript checks",
15
+ default: true,
16
+ allowNo: true,
17
+ }),
18
+ };
19
+
20
+ async run() {
21
+ const { flags } = await this.parse(DoctorCommand);
22
+ const result = await runDoctor({ dir: flags.dir, typecheck: flags.typecheck });
23
+
24
+ if (!this.jsonEnabled()) {
25
+ this.log(`testkit doctor ${result.ok ? "passed" : "failed"} for ${result.productDir}`);
26
+ for (const check of result.checks) {
27
+ this.log(`${check.level.toUpperCase()} ${check.code} ${check.message}`);
28
+ }
29
+ }
30
+
31
+ if (!result.ok) {
32
+ const error = new Error("testkit doctor failed");
33
+ error.result = result;
34
+ throw error;
35
+ }
36
+
37
+ return result;
38
+ }
39
+ }
@@ -0,0 +1,28 @@
1
+ import { Command, Flags } from "@oclif/core";
2
+ import { runTestkitTypecheck } from "../../app/typecheck.mjs";
3
+
4
+ export default class TypecheckCommand extends Command {
5
+ static summary = "Typecheck testkit setup, helpers, and suites";
6
+
7
+ static enableJsonFlag = true;
8
+
9
+ static flags = {
10
+ dir: Flags.string({
11
+ description: "Product directory",
12
+ }),
13
+ };
14
+
15
+ async run() {
16
+ const { flags } = await this.parse(TypecheckCommand);
17
+ const result = await runTestkitTypecheck({ dir: flags.dir });
18
+
19
+ if (!this.jsonEnabled()) {
20
+ this.log(`Typechecked ${result.results.length} testkit program(s) in ${result.productDir}`);
21
+ for (const entry of result.results) {
22
+ this.log(`PASS ${entry.label} ${entry.tsconfigPath}`);
23
+ }
24
+ }
25
+
26
+ return result;
27
+ }
28
+ }
@@ -9,6 +9,8 @@ export function normalizeCliArgs(argv) {
9
9
  "artifacts",
10
10
  "watch",
11
11
  "discover",
12
+ "typecheck",
13
+ "doctor",
12
14
  "browser",
13
15
  "known-failures",
14
16
  "db",
@@ -3,6 +3,7 @@ import path from "path";
3
3
  import { discoverProject } from "./discovery.mjs";
4
4
  import { loadTestkitSetup } from "./setup-loader.mjs";
5
5
  import { normalizeToolchainRegistry } from "../toolchains/index.mjs";
6
+ import { loadTestFileMetadataMap } from "../discovery/file-metadata.mjs";
6
7
  import { mergeDiscoveryConfigs } from "../discovery/path-policy.mjs";
7
8
  import { normalizeDatabaseConfig } from "./database.mjs";
8
9
  import { normalizeRepoDiscoveryConfig, normalizeServiceDiscoveryConfig } from "./discovery-config.mjs";
@@ -37,6 +38,7 @@ export async function loadConfigContext(opts = {}) {
37
38
  ...(opts.discoveryOptions || {}),
38
39
  discovery: discoveryConfig,
39
40
  });
41
+ const fileMetadataByPath = loadTestFileMetadataMap(productDir, discovery.suitesByService);
40
42
  const serviceNames = new Set([
41
43
  ...Object.keys(explicitServices),
42
44
  ...Object.keys(discovery.suitesByService),
@@ -57,6 +59,7 @@ export async function loadConfigContext(opts = {}) {
57
59
  explicitService: explicitServices[name] || {},
58
60
  discoveredService: discovery.services[name] || null,
59
61
  suites: discovery.suitesByService[name] || {},
62
+ fileMetadataByPath,
60
63
  })
61
64
  );
62
65
 
@@ -104,6 +107,7 @@ function normalizeServiceConfig({
104
107
  explicitService,
105
108
  discoveredService,
106
109
  suites,
110
+ fileMetadataByPath,
107
111
  }) {
108
112
  const local = normalizeLocalConfig(name, explicitService, discoveredService, productDir);
109
113
  const envFiles = inferEnvFiles(productDir, explicitService, local);
@@ -125,6 +129,12 @@ function normalizeServiceConfig({
125
129
  const runtime = normalizeRuntimeConfig(explicitService.runtime, name, toolchains);
126
130
  const skip = normalizeSkipConfig(explicitService.skip, { name, suites });
127
131
  const requirements = normalizeServiceRequirements(explicitService.requirements, { name, suites });
132
+ const serviceFilePaths = new Set(
133
+ Object.values(suites || {}).flatMap((suiteList) => suiteList.flatMap((suite) => suite.files || []))
134
+ );
135
+ const serviceFileMetadata = new Map(
136
+ [...fileMetadataByPath.entries()].filter(([filePath]) => serviceFilePaths.has(filePath))
137
+ );
128
138
 
129
139
  validateServiceConfig({
130
140
  name,
@@ -157,6 +167,7 @@ function normalizeServiceConfig({
157
167
  skip,
158
168
  runtime,
159
169
  browser,
170
+ fileMetadataByPath: serviceFileMetadata,
160
171
  local,
161
172
  },
162
173
  };
@@ -14,6 +14,7 @@ import {
14
14
  normalizeOptionalString,
15
15
  parseModuleSpecifier,
16
16
  } from "../shared/configured-steps.mjs";
17
+ import { buildConfigToPrepare, normalizeBuildConfig } from "../shared/build-config.mjs";
17
18
  import { normalizeKnownFailureIssueValidationConfig } from "../known-failures/github.mjs";
18
19
  import { normalizeRuntimeToolchain } from "../toolchains/index.mjs";
19
20
  import { resolveServiceCwd } from "./paths.mjs";
@@ -77,6 +78,7 @@ export function inferLocalRuntime(productDir, cwd) {
77
78
  export function normalizeRuntimeConfig(value, serviceName, toolchains) {
78
79
  if (!value) {
79
80
  return {
81
+ build: null,
80
82
  instances: 1,
81
83
  maxConcurrentTasks: Number.POSITIVE_INFINITY,
82
84
  prepare: {
@@ -87,13 +89,21 @@ export function normalizeRuntimeConfig(value, serviceName, toolchains) {
87
89
  };
88
90
  }
89
91
 
92
+ const build = normalizeBuildConfig(value.build, `Service "${serviceName}" runtime.build`);
93
+ const prepare = normalizeRuntimePrepareConfig(value.prepare, serviceName);
94
+ const buildPrepare = buildConfigToPrepare(build);
95
+
90
96
  return {
97
+ build,
91
98
  instances: normalizeRuntimeInstances(value.instances ?? 1, `Service "${serviceName}" runtime.instances`),
92
99
  maxConcurrentTasks: normalizeRuntimeMaxConcurrentTasks(
93
100
  value.maxConcurrentTasks,
94
101
  `Service "${serviceName}" runtime.maxConcurrentTasks`
95
102
  ),
96
- prepare: normalizeRuntimePrepareConfig(value.prepare, serviceName),
103
+ prepare: {
104
+ inputs: [...new Set([...(buildPrepare.inputs || []), ...(prepare.inputs || [])])],
105
+ steps: [...(buildPrepare.steps || []), ...(prepare.steps || [])],
106
+ },
97
107
  toolchain: normalizeRuntimeToolchain(
98
108
  value.toolchain,
99
109
  `Service "${serviceName}" runtime.toolchain`,