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