@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 +20 -0
- package/lib/cli/index.mjs +4 -0
- 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 +1 -1
- package/lib/runner/planning.mjs +55 -4
- package/lib/runner/planning.test.mjs +51 -0
- package/lib/runner/playwright-runner.mjs +2 -1
- package/lib/runner/reporting.mjs +34 -7
- package/lib/runner/reporting.test.mjs +30 -5
- package/lib/runner/results.mjs +54 -14
- package/lib/runner/results.test.mjs +114 -0
- package/lib/runner/selection.mjs +3 -1
- package/lib/setup/index.d.ts +16 -0
- package/package.json +1 -1
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,
|
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([
|
|
@@ -7,12 +7,18 @@ export function formatDuration(durationMs) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export function formatServiceSummary(result) {
|
|
10
|
-
const
|
|
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
|
|
89
|
-
|
|
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
|
|
package/lib/runner/planning.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
package/lib/runner/reporting.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
-
{
|
|
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:
|
|
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:
|
|
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: "
|
|
187
|
+
status: "skipped",
|
|
188
|
+
reason: "Billing is stubbed",
|
|
164
189
|
},
|
|
165
190
|
],
|
|
166
191
|
});
|
package/lib/runner/results.mjs
CHANGED
|
@@ -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 =
|
|
116
|
+
existingFileResult.failed = status === "failed";
|
|
102
117
|
existingFileResult.durationMs = outcomeDurationMs;
|
|
103
118
|
existingFileResult.error = outcome.error;
|
|
104
|
-
existingFileResult.
|
|
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:
|
|
124
|
+
failed: status === "failed",
|
|
109
125
|
durationMs: outcomeDurationMs,
|
|
110
126
|
error: outcome.error,
|
|
111
|
-
|
|
127
|
+
reason: outcome.reason || null,
|
|
128
|
+
status,
|
|
112
129
|
});
|
|
113
130
|
}
|
|
114
|
-
if (
|
|
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
|
|
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
|
-
|
|
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" }])
|
package/lib/runner/selection.mjs
CHANGED
|
@@ -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));
|
package/lib/setup/index.d.ts
CHANGED
|
@@ -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 {
|