@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 +30 -11
- package/lib/cli/args.mjs +28 -20
- package/lib/cli/args.test.mjs +22 -17
- package/lib/cli/index.mjs +27 -38
- package/lib/config/index.mjs +116 -0
- package/lib/reporters/playwright.mjs +41 -5
- package/lib/reporters/playwright.test.mjs +83 -0
- package/lib/runner/formatting.mjs +36 -4
- package/lib/runner/formatting.test.mjs +49 -0
- package/lib/runner/orchestrator.mjs +13 -16
- package/lib/runner/planning.mjs +65 -13
- package/lib/runner/planning.test.mjs +57 -5
- package/lib/runner/playwright-runner.mjs +2 -1
- package/lib/runner/reporting.mjs +43 -24
- package/lib/runner/reporting.test.mjs +41 -20
- package/lib/runner/results.mjs +56 -15
- package/lib/runner/results.test.mjs +114 -0
- package/lib/runner/selection.mjs +9 -7
- package/lib/runner/selection.test.mjs +5 -6
- package/lib/runner/suite-selection.mjs +91 -0
- package/lib/runner/suite-selection.test.mjs +42 -0
- package/lib/setup/index.d.ts +16 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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([...
|
|
6
|
+
export const RESERVED = new Set([...POSITIONAL_TYPES, ...LIFECYCLE]);
|
|
6
7
|
|
|
7
|
-
export function resolveCliSelection({ first, second,
|
|
8
|
-
|
|
9
|
-
|
|
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 &&
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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 {
|
|
27
|
+
return { lifecycle, positionalType };
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
export function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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) {
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -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
|
|
12
|
+
it("resolves a positional suite type", () => {
|
|
12
13
|
expect(
|
|
13
14
|
resolveCliSelection({
|
|
14
|
-
first: "
|
|
15
|
-
second:
|
|
16
|
-
|
|
15
|
+
first: "int",
|
|
16
|
+
second: null,
|
|
17
|
+
third: null,
|
|
17
18
|
})
|
|
18
19
|
).toEqual({
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
lifecycle: null,
|
|
21
|
+
positionalType: "int",
|
|
21
22
|
});
|
|
22
23
|
});
|
|
23
24
|
|
|
24
|
-
it("resolves
|
|
25
|
+
it("resolves lifecycle commands", () => {
|
|
25
26
|
expect(
|
|
26
27
|
resolveCliSelection({
|
|
27
|
-
first: "
|
|
28
|
+
first: "status",
|
|
28
29
|
second: null,
|
|
29
|
-
|
|
30
|
+
third: null,
|
|
30
31
|
})
|
|
31
32
|
).toEqual({
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
43
|
+
third: null,
|
|
43
44
|
})
|
|
44
45
|
).toThrow('Unknown argument "mystery"');
|
|
45
46
|
});
|
|
46
47
|
|
|
47
|
-
it("
|
|
48
|
-
expect(
|
|
49
|
-
expect(
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
third,
|
|
49
40
|
});
|
|
50
|
-
const
|
|
51
|
-
|
|
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 (
|
|
51
|
+
if (lifecycle === "cleanup") {
|
|
60
52
|
await runner.cleanup(allConfigs[0]?.productDir || process.cwd());
|
|
61
53
|
return;
|
|
62
54
|
}
|
|
63
55
|
|
|
64
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
74
|
+
typeValues,
|
|
75
|
+
suiteSelectors,
|
|
87
76
|
{
|
|
88
77
|
...options,
|
|
89
|
-
|
|
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
|
);
|
package/lib/config/index.mjs
CHANGED
|
@@ -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
|
|
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([
|