@elench/testkit 0.1.24 → 0.1.26
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 +3 -0
- package/lib/cli/args.mjs +36 -0
- package/lib/cli/args.test.mjs +15 -0
- package/lib/cli/index.mjs +6 -1
- package/lib/config/model.mjs +0 -88
- package/lib/config/model.test.mjs +0 -31
- package/lib/index.d.ts +92 -0
- package/lib/package.test.mjs +24 -0
- package/lib/runner/index.mjs +172 -50
- package/lib/runner/results.mjs +71 -3
- package/lib/runner/results.test.mjs +55 -1
- package/lib/runtime/index.d.ts +183 -0
- package/lib/runtime-src/k6/checks.js +9 -0
- package/lib/runtime-src/k6/dal-suite.js +25 -7
- package/lib/runtime-src/k6/suite.js +28 -10
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -83,6 +83,9 @@ import { check, group, http } from "@elench/testkit/runtime";
|
|
|
83
83
|
|
|
84
84
|
`testkit` bundles these imports before execution, so tests do not need
|
|
85
85
|
generated `_testkit` files, direct package-manager path imports, or any separate engine installation.
|
|
86
|
+
The published package also ships first-party TypeScript declarations for both
|
|
87
|
+
`@elench/testkit` and `@elench/testkit/runtime`, so consumer repos do not need
|
|
88
|
+
local ambient module shims for the supported authoring surface.
|
|
86
89
|
|
|
87
90
|
Legacy compatibility:
|
|
88
91
|
|
package/lib/cli/args.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
|
|
1
3
|
export const SUITE_TYPES = new Set(["int", "integration", "e2e", "dal", "load", "all"]);
|
|
2
4
|
export const LIFECYCLE = new Set(["status", "destroy"]);
|
|
3
5
|
export const RESERVED = new Set([...SUITE_TYPES, ...LIFECYCLE]);
|
|
@@ -56,3 +58,37 @@ export function parseShardOption(value) {
|
|
|
56
58
|
|
|
57
59
|
return { index, total };
|
|
58
60
|
}
|
|
61
|
+
|
|
62
|
+
export function resolveRequestedFiles(fileNames, productDir, invocationCwd = process.cwd()) {
|
|
63
|
+
const resolved = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
|
|
66
|
+
for (const rawFile of fileNames || []) {
|
|
67
|
+
const normalized = resolveRequestedFile(rawFile, productDir, invocationCwd);
|
|
68
|
+
if (seen.has(normalized)) continue;
|
|
69
|
+
seen.add(normalized);
|
|
70
|
+
resolved.push(normalized);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return resolved;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveRequestedFile(rawFile, productDir, invocationCwd) {
|
|
77
|
+
const rawValue = String(rawFile);
|
|
78
|
+
const candidates = path.isAbsolute(rawValue)
|
|
79
|
+
? [rawValue]
|
|
80
|
+
: [path.resolve(invocationCwd, rawValue), path.resolve(productDir, rawValue)];
|
|
81
|
+
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
const relative = normalizePathSeparators(path.relative(productDir, candidate));
|
|
84
|
+
if (!relative.startsWith("../") && relative !== ".." && !path.isAbsolute(relative)) {
|
|
85
|
+
return relative.replace(/^\.\/+/, "");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return normalizePathSeparators(rawValue).replace(/^\.\/+/, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizePathSeparators(filePath) {
|
|
93
|
+
return String(filePath).split(path.sep).join("/");
|
|
94
|
+
}
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
parseJobsOption,
|
|
4
4
|
parseShardOption,
|
|
5
|
+
resolveRequestedFiles,
|
|
5
6
|
resolveCliSelection,
|
|
6
7
|
validateFrameworkOption,
|
|
7
8
|
} from "./args.mjs";
|
|
@@ -60,4 +61,18 @@ describe("cli-args", () => {
|
|
|
60
61
|
expect(() => parseShardOption("2-of-5")).toThrow("Invalid --shard value");
|
|
61
62
|
expect(() => parseShardOption("3/2")).toThrow("Expected 1 <= i <= n");
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
it("normalizes requested file paths against the product directory", () => {
|
|
66
|
+
expect(
|
|
67
|
+
resolveRequestedFiles(
|
|
68
|
+
[
|
|
69
|
+
"/tmp/product/tests/api/integration/health.int.testkit.ts",
|
|
70
|
+
"./tests/api/integration/health.int.testkit.ts",
|
|
71
|
+
"product/tests/api/integration/health.int.testkit.ts",
|
|
72
|
+
],
|
|
73
|
+
"/tmp/product",
|
|
74
|
+
"/tmp"
|
|
75
|
+
)
|
|
76
|
+
).toEqual(["tests/api/integration/health.int.testkit.ts"]);
|
|
77
|
+
});
|
|
63
78
|
});
|
package/lib/cli/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
parseJobsOption,
|
|
5
5
|
parseShardOption,
|
|
6
6
|
RESERVED,
|
|
7
|
+
resolveRequestedFiles,
|
|
7
8
|
resolveCliSelection,
|
|
8
9
|
validateFrameworkOption,
|
|
9
10
|
} from "./args.mjs";
|
|
@@ -52,6 +53,7 @@ export function run() {
|
|
|
52
53
|
default: "all",
|
|
53
54
|
})
|
|
54
55
|
.option("--write-status", "Write a deterministic testkit.status.json snapshot")
|
|
56
|
+
.option("--allow-partial-status", "Allow --write-status for filtered runs")
|
|
55
57
|
.action(async (first, second, third, options) => {
|
|
56
58
|
// Resolve: service filter, suite type, and --dir.
|
|
57
59
|
//
|
|
@@ -99,7 +101,9 @@ export function run() {
|
|
|
99
101
|
|
|
100
102
|
const suiteType = type || "all";
|
|
101
103
|
const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
|
|
102
|
-
const
|
|
104
|
+
const rawFileNames = Array.isArray(options.file) ? options.file : [options.file].filter(Boolean);
|
|
105
|
+
const productDir = allConfigs[0]?.productDir || process.cwd();
|
|
106
|
+
const fileNames = resolveRequestedFiles(rawFileNames, productDir, process.cwd());
|
|
103
107
|
await runner.runAll(
|
|
104
108
|
configs,
|
|
105
109
|
suiteType,
|
|
@@ -110,6 +114,7 @@ export function run() {
|
|
|
110
114
|
fileNames,
|
|
111
115
|
jobs,
|
|
112
116
|
shard,
|
|
117
|
+
serviceFilter: service,
|
|
113
118
|
},
|
|
114
119
|
allConfigs
|
|
115
120
|
);
|
package/lib/config/model.mjs
CHANGED
|
@@ -21,94 +21,6 @@ export function parseDotenvString(source) {
|
|
|
21
21
|
return env;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function validateRunnerManifest(raw, manifestName = "runner.manifest.json", manifestPath = manifestName) {
|
|
25
|
-
if (!isObject(raw.services)) {
|
|
26
|
-
throw new Error(`${manifestName} must have a "services" object (${manifestPath})`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
for (const [serviceName, service] of Object.entries(raw.services)) {
|
|
30
|
-
if (!isObject(service)) {
|
|
31
|
-
throw new Error(`Service "${serviceName}" in ${manifestName} must be an object`);
|
|
32
|
-
}
|
|
33
|
-
if (!isObject(service.suites)) {
|
|
34
|
-
throw new Error(`Service "${serviceName}" in ${manifestName} must define suites`);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
for (const [suiteType, suites] of Object.entries(service.suites)) {
|
|
38
|
-
if (!Array.isArray(suites)) {
|
|
39
|
-
throw new Error(
|
|
40
|
-
`Service "${serviceName}" suite type "${suiteType}" must be an array`
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const seenNames = new Set();
|
|
45
|
-
for (const suite of suites) {
|
|
46
|
-
if (!isObject(suite)) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
`Service "${serviceName}" suite type "${suiteType}" contains a non-object suite`
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
if (typeof suite.name !== "string" || !suite.name.length) {
|
|
52
|
-
throw new Error(
|
|
53
|
-
`Service "${serviceName}" suite type "${suiteType}" has a suite with no name`
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
if (seenNames.has(suite.name)) {
|
|
57
|
-
throw new Error(
|
|
58
|
-
`Service "${serviceName}" suite type "${suiteType}" has duplicate suite name "${suite.name}"`
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
seenNames.add(suite.name);
|
|
62
|
-
|
|
63
|
-
if (!Array.isArray(suite.files) || suite.files.length === 0) {
|
|
64
|
-
throw new Error(
|
|
65
|
-
`Service "${serviceName}" suite "${suite.name}" must define one or more files`
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
for (const file of suite.files) {
|
|
69
|
-
if (typeof file !== "string" || !file.length) {
|
|
70
|
-
throw new Error(
|
|
71
|
-
`Service "${serviceName}" suite "${suite.name}" contains an invalid file entry`
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const framework = suite.framework || "k6";
|
|
77
|
-
if (!VALID_FRAMEWORKS.has(framework)) {
|
|
78
|
-
throw new Error(
|
|
79
|
-
`Service "${serviceName}" suite "${suite.name}" uses unsupported framework "${framework}"`
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (suite.testkit !== undefined) {
|
|
84
|
-
if (!isObject(suite.testkit)) {
|
|
85
|
-
throw new Error(
|
|
86
|
-
`Service "${serviceName}" suite "${suite.name}" testkit config must be an object`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
if (
|
|
90
|
-
suite.testkit.maxFileConcurrency !== undefined &&
|
|
91
|
-
(!Number.isInteger(suite.testkit.maxFileConcurrency) ||
|
|
92
|
-
suite.testkit.maxFileConcurrency <= 0)
|
|
93
|
-
) {
|
|
94
|
-
throw new Error(
|
|
95
|
-
`Service "${serviceName}" suite "${suite.name}" testkit.maxFileConcurrency must be a positive integer`
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
if (
|
|
99
|
-
suite.testkit.weight !== undefined &&
|
|
100
|
-
(!Number.isInteger(suite.testkit.weight) || suite.testkit.weight <= 0)
|
|
101
|
-
) {
|
|
102
|
-
throw new Error(
|
|
103
|
-
`Service "${serviceName}" suite "${suite.name}" testkit.weight must be a positive integer`
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
24
|
export function validateConfigCoverage(
|
|
113
25
|
config,
|
|
114
26
|
configName = "testkit.config.json"
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
resolveSelectedDatabase,
|
|
9
9
|
validateConfigCoverage,
|
|
10
10
|
validateLifecycleConfig,
|
|
11
|
-
validateRunnerManifest,
|
|
12
11
|
validateServiceConfig,
|
|
13
12
|
validateTelemetryConfig,
|
|
14
13
|
} from "./model.mjs";
|
|
@@ -29,36 +28,6 @@ QUX='zap'
|
|
|
29
28
|
});
|
|
30
29
|
});
|
|
31
30
|
|
|
32
|
-
it("validates runner manifests", () => {
|
|
33
|
-
expect(() =>
|
|
34
|
-
validateRunnerManifest({
|
|
35
|
-
services: {
|
|
36
|
-
api: {
|
|
37
|
-
suites: {
|
|
38
|
-
integration: [
|
|
39
|
-
{ name: "health", files: ["tests/health.js"] },
|
|
40
|
-
],
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
})
|
|
45
|
-
).not.toThrow();
|
|
46
|
-
|
|
47
|
-
expect(() =>
|
|
48
|
-
validateRunnerManifest({
|
|
49
|
-
services: {
|
|
50
|
-
api: {
|
|
51
|
-
suites: {
|
|
52
|
-
integration: [
|
|
53
|
-
{ name: "health", files: ["a.js"], framework: "jest" },
|
|
54
|
-
],
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
})
|
|
59
|
-
).toThrow("unsupported framework");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
31
|
it("validates config coverage", () => {
|
|
63
32
|
const config = {
|
|
64
33
|
services: {
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HttpClient,
|
|
3
|
+
HttpClientConfig,
|
|
4
|
+
RuntimeDb,
|
|
5
|
+
RuntimeDalContext,
|
|
6
|
+
RuntimeEnv,
|
|
7
|
+
RuntimeHeaders,
|
|
8
|
+
RuntimeOptions,
|
|
9
|
+
RuntimeResponse,
|
|
10
|
+
} from "./runtime/index";
|
|
11
|
+
|
|
12
|
+
export interface TestkitSuite<TSetup = unknown> {
|
|
13
|
+
options: RuntimeOptions;
|
|
14
|
+
setup: () => TSetup | null;
|
|
15
|
+
exec: (setupData: TSetup | null) => unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HeaderBuilderContext {
|
|
19
|
+
env: RuntimeEnv;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type HeaderBuilder<TSetup = unknown> = (
|
|
23
|
+
setupData?: TSetup | null,
|
|
24
|
+
context?: HeaderBuilderContext
|
|
25
|
+
) => RuntimeHeaders | void;
|
|
26
|
+
|
|
27
|
+
export interface AuthAdapter<TSetup = unknown> {
|
|
28
|
+
setup?: (context: { env: RuntimeEnv }) => TSetup;
|
|
29
|
+
headers?: HeaderBuilder<TSetup>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HttpSuiteContext<TSetup = unknown> {
|
|
33
|
+
env: RuntimeEnv;
|
|
34
|
+
req: HttpClient<TSetup>["request"];
|
|
35
|
+
rawReq: HttpClient["raw"];
|
|
36
|
+
getWithHeaders: HttpClient<TSetup>["getWithHeaders"];
|
|
37
|
+
setupData: TSetup | null;
|
|
38
|
+
session: TSetup | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HttpSuiteConfig<TSetup = unknown> {
|
|
42
|
+
auth?: AuthAdapter<TSetup> | null;
|
|
43
|
+
env?: RuntimeEnv;
|
|
44
|
+
headers?: HeaderBuilder<TSetup>;
|
|
45
|
+
rawHeaders?: HeaderBuilder<never>;
|
|
46
|
+
options?: RuntimeOptions;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DalSuiteContext<TSetup = unknown> {
|
|
50
|
+
db: RuntimeDb;
|
|
51
|
+
dal: RuntimeDalContext;
|
|
52
|
+
setupData: TSetup | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DalSuiteConfig<TSetup = unknown> {
|
|
56
|
+
db?: RuntimeDb;
|
|
57
|
+
options?: RuntimeOptions;
|
|
58
|
+
setup?: (context: { db: RuntimeDb; dal: RuntimeDalContext }) => TSetup;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export declare function defineHttpSuite<TSetup = unknown>(
|
|
62
|
+
run: (context: HttpSuiteContext<TSetup>) => unknown
|
|
63
|
+
): TestkitSuite<TSetup>;
|
|
64
|
+
|
|
65
|
+
export declare function defineHttpSuite<TSetup = unknown>(
|
|
66
|
+
config: HttpSuiteConfig<TSetup>,
|
|
67
|
+
run: (context: HttpSuiteContext<TSetup>) => unknown
|
|
68
|
+
): TestkitSuite<TSetup>;
|
|
69
|
+
|
|
70
|
+
export declare function defineDalSuite<TSetup = unknown>(
|
|
71
|
+
run: (context: DalSuiteContext<TSetup>) => unknown
|
|
72
|
+
): TestkitSuite<TSetup>;
|
|
73
|
+
|
|
74
|
+
export declare function defineDalSuite<TSetup = unknown>(
|
|
75
|
+
config: DalSuiteConfig<TSetup>,
|
|
76
|
+
run: (context: DalSuiteContext<TSetup>) => unknown
|
|
77
|
+
): TestkitSuite<TSetup>;
|
|
78
|
+
|
|
79
|
+
export declare function createAuthAdapter<TSetup = unknown>(
|
|
80
|
+
adapter?: AuthAdapter<TSetup>
|
|
81
|
+
): AuthAdapter<TSetup>;
|
|
82
|
+
|
|
83
|
+
export type {
|
|
84
|
+
HttpClient,
|
|
85
|
+
HttpClientConfig,
|
|
86
|
+
RuntimeDb,
|
|
87
|
+
RuntimeDalContext,
|
|
88
|
+
RuntimeEnv,
|
|
89
|
+
RuntimeHeaders,
|
|
90
|
+
RuntimeOptions,
|
|
91
|
+
RuntimeResponse,
|
|
92
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const packageJsonPath = path.join(rootDir, "package.json");
|
|
8
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
9
|
+
|
|
10
|
+
describe("package metadata", () => {
|
|
11
|
+
it("ships first-party type declarations for the public exports", () => {
|
|
12
|
+
expect(packageJson.types).toBe("./lib/index.d.ts");
|
|
13
|
+
expect(packageJson.exports["."]).toEqual({
|
|
14
|
+
types: "./lib/index.d.ts",
|
|
15
|
+
default: "./lib/index.mjs",
|
|
16
|
+
});
|
|
17
|
+
expect(packageJson.exports["./runtime"]).toEqual({
|
|
18
|
+
types: "./lib/runtime/index.d.ts",
|
|
19
|
+
default: "./lib/runtime/index.mjs",
|
|
20
|
+
});
|
|
21
|
+
expect(fs.existsSync(path.join(rootDir, "lib", "index.d.ts"))).toBe(true);
|
|
22
|
+
expect(fs.existsSync(path.join(rootDir, "lib", "runtime", "index.d.ts"))).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
package/lib/runner/index.mjs
CHANGED
|
@@ -108,6 +108,28 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
108
108
|
},
|
|
109
109
|
testkitVersion: readPackageMetadata().version,
|
|
110
110
|
};
|
|
111
|
+
const requestedFiles = opts.fileNames || [];
|
|
112
|
+
if (requestedFiles.length > 0) {
|
|
113
|
+
const unmatchedFiles = findUnmatchedRequestedFiles(
|
|
114
|
+
configs,
|
|
115
|
+
suiteType,
|
|
116
|
+
suiteNames,
|
|
117
|
+
opts.framework || "all",
|
|
118
|
+
requestedFiles
|
|
119
|
+
);
|
|
120
|
+
if (unmatchedFiles.length > 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Requested file${unmatchedFiles.length === 1 ? "" : "s"} did not match any selected suites:\n` +
|
|
123
|
+
unmatchedFiles.map((file) => `- ${file}`).join("\n")
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (opts.writeStatus && !opts.allowPartialStatus && !isFullRunSelection(suiteType, suiteNames, requestedFiles, opts)) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Refusing to overwrite testkit.status.json from a filtered run. " +
|
|
130
|
+
"Run the full suite with --write-status, or pass --allow-partial-status to opt in."
|
|
131
|
+
);
|
|
132
|
+
}
|
|
111
133
|
const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
|
|
112
134
|
const trackers = buildServiceTrackers(servicePlans, startedAt);
|
|
113
135
|
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
@@ -155,10 +177,10 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
155
177
|
workerCount,
|
|
156
178
|
suiteType,
|
|
157
179
|
suiteNames,
|
|
158
|
-
fileNames:
|
|
180
|
+
fileNames: requestedFiles,
|
|
159
181
|
framework: opts.framework || "all",
|
|
160
182
|
shard: opts.shard || null,
|
|
161
|
-
serviceFilter:
|
|
183
|
+
serviceFilter: opts.serviceFilter || null,
|
|
162
184
|
metadata,
|
|
163
185
|
});
|
|
164
186
|
|
|
@@ -171,10 +193,10 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
171
193
|
results,
|
|
172
194
|
suiteType,
|
|
173
195
|
suiteNames,
|
|
174
|
-
fileNames:
|
|
196
|
+
fileNames: requestedFiles,
|
|
175
197
|
framework: opts.framework || "all",
|
|
176
198
|
shard: opts.shard || null,
|
|
177
|
-
serviceFilter:
|
|
199
|
+
serviceFilter: opts.serviceFilter || null,
|
|
178
200
|
metadata,
|
|
179
201
|
})
|
|
180
202
|
);
|
|
@@ -556,27 +578,14 @@ async function runHttpK6Task(targetConfig, task, baseUrl) {
|
|
|
556
578
|
sourceFile: absFile,
|
|
557
579
|
});
|
|
558
580
|
console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
task,
|
|
568
|
-
failed: false,
|
|
569
|
-
error: null,
|
|
570
|
-
durationMs: Date.now() - startedAt,
|
|
571
|
-
};
|
|
572
|
-
} catch (error) {
|
|
573
|
-
return {
|
|
574
|
-
task,
|
|
575
|
-
failed: true,
|
|
576
|
-
error: formatError(error),
|
|
577
|
-
durationMs: Date.now() - startedAt,
|
|
578
|
-
};
|
|
579
|
-
}
|
|
581
|
+
return runDefaultRuntimeTask(targetConfig, task, [
|
|
582
|
+
"run",
|
|
583
|
+
"--address",
|
|
584
|
+
"127.0.0.1:0",
|
|
585
|
+
"-e",
|
|
586
|
+
`BASE_URL=${baseUrl}`,
|
|
587
|
+
bundledFile,
|
|
588
|
+
]);
|
|
580
589
|
}
|
|
581
590
|
|
|
582
591
|
async function runDalBatch(targetConfig, batch) {
|
|
@@ -603,31 +612,14 @@ async function runDalTask(targetConfig, task, databaseUrl) {
|
|
|
603
612
|
sourceFile: absFile,
|
|
604
613
|
});
|
|
605
614
|
console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
stdio: "inherit",
|
|
615
|
-
}
|
|
616
|
-
);
|
|
617
|
-
return {
|
|
618
|
-
task,
|
|
619
|
-
failed: false,
|
|
620
|
-
error: null,
|
|
621
|
-
durationMs: Date.now() - startedAt,
|
|
622
|
-
};
|
|
623
|
-
} catch (error) {
|
|
624
|
-
return {
|
|
625
|
-
task,
|
|
626
|
-
failed: true,
|
|
627
|
-
error: formatError(error),
|
|
628
|
-
durationMs: Date.now() - startedAt,
|
|
629
|
-
};
|
|
630
|
-
}
|
|
615
|
+
return runDefaultRuntimeTask(targetConfig, task, [
|
|
616
|
+
"run",
|
|
617
|
+
"--address",
|
|
618
|
+
"127.0.0.1:0",
|
|
619
|
+
"-e",
|
|
620
|
+
`DATABASE_URL=${databaseUrl}`,
|
|
621
|
+
bundledFile,
|
|
622
|
+
]);
|
|
631
623
|
}
|
|
632
624
|
|
|
633
625
|
async function runPlaywrightBatch(targetConfig, batch) {
|
|
@@ -840,12 +832,15 @@ function printRunSummary(results, durationMs) {
|
|
|
840
832
|
);
|
|
841
833
|
const failedSuites = executedServices.reduce((sum, result) => sum + result.failedSuiteCount, 0);
|
|
842
834
|
const passedSuites = completedSuites - failedSuites;
|
|
835
|
+
const totalFiles = executedServices.reduce((sum, result) => sum + (result.totalFileCount || 0), 0);
|
|
836
|
+
const passedFiles = executedServices.reduce((sum, result) => sum + (result.passedFileCount || 0), 0);
|
|
843
837
|
|
|
844
838
|
console.log("\n══ Summary ══");
|
|
845
839
|
console.log(
|
|
846
840
|
[
|
|
847
841
|
`services ${passedServices.length}/${executedServices.length} passed`,
|
|
848
842
|
`suites ${passedSuites}/${totalSuites} passed`,
|
|
843
|
+
totalFiles > 0 ? `files ${passedFiles}/${totalFiles} passed` : null,
|
|
849
844
|
skippedServices.length > 0 ? `${skippedServices.length} skipped` : null,
|
|
850
845
|
`duration ${formatDuration(durationMs)}`,
|
|
851
846
|
]
|
|
@@ -968,11 +963,23 @@ function writeRunArtifact(productDir, artifact) {
|
|
|
968
963
|
function buildStatusArtifact({
|
|
969
964
|
productDir,
|
|
970
965
|
results,
|
|
966
|
+
suiteType,
|
|
967
|
+
suiteNames,
|
|
968
|
+
fileNames,
|
|
969
|
+
framework,
|
|
970
|
+
shard,
|
|
971
|
+
serviceFilter,
|
|
971
972
|
metadata,
|
|
972
973
|
}) {
|
|
973
974
|
return buildStatusArtifactModel({
|
|
974
975
|
productDir,
|
|
975
976
|
results,
|
|
977
|
+
suiteType,
|
|
978
|
+
suiteNames,
|
|
979
|
+
fileNames,
|
|
980
|
+
framework,
|
|
981
|
+
shard,
|
|
982
|
+
serviceFilter,
|
|
976
983
|
metadata,
|
|
977
984
|
});
|
|
978
985
|
}
|
|
@@ -1064,6 +1071,121 @@ function formatError(error) {
|
|
|
1064
1071
|
return formatErrorModel(error);
|
|
1065
1072
|
}
|
|
1066
1073
|
|
|
1074
|
+
async function runDefaultRuntimeTask(targetConfig, task, args) {
|
|
1075
|
+
const k6Binary = resolveK6Binary();
|
|
1076
|
+
const summaryFile = buildDefaultRuntimeSummaryPath(targetConfig, task);
|
|
1077
|
+
fs.mkdirSync(path.dirname(summaryFile), { recursive: true });
|
|
1078
|
+
const startedAt = Date.now();
|
|
1079
|
+
const result = await execa(k6Binary, [...args.slice(0, 1), "--summary-export", summaryFile, ...args.slice(1)], {
|
|
1080
|
+
cwd: targetConfig.productDir,
|
|
1081
|
+
env: buildExecutionEnv(targetConfig),
|
|
1082
|
+
reject: false,
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
1086
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
1087
|
+
|
|
1088
|
+
const summary = readDefaultRuntimeSummary(summaryFile);
|
|
1089
|
+
const runtimeError = determineDefaultRuntimeFailure(result, summary);
|
|
1090
|
+
|
|
1091
|
+
return {
|
|
1092
|
+
task,
|
|
1093
|
+
failed: runtimeError !== null,
|
|
1094
|
+
error: runtimeError,
|
|
1095
|
+
durationMs: Date.now() - startedAt,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function buildDefaultRuntimeSummaryPath(targetConfig, task) {
|
|
1100
|
+
return path.join(
|
|
1101
|
+
targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit"),
|
|
1102
|
+
"_runtime",
|
|
1103
|
+
`task-${task.id}.summary.json`
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function readDefaultRuntimeSummary(filePath) {
|
|
1108
|
+
try {
|
|
1109
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
1110
|
+
} catch {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function determineDefaultRuntimeFailure(result, summary) {
|
|
1116
|
+
const fatalRuntimeError = extractDefaultRuntimeFatalError(result.stderr || "");
|
|
1117
|
+
if (fatalRuntimeError) {
|
|
1118
|
+
return `Default runtime uncaught error: ${fatalRuntimeError}`;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const failedThresholds = extractDefaultRuntimeThresholdFailures(summary);
|
|
1122
|
+
if (failedThresholds.length > 0) {
|
|
1123
|
+
return `Default runtime thresholds failed: ${failedThresholds.join(", ")}`;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (result.exitCode !== 0) {
|
|
1127
|
+
return sanitizeDefaultRuntimeExitError(result.exitCode, result.stderr || result.stdout || "");
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return null;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function extractDefaultRuntimeFatalError(stderr) {
|
|
1134
|
+
if (!stderr || !/source=stacktrace/.test(stderr)) return null;
|
|
1135
|
+
const matched = stderr.match(/Error:\s([^\n]+)/);
|
|
1136
|
+
return matched?.[1]?.trim() || firstLine(stderr);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function extractDefaultRuntimeThresholdFailures(summary) {
|
|
1140
|
+
const metrics = summary?.metrics;
|
|
1141
|
+
if (!metrics || typeof metrics !== "object") return [];
|
|
1142
|
+
|
|
1143
|
+
const failures = [];
|
|
1144
|
+
for (const [metricName, metricSummary] of Object.entries(metrics)) {
|
|
1145
|
+
const thresholds = metricSummary?.thresholds;
|
|
1146
|
+
if (!thresholds || typeof thresholds !== "object") continue;
|
|
1147
|
+
for (const [threshold, crossed] of Object.entries(thresholds)) {
|
|
1148
|
+
if (crossed === true) {
|
|
1149
|
+
failures.push(`${metricName}(${threshold})`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return failures.sort();
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function sanitizeDefaultRuntimeExitError(exitCode, output) {
|
|
1158
|
+
const message = firstLine(output);
|
|
1159
|
+
if (message) {
|
|
1160
|
+
return `Default runtime failed with exit code ${exitCode}: ${message}`;
|
|
1161
|
+
}
|
|
1162
|
+
return `Default runtime failed with exit code ${exitCode}`;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function findUnmatchedRequestedFiles(configs, suiteType, suiteNames, framework, fileNames) {
|
|
1166
|
+
const matchedFiles = new Set();
|
|
1167
|
+
for (const config of configs) {
|
|
1168
|
+
const suites = collectSuites(config, suiteType, suiteNames, framework, []);
|
|
1169
|
+
for (const suite of suites) {
|
|
1170
|
+
for (const file of suite.files) {
|
|
1171
|
+
matchedFiles.add(normalizePathSeparators(file));
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return [...new Set(fileNames.map(normalizePathSeparators))].filter((file) => !matchedFiles.has(file));
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function isFullRunSelection(suiteType, suiteNames, fileNames, opts) {
|
|
1180
|
+
return (
|
|
1181
|
+
(suiteNames || []).length === 0 &&
|
|
1182
|
+
(fileNames || []).length === 0 &&
|
|
1183
|
+
(opts.framework || "all") === "all" &&
|
|
1184
|
+
(opts.shard || null) === null &&
|
|
1185
|
+
(opts.serviceFilter || null) === null
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1067
1189
|
function loadTimings(productDir) {
|
|
1068
1190
|
const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
|
|
1069
1191
|
if (!fs.existsSync(filePath)) {
|
package/lib/runner/results.mjs
CHANGED
|
@@ -156,6 +156,19 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
|
156
156
|
(suite) => suite.completedFileCount === suite.fileCount
|
|
157
157
|
).length;
|
|
158
158
|
const failedSuiteCount = suites.filter((suite) => suite.failedFiles.length > 0).length;
|
|
159
|
+
const totalFileCount = suites.reduce((sum, suite) => sum + suite.fileCount, 0);
|
|
160
|
+
const completedFileCount = suites.reduce(
|
|
161
|
+
(sum, suite) => sum + suite.completedFileCount,
|
|
162
|
+
0
|
|
163
|
+
);
|
|
164
|
+
const failedFileCount = suites.reduce((sum, suite) => sum + suite.failedFiles.length, 0);
|
|
165
|
+
const passedFileCount = suites.reduce(
|
|
166
|
+
(sum, suite) =>
|
|
167
|
+
sum +
|
|
168
|
+
suite.fileResults.filter((file) => file.status === "passed").length,
|
|
169
|
+
0
|
|
170
|
+
);
|
|
171
|
+
const notRunFileCount = totalFileCount - completedFileCount;
|
|
159
172
|
const accumulatedDurationMs = suites.reduce((sum, suite) => sum + suite.durationMs, 0);
|
|
160
173
|
const durationMs =
|
|
161
174
|
tracker.firstTaskAt && tracker.lastTaskAt
|
|
@@ -173,6 +186,11 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
|
173
186
|
suiteCount: tracker.suiteCount,
|
|
174
187
|
completedSuiteCount,
|
|
175
188
|
failedSuiteCount,
|
|
189
|
+
totalFileCount,
|
|
190
|
+
completedFileCount,
|
|
191
|
+
passedFileCount,
|
|
192
|
+
failedFileCount,
|
|
193
|
+
notRunFileCount,
|
|
176
194
|
durationMs,
|
|
177
195
|
suites: suites.map((suite) => ({
|
|
178
196
|
name: suite.name,
|
|
@@ -180,6 +198,10 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
|
180
198
|
framework: formatFrameworkForArtifact(suite.framework),
|
|
181
199
|
failed: suite.failedFiles.length > 0,
|
|
182
200
|
fileCount: suite.fileCount,
|
|
201
|
+
completedFileCount: suite.completedFileCount,
|
|
202
|
+
passedFileCount: suite.fileResults.filter((file) => file.status === "passed").length,
|
|
203
|
+
failedFileCount: suite.failedFiles.length,
|
|
204
|
+
notRunFileCount: suite.fileCount - suite.completedFileCount,
|
|
183
205
|
failedFiles: suite.failedFiles,
|
|
184
206
|
durationMs: suite.durationMs,
|
|
185
207
|
error: suite.error,
|
|
@@ -201,6 +223,12 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
|
201
223
|
export function buildStatusArtifact({
|
|
202
224
|
productDir,
|
|
203
225
|
results,
|
|
226
|
+
suiteType,
|
|
227
|
+
suiteNames,
|
|
228
|
+
fileNames,
|
|
229
|
+
framework,
|
|
230
|
+
shard,
|
|
231
|
+
serviceFilter,
|
|
204
232
|
metadata,
|
|
205
233
|
}) {
|
|
206
234
|
const executedResults = results.filter((result) => !result.skipped);
|
|
@@ -240,6 +268,21 @@ export function buildStatusArtifact({
|
|
|
240
268
|
},
|
|
241
269
|
};
|
|
242
270
|
|
|
271
|
+
const scope = {
|
|
272
|
+
suiteType,
|
|
273
|
+
suiteNames: [...(suiteNames || [])].sort(),
|
|
274
|
+
fileNames: [...(fileNames || [])].sort(),
|
|
275
|
+
framework: formatFrameworkForArtifact(framework || "all"),
|
|
276
|
+
shard: shard || null,
|
|
277
|
+
serviceFilter: serviceFilter || null,
|
|
278
|
+
};
|
|
279
|
+
scope.isFullRun =
|
|
280
|
+
scope.suiteNames.length === 0 &&
|
|
281
|
+
scope.fileNames.length === 0 &&
|
|
282
|
+
scope.framework === "all" &&
|
|
283
|
+
scope.shard === null &&
|
|
284
|
+
scope.serviceFilter === null;
|
|
285
|
+
|
|
243
286
|
return {
|
|
244
287
|
schemaVersion: 1,
|
|
245
288
|
source: "testkit",
|
|
@@ -252,6 +295,7 @@ export function buildStatusArtifact({
|
|
|
252
295
|
commitSha: metadata.git?.commitSha || null,
|
|
253
296
|
},
|
|
254
297
|
testkitVersion: metadata.testkitVersion,
|
|
298
|
+
scope,
|
|
255
299
|
summary,
|
|
256
300
|
tests,
|
|
257
301
|
};
|
|
@@ -277,6 +321,10 @@ export function buildRunArtifact({
|
|
|
277
321
|
const totalSuites = executed.reduce((sum, result) => sum + result.suiteCount, 0);
|
|
278
322
|
const completedSuites = executed.reduce((sum, result) => sum + result.completedSuiteCount, 0);
|
|
279
323
|
const failedSuites = executed.reduce((sum, result) => sum + result.failedSuiteCount, 0);
|
|
324
|
+
const totalFiles = executed.reduce((sum, result) => sum + (result.totalFileCount || 0), 0);
|
|
325
|
+
const passedFiles = executed.reduce((sum, result) => sum + (result.passedFileCount || 0), 0);
|
|
326
|
+
const failedFiles = executed.reduce((sum, result) => sum + (result.failedFileCount || 0), 0);
|
|
327
|
+
const notRunFiles = executed.reduce((sum, result) => sum + (result.notRunFileCount || 0), 0);
|
|
280
328
|
const dbBackend = summarizeDbBackend(results);
|
|
281
329
|
|
|
282
330
|
return {
|
|
@@ -317,6 +365,12 @@ export function buildRunArtifact({
|
|
|
317
365
|
passed: completedSuites - failedSuites,
|
|
318
366
|
failed: failedSuites,
|
|
319
367
|
},
|
|
368
|
+
files: {
|
|
369
|
+
total: totalFiles,
|
|
370
|
+
passed: passedFiles,
|
|
371
|
+
failed: failedFiles,
|
|
372
|
+
notRun: notRunFiles,
|
|
373
|
+
},
|
|
320
374
|
},
|
|
321
375
|
services: results.map((result) => ({
|
|
322
376
|
name: result.name,
|
|
@@ -325,6 +379,11 @@ export function buildRunArtifact({
|
|
|
325
379
|
suiteCount: result.suiteCount,
|
|
326
380
|
completedSuiteCount: result.completedSuiteCount,
|
|
327
381
|
failedSuiteCount: result.failedSuiteCount,
|
|
382
|
+
totalFileCount: result.totalFileCount,
|
|
383
|
+
completedFileCount: result.completedFileCount,
|
|
384
|
+
passedFileCount: result.passedFileCount,
|
|
385
|
+
failedFileCount: result.failedFileCount,
|
|
386
|
+
notRunFileCount: result.notRunFileCount,
|
|
328
387
|
durationMs: result.durationMs,
|
|
329
388
|
dbBackend: result.dbBackend,
|
|
330
389
|
suites: result.suites,
|
|
@@ -350,10 +409,15 @@ export function formatDuration(durationMs) {
|
|
|
350
409
|
|
|
351
410
|
export function formatServiceSummary(result) {
|
|
352
411
|
const passedSuites = result.completedSuiteCount - result.failedSuiteCount;
|
|
353
|
-
const
|
|
412
|
+
const notRunSuites = result.suiteCount - result.completedSuiteCount;
|
|
354
413
|
let detail = `${passedSuites}/${result.suiteCount} suites passed`;
|
|
355
|
-
if (
|
|
356
|
-
detail += `, ${
|
|
414
|
+
if ((result.totalFileCount || 0) > 0) {
|
|
415
|
+
detail += `, ${result.passedFileCount}/${result.totalFileCount} files passed`;
|
|
416
|
+
}
|
|
417
|
+
if (notRunSuites > 0) {
|
|
418
|
+
detail += `, ${notRunSuites} ${pluralize(notRunSuites, "suite", "suites")} not run`;
|
|
419
|
+
} else if ((result.notRunFileCount || 0) > 0) {
|
|
420
|
+
detail += `, ${result.notRunFileCount} ${pluralize(result.notRunFileCount, "file", "files")} not run`;
|
|
357
421
|
}
|
|
358
422
|
return detail;
|
|
359
423
|
}
|
|
@@ -382,3 +446,7 @@ function sanitizeErrorMessage(message) {
|
|
|
382
446
|
.replace(/Command failed with exit code (\d+): k6 run\b/g, "Default runtime failed with exit code $1:")
|
|
383
447
|
.replace(/[\\/]vendor[\\/]k6\b/g, "default-runtime");
|
|
384
448
|
}
|
|
449
|
+
|
|
450
|
+
function pluralize(value, singular, plural) {
|
|
451
|
+
return value === 1 ? singular : plural;
|
|
452
|
+
}
|
|
@@ -64,8 +64,12 @@ describe("runner-results", () => {
|
|
|
64
64
|
const result = finalizeServiceResult(tracker, 1000, 1500);
|
|
65
65
|
expect(result.failed).toBe(true);
|
|
66
66
|
expect(result.failedSuiteCount).toBe(1);
|
|
67
|
+
expect(result.totalFileCount).toBe(1);
|
|
68
|
+
expect(result.failedFileCount).toBe(1);
|
|
69
|
+
expect(result.passedFileCount).toBe(0);
|
|
67
70
|
expect(result.errors).toEqual(["worker failed", "graph failed"]);
|
|
68
71
|
expect(result.suites[0].framework).toBe("default");
|
|
72
|
+
expect(result.suites[0].failedFileCount).toBe(1);
|
|
69
73
|
expect(result.suites[0].files).toEqual([
|
|
70
74
|
{
|
|
71
75
|
path: "tests/health.js",
|
|
@@ -86,6 +90,11 @@ describe("runner-results", () => {
|
|
|
86
90
|
suiteCount: 1,
|
|
87
91
|
completedSuiteCount: 1,
|
|
88
92
|
failedSuiteCount: 0,
|
|
93
|
+
totalFileCount: 3,
|
|
94
|
+
completedFileCount: 3,
|
|
95
|
+
passedFileCount: 3,
|
|
96
|
+
failedFileCount: 0,
|
|
97
|
+
notRunFileCount: 0,
|
|
89
98
|
durationMs: 1200,
|
|
90
99
|
dbBackend: "local",
|
|
91
100
|
suites: [],
|
|
@@ -98,6 +107,11 @@ describe("runner-results", () => {
|
|
|
98
107
|
suiteCount: 0,
|
|
99
108
|
completedSuiteCount: 0,
|
|
100
109
|
failedSuiteCount: 0,
|
|
110
|
+
totalFileCount: 0,
|
|
111
|
+
completedFileCount: 0,
|
|
112
|
+
passedFileCount: 0,
|
|
113
|
+
failedFileCount: 0,
|
|
114
|
+
notRunFileCount: 0,
|
|
101
115
|
durationMs: 0,
|
|
102
116
|
dbBackend: null,
|
|
103
117
|
suites: [],
|
|
@@ -134,6 +148,12 @@ describe("runner-results", () => {
|
|
|
134
148
|
|
|
135
149
|
expect(artifact.product.name).toBe("my-product");
|
|
136
150
|
expect(artifact.summary.services.total).toBe(1);
|
|
151
|
+
expect(artifact.summary.files).toEqual({
|
|
152
|
+
total: 3,
|
|
153
|
+
passed: 3,
|
|
154
|
+
failed: 0,
|
|
155
|
+
notRun: 0,
|
|
156
|
+
});
|
|
137
157
|
expect(summarizeDbBackend(results)).toBe("local");
|
|
138
158
|
expect(formatDuration(65_000)).toBe("1m 05s");
|
|
139
159
|
expect(
|
|
@@ -141,8 +161,11 @@ describe("runner-results", () => {
|
|
|
141
161
|
completedSuiteCount: 2,
|
|
142
162
|
failedSuiteCount: 1,
|
|
143
163
|
suiteCount: 3,
|
|
164
|
+
totalFileCount: 6,
|
|
165
|
+
passedFileCount: 5,
|
|
166
|
+
notRunFileCount: 1,
|
|
144
167
|
})
|
|
145
|
-
).toBe("1/3 suites passed, 1 not run");
|
|
168
|
+
).toBe("1/3 suites passed, 5/6 files passed, 1 suite not run");
|
|
146
169
|
expect(formatError(new Error("boom"))).toBe("boom");
|
|
147
170
|
});
|
|
148
171
|
|
|
@@ -194,6 +217,15 @@ describe("runner-results", () => {
|
|
|
194
217
|
commitSha: "abc123",
|
|
195
218
|
},
|
|
196
219
|
testkitVersion: "0.1.20",
|
|
220
|
+
scope: {
|
|
221
|
+
suiteType: "int",
|
|
222
|
+
suiteNames: ["health"],
|
|
223
|
+
fileNames: ["tests/api/integration/b.int.testkit.ts"],
|
|
224
|
+
framework: "default",
|
|
225
|
+
shard: null,
|
|
226
|
+
serviceFilter: "api",
|
|
227
|
+
isFullRun: false,
|
|
228
|
+
},
|
|
197
229
|
summary: {
|
|
198
230
|
services: {
|
|
199
231
|
total: 1,
|
|
@@ -223,4 +255,26 @@ describe("runner-results", () => {
|
|
|
223
255
|
],
|
|
224
256
|
});
|
|
225
257
|
});
|
|
258
|
+
|
|
259
|
+
it("marks unfiltered status artifacts as full runs", () => {
|
|
260
|
+
const status = buildStatusArtifact({
|
|
261
|
+
productDir: "/tmp/my-product",
|
|
262
|
+
results: [],
|
|
263
|
+
suiteType: "all",
|
|
264
|
+
suiteNames: [],
|
|
265
|
+
fileNames: [],
|
|
266
|
+
framework: "all",
|
|
267
|
+
shard: null,
|
|
268
|
+
serviceFilter: null,
|
|
269
|
+
metadata: {
|
|
270
|
+
git: {
|
|
271
|
+
branch: "main",
|
|
272
|
+
commitSha: "abc123",
|
|
273
|
+
},
|
|
274
|
+
testkitVersion: "0.1.20",
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(status.scope.isFullRun).toBe(true);
|
|
279
|
+
});
|
|
226
280
|
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
export type RuntimeMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
2
|
+
|
|
3
|
+
export interface RuntimeHeaders {
|
|
4
|
+
[key: string]: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface RuntimeCookie {
|
|
8
|
+
value: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RuntimeResponse {
|
|
12
|
+
body: string;
|
|
13
|
+
cookies?: Record<string, RuntimeCookie[]>;
|
|
14
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
15
|
+
status: number;
|
|
16
|
+
timings?: {
|
|
17
|
+
duration: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RuntimeOptions {
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
thresholds?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RuntimeEnv {
|
|
27
|
+
BASE: string;
|
|
28
|
+
MACHINE_ID?: string;
|
|
29
|
+
routeParams: RuntimeHeaders;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RuntimeDb {
|
|
33
|
+
exec(sql: string): unknown;
|
|
34
|
+
query<T = Record<string, unknown>>(sql: string): T[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RuntimeDalContext {
|
|
38
|
+
db: RuntimeDb;
|
|
39
|
+
truncate(...tables: string[]): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HttpRequestParams {
|
|
43
|
+
headers?: RuntimeHeaders;
|
|
44
|
+
redirects?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RuntimeHttpClient {
|
|
48
|
+
del(url: string, body?: unknown, params?: HttpRequestParams): RuntimeResponse;
|
|
49
|
+
file(data: unknown, filename?: string, contentType?: string): unknown;
|
|
50
|
+
get(url: string, params?: HttpRequestParams): RuntimeResponse;
|
|
51
|
+
patch(url: string, body?: unknown, params?: HttpRequestParams): RuntimeResponse;
|
|
52
|
+
post(url: string, body?: unknown, params?: HttpRequestParams): RuntimeResponse;
|
|
53
|
+
put(url: string, body?: unknown, params?: HttpRequestParams): RuntimeResponse;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Metric {
|
|
57
|
+
add(value: number): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export declare class Rate implements Metric {
|
|
61
|
+
constructor(name: string, isTime?: boolean);
|
|
62
|
+
add(value: number): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export declare class Trend implements Metric {
|
|
66
|
+
constructor(name: string, isTime?: boolean);
|
|
67
|
+
add(value: number): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface HttpClientConfig<TSetup = unknown> {
|
|
71
|
+
baseUrl: string;
|
|
72
|
+
defaultHeaders?: RuntimeHeaders;
|
|
73
|
+
getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void;
|
|
74
|
+
getRawHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void;
|
|
75
|
+
routeHeaders?: RuntimeHeaders;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface HttpClient<TSetup = unknown> {
|
|
79
|
+
delete(path: string, setupData?: TSetup | null, extraHeaders?: RuntimeHeaders): RuntimeResponse;
|
|
80
|
+
get(path: string, setupData?: TSetup | null, extraHeaders?: RuntimeHeaders): RuntimeResponse;
|
|
81
|
+
getWithHeaders(
|
|
82
|
+
path: string,
|
|
83
|
+
setupData?: TSetup | null,
|
|
84
|
+
extraHeaders?: RuntimeHeaders
|
|
85
|
+
): RuntimeResponse;
|
|
86
|
+
patch(
|
|
87
|
+
path: string,
|
|
88
|
+
setupData?: TSetup | null,
|
|
89
|
+
body?: unknown,
|
|
90
|
+
extraHeaders?: RuntimeHeaders
|
|
91
|
+
): RuntimeResponse;
|
|
92
|
+
post(
|
|
93
|
+
path: string,
|
|
94
|
+
setupData?: TSetup | null,
|
|
95
|
+
body?: unknown,
|
|
96
|
+
extraHeaders?: RuntimeHeaders
|
|
97
|
+
): RuntimeResponse;
|
|
98
|
+
put(
|
|
99
|
+
path: string,
|
|
100
|
+
setupData?: TSetup | null,
|
|
101
|
+
body?: unknown,
|
|
102
|
+
extraHeaders?: RuntimeHeaders
|
|
103
|
+
): RuntimeResponse;
|
|
104
|
+
raw(
|
|
105
|
+
method: RuntimeMethod,
|
|
106
|
+
path: string,
|
|
107
|
+
body?: unknown,
|
|
108
|
+
extraHeaders?: RuntimeHeaders
|
|
109
|
+
): RuntimeResponse;
|
|
110
|
+
rawDelete(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
|
|
111
|
+
rawGet(path: string, extraHeaders?: RuntimeHeaders): RuntimeResponse;
|
|
112
|
+
rawPatch(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
|
|
113
|
+
rawPost(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
|
|
114
|
+
rawPut(path: string, body?: unknown, extraHeaders?: RuntimeHeaders): RuntimeResponse;
|
|
115
|
+
request(
|
|
116
|
+
method: RuntimeMethod,
|
|
117
|
+
path: string,
|
|
118
|
+
setupData?: TSetup | null,
|
|
119
|
+
body?: unknown,
|
|
120
|
+
extraHeaders?: RuntimeHeaders
|
|
121
|
+
): RuntimeResponse;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export declare const check: <T>(
|
|
125
|
+
value: T,
|
|
126
|
+
checks: Record<string, (value: T) => boolean>
|
|
127
|
+
) => boolean;
|
|
128
|
+
export declare const fail: (message: string) => never;
|
|
129
|
+
export declare const group: (name: string, fn: () => void) => void;
|
|
130
|
+
export declare const sleep: (seconds?: number) => void;
|
|
131
|
+
|
|
132
|
+
export declare const http: RuntimeHttpClient;
|
|
133
|
+
|
|
134
|
+
export declare function file(data: unknown, filename?: string, contentType?: string): unknown;
|
|
135
|
+
export declare function json<T = unknown>(response: Pick<RuntimeResponse, "body">): T;
|
|
136
|
+
export declare function contains<T extends Record<string, unknown>>(
|
|
137
|
+
rows: T[],
|
|
138
|
+
field: keyof T | string,
|
|
139
|
+
value: unknown
|
|
140
|
+
): boolean;
|
|
141
|
+
export declare function allMatch<T>(
|
|
142
|
+
rows: T[],
|
|
143
|
+
predicate: (row: T) => boolean
|
|
144
|
+
): boolean;
|
|
145
|
+
export declare function isSorted<T extends Record<string, unknown>>(
|
|
146
|
+
rows: T[],
|
|
147
|
+
field: keyof T | string,
|
|
148
|
+
direction?: "asc" | "desc"
|
|
149
|
+
): boolean;
|
|
150
|
+
|
|
151
|
+
export declare function singleIterationOptions(overrides?: RuntimeOptions): RuntimeOptions;
|
|
152
|
+
export declare const defaultOptions: RuntimeOptions;
|
|
153
|
+
export declare const httpDefaultOptions: RuntimeOptions;
|
|
154
|
+
|
|
155
|
+
export declare function createDalContext(db?: RuntimeDb): RuntimeDalContext;
|
|
156
|
+
export declare function openDb(): RuntimeDb;
|
|
157
|
+
export declare function truncate(db: RuntimeDb, ...tables: string[]): void;
|
|
158
|
+
|
|
159
|
+
export declare function getEnv(): RuntimeEnv;
|
|
160
|
+
export declare function createHttpClient<TSetup = unknown>(
|
|
161
|
+
config: HttpClientConfig<TSetup>
|
|
162
|
+
): HttpClient<TSetup>;
|
|
163
|
+
export declare function makeReq<TSetup = unknown>(
|
|
164
|
+
baseUrl: string,
|
|
165
|
+
routeHeaders?: RuntimeHeaders,
|
|
166
|
+
getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void
|
|
167
|
+
): HttpClient<TSetup>["request"];
|
|
168
|
+
export declare function makeRawReq(
|
|
169
|
+
baseUrl: string,
|
|
170
|
+
routeHeaders?: RuntimeHeaders,
|
|
171
|
+
getRawHeaders?: (setupData?: never) => RuntimeHeaders | void
|
|
172
|
+
): HttpClient["raw"];
|
|
173
|
+
export declare function makeGetWithHeaders<TSetup = unknown>(
|
|
174
|
+
baseUrl: string,
|
|
175
|
+
routeHeaders?: RuntimeHeaders,
|
|
176
|
+
getHeaders?: (setupData?: TSetup | null) => RuntimeHeaders | void
|
|
177
|
+
): HttpClient<TSetup>["getWithHeaders"];
|
|
178
|
+
|
|
179
|
+
declare global {
|
|
180
|
+
const __ENV: Record<string, string | undefined>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export {};
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { Rate } from "k6/metrics";
|
|
2
|
+
|
|
3
|
+
export const runtimeFailures = new Rate("testkit_runtime_failures");
|
|
4
|
+
|
|
1
5
|
export function singleIterationOptions(overrides = {}) {
|
|
2
6
|
return {
|
|
3
7
|
iterations: 1,
|
|
4
8
|
thresholds: {
|
|
5
9
|
checks: ["rate==1.0"],
|
|
10
|
+
testkit_runtime_failures: ["rate==0"],
|
|
6
11
|
...(overrides.thresholds || {}),
|
|
7
12
|
},
|
|
8
13
|
...overrides,
|
|
@@ -37,3 +42,7 @@ export function isSorted(rows, field, direction = "asc") {
|
|
|
37
42
|
|
|
38
43
|
return true;
|
|
39
44
|
}
|
|
45
|
+
|
|
46
|
+
export function recordRuntimeFailure() {
|
|
47
|
+
runtimeFailures.add(1);
|
|
48
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fail } from "k6";
|
|
2
|
+
import { defaultOptions, recordRuntimeFailure } from "./checks.js";
|
|
2
3
|
import { createDalContext, openDb } from "./dal.js";
|
|
3
4
|
|
|
4
5
|
export function defineDalSuite(configOrRun, maybeRun) {
|
|
@@ -10,14 +11,24 @@ export function defineDalSuite(configOrRun, maybeRun) {
|
|
|
10
11
|
options: config.options || defaultOptions,
|
|
11
12
|
setup() {
|
|
12
13
|
if (typeof config.setup !== "function") return null;
|
|
13
|
-
|
|
14
|
+
try {
|
|
15
|
+
return config.setup({ db, dal });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
recordRuntimeFailure();
|
|
18
|
+
fail(formatFatalSuiteError("setup", error));
|
|
19
|
+
}
|
|
14
20
|
},
|
|
15
21
|
exec(setupData) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
try {
|
|
23
|
+
return run({
|
|
24
|
+
db,
|
|
25
|
+
dal,
|
|
26
|
+
setupData,
|
|
27
|
+
});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
recordRuntimeFailure();
|
|
30
|
+
fail(formatFatalSuiteError("exec", error));
|
|
31
|
+
}
|
|
21
32
|
},
|
|
22
33
|
};
|
|
23
34
|
}
|
|
@@ -31,3 +42,10 @@ function normalizeSuiteArgs(configOrRun, maybeRun) {
|
|
|
31
42
|
}
|
|
32
43
|
return { config: configOrRun || {}, run: maybeRun };
|
|
33
44
|
}
|
|
45
|
+
|
|
46
|
+
function formatFatalSuiteError(phase, error) {
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
return `Uncaught testkit suite error during ${phase}: ${error.message}`;
|
|
49
|
+
}
|
|
50
|
+
return `Uncaught testkit suite error during ${phase}: ${String(error)}`;
|
|
51
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fail } from "k6";
|
|
2
|
+
import { defaultOptions, recordRuntimeFailure } from "./checks.js";
|
|
2
3
|
import { createHttpClient, getEnv } from "./http.js";
|
|
3
4
|
|
|
4
5
|
export function defineHttpSuite(configOrRun, maybeRun) {
|
|
@@ -24,17 +25,27 @@ export function defineHttpSuite(configOrRun, maybeRun) {
|
|
|
24
25
|
options: config.options || defaultOptions,
|
|
25
26
|
setup() {
|
|
26
27
|
if (typeof auth?.setup !== "function") return null;
|
|
27
|
-
|
|
28
|
+
try {
|
|
29
|
+
return auth.setup({ env });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
recordRuntimeFailure();
|
|
32
|
+
fail(formatFatalSuiteError("setup", error));
|
|
33
|
+
}
|
|
28
34
|
},
|
|
29
35
|
exec(setupData) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
try {
|
|
37
|
+
return run({
|
|
38
|
+
env,
|
|
39
|
+
req: client.request,
|
|
40
|
+
rawReq: client.raw,
|
|
41
|
+
getWithHeaders: client.getWithHeaders,
|
|
42
|
+
setupData,
|
|
43
|
+
session: setupData,
|
|
44
|
+
});
|
|
45
|
+
} catch (error) {
|
|
46
|
+
recordRuntimeFailure();
|
|
47
|
+
fail(formatFatalSuiteError("exec", error));
|
|
48
|
+
}
|
|
38
49
|
},
|
|
39
50
|
};
|
|
40
51
|
}
|
|
@@ -53,3 +64,10 @@ function callHeaders(builder, setupData, env) {
|
|
|
53
64
|
if (typeof builder !== "function") return {};
|
|
54
65
|
return builder(setupData, { env }) || {};
|
|
55
66
|
}
|
|
67
|
+
|
|
68
|
+
function formatFatalSuiteError(phase, error) {
|
|
69
|
+
if (error instanceof Error) {
|
|
70
|
+
return `Uncaught testkit suite error during ${phase}: ${error.message}`;
|
|
71
|
+
}
|
|
72
|
+
return `Uncaught testkit suite error during ${phase}: ${String(error)}`;
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"types": "./lib/index.d.ts",
|
|
6
7
|
"exports": {
|
|
7
|
-
".":
|
|
8
|
-
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.ts",
|
|
10
|
+
"default": "./lib/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./runtime": {
|
|
13
|
+
"types": "./lib/runtime/index.d.ts",
|
|
14
|
+
"default": "./lib/runtime/index.mjs"
|
|
15
|
+
},
|
|
9
16
|
"./package.json": "./package.json"
|
|
10
17
|
},
|
|
11
18
|
"bin": {
|