@elench/testkit 0.1.35 → 0.1.36

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
@@ -35,6 +35,9 @@ npx @elench/testkit --type int,e2e,dal -s dal:queries
35
35
  # Exact file
36
36
  npx @elench/testkit --type int --file __testkit__/health/health.int.testkit.ts
37
37
 
38
+ # Temporarily ignore repo-declared skip rules
39
+ npx @elench/testkit --ignore-skip-rules --file __testkit__/billing/billing.int.testkit.ts
40
+
38
41
  # Deterministic git-trackable status snapshot
39
42
  npx @elench/testkit --type int --write-status
40
43
 
@@ -83,6 +86,22 @@ export default defineTestkitSetup({
83
86
  dependsOn: ["api"],
84
87
  envFiles: ["frontend/.env.testkit"],
85
88
  }),
89
+ billing: service({
90
+ skip: {
91
+ files: [
92
+ {
93
+ path: "__testkit__/invoices/invoices.int.testkit.ts",
94
+ reason: "Billing is still stubbed locally",
95
+ },
96
+ ],
97
+ suites: [
98
+ {
99
+ selector: "pw:lifecycle",
100
+ reason: "End-to-end billing lifecycle is not implemented yet",
101
+ },
102
+ ],
103
+ },
104
+ }),
86
105
  },
87
106
  });
88
107
  ```
@@ -95,6 +114,7 @@ for:
95
114
  - migrate / seed commands
96
115
  - test-local migrate / seed overrides
97
116
  - named HTTP suite profiles
117
+ - repo-declared suite/file skip policies with explicit reasons
98
118
  - telemetry upload configuration
99
119
 
100
120
  ## Authoring
package/lib/cli/index.mjs CHANGED
@@ -28,6 +28,10 @@ export function run() {
28
28
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
29
29
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
30
30
  .option("--allow-partial-status", "Allow --write-status for filtered runs")
31
+ .option(
32
+ "--ignore-skip-rules",
33
+ "Run files even if testkit.setup.ts marks them skipped"
34
+ )
31
35
  .action(async (first, second, third, options) => {
32
36
  const { lifecycle, positionalType } = resolveCliSelection({
33
37
  first,
@@ -3,6 +3,11 @@ import path from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  import { discoverProject } from "./discovery.mjs";
5
5
  import { loadTestkitSetup } from "./setup-loader.mjs";
6
+ import {
7
+ matchesSuiteSelectors,
8
+ parseSuiteSelectors,
9
+ suiteSelectionType,
10
+ } from "../runner/suite-selection.mjs";
6
11
 
7
12
  const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
8
13
  const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
@@ -100,6 +105,11 @@ function normalizeServiceConfig({
100
105
  const database = normalizeDatabaseConfig(explicitService, name);
101
106
  const migrate = normalizeLifecycle(explicitService.migrate);
102
107
  const seed = normalizeLifecycle(explicitService.seed);
108
+ const skip = normalizeSkipConfig(explicitService.skip, {
109
+ name,
110
+ productDir,
111
+ suites,
112
+ });
103
113
 
104
114
  if (!explicitService.databaseFrom && !database && (migrate || seed)) {
105
115
  throw new Error(
@@ -134,6 +144,7 @@ function normalizeServiceConfig({
134
144
  serviceEnv,
135
145
  migrate,
136
146
  seed,
147
+ skip,
137
148
  local,
138
149
  },
139
150
  };
@@ -234,6 +245,96 @@ function normalizeLifecycle(value) {
234
245
  };
235
246
  }
236
247
 
248
+ function normalizeSkipConfig(value, { name, productDir, suites }) {
249
+ if (!value) return undefined;
250
+
251
+ const discoveredFiles = new Set();
252
+ const discoveredSuites = [];
253
+ for (const [type, typedSuites] of Object.entries(suites || {})) {
254
+ for (const suite of typedSuites || []) {
255
+ const displayType = suiteSelectionType(type, suite.framework || "k6");
256
+ discoveredSuites.push({
257
+ type,
258
+ displayType,
259
+ name: suite.name,
260
+ });
261
+ for (const file of suite.files || []) {
262
+ discoveredFiles.add(normalizePath(file));
263
+ }
264
+ }
265
+ }
266
+
267
+ const seenFiles = new Set();
268
+ const files = [];
269
+ for (const rule of value.files || []) {
270
+ if (!rule || typeof rule !== "object") {
271
+ throw new Error(`Service "${name}" skip.files entries must be objects`);
272
+ }
273
+ const filePath = normalizePath(rule.path);
274
+ const reason = normalizeSkipReason(rule.reason, `Service "${name}" skip.files["${filePath}"]`);
275
+ if (!filePath) {
276
+ throw new Error(`Service "${name}" skip.files entries require a non-empty path`);
277
+ }
278
+ if (seenFiles.has(filePath)) {
279
+ throw new Error(`Service "${name}" defines duplicate skip.files path "${filePath}"`);
280
+ }
281
+ if (!discoveredFiles.has(filePath)) {
282
+ throw new Error(
283
+ `Service "${name}" skip.files path "${filePath}" did not match any discovered suite file`
284
+ );
285
+ }
286
+ seenFiles.add(filePath);
287
+ files.push({ path: filePath, reason });
288
+ }
289
+
290
+ const parsedSelectors = (value.suites || []).flatMap((rule, index) => {
291
+ if (!rule || typeof rule !== "object") {
292
+ throw new Error(`Service "${name}" skip.suites entries must be objects`);
293
+ }
294
+ const selector = String(rule.selector || "").trim();
295
+ if (!selector) {
296
+ throw new Error(`Service "${name}" skip.suites[${index}] requires a non-empty selector`);
297
+ }
298
+ const reason = normalizeSkipReason(
299
+ rule.reason,
300
+ `Service "${name}" skip.suites["${selector}"]`
301
+ );
302
+ const parsed = parseSuiteSelectors([selector]);
303
+ if (parsed.length !== 1) {
304
+ throw new Error(`Service "${name}" skip.suites["${selector}"] is invalid`);
305
+ }
306
+ return [{ selector: parsed[0], reason }];
307
+ });
308
+
309
+ const seenSelectors = new Set();
310
+ const suitesWithReasons = [];
311
+ for (const rule of parsedSelectors) {
312
+ if (seenSelectors.has(rule.selector.raw)) {
313
+ throw new Error(
314
+ `Service "${name}" defines duplicate skip.suites selector "${rule.selector.raw}"`
315
+ );
316
+ }
317
+ const matched = discoveredSuites.some((suite) =>
318
+ matchesSuiteSelectors(suite.displayType, suite.name, [rule.selector])
319
+ );
320
+ if (!matched) {
321
+ throw new Error(
322
+ `Service "${name}" skip.suites selector "${rule.selector.raw}" did not match any discovered suite`
323
+ );
324
+ }
325
+ seenSelectors.add(rule.selector.raw);
326
+ suitesWithReasons.push(rule);
327
+ }
328
+
329
+ if (files.length === 0 && suitesWithReasons.length === 0) return undefined;
330
+
331
+ return {
332
+ files,
333
+ fileReasonByPath: new Map(files.map((rule) => [rule.path, rule.reason])),
334
+ suites: suitesWithReasons,
335
+ };
336
+ }
337
+
237
338
  function inferEnvFiles(productDir, explicitService, local) {
238
339
  if (explicitService.envFile || explicitService.envFiles) {
239
340
  const files = [];
@@ -256,6 +357,14 @@ function inferEnvFiles(productDir, explicitService, local) {
256
357
  .filter((candidate) => fs.existsSync(resolveServiceCwd(productDir, candidate)));
257
358
  }
258
359
 
360
+ function normalizeSkipReason(reason, label) {
361
+ const normalized = String(reason || "").trim();
362
+ if (!normalized) {
363
+ throw new Error(`${label} requires a non-empty reason`);
364
+ }
365
+ return normalized;
366
+ }
367
+
259
368
  function loadServiceEnv(productDir, envFiles) {
260
369
  const env = {};
261
370
  for (const envFile of envFiles) {
@@ -361,6 +470,13 @@ function normalizeTelemetryConfig(telemetry) {
361
470
  };
362
471
  }
363
472
 
473
+ function normalizePath(value) {
474
+ return String(value || "")
475
+ .split(path.sep)
476
+ .join("/")
477
+ .replace(/^\.\/+/, "");
478
+ }
479
+
364
480
  function resolveProductDir(cwd, explicitDir) {
365
481
  const dir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
366
482
  if (!fs.existsSync(dir)) {
@@ -18,7 +18,7 @@ export function parsePlaywrightJsonResults(stdout, cwd) {
18
18
  const fileResults = new Map();
19
19
  visitPlaywrightSuites(parsed.suites || [], null, fileResults, cwd);
20
20
  return {
21
- fileResults,
21
+ fileResults: sanitizePlaywrightFileResults(fileResults),
22
22
  errors: (parsed.errors || []).map(formatPlaywrightReporterError).filter(Boolean),
23
23
  };
24
24
  }
@@ -41,8 +41,11 @@ export function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
41
41
 
42
42
  const current = fileResults.get(file) || {
43
43
  failed: false,
44
+ status: "passed",
44
45
  error: null,
45
46
  durationMs: 0,
47
+ passedCount: 0,
48
+ skippedCount: 0,
46
49
  };
47
50
 
48
51
  for (const test of spec.tests || []) {
@@ -53,16 +56,26 @@ export function collectPlaywrightSpec(spec, inheritedFile, fileResults, cwd) {
53
56
  );
54
57
 
55
58
  const final = choosePlaywrightFinalResult(results);
56
- const failed =
57
- test.outcome === "unexpected" ||
58
- !isPlaywrightPassingStatus(final?.status);
59
+ const status = classifyPlaywrightTestOutcome(test, final);
59
60
 
60
- if (failed) {
61
+ if (status === "failed") {
61
62
  current.failed = true;
63
+ current.status = "failed";
62
64
  current.error ||= extractPlaywrightFailure(final, spec, test);
65
+ continue;
63
66
  }
67
+
68
+ if (status === "skipped") {
69
+ current.skippedCount += 1;
70
+ continue;
71
+ }
72
+
73
+ current.passedCount += 1;
64
74
  }
65
75
 
76
+ if (!current.failed) {
77
+ current.status = current.passedCount === 0 && current.skippedCount > 0 ? "skipped" : "passed";
78
+ }
66
79
  fileResults.set(file, current);
67
80
  }
68
81
 
@@ -75,6 +88,16 @@ export function isPlaywrightPassingStatus(status) {
75
88
  return !status || ["passed", "skipped", "expected"].includes(status);
76
89
  }
77
90
 
91
+ export function classifyPlaywrightTestOutcome(test, finalResult) {
92
+ if (test?.outcome === "unexpected" || !isPlaywrightPassingStatus(finalResult?.status)) {
93
+ return "failed";
94
+ }
95
+ if (test?.outcome === "skipped" || finalResult?.status === "skipped") {
96
+ return "skipped";
97
+ }
98
+ return "passed";
99
+ }
100
+
78
101
  export function extractPlaywrightFailure(finalResult, spec, test) {
79
102
  const fromResult =
80
103
  finalResult?.error?.message ||
@@ -123,3 +146,16 @@ function formatError(error) {
123
146
  if (error instanceof Error) return error.message;
124
147
  return String(error);
125
148
  }
149
+
150
+ function sanitizePlaywrightFileResults(fileResults) {
151
+ const sanitized = new Map();
152
+ for (const [file, result] of fileResults.entries()) {
153
+ sanitized.set(file, {
154
+ failed: result.failed,
155
+ status: result.status,
156
+ error: result.error,
157
+ durationMs: result.durationMs,
158
+ });
159
+ }
160
+ return sanitized;
161
+ }
@@ -49,11 +49,94 @@ describe("playwright-report", () => {
49
49
  expect(parsed.errors).toEqual(["reporter failure"]);
50
50
  expect(parsed.fileResults.get("tests/auth.spec.js")).toEqual({
51
51
  failed: true,
52
+ status: "failed",
52
53
  error: "boom",
53
54
  durationMs: 15,
54
55
  });
55
56
  });
56
57
 
58
+ it("marks files as skipped when every Playwright test is skipped", () => {
59
+ const stdout = JSON.stringify({
60
+ suites: [
61
+ {
62
+ file: "/tmp/tests/billing.spec.js",
63
+ specs: [
64
+ {
65
+ title: "billing is stubbed",
66
+ tests: [
67
+ {
68
+ outcome: "skipped",
69
+ results: [
70
+ {
71
+ status: "skipped",
72
+ duration: 7,
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ },
78
+ ],
79
+ },
80
+ ],
81
+ });
82
+
83
+ const parsed = parsePlaywrightJsonResults(stdout, "/tmp");
84
+ expect(parsed.fileResults.get("tests/billing.spec.js")).toEqual({
85
+ failed: false,
86
+ status: "skipped",
87
+ error: null,
88
+ durationMs: 7,
89
+ });
90
+ });
91
+
92
+ it("keeps file status passed when one spec passes and another is skipped", () => {
93
+ const stdout = JSON.stringify({
94
+ suites: [
95
+ {
96
+ file: "/tmp/tests/mixed.spec.js",
97
+ specs: [
98
+ {
99
+ title: "passes",
100
+ tests: [
101
+ {
102
+ outcome: "expected",
103
+ results: [
104
+ {
105
+ status: "passed",
106
+ duration: 5,
107
+ },
108
+ ],
109
+ },
110
+ ],
111
+ },
112
+ {
113
+ title: "skips",
114
+ tests: [
115
+ {
116
+ outcome: "skipped",
117
+ results: [
118
+ {
119
+ status: "skipped",
120
+ duration: 3,
121
+ },
122
+ ],
123
+ },
124
+ ],
125
+ },
126
+ ],
127
+ },
128
+ ],
129
+ });
130
+
131
+ const parsed = parsePlaywrightJsonResults(stdout, "/tmp");
132
+ expect(parsed.fileResults.get("tests/mixed.spec.js")).toEqual({
133
+ failed: false,
134
+ status: "passed",
135
+ error: null,
136
+ durationMs: 8,
137
+ });
138
+ });
139
+
57
140
  it("chooses the final result and extracts failures", () => {
58
141
  expect(
59
142
  choosePlaywrightFinalResult([
@@ -7,12 +7,18 @@ export function formatDuration(durationMs) {
7
7
  }
8
8
 
9
9
  export function formatServiceSummary(result) {
10
- const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
10
+ const skippedSuites = result.skippedSuiteCount || 0;
11
+ const passedSuites = result.completedSuiteCount - result.failedSuiteCount - skippedSuites;
11
12
  const notRunSuites = result.suiteCount - result.completedSuiteCount;
12
13
  let detail = `${passedSuites}/${result.suiteCount} suites passed`;
13
14
  if ((result.totalFileCount || 0) > 0) {
14
15
  detail += `, ${result.passedFileCount}/${result.totalFileCount} files passed`;
15
16
  }
17
+ if (skippedSuites > 0) {
18
+ detail += `, ${skippedSuites} ${pluralize(skippedSuites, "suite", "suites")} skipped`;
19
+ } else if ((result.skippedFileCount || 0) > 0) {
20
+ detail += `, ${result.skippedFileCount} ${pluralize(result.skippedFileCount, "file", "files")} skipped`;
21
+ }
16
22
  if (notRunSuites > 0) {
17
23
  detail += `, ${notRunSuites} ${pluralize(notRunSuites, "suite", "suites")} not run`;
18
24
  } else if ((result.notRunFileCount || 0) > 0) {
@@ -66,10 +72,18 @@ export function buildRunSummaryLines(results, durationMs) {
66
72
  (sum, result) => sum + result.completedSuiteCount,
67
73
  0
68
74
  );
75
+ const skippedSuites = executedServices.reduce(
76
+ (sum, result) => sum + (result.skippedSuiteCount || 0),
77
+ 0
78
+ );
69
79
  const failedSuites = executedServices.reduce((sum, result) => sum + result.failedSuiteCount, 0);
70
- const passedSuites = completedSuites - failedSuites;
80
+ const passedSuites = completedSuites - failedSuites - skippedSuites;
71
81
  const totalFiles = executedServices.reduce((sum, result) => sum + (result.totalFileCount || 0), 0);
72
82
  const passedFiles = executedServices.reduce((sum, result) => sum + (result.passedFileCount || 0), 0);
83
+ const skippedFiles = executedServices.reduce(
84
+ (sum, result) => sum + (result.skippedFileCount || 0),
85
+ 0
86
+ );
73
87
  const lines = [
74
88
  "",
75
89
  "══ Summary ══",
@@ -77,6 +91,8 @@ export function buildRunSummaryLines(results, durationMs) {
77
91
  `services ${passedServices.length}/${executedServices.length} passed`,
78
92
  `suites ${passedSuites}/${totalSuites} passed`,
79
93
  totalFiles > 0 ? `files ${passedFiles}/${totalFiles} passed` : null,
94
+ skippedSuites > 0 ? `suites ${skippedSuites} skipped` : null,
95
+ skippedFiles > 0 ? `files ${skippedFiles} skipped` : null,
80
96
  skippedServices.length > 0 ? `${skippedServices.length} skipped` : null,
81
97
  `duration ${formatDuration(durationMs)}`,
82
98
  ]
@@ -85,8 +101,14 @@ export function buildRunSummaryLines(results, durationMs) {
85
101
  ];
86
102
 
87
103
  for (const result of results) {
88
- const status = result.skipped ? "SKIP" : result.failed ? "FAIL" : "PASS";
89
- const detail = result.skipped ? "no matching suites" : formatServiceSummary(result);
104
+ const status = isServiceEffectivelySkipped(result)
105
+ ? "SKIP"
106
+ : result.failed
107
+ ? "FAIL"
108
+ : "PASS";
109
+ const detail = result.skipped
110
+ ? "no matching suites"
111
+ : formatServiceSummary(result);
90
112
  lines.push(
91
113
  `${status.padEnd(4)} ${result.name.padEnd(longestServiceName(results))} ${detail} · ${formatDuration(result.durationMs)}`
92
114
  );
@@ -127,3 +149,13 @@ function sanitizeErrorMessage(message) {
127
149
  function pluralize(value, singular, plural) {
128
150
  return value === 1 ? singular : plural;
129
151
  }
152
+
153
+ function isServiceEffectivelySkipped(result) {
154
+ if (result.skipped) return true;
155
+ return (
156
+ !result.failed &&
157
+ (result.skippedSuiteCount || 0) > 0 &&
158
+ (result.skippedSuiteCount || 0) === result.suiteCount &&
159
+ (result.notRunFileCount || 0) === 0
160
+ );
161
+ }
@@ -28,14 +28,31 @@ describe("runner formatting", () => {
28
28
  formatServiceSummary({
29
29
  completedSuiteCount: 2,
30
30
  failedSuiteCount: 1,
31
+ skippedSuiteCount: 0,
31
32
  suiteCount: 3,
32
33
  totalFileCount: 5,
33
34
  passedFileCount: 3,
35
+ skippedFileCount: 0,
34
36
  notRunFileCount: 1,
35
37
  })
36
38
  ).toBe("1/3 suites passed, 3/5 files passed, 1 suite not run");
37
39
  });
38
40
 
41
+ it("formats skipped suites distinctly from passed suites", () => {
42
+ expect(
43
+ formatServiceSummary({
44
+ completedSuiteCount: 2,
45
+ failedSuiteCount: 0,
46
+ skippedSuiteCount: 1,
47
+ suiteCount: 2,
48
+ totalFileCount: 3,
49
+ passedFileCount: 1,
50
+ skippedFileCount: 2,
51
+ notRunFileCount: 0,
52
+ })
53
+ ).toBe("1/2 suites passed, 1/3 files passed, 1 suite skipped");
54
+ });
55
+
39
56
  it("formats batch descriptors", () => {
40
57
  expect(formatBatchDescriptor({ framework: "k6", tasks: [{}, {}] })).toBe(" (2 files)");
41
58
  expect(formatBatchDescriptor({ framework: "playwright", tasks: [{}] })).toBe(
@@ -71,9 +88,12 @@ describe("runner formatting", () => {
71
88
  failed: true,
72
89
  suiteCount: 2,
73
90
  completedSuiteCount: 2,
91
+ skippedSuiteCount: 0,
74
92
  failedSuiteCount: 1,
75
93
  totalFileCount: 3,
76
94
  passedFileCount: 2,
95
+ skippedFileCount: 0,
96
+ notRunFileCount: 0,
77
97
  durationMs: 20_000,
78
98
  suites: [
79
99
  {
@@ -97,4 +117,33 @@ describe("runner formatting", () => {
97
117
  expect(lines.join("\n")).toContain("worker error: worker broke");
98
118
  expect(lines.at(-1)).toBe("Result: FAILED (1/1 services failed)");
99
119
  });
120
+
121
+ it("marks services with only skipped suites as SKIP", () => {
122
+ const lines = buildRunSummaryLines(
123
+ [
124
+ {
125
+ name: "api",
126
+ skipped: false,
127
+ failed: false,
128
+ suiteCount: 1,
129
+ completedSuiteCount: 1,
130
+ skippedSuiteCount: 1,
131
+ failedSuiteCount: 0,
132
+ totalFileCount: 1,
133
+ passedFileCount: 0,
134
+ skippedFileCount: 1,
135
+ notRunFileCount: 0,
136
+ durationMs: 0,
137
+ suites: [],
138
+ errors: [],
139
+ },
140
+ ],
141
+ 0
142
+ );
143
+
144
+ expect(lines.join("\n")).toContain("suites 1 skipped");
145
+ expect(lines.join("\n")).toContain("files 1 skipped");
146
+ expect(lines.join("\n")).toContain("SKIP api");
147
+ expect(lines.at(-1)).toBe("Result: PASSED");
148
+ });
100
149
  });
@@ -189,7 +189,7 @@ function collectServicePlans(configs, configMap, typeValues, suiteSelectors, opt
189
189
  return configs.map((config) => {
190
190
  console.log(`\n══ ${config.name} ══`);
191
191
  const suites = applyShard(
192
- collectSuites(config, typeValues, suiteSelectors, opts.fileNames || []),
192
+ collectSuites(config, typeValues, suiteSelectors, opts.fileNames || [], opts),
193
193
  opts.shard
194
194
  );
195
195
 
@@ -39,7 +39,7 @@ export function resolveRuntimeConfigs(targetConfig, configMap) {
39
39
  return ordered;
40
40
  }
41
41
 
42
- export function collectSuites(config, typeValues, suiteSelectors, fileNames = []) {
42
+ export function collectSuites(config, typeValues, suiteSelectors, fileNames = [], opts = {}) {
43
43
  const selectedFiles = new Set(fileNames.map(normalizePathSeparators));
44
44
  const suites = [];
45
45
  let orderIndex = 0;
@@ -48,17 +48,26 @@ export function collectSuites(config, typeValues, suiteSelectors, fileNames = []
48
48
  for (const suite of config.suites[type] || []) {
49
49
  const framework = suite.framework || "k6";
50
50
  const displayType = suiteSelectionType(type, framework);
51
- const files =
51
+ const selectedSuiteFiles =
52
52
  selectedFiles.size === 0
53
53
  ? suite.files
54
54
  : suite.files.filter((file) => selectedFiles.has(normalizePathSeparators(file)));
55
55
  if (!matchesSelectedTypes(displayType, typeValues)) continue;
56
56
  if (!matchesSuiteSelectors(displayType, suite.name, suiteSelectors)) continue;
57
- if (files.length === 0) continue;
57
+ if (selectedSuiteFiles.length === 0) continue;
58
+
59
+ const { files, skippedFiles } = applySkipRules(
60
+ config,
61
+ displayType,
62
+ suite.name,
63
+ selectedSuiteFiles,
64
+ opts
65
+ );
58
66
 
59
67
  suites.push({
60
68
  ...suite,
61
69
  files,
70
+ skippedFiles,
62
71
  framework,
63
72
  type,
64
73
  displayType,
@@ -67,12 +76,13 @@ export function collectSuites(config, typeValues, suiteSelectors, fileNames = []
67
76
  weight:
68
77
  suite.testkit?.weight ||
69
78
  (framework === "playwright"
70
- ? Math.max(2, files.length)
79
+ ? Math.max(2, Math.max(1, files.length))
71
80
  : Math.max(1, files.length)),
72
81
  maxFileConcurrency:
73
82
  framework === "k6" || framework === "playwright"
74
83
  ? suite.testkit?.maxFileConcurrency || 1
75
84
  : 1,
85
+ totalFileCount: selectedSuiteFiles.length,
76
86
  });
77
87
  orderIndex += 1;
78
88
  }
@@ -262,6 +272,47 @@ function normalizePathSeparators(filePath) {
262
272
  return String(filePath).split("\\").join("/");
263
273
  }
264
274
 
275
+ function applySkipRules(config, displayType, suiteName, files, opts = {}) {
276
+ if (opts.ignoreSkipRules) {
277
+ return {
278
+ files,
279
+ skippedFiles: [],
280
+ };
281
+ }
282
+ const skip = config.testkit?.skip;
283
+ if (!skip) {
284
+ return {
285
+ files,
286
+ skippedFiles: [],
287
+ };
288
+ }
289
+
290
+ const matchingSuiteRules = skip.suites.filter((rule) =>
291
+ matchesSuiteSelectors(displayType, suiteName, [rule.selector])
292
+ );
293
+ const suiteReason = matchingSuiteRules[0]?.reason || null;
294
+ const runnableFiles = [];
295
+ const skippedFiles = [];
296
+
297
+ for (const file of files) {
298
+ const normalizedFile = normalizePathSeparators(file);
299
+ const reason = skip.fileReasonByPath.get(normalizedFile) || suiteReason;
300
+ if (reason) {
301
+ skippedFiles.push({
302
+ path: normalizedFile,
303
+ reason,
304
+ });
305
+ continue;
306
+ }
307
+ runnableFiles.push(file);
308
+ }
309
+
310
+ return {
311
+ files: runnableFiles,
312
+ skippedFiles,
313
+ };
314
+ }
315
+
265
316
  export function buildGraphDirName(runtimeNames) {
266
317
  const slug = runtimeNames.map(slugSegment).join("__");
267
318
  return slug.length > 0 ? slug : "graph";
@@ -85,6 +85,57 @@ describe("runner-planning", () => {
85
85
  });
86
86
  });
87
87
 
88
+ it("keeps skipped files visible while removing them from runnable work", () => {
89
+ const config = makeConfig("api", {
90
+ suites: {
91
+ integration: [
92
+ {
93
+ name: "billing",
94
+ files: [
95
+ "__testkit__/billing/a.int.testkit.ts",
96
+ "__testkit__/billing/b.int.testkit.ts",
97
+ ],
98
+ },
99
+ ],
100
+ },
101
+ testkit: {
102
+ dependsOn: [],
103
+ skip: {
104
+ fileReasonByPath: new Map([
105
+ ["__testkit__/billing/a.int.testkit.ts", "Billing is stubbed"],
106
+ ]),
107
+ suites: [],
108
+ },
109
+ },
110
+ });
111
+
112
+ expect(collectSuites(config, ["int"], [], [])).toEqual([
113
+ expect.objectContaining({
114
+ name: "billing",
115
+ files: ["__testkit__/billing/b.int.testkit.ts"],
116
+ skippedFiles: [
117
+ {
118
+ path: "__testkit__/billing/a.int.testkit.ts",
119
+ reason: "Billing is stubbed",
120
+ },
121
+ ],
122
+ totalFileCount: 2,
123
+ }),
124
+ ]);
125
+
126
+ expect(collectSuites(config, ["int"], [], [], { ignoreSkipRules: true })).toEqual([
127
+ expect.objectContaining({
128
+ name: "billing",
129
+ files: [
130
+ "__testkit__/billing/a.int.testkit.ts",
131
+ "__testkit__/billing/b.int.testkit.ts",
132
+ ],
133
+ skippedFiles: [],
134
+ totalFileCount: 2,
135
+ }),
136
+ ]);
137
+ });
138
+
88
139
  it("applies shards, builds graphs, queues tasks, and claims batches", () => {
89
140
  const api = makeConfig("api");
90
141
  const frontend = makeConfig("frontend");
@@ -62,7 +62,8 @@ export async function runPlaywrightBatch(targetConfig, batch, lifecycle) {
62
62
  if (fileResult) {
63
63
  return {
64
64
  task,
65
- failed: fileResult.failed,
65
+ failed: fileResult.status === "failed",
66
+ status: fileResult.status,
66
67
  error: fileResult.error,
67
68
  durationMs:
68
69
  fileResult.durationMs > 0
@@ -11,17 +11,22 @@ export function buildStatusArtifact({
11
11
  metadata,
12
12
  }) {
13
13
  const executedResults = results.filter((result) => !result.skipped);
14
+ const effectivelySkippedResults = executedResults.filter(isEffectivelySkippedService);
14
15
  const tests = [];
15
16
 
16
17
  for (const result of executedResults) {
17
18
  for (const suite of result.suites) {
18
19
  for (const file of suite.files) {
19
- tests.push({
20
+ const test = {
20
21
  service: result.name,
21
22
  type: suite.type,
22
23
  path: file.path,
23
24
  status: file.status,
24
- });
25
+ };
26
+ if (file.reason) {
27
+ test.reason = file.reason;
28
+ }
29
+ tests.push(test);
25
30
  }
26
31
  }
27
32
  }
@@ -36,13 +41,17 @@ export function buildStatusArtifact({
36
41
  const summary = {
37
42
  services: {
38
43
  total: executedResults.length,
39
- passed: executedResults.filter((result) => !result.failed).length,
44
+ passed: executedResults.filter(
45
+ (result) => !result.failed && !isEffectivelySkippedService(result)
46
+ ).length,
40
47
  failed: executedResults.filter((result) => result.failed).length,
48
+ skipped: effectivelySkippedResults.length,
41
49
  },
42
50
  tests: {
43
51
  total: tests.length,
44
52
  passed: tests.filter((test) => test.status === "passed").length,
45
53
  failed: tests.filter((test) => test.status === "failed").length,
54
+ skipped: tests.filter((test) => test.status === "skipped").length,
46
55
  notRun: tests.filter((test) => test.status === "not_run").length,
47
56
  },
48
57
  };
@@ -63,7 +72,7 @@ export function buildStatusArtifact({
63
72
  scope.serviceFilter === null;
64
73
 
65
74
  return {
66
- schemaVersion: 2,
75
+ schemaVersion: 3,
67
76
  source: "testkit",
68
77
  notice: "Generated file. Do not edit manually.",
69
78
  product: {
@@ -96,18 +105,21 @@ export function buildRunArtifact({
96
105
  summarizeDbBackend,
97
106
  }) {
98
107
  const executed = results.filter((result) => !result.skipped);
108
+ const effectivelySkippedServices = executed.filter(isEffectivelySkippedService);
99
109
  const failedServices = executed.filter((result) => result.failed);
100
110
  const totalSuites = executed.reduce((sum, result) => sum + result.suiteCount, 0);
101
111
  const completedSuites = executed.reduce((sum, result) => sum + result.completedSuiteCount, 0);
112
+ const skippedSuites = executed.reduce((sum, result) => sum + (result.skippedSuiteCount || 0), 0);
102
113
  const failedSuites = executed.reduce((sum, result) => sum + result.failedSuiteCount, 0);
103
114
  const totalFiles = executed.reduce((sum, result) => sum + (result.totalFileCount || 0), 0);
104
115
  const passedFiles = executed.reduce((sum, result) => sum + (result.passedFileCount || 0), 0);
105
116
  const failedFiles = executed.reduce((sum, result) => sum + (result.failedFileCount || 0), 0);
117
+ const skippedFiles = executed.reduce((sum, result) => sum + (result.skippedFileCount || 0), 0);
106
118
  const notRunFiles = executed.reduce((sum, result) => sum + (result.notRunFileCount || 0), 0);
107
119
  const dbBackend = summarizeDbBackend(results);
108
120
 
109
121
  return {
110
- schemaVersion: 2,
122
+ schemaVersion: 3,
111
123
  source: "testkit",
112
124
  generatedAt: new Date(finishedAt).toISOString(),
113
125
  product: {
@@ -134,19 +146,22 @@ export function buildRunArtifact({
134
146
  summary: {
135
147
  services: {
136
148
  total: executed.length,
137
- passed: executed.length - failedServices.length,
149
+ passed: executed.length - failedServices.length - effectivelySkippedServices.length,
138
150
  failed: failedServices.length,
151
+ skipped: effectivelySkippedServices.length,
139
152
  },
140
153
  suites: {
141
154
  total: totalSuites,
142
155
  completed: completedSuites,
143
- passed: completedSuites - failedSuites,
156
+ passed: completedSuites - failedSuites - skippedSuites,
144
157
  failed: failedSuites,
158
+ skipped: skippedSuites,
145
159
  },
146
160
  files: {
147
161
  total: totalFiles,
148
162
  passed: passedFiles,
149
163
  failed: failedFiles,
164
+ skipped: skippedFiles,
150
165
  notRun: notRunFiles,
151
166
  },
152
167
  },
@@ -156,11 +171,13 @@ export function buildRunArtifact({
156
171
  skipped: result.skipped,
157
172
  suiteCount: result.suiteCount,
158
173
  completedSuiteCount: result.completedSuiteCount,
174
+ skippedSuiteCount: result.skippedSuiteCount,
159
175
  failedSuiteCount: result.failedSuiteCount,
160
176
  totalFileCount: result.totalFileCount,
161
177
  completedFileCount: result.completedFileCount,
162
178
  passedFileCount: result.passedFileCount,
163
179
  failedFileCount: result.failedFileCount,
180
+ skippedFileCount: result.skippedFileCount,
164
181
  notRunFileCount: result.notRunFileCount,
165
182
  durationMs: result.durationMs,
166
183
  totalTaskDurationMs: result.totalTaskDurationMs,
@@ -170,3 +187,13 @@ export function buildRunArtifact({
170
187
  })),
171
188
  };
172
189
  }
190
+
191
+ function isEffectivelySkippedService(result) {
192
+ return (
193
+ !result.skipped &&
194
+ !result.failed &&
195
+ (result.skippedSuiteCount || 0) > 0 &&
196
+ (result.skippedSuiteCount || 0) === result.suiteCount &&
197
+ (result.notRunFileCount || 0) === 0
198
+ );
199
+ }
@@ -10,11 +10,13 @@ describe("runner reporting", () => {
10
10
  skipped: false,
11
11
  suiteCount: 1,
12
12
  completedSuiteCount: 1,
13
+ skippedSuiteCount: 0,
13
14
  failedSuiteCount: 0,
14
15
  totalFileCount: 3,
15
16
  completedFileCount: 3,
16
17
  passedFileCount: 3,
17
18
  failedFileCount: 0,
19
+ skippedFileCount: 0,
18
20
  notRunFileCount: 0,
19
21
  durationMs: 1200,
20
22
  totalTaskDurationMs: 2400,
@@ -28,11 +30,13 @@ describe("runner reporting", () => {
28
30
  skipped: true,
29
31
  suiteCount: 0,
30
32
  completedSuiteCount: 0,
33
+ skippedSuiteCount: 0,
31
34
  failedSuiteCount: 0,
32
35
  totalFileCount: 0,
33
36
  completedFileCount: 0,
34
37
  passedFileCount: 0,
35
38
  failedFileCount: 0,
39
+ skippedFileCount: 0,
36
40
  notRunFileCount: 0,
37
41
  durationMs: 0,
38
42
  totalTaskDurationMs: 0,
@@ -70,11 +74,25 @@ describe("runner reporting", () => {
70
74
  });
71
75
 
72
76
  expect(artifact.product.name).toBe("my-product");
73
- expect(artifact.summary.services.total).toBe(1);
77
+ expect(artifact.schemaVersion).toBe(3);
78
+ expect(artifact.summary.services).toEqual({
79
+ total: 1,
80
+ passed: 1,
81
+ failed: 0,
82
+ skipped: 0,
83
+ });
84
+ expect(artifact.summary.suites).toEqual({
85
+ total: 1,
86
+ completed: 1,
87
+ passed: 1,
88
+ failed: 0,
89
+ skipped: 0,
90
+ });
74
91
  expect(artifact.summary.files).toEqual({
75
92
  total: 3,
76
93
  passed: 3,
77
94
  failed: 0,
95
+ skipped: 0,
78
96
  notRun: 0,
79
97
  });
80
98
  expect(artifact.services[0].durationMs).toBe(1200);
@@ -96,7 +114,11 @@ describe("runner reporting", () => {
96
114
  framework: "k6",
97
115
  files: [
98
116
  { path: "tests/api/integration/a.int.testkit.ts", status: "passed" },
99
- { path: "tests/api/integration/b.int.testkit.ts", status: "failed" },
117
+ {
118
+ path: "tests/api/integration/b.int.testkit.ts",
119
+ status: "skipped",
120
+ reason: "Billing is stubbed",
121
+ },
100
122
  ],
101
123
  },
102
124
  ],
@@ -117,7 +139,7 @@ describe("runner reporting", () => {
117
139
  });
118
140
 
119
141
  expect(status).toEqual({
120
- schemaVersion: 2,
142
+ schemaVersion: 3,
121
143
  source: "testkit",
122
144
  notice: "Generated file. Do not edit manually.",
123
145
  product: {
@@ -141,11 +163,13 @@ describe("runner reporting", () => {
141
163
  total: 1,
142
164
  passed: 0,
143
165
  failed: 1,
166
+ skipped: 0,
144
167
  },
145
168
  tests: {
146
169
  total: 2,
147
170
  passed: 1,
148
- failed: 1,
171
+ failed: 0,
172
+ skipped: 1,
149
173
  notRun: 0,
150
174
  },
151
175
  },
@@ -160,7 +184,8 @@ describe("runner reporting", () => {
160
184
  service: "api",
161
185
  type: "int",
162
186
  path: "tests/api/integration/b.int.testkit.ts",
163
- status: "failed",
187
+ status: "skipped",
188
+ reason: "Billing is stubbed",
164
189
  },
165
190
  ],
166
191
  });
@@ -29,12 +29,12 @@ export function buildServiceTrackers(servicePlans, startedAt) {
29
29
  displayType: suite.displayType || suite.type,
30
30
  framework: suite.framework,
31
31
  orderIndex: suite.orderIndex,
32
- fileCount: suite.files.length,
32
+ fileCount: suite.totalFileCount ?? suite.files.length,
33
33
  completedFileCount: 0,
34
34
  failedFiles: [],
35
35
  failedFileSet: new Set(),
36
- fileResultsByPath: new Map(
37
- suite.files.map((file) => {
36
+ fileResultsByPath: new Map([
37
+ ...suite.files.map((file) => {
38
38
  const normalizedPath = normalizePathSeparators(file);
39
39
  return [
40
40
  normalizedPath,
@@ -43,11 +43,23 @@ export function buildServiceTrackers(servicePlans, startedAt) {
43
43
  failed: false,
44
44
  durationMs: 0,
45
45
  error: null,
46
+ reason: null,
46
47
  status: "not_run",
47
48
  },
48
49
  ];
49
- })
50
- ),
50
+ }),
51
+ ...(suite.skippedFiles || []).map((file) => [
52
+ file.path,
53
+ {
54
+ path: file.path,
55
+ failed: false,
56
+ durationMs: 0,
57
+ error: null,
58
+ reason: file.reason,
59
+ status: "skipped",
60
+ },
61
+ ]),
62
+ ]),
51
63
  durationMs: 0,
52
64
  error: null,
53
65
  }));
@@ -93,25 +105,30 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
93
105
  : Math.max(tracker.lastTaskAt, outcomeFinishedAt);
94
106
  tracker.totalTaskDurationMs += outcomeDurationMs;
95
107
 
96
- suite.completedFileCount += 1;
97
108
  suite.durationMs += outcomeDurationMs;
98
109
  const normalizedPath = normalizePathSeparators(task.file);
99
110
  const existingFileResult = suite.fileResultsByPath.get(normalizedPath);
111
+ const status = normalizeOutcomeStatus(outcome);
112
+ if (status !== "skipped") {
113
+ suite.completedFileCount += 1;
114
+ }
100
115
  if (existingFileResult) {
101
- existingFileResult.failed = outcome.failed;
116
+ existingFileResult.failed = status === "failed";
102
117
  existingFileResult.durationMs = outcomeDurationMs;
103
118
  existingFileResult.error = outcome.error;
104
- existingFileResult.status = outcome.failed ? "failed" : "passed";
119
+ existingFileResult.reason = outcome.reason || null;
120
+ existingFileResult.status = status;
105
121
  } else {
106
122
  suite.fileResultsByPath.set(normalizedPath, {
107
123
  path: normalizedPath,
108
- failed: outcome.failed,
124
+ failed: status === "failed",
109
125
  durationMs: outcomeDurationMs,
110
126
  error: outcome.error,
111
- status: outcome.failed ? "failed" : "passed",
127
+ reason: outcome.reason || null,
128
+ status,
112
129
  });
113
130
  }
114
- if (outcome.failed && !suite.failedFileSet.has(task.file)) {
131
+ if (status === "failed" && !suite.failedFileSet.has(task.file)) {
115
132
  suite.failedFileSet.add(task.file);
116
133
  suite.failedFiles.push(task.file);
117
134
  }
@@ -145,7 +162,14 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
145
162
  skipped: true,
146
163
  suiteCount: 0,
147
164
  completedSuiteCount: 0,
165
+ skippedSuiteCount: 0,
148
166
  failedSuiteCount: 0,
167
+ totalFileCount: 0,
168
+ completedFileCount: 0,
169
+ passedFileCount: 0,
170
+ failedFileCount: 0,
171
+ skippedFileCount: 0,
172
+ notRunFileCount: 0,
149
173
  durationMs: 0,
150
174
  totalTaskDurationMs: 0,
151
175
  suites: [],
@@ -158,14 +182,18 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
158
182
  .map((suite) => finalizeSuite(suite));
159
183
 
160
184
  const completedSuiteCount = suites.filter(
161
- (suite) => suite.completedFileCount === suite.fileCount
185
+ (suite) => suite.completedFileCount + suite.skippedFileCount === suite.fileCount
186
+ ).length;
187
+ const skippedSuiteCount = suites.filter(
188
+ (suite) => suite.skippedFileCount === suite.fileCount && suite.fileCount > 0
162
189
  ).length;
163
190
  const failedSuiteCount = suites.filter((suite) => suite.failedFileCount > 0).length;
164
191
  const totalFileCount = suites.reduce((sum, suite) => sum + suite.fileCount, 0);
165
192
  const completedFileCount = suites.reduce((sum, suite) => sum + suite.completedFileCount, 0);
166
193
  const failedFileCount = suites.reduce((sum, suite) => sum + suite.failedFileCount, 0);
167
194
  const passedFileCount = suites.reduce((sum, suite) => sum + suite.passedFileCount, 0);
168
- const notRunFileCount = totalFileCount - completedFileCount;
195
+ const skippedFileCount = suites.reduce((sum, suite) => sum + suite.skippedFileCount, 0);
196
+ const notRunFileCount = totalFileCount - completedFileCount - skippedFileCount;
169
197
  const totalTaskDurationMs =
170
198
  tracker.totalTaskDurationMs || suites.reduce((sum, suite) => sum + suite.durationMs, 0);
171
199
  const durationMs =
@@ -183,11 +211,13 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
183
211
  skipped: false,
184
212
  suiteCount: tracker.suiteCount,
185
213
  completedSuiteCount,
214
+ skippedSuiteCount,
186
215
  failedSuiteCount,
187
216
  totalFileCount,
188
217
  completedFileCount,
189
218
  passedFileCount,
190
219
  failedFileCount,
220
+ skippedFileCount,
191
221
  notRunFileCount,
192
222
  durationMs,
193
223
  totalTaskDurationMs,
@@ -212,6 +242,7 @@ function finalizeSuite(suite) {
212
242
  status: file.status,
213
243
  durationMs: file.durationMs,
214
244
  error: file.error,
245
+ reason: file.reason,
215
246
  }));
216
247
 
217
248
  return {
@@ -223,7 +254,11 @@ function finalizeSuite(suite) {
223
254
  completedFileCount: suite.completedFileCount,
224
255
  passedFileCount: files.filter((file) => file.status === "passed").length,
225
256
  failedFileCount: suite.failedFiles.length,
226
- notRunFileCount: suite.fileCount - suite.completedFileCount,
257
+ skippedFileCount: files.filter((file) => file.status === "skipped").length,
258
+ notRunFileCount:
259
+ suite.fileCount -
260
+ suite.completedFileCount -
261
+ files.filter((file) => file.status === "skipped").length,
227
262
  failedFiles: suite.failedFiles,
228
263
  durationMs: suite.durationMs,
229
264
  error: suite.error,
@@ -239,3 +274,8 @@ function formatFrameworkForArtifact(framework) {
239
274
  if (framework === "k6") return "default";
240
275
  return framework;
241
276
  }
277
+
278
+ function normalizeOutcomeStatus(outcome) {
279
+ if (outcome?.status === "skipped") return "skipped";
280
+ return outcome?.failed ? "failed" : "passed";
281
+ }
@@ -72,6 +72,7 @@ describe("runner results", () => {
72
72
  status: "failed",
73
73
  durationMs: 250,
74
74
  error: "boom",
75
+ reason: null,
75
76
  },
76
77
  ]);
77
78
  });
@@ -193,6 +194,119 @@ describe("runner results", () => {
193
194
  expect(result.totalTaskDurationMs).toBe(3_000);
194
195
  });
195
196
 
197
+ it("counts config-skipped files and runtime-skipped files separately from passed work", () => {
198
+ const trackers = buildServiceTrackers(
199
+ [
200
+ {
201
+ skipped: false,
202
+ config: {
203
+ name: "api",
204
+ testkit: {
205
+ database: {
206
+ selectedBackend: null,
207
+ },
208
+ },
209
+ },
210
+ suites: [
211
+ {
212
+ name: "billing",
213
+ type: "integration",
214
+ framework: "k6",
215
+ files: ["tests/billing-live.int.testkit.ts"],
216
+ skippedFiles: [
217
+ {
218
+ path: "tests/billing-stubbed.int.testkit.ts",
219
+ reason: "Billing is stubbed",
220
+ },
221
+ ],
222
+ totalFileCount: 2,
223
+ orderIndex: 0,
224
+ },
225
+ {
226
+ name: "frontend-smoke",
227
+ type: "e2e",
228
+ framework: "playwright",
229
+ files: ["tests/frontend-smoke.pw.testkit.ts"],
230
+ orderIndex: 1,
231
+ },
232
+ ],
233
+ },
234
+ ],
235
+ 1000
236
+ );
237
+
238
+ recordTaskOutcome(
239
+ trackers,
240
+ {
241
+ serviceName: "api",
242
+ suiteKey: "integration:billing",
243
+ file: "tests/billing-live.int.testkit.ts",
244
+ },
245
+ {
246
+ failed: false,
247
+ durationMs: 100,
248
+ error: null,
249
+ },
250
+ 1100
251
+ );
252
+ recordTaskOutcome(
253
+ trackers,
254
+ {
255
+ serviceName: "api",
256
+ suiteKey: "e2e:frontend-smoke",
257
+ file: "tests/frontend-smoke.pw.testkit.ts",
258
+ },
259
+ {
260
+ failed: false,
261
+ status: "skipped",
262
+ reason: "Playwright test.skip()",
263
+ durationMs: 50,
264
+ error: null,
265
+ },
266
+ 1150
267
+ );
268
+
269
+ const result = finalizeServiceResult(trackers.get("api"), 1000, 1200);
270
+
271
+ expect(result.failed).toBe(false);
272
+ expect(result.completedSuiteCount).toBe(2);
273
+ expect(result.skippedSuiteCount).toBe(1);
274
+ expect(result.totalFileCount).toBe(3);
275
+ expect(result.completedFileCount).toBe(1);
276
+ expect(result.passedFileCount).toBe(1);
277
+ expect(result.failedFileCount).toBe(0);
278
+ expect(result.skippedFileCount).toBe(2);
279
+ expect(result.notRunFileCount).toBe(0);
280
+ expect(result.suites[0].files).toEqual([
281
+ {
282
+ path: "tests/billing-live.int.testkit.ts",
283
+ failed: false,
284
+ status: "passed",
285
+ durationMs: 100,
286
+ error: null,
287
+ reason: null,
288
+ },
289
+ {
290
+ path: "tests/billing-stubbed.int.testkit.ts",
291
+ failed: false,
292
+ status: "skipped",
293
+ durationMs: 0,
294
+ error: null,
295
+ reason: "Billing is stubbed",
296
+ },
297
+ ]);
298
+ expect(result.suites[1].files).toEqual([
299
+ {
300
+ path: "tests/frontend-smoke.pw.testkit.ts",
301
+ failed: false,
302
+ status: "skipped",
303
+ durationMs: 50,
304
+ error: null,
305
+ reason: "Playwright test.skip()",
306
+ },
307
+ ]);
308
+ });
309
+
196
310
  it("summarizes mixed db backends", () => {
197
311
  expect(
198
312
  summarizeDbBackend([{ dbBackend: "local" }, { dbBackend: "neon" }])
@@ -8,7 +8,9 @@ export function findUnmatchedRequestedFiles(
8
8
  ) {
9
9
  const matchedFiles = new Set();
10
10
  for (const config of configs) {
11
- const suites = collectSuites(config, typeValues, suiteSelectors, []);
11
+ const suites = collectSuites(config, typeValues, suiteSelectors, [], {
12
+ ignoreSkipRules: true,
13
+ });
12
14
  for (const suite of suites) {
13
15
  for (const file of suite.files) {
14
16
  matchedFiles.add(normalizePathSeparators(file));
@@ -18,6 +18,21 @@ export interface LifecycleConfig {
18
18
  testkitCwd?: string;
19
19
  }
20
20
 
21
+ export interface SkipFileRule {
22
+ path: string;
23
+ reason: string;
24
+ }
25
+
26
+ export interface SkipSuiteRule {
27
+ selector: string;
28
+ reason: string;
29
+ }
30
+
31
+ export interface SkipConfig {
32
+ files?: SkipFileRule[];
33
+ suites?: SkipSuiteRule[];
34
+ }
35
+
21
36
  export interface ServiceConfig {
22
37
  database?: LocalDatabaseConfig;
23
38
  databaseFrom?: string;
@@ -38,6 +53,7 @@ export interface ServiceConfig {
38
53
  };
39
54
  migrate?: LifecycleConfig;
40
55
  seed?: LifecycleConfig;
56
+ skip?: SkipConfig;
41
57
  }
42
58
 
43
59
  export interface TestkitSetup {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",