@elench/testkit 0.1.34 → 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
@@ -15,13 +15,11 @@ cd my-product
15
15
  npx @elench/testkit
16
16
 
17
17
  # Filter by type
18
- npx @elench/testkit int
19
- npx @elench/testkit dal
20
- npx @elench/testkit e2e
21
-
22
- # Filter by framework
23
- npx @elench/testkit --framework playwright
24
- npx @elench/testkit --framework default
18
+ npx @elench/testkit --type int
19
+ npx @elench/testkit --type dal
20
+ npx @elench/testkit --type e2e
21
+ npx @elench/testkit --type int,e2e,dal
22
+ npx @elench/testkit --type pw
25
23
 
26
24
  # Parallelize with isolated worker stacks
27
25
  npx @elench/testkit --jobs 3
@@ -30,14 +28,18 @@ npx @elench/testkit --jobs 3
30
28
  npx @elench/testkit --shard 1/3
31
29
 
32
30
  # Specific service / suite
33
- npx @elench/testkit frontend e2e -s auth
34
- npx @elench/testkit api int -s health
31
+ npx @elench/testkit --service frontend --type pw -s navigation
32
+ npx @elench/testkit --service api --type int -s health
33
+ npx @elench/testkit --type int,e2e,dal -s dal:queries
35
34
 
36
35
  # Exact file
37
- npx @elench/testkit int --file __testkit__/health/health.int.testkit.ts
36
+ npx @elench/testkit --type int --file __testkit__/health/health.int.testkit.ts
37
+
38
+ # Temporarily ignore repo-declared skip rules
39
+ npx @elench/testkit --ignore-skip-rules --file __testkit__/billing/billing.int.testkit.ts
38
40
 
39
41
  # Deterministic git-trackable status snapshot
40
- npx @elench/testkit int --write-status
42
+ npx @elench/testkit --type int --write-status
41
43
 
42
44
  # Lifecycle
43
45
  npx @elench/testkit status
@@ -84,6 +86,22 @@ export default defineTestkitSetup({
84
86
  dependsOn: ["api"],
85
87
  envFiles: ["frontend/.env.testkit"],
86
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
+ }),
87
105
  },
88
106
  });
89
107
  ```
@@ -96,6 +114,7 @@ for:
96
114
  - migrate / seed commands
97
115
  - test-local migrate / seed overrides
98
116
  - named HTTP suite profiles
117
+ - repo-declared suite/file skip policies with explicit reasons
99
118
  - telemetry upload configuration
100
119
 
101
120
  ## Authoring
package/lib/cli/args.mjs CHANGED
@@ -1,35 +1,43 @@
1
1
  import path from "path";
2
+ import { normalizeTypeValues, parseSuiteSelectors } from "../runner/suite-selection.mjs";
2
3
 
3
- export const SUITE_TYPES = new Set(["int", "integration", "e2e", "dal", "load", "all"]);
4
+ export const POSITIONAL_TYPES = new Set(["int", "e2e", "dal", "load", "pw", "all"]);
4
5
  export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
5
- export const RESERVED = new Set([...SUITE_TYPES, ...LIFECYCLE]);
6
+ export const RESERVED = new Set([...POSITIONAL_TYPES, ...LIFECYCLE]);
6
7
 
7
- export function resolveCliSelection({ first, second, serviceNames }) {
8
- let service = null;
9
- let type = null;
8
+ export function resolveCliSelection({ first, second, third }) {
9
+ if (second || third) {
10
+ throw new Error(`Unexpected extra positional arguments. Use --service and --type instead.`);
11
+ }
12
+
13
+ let lifecycle = null;
14
+ let positionalType = null;
10
15
 
11
- if (first && serviceNames.has(first)) {
12
- service = first;
13
- type = second || null;
14
- } else if (first && RESERVED.has(first)) {
15
- type = first;
16
+ if (first && LIFECYCLE.has(first)) {
17
+ lifecycle = first;
18
+ } else if (first && POSITIONAL_TYPES.has(first)) {
19
+ positionalType = first;
16
20
  } else if (first) {
17
21
  throw new Error(
18
- `Unknown argument "${first}". Expected a service name (${[...serviceNames].join(", ") || "none found"}) ` +
19
- `or suite type (int, e2e, dal, all).`
22
+ `Unknown argument "${first}". Expected a lifecycle command (status, destroy, cleanup) ` +
23
+ `or suite type (int, e2e, dal, load, pw, all).`
20
24
  );
21
25
  }
22
26
 
23
- return { service, type };
27
+ return { lifecycle, positionalType };
24
28
  }
25
29
 
26
- export function validateFrameworkOption(value) {
27
- if (!["all", "default", "playwright"].includes(value)) {
28
- throw new Error(
29
- `Unknown framework "${value}". Expected one of: all, default, playwright.`
30
- );
31
- }
32
- return value === "default" ? "k6" : value;
30
+ export function parseTypeOption(values, positionalType = null) {
31
+ const input = [];
32
+ if (positionalType) input.push(positionalType);
33
+ if (Array.isArray(values)) input.push(...values);
34
+ else if (values) input.push(values);
35
+ return normalizeTypeValues(input);
36
+ }
37
+
38
+ export function parseSuiteOption(values) {
39
+ const input = Array.isArray(values) ? values : values ? [values] : [];
40
+ return parseSuiteSelectors(input);
33
41
  }
34
42
 
35
43
  export function parseJobsOption(value) {
@@ -2,35 +2,36 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  parseJobsOption,
4
4
  parseShardOption,
5
+ parseSuiteOption,
6
+ parseTypeOption,
5
7
  resolveRequestedFiles,
6
8
  resolveCliSelection,
7
- validateFrameworkOption,
8
9
  } from "./args.mjs";
9
10
 
10
11
  describe("cli-args", () => {
11
- it("resolves a service and suite type", () => {
12
+ it("resolves a positional suite type", () => {
12
13
  expect(
13
14
  resolveCliSelection({
14
- first: "api",
15
- second: "int",
16
- serviceNames: new Set(["api", "frontend"]),
15
+ first: "int",
16
+ second: null,
17
+ third: null,
17
18
  })
18
19
  ).toEqual({
19
- service: "api",
20
- type: "int",
20
+ lifecycle: null,
21
+ positionalType: "int",
21
22
  });
22
23
  });
23
24
 
24
- it("resolves a reserved suite type without a service", () => {
25
+ it("resolves lifecycle commands", () => {
25
26
  expect(
26
27
  resolveCliSelection({
27
- first: "e2e",
28
+ first: "status",
28
29
  second: null,
29
- serviceNames: new Set(["api"]),
30
+ third: null,
30
31
  })
31
32
  ).toEqual({
32
- service: null,
33
- type: "e2e",
33
+ lifecycle: "status",
34
+ positionalType: null,
34
35
  });
35
36
  });
36
37
 
@@ -39,15 +40,19 @@ describe("cli-args", () => {
39
40
  resolveCliSelection({
40
41
  first: "mystery",
41
42
  second: null,
42
- serviceNames: new Set(["api"]),
43
+ third: null,
43
44
  })
44
45
  ).toThrow('Unknown argument "mystery"');
45
46
  });
46
47
 
47
- it("validates framework names", () => {
48
- expect(validateFrameworkOption("playwright")).toBe("playwright");
49
- expect(validateFrameworkOption("default")).toBe("k6");
50
- expect(() => validateFrameworkOption("jest")).toThrow("Unknown framework");
48
+ it("parses types and suite selectors", () => {
49
+ expect(parseTypeOption(["e2e,dal"], "int")).toEqual(["int", "e2e", "dal"]);
50
+ expect(() => parseTypeOption(["all", "int"])).toThrow("cannot be combined");
51
+
52
+ expect(parseSuiteOption(["auth,dal:queries"])).toEqual([
53
+ { kind: "plain", name: "auth", raw: "auth" },
54
+ { kind: "typed", type: "dal", name: "queries", raw: "dal:queries" },
55
+ ]);
51
56
  });
52
57
 
53
58
  it("parses and validates jobs", () => {
package/lib/cli/index.mjs CHANGED
@@ -3,9 +3,10 @@ import { loadConfigs } from "../config/index.mjs";
3
3
  import {
4
4
  parseJobsOption,
5
5
  parseShardOption,
6
+ parseSuiteOption,
7
+ parseTypeOption,
6
8
  resolveRequestedFiles,
7
9
  resolveCliSelection,
8
- validateFrameworkOption,
9
10
  } from "./args.mjs";
10
11
  import * as runner from "../runner/index.mjs";
11
12
 
@@ -13,7 +14,11 @@ export function run() {
13
14
  const cli = cac("testkit");
14
15
 
15
16
  cli
16
- .command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
17
+ .command("[first] [second] [third]", "Run test suites")
18
+ .option("--service <name>", "Run only one service")
19
+ .option("-t, --type <name>", "Run specific suite type(s): int, e2e, dal, load, pw, all", {
20
+ default: [],
21
+ })
17
22
  .option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
18
23
  .option("-f, --file <path>", "Run specific file(s)", { default: [] })
19
24
  .option("--dir <path>", "Explicit product directory")
@@ -21,76 +26,60 @@ export function run() {
21
26
  default: "1",
22
27
  })
23
28
  .option("--shard <i/n>", "Run only shard i of n at suite granularity")
24
- .option("--framework <name>", "Filter by framework (default, playwright, all)", {
25
- default: "all",
26
- })
27
29
  .option("--write-status", "Write a deterministic testkit.status.json snapshot")
28
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
+ )
29
35
  .action(async (first, second, third, options) => {
30
- // Resolve: service filter, suite type, and --dir.
31
- //
32
- // From product dir:
33
- // testkit → all services, all types
34
- // testkit int -s health → all services, int, health
35
- // testkit api int → one service, int
36
- // testkit api → one service, all types
37
- //
38
- // From workspace root:
39
- // testkit --dir my-product int → all services, int
40
- // testkit --dir my-product api int → one service, int
41
-
42
- // Now resolve service vs type from remaining args
43
- const allConfigs = await loadConfigs({ dir: options.dir });
44
- const serviceNames = new Set(allConfigs.map((config) => config.name));
45
- const { service, type } = resolveCliSelection({
36
+ const { lifecycle, positionalType } = resolveCliSelection({
46
37
  first,
47
38
  second,
48
- serviceNames,
39
+ third,
49
40
  });
50
- const configs = service
51
- ? allConfigs.filter((config) => config.name === service)
41
+ const allConfigs = await loadConfigs({ dir: options.dir });
42
+ const configs = options.service
43
+ ? allConfigs.filter((config) => config.name === options.service)
52
44
  : allConfigs;
53
- if (service && configs.length === 0) {
45
+ if (options.service && configs.length === 0) {
54
46
  const available = allConfigs.map((config) => config.name).join(", ");
55
- throw new Error(`Service "${service}" not found. Available: ${available}`);
47
+ throw new Error(`Service "${options.service}" not found. Available: ${available}`);
56
48
  }
57
49
 
58
50
  // Lifecycle commands
59
- if (type === "cleanup") {
51
+ if (lifecycle === "cleanup") {
60
52
  await runner.cleanup(allConfigs[0]?.productDir || process.cwd());
61
53
  return;
62
54
  }
63
55
 
64
- if (type === "status" || type === "destroy") {
56
+ if (lifecycle === "status" || lifecycle === "destroy") {
65
57
  for (const config of configs) {
66
58
  if (configs.length > 1) console.log(`\n── ${config.name} ──`);
67
- if (type === "status") runner.showStatus(config);
59
+ if (lifecycle === "status") runner.showStatus(config);
68
60
  else await runner.destroy(config);
69
61
  }
70
62
  return;
71
63
  }
72
64
 
73
- const framework = validateFrameworkOption(options.framework);
74
-
75
65
  const jobs = parseJobsOption(options.jobs);
76
66
  const shard = parseShardOption(options.shard);
77
-
78
- const suiteType = type || "all";
79
- const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
67
+ const typeValues = parseTypeOption(options.type, positionalType);
68
+ const suiteSelectors = parseSuiteOption(options.suite);
80
69
  const rawFileNames = Array.isArray(options.file) ? options.file : [options.file].filter(Boolean);
81
70
  const productDir = allConfigs[0]?.productDir || process.cwd();
82
71
  const fileNames = resolveRequestedFiles(rawFileNames, productDir, process.cwd());
83
72
  await runner.runAll(
84
73
  configs,
85
- suiteType,
86
- suiteNames,
74
+ typeValues,
75
+ suiteSelectors,
87
76
  {
88
77
  ...options,
89
- framework,
78
+ typeValues,
90
79
  fileNames,
91
80
  jobs,
92
81
  shard,
93
- serviceFilter: service,
82
+ serviceFilter: options.service || null,
94
83
  },
95
84
  allConfigs
96
85
  );
@@ -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([