@elench/testkit 0.1.35 → 0.1.37

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,6 +7,7 @@ import {
7
7
  } from "../timing/index.mjs";
8
8
 
9
9
  const TIMINGS_FILENAME = "timings.json";
10
+ const RESULT_ARTIFACTS_DIRNAME = "artifacts";
10
11
 
11
12
  export function writeRunArtifact(productDir, artifact) {
12
13
  const resultsDir = path.join(productDir, ".testkit", "results");
@@ -21,6 +22,65 @@ export function writeStatusArtifact(productDir, artifact) {
21
22
  );
22
23
  }
23
24
 
25
+ export function resetResultArtifacts(productDir) {
26
+ fs.rmSync(path.join(productDir, ".testkit", "results", RESULT_ARTIFACTS_DIRNAME), {
27
+ recursive: true,
28
+ force: true,
29
+ });
30
+ }
31
+
32
+ export function persistTaskArtifacts(productDir, task, emittedArtifacts) {
33
+ if (!Array.isArray(emittedArtifacts) || emittedArtifacts.length === 0) return [];
34
+
35
+ const artifactsDir = path.join(
36
+ productDir,
37
+ ".testkit",
38
+ "results",
39
+ RESULT_ARTIFACTS_DIRNAME,
40
+ sanitizePathSegment(task.serviceName || "service")
41
+ );
42
+ fs.mkdirSync(artifactsDir, { recursive: true });
43
+
44
+ return emittedArtifacts.map((artifact, index) => {
45
+ const fileName = `task-${task.id}-${String(index + 1).padStart(2, "0")}-${sanitizePathSegment(artifact.name || "artifact")}.json`;
46
+ const relativePath = path.join(
47
+ ".testkit",
48
+ "results",
49
+ RESULT_ARTIFACTS_DIRNAME,
50
+ sanitizePathSegment(task.serviceName || "service"),
51
+ fileName
52
+ );
53
+ const absolutePath = path.join(productDir, relativePath);
54
+ const payload = {
55
+ schemaVersion: 1,
56
+ source: "testkit-runtime-artifact",
57
+ service: task.serviceName,
58
+ suite: {
59
+ key: task.suiteKey,
60
+ name: task.suiteName,
61
+ type: task.type,
62
+ },
63
+ file: task.file,
64
+ taskId: task.id,
65
+ index,
66
+ name: artifact.name,
67
+ kind: artifact.kind || null,
68
+ summary: artifact.summary || null,
69
+ contentType: artifact.contentType || "application/json",
70
+ emittedAt: artifact.emittedAt || null,
71
+ data: artifact.data,
72
+ };
73
+ fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`);
74
+ return {
75
+ name: payload.name,
76
+ kind: payload.kind,
77
+ summary: payload.summary,
78
+ contentType: payload.contentType,
79
+ path: normalizePath(relativePath),
80
+ };
81
+ });
82
+ }
83
+
24
84
  export function loadTimings(productDir) {
25
85
  const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
26
86
  if (!fs.existsSync(filePath)) {
@@ -41,3 +101,15 @@ export function saveTimings(productDir, timings, updates) {
41
101
  fs.mkdirSync(rootDir, { recursive: true });
42
102
  fs.writeFileSync(path.join(rootDir, TIMINGS_FILENAME), JSON.stringify(next, null, 2));
43
103
  }
104
+
105
+ function sanitizePathSegment(value) {
106
+ return String(value)
107
+ .trim()
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9._-]+/g, "-")
110
+ .replace(/^-+|-+$/g, "") || "artifact";
111
+ }
112
+
113
+ function normalizePath(filePath) {
114
+ return filePath.split(path.sep).join("/");
115
+ }
@@ -3,6 +3,8 @@ import path from "path";
3
3
  import { execa } from "execa";
4
4
  import { bundleK6File } from "../bundler/index.mjs";
5
5
  import { resolveK6Binary } from "../config/index.mjs";
6
+ import { persistTaskArtifacts } from "./artifacts.mjs";
7
+ import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
6
8
  import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
7
9
  import { formatBatchDescriptor } from "./formatting.mjs";
8
10
  import { buildExecutionEnv } from "./template.mjs";
@@ -85,10 +87,17 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
85
87
  }
86
88
  );
87
89
 
88
- if (result.stdout) process.stdout.write(result.stdout);
89
- if (result.stderr) process.stderr.write(result.stderr);
90
+ const stdout = parseDefaultRuntimeOutput(result.stdout || "");
91
+ const stderr = parseDefaultRuntimeOutput(result.stderr || "");
92
+ if (stdout.visibleOutput) process.stdout.write(stdout.visibleOutput);
93
+ if (stderr.visibleOutput) process.stderr.write(stderr.visibleOutput);
90
94
 
91
95
  const summary = readDefaultRuntimeSummary(summaryFile);
96
+ const runtimeArtifacts = persistTaskArtifacts(
97
+ targetConfig.productDir,
98
+ task,
99
+ [...stdout.artifacts, ...stderr.artifacts]
100
+ );
92
101
  const runtimeError = determineDefaultRuntimeFailure(result, summary, firstLine);
93
102
  const finishedAt = Date.now();
94
103
 
@@ -99,6 +108,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
99
108
  durationMs: finishedAt - startedAt,
100
109
  startedAt,
101
110
  finishedAt,
111
+ artifacts: runtimeArtifacts,
102
112
  };
103
113
  }
104
114
 
@@ -117,3 +127,42 @@ export function readDefaultRuntimeSummary(filePath) {
117
127
  return null;
118
128
  }
119
129
  }
130
+
131
+ function parseDefaultRuntimeOutput(output) {
132
+ if (!output) {
133
+ return {
134
+ visibleOutput: "",
135
+ artifacts: [],
136
+ };
137
+ }
138
+
139
+ const visibleLines = [];
140
+ const artifacts = [];
141
+ for (const line of output.split(/\r?\n/)) {
142
+ const rawPayload = extractArtifactPayload(line);
143
+ if (rawPayload !== null) {
144
+ try {
145
+ artifacts.push(JSON.parse(decodeURIComponent(rawPayload)));
146
+ } catch {
147
+ visibleLines.push(line);
148
+ }
149
+ continue;
150
+ }
151
+ visibleLines.push(line);
152
+ }
153
+
154
+ return {
155
+ visibleOutput: visibleLines.join("\n"),
156
+ artifacts,
157
+ };
158
+ }
159
+
160
+ function extractArtifactPayload(line) {
161
+ if (line.startsWith(RUNTIME_ARTIFACT_MARKER)) {
162
+ return line.slice(RUNTIME_ARTIFACT_MARKER.length);
163
+ }
164
+
165
+ const k6ConsoleMatch = line.match(/msg="TESTKIT_ARTIFACT:(.*)"(?:\s+source=console)?$/);
166
+ if (!k6ConsoleMatch) return null;
167
+ return k6ConsoleMatch[1];
168
+ }
@@ -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
+ }