@elench/testkit 0.1.20 → 0.1.22
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 +12 -26
- package/lib/cli/index.mjs +4 -0
- package/lib/config/discovery.mjs +197 -0
- package/lib/config/discovery.test.mjs +102 -0
- package/lib/config/index.mjs +20 -80
- package/lib/config/model.mjs +11 -25
- package/lib/config/model.test.mjs +8 -7
- package/lib/runner/index.mjs +108 -16
- package/lib/runner/planning.mjs +14 -3
- package/lib/runner/planning.test.mjs +22 -2
- package/lib/runner/results.mjs +90 -2
- package/lib/runner/results.test.mjs +83 -0
- package/lib/runtime/index.mjs +1 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
# @elench/testkit
|
|
2
2
|
|
|
3
|
-
CLI that reads `
|
|
3
|
+
CLI that reads `testkit.config.json` from a product repo, discovers `*.testkit.ts` files by convention, starts the required local services, provisions local Postgres isolation, and runs suites across `k6` and Playwright.
|
|
4
4
|
|
|
5
5
|
## Prerequisites
|
|
6
6
|
|
|
7
|
-
```bash
|
|
8
|
-
sudo apt-get install -y jq
|
|
9
|
-
```
|
|
10
|
-
|
|
11
7
|
`@elench/testkit` ships its own `k6` binary and uses it for both HTTP and DAL suites by default.
|
|
12
8
|
If you need to force a different binary, set `TESTKIT_K6_BIN` to an absolute or relative path.
|
|
13
9
|
|
|
@@ -41,6 +37,12 @@ npx @elench/testkit --jobs 2 --shard 2/3
|
|
|
41
37
|
npx @elench/testkit frontend e2e -s auth
|
|
42
38
|
npx @elench/testkit api int -s health
|
|
43
39
|
|
|
40
|
+
# Exact file
|
|
41
|
+
npx @elench/testkit int --file tests/api/integration/health.int.testkit.ts
|
|
42
|
+
|
|
43
|
+
# Deterministic git-trackable status snapshot
|
|
44
|
+
npx @elench/testkit int --write-status
|
|
45
|
+
|
|
44
46
|
# Lifecycle
|
|
45
47
|
npx @elench/testkit status
|
|
46
48
|
npx @elench/testkit destroy
|
|
@@ -100,7 +102,8 @@ npx @elench/testkit --dir my-product api int -s health
|
|
|
100
102
|
|
|
101
103
|
## How it works
|
|
102
104
|
|
|
103
|
-
1. **Discovery** — reads `
|
|
105
|
+
1. **Discovery** — reads `testkit.config.json` for services, then discovers files by suffix:
|
|
106
|
+
`*.int.testkit.ts`, `*.e2e.testkit.ts`, `*.dal.testkit.ts`, `*.load.testkit.ts`, `*.pw.testkit.ts`
|
|
104
107
|
2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, and database settings
|
|
105
108
|
Per-service `.env` files declared in config are loaded when present.
|
|
106
109
|
3. **Database** — provisions Docker-managed local Postgres when a service declares one
|
|
@@ -111,16 +114,17 @@ npx @elench/testkit --dir my-product api int -s health
|
|
|
111
114
|
|
|
112
115
|
Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
|
|
113
116
|
|
|
114
|
-
`testkit` also writes `.testkit/results/latest.json` for every run. When `testkit.config.json` includes a top-level `telemetry` block, it can best-effort upload
|
|
117
|
+
`testkit` also writes `.testkit/results/latest.json` for every run. With `--write-status`, it writes a deterministic `testkit.status.json` snapshot that is suitable for git tracking. When `testkit.config.json` includes a top-level `telemetry` block, it can best-effort upload the richer run artifact to a configured HTTP endpoint with bearer auth.
|
|
115
118
|
|
|
116
119
|
## File roles
|
|
117
120
|
|
|
118
|
-
- `runner.manifest.json`: canonical test inventory
|
|
119
121
|
- `testkit.config.json`: local execution and provisioning config
|
|
122
|
+
- `testkit.status.json`: optional deterministic run snapshot written by `--write-status`
|
|
120
123
|
|
|
121
124
|
`testkit.config.json` can also declare:
|
|
122
125
|
|
|
123
126
|
- `telemetry` for optional generic HTTP result upload
|
|
127
|
+
- no test registration in config; `testkit` discovers `*.testkit.ts` files from the filesystem automatically
|
|
124
128
|
- `envFile` / `envFiles` for service-specific environment loading
|
|
125
129
|
- `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
|
|
126
130
|
- `database.provider` for local Postgres settings
|
|
@@ -145,24 +149,6 @@ Use `--shard <i/n>` to split the selected suite set deterministically before wor
|
|
|
145
149
|
|
|
146
150
|
`testkit` also writes `.testkit/timings.json` and uses those file timings on later runs for longest-first balancing.
|
|
147
151
|
|
|
148
|
-
## Suite metadata
|
|
149
|
-
|
|
150
|
-
`runner.manifest.json` remains the source of truth for suites. Optional per-suite `testkit` metadata can tune execution:
|
|
151
|
-
|
|
152
|
-
```json
|
|
153
|
-
{
|
|
154
|
-
"name": "health",
|
|
155
|
-
"files": ["tests/example.js"],
|
|
156
|
-
"testkit": {
|
|
157
|
-
"maxFileConcurrency": 2,
|
|
158
|
-
"weight": 3
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
- `maxFileConcurrency`: k6-only opt-in for batching multiple files from the same suite onto one worker
|
|
164
|
-
- `weight`: optional fallback scheduling weight when no file timing history exists yet
|
|
165
|
-
|
|
166
152
|
## Schema
|
|
167
153
|
|
|
168
154
|
See [testkit-config-schema.md](testkit-config-schema.md).
|
package/lib/cli/index.mjs
CHANGED
|
@@ -41,6 +41,7 @@ export function run() {
|
|
|
41
41
|
cli
|
|
42
42
|
.command("[first] [second] [third]", "Run test suites (int, e2e, dal, all)")
|
|
43
43
|
.option("-s, --suite <name>", "Run specific suite(s)", { default: [] })
|
|
44
|
+
.option("-f, --file <path>", "Run specific file(s)", { default: [] })
|
|
44
45
|
.option("--dir <path>", "Explicit product directory")
|
|
45
46
|
.option("--jobs <n>", "Number of isolated worker stacks for the whole run", {
|
|
46
47
|
default: "1",
|
|
@@ -49,6 +50,7 @@ export function run() {
|
|
|
49
50
|
.option("--framework <name>", "Filter by framework (k6, playwright, all)", {
|
|
50
51
|
default: "all",
|
|
51
52
|
})
|
|
53
|
+
.option("--write-status", "Write a deterministic testkit.status.json snapshot")
|
|
52
54
|
.action(async (first, second, third, options) => {
|
|
53
55
|
// Resolve: service filter, suite type, and --dir.
|
|
54
56
|
//
|
|
@@ -96,12 +98,14 @@ export function run() {
|
|
|
96
98
|
|
|
97
99
|
const suiteType = type || "all";
|
|
98
100
|
const suiteNames = Array.isArray(options.suite) ? options.suite : [options.suite].filter(Boolean);
|
|
101
|
+
const fileNames = Array.isArray(options.file) ? options.file : [options.file].filter(Boolean);
|
|
99
102
|
await runner.runAll(
|
|
100
103
|
configs,
|
|
101
104
|
suiteType,
|
|
102
105
|
suiteNames,
|
|
103
106
|
{
|
|
104
107
|
...options,
|
|
108
|
+
fileNames,
|
|
105
109
|
jobs,
|
|
106
110
|
shard,
|
|
107
111
|
},
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
const DISCOVERY_RULES = [
|
|
5
|
+
{ suffix: ".int.testkit.ts", type: "integration", framework: "k6" },
|
|
6
|
+
{ suffix: ".e2e.testkit.ts", type: "e2e", framework: "k6" },
|
|
7
|
+
{ suffix: ".dal.testkit.ts", type: "dal", framework: "k6" },
|
|
8
|
+
{ suffix: ".load.testkit.ts", type: "load", framework: "k6" },
|
|
9
|
+
{ suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const IGNORED_DIRS = new Set([
|
|
13
|
+
".cache",
|
|
14
|
+
".git",
|
|
15
|
+
".hg",
|
|
16
|
+
".next",
|
|
17
|
+
".nuxt",
|
|
18
|
+
".playwright-browsers",
|
|
19
|
+
".svn",
|
|
20
|
+
".testkit",
|
|
21
|
+
".turbo",
|
|
22
|
+
"build",
|
|
23
|
+
"coverage",
|
|
24
|
+
"dist",
|
|
25
|
+
"node_modules",
|
|
26
|
+
"playwright-report",
|
|
27
|
+
"test-results",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function discoverSuites(productDir, services) {
|
|
31
|
+
const serviceEntries = Object.entries(services || {});
|
|
32
|
+
const rules = serviceEntries.map(([serviceName, serviceConfig]) =>
|
|
33
|
+
buildServiceRule(serviceName, serviceConfig)
|
|
34
|
+
);
|
|
35
|
+
const groupedByService = Object.fromEntries(
|
|
36
|
+
serviceEntries.map(([serviceName]) => [serviceName, {}])
|
|
37
|
+
);
|
|
38
|
+
const discoveredFiles = discoverFiles(productDir);
|
|
39
|
+
const unowned = [];
|
|
40
|
+
const ambiguous = [];
|
|
41
|
+
|
|
42
|
+
for (const filePath of discoveredFiles) {
|
|
43
|
+
const rule = inferRule(filePath);
|
|
44
|
+
if (!rule) continue;
|
|
45
|
+
|
|
46
|
+
const matches = rules
|
|
47
|
+
.filter((serviceRule) => ownsFile(serviceRule, filePath))
|
|
48
|
+
.map((serviceRule) => serviceRule.name);
|
|
49
|
+
|
|
50
|
+
if (matches.length === 0) {
|
|
51
|
+
unowned.push(filePath);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (matches.length > 1) {
|
|
56
|
+
ambiguous.push({
|
|
57
|
+
filePath,
|
|
58
|
+
serviceNames: matches.sort((left, right) => left.localeCompare(right)),
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const serviceName = matches[0];
|
|
64
|
+
const serviceRule = rules.find((candidate) => candidate.name === serviceName);
|
|
65
|
+
const relativeToService = relativeToServiceRoot(serviceRule, filePath);
|
|
66
|
+
const suiteName = deriveSuiteName(relativeToService, rule.suffix);
|
|
67
|
+
const grouped = groupedByService[serviceName];
|
|
68
|
+
const suitesForType = grouped[rule.type] || [];
|
|
69
|
+
let suite = suitesForType.find(
|
|
70
|
+
(candidate) => candidate.name === suiteName && candidate.framework === rule.framework
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!suite) {
|
|
74
|
+
suite = {
|
|
75
|
+
name: suiteName,
|
|
76
|
+
files: [],
|
|
77
|
+
framework: rule.framework,
|
|
78
|
+
};
|
|
79
|
+
suitesForType.push(suite);
|
|
80
|
+
grouped[rule.type] = suitesForType;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
suite.files.push(filePath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (unowned.length > 0 || ambiguous.length > 0) {
|
|
87
|
+
throw buildDiscoveryError(unowned, ambiguous);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const grouped of Object.values(groupedByService)) {
|
|
91
|
+
for (const suites of Object.values(grouped)) {
|
|
92
|
+
for (const suite of suites) {
|
|
93
|
+
suite.files.sort((left, right) => left.localeCompare(right));
|
|
94
|
+
}
|
|
95
|
+
suites.sort((left, right) => left.name.localeCompare(right.name));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return groupedByService;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function discoverFiles(productDir) {
|
|
103
|
+
const files = [];
|
|
104
|
+
const queue = [productDir];
|
|
105
|
+
|
|
106
|
+
while (queue.length > 0) {
|
|
107
|
+
const current = queue.pop();
|
|
108
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
109
|
+
const absolutePath = path.join(current, entry.name);
|
|
110
|
+
|
|
111
|
+
if (entry.isSymbolicLink()) continue;
|
|
112
|
+
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
115
|
+
queue.push(absolutePath);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!entry.isFile()) continue;
|
|
120
|
+
|
|
121
|
+
const relativePath = normalizePath(path.relative(productDir, absolutePath));
|
|
122
|
+
if (inferRule(relativePath)) files.push(relativePath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildServiceRule(serviceName, serviceConfig) {
|
|
130
|
+
const testPrefix = normalizePath(path.posix.join("tests", serviceName));
|
|
131
|
+
const cwd = normalizePath(serviceConfig?.local?.cwd || ".");
|
|
132
|
+
return {
|
|
133
|
+
name: serviceName,
|
|
134
|
+
testPrefix,
|
|
135
|
+
cwdPrefix: cwd === "." ? null : cwd,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ownsFile(serviceRule, filePath) {
|
|
140
|
+
if (hasPrefix(filePath, serviceRule.testPrefix)) return true;
|
|
141
|
+
if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) return true;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function relativeToServiceRoot(serviceRule, filePath) {
|
|
146
|
+
if (hasPrefix(filePath, serviceRule.testPrefix)) {
|
|
147
|
+
return path.posix.relative(serviceRule.testPrefix, filePath);
|
|
148
|
+
}
|
|
149
|
+
if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) {
|
|
150
|
+
return path.posix.relative(serviceRule.cwdPrefix, filePath);
|
|
151
|
+
}
|
|
152
|
+
return filePath;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function deriveSuiteName(relativePath, suffix) {
|
|
156
|
+
const parts = relativePath.split("/").filter(Boolean);
|
|
157
|
+
if (parts.length >= 3) return parts[1];
|
|
158
|
+
return path.posix.basename(relativePath, suffix);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function buildDiscoveryError(unowned, ambiguous) {
|
|
162
|
+
const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
|
|
163
|
+
|
|
164
|
+
if (unowned.length > 0) {
|
|
165
|
+
lines.push("");
|
|
166
|
+
lines.push("Unowned test files:");
|
|
167
|
+
for (const filePath of unowned) {
|
|
168
|
+
lines.push(`- ${filePath}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (ambiguous.length > 0) {
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push("Ambiguous test files:");
|
|
175
|
+
for (const entry of ambiguous) {
|
|
176
|
+
lines.push(`- ${entry.filePath} -> ${entry.serviceNames.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push('Expected test files to live under "tests/<service>/..." or a non-root service local.cwd directory.');
|
|
182
|
+
return new Error(lines.join("\n"));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function inferRule(filePath) {
|
|
186
|
+
return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasPrefix(filePath, prefix) {
|
|
190
|
+
return filePath === prefix || filePath.startsWith(`${prefix}/`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizePath(value) {
|
|
194
|
+
const normalized = String(value).split(path.sep).join("/");
|
|
195
|
+
if (normalized === "." || normalized === "./") return ".";
|
|
196
|
+
return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "") || ".";
|
|
197
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { discoverSuites } from "./discovery.mjs";
|
|
6
|
+
|
|
7
|
+
const cleanups = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
while (cleanups.length > 0) {
|
|
11
|
+
cleanups.pop()();
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("filesystem-discovery", () => {
|
|
16
|
+
it("discovers tests by convention without config registration", () => {
|
|
17
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
|
|
18
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
19
|
+
|
|
20
|
+
writeFile(productDir, "tests/api/integration/health.int.testkit.ts");
|
|
21
|
+
writeFile(productDir, "tests/api/integration/auth/me.int.testkit.ts");
|
|
22
|
+
writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
|
|
23
|
+
|
|
24
|
+
const suites = discoverSuites(productDir, {
|
|
25
|
+
api: {
|
|
26
|
+
local: {
|
|
27
|
+
cwd: ".",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
frontend: {
|
|
31
|
+
local: {
|
|
32
|
+
cwd: "frontend",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(suites.api.integration).toEqual([
|
|
38
|
+
{
|
|
39
|
+
name: "auth",
|
|
40
|
+
files: ["tests/api/integration/auth/me.int.testkit.ts"],
|
|
41
|
+
framework: "k6",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "health",
|
|
45
|
+
files: ["tests/api/integration/health.int.testkit.ts"],
|
|
46
|
+
framework: "k6",
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
expect(suites.frontend.e2e).toEqual([
|
|
50
|
+
{
|
|
51
|
+
name: "homepage",
|
|
52
|
+
files: ["frontend/e2e/homepage.pw.testkit.ts"],
|
|
53
|
+
framework: "playwright",
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("fails when a discovered file does not map to any configured service", () => {
|
|
59
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
|
|
60
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
61
|
+
|
|
62
|
+
writeFile(productDir, "tests/unknown/integration/health.int.testkit.ts");
|
|
63
|
+
|
|
64
|
+
expect(() =>
|
|
65
|
+
discoverSuites(productDir, {
|
|
66
|
+
api: {
|
|
67
|
+
local: {
|
|
68
|
+
cwd: ".",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
).toThrow("Unowned test files");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("fails when a discovered file maps to multiple services", () => {
|
|
76
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
|
|
77
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
78
|
+
|
|
79
|
+
writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
|
|
80
|
+
|
|
81
|
+
expect(() =>
|
|
82
|
+
discoverSuites(productDir, {
|
|
83
|
+
frontend: {
|
|
84
|
+
local: {
|
|
85
|
+
cwd: "frontend",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
web: {
|
|
89
|
+
local: {
|
|
90
|
+
cwd: "frontend",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
).toThrow("Ambiguous test files");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function writeFile(productDir, relativePath) {
|
|
99
|
+
const absolutePath = path.join(productDir, relativePath);
|
|
100
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
101
|
+
fs.writeFileSync(absolutePath, "export {};\n");
|
|
102
|
+
}
|
package/lib/config/index.mjs
CHANGED
|
@@ -1,27 +1,19 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
|
+
import { discoverSuites } from "./discovery.mjs";
|
|
4
5
|
import {
|
|
5
6
|
getServiceEnvFiles as getServiceEnvFilesModel,
|
|
6
|
-
isDalSuiteType as isDalSuiteTypeModel,
|
|
7
7
|
isObject as isObjectModel,
|
|
8
8
|
normalizeTelemetryConfig as normalizeTelemetryConfigModel,
|
|
9
|
-
normalizeTemplateConfig as normalizeTemplateConfigModel,
|
|
10
9
|
parseDotenvString,
|
|
11
|
-
requireString as requireStringModel,
|
|
12
10
|
resolveLifecycleConfig as resolveLifecycleConfigModel,
|
|
13
11
|
resolveSelectedDatabase as resolveSelectedDatabaseModel,
|
|
14
12
|
validateConfigCoverage as validateConfigCoverageModel,
|
|
15
|
-
validateDatabaseProviderConfig as validateDatabaseProviderConfigModel,
|
|
16
|
-
validateHttpUrl as validateHttpUrlModel,
|
|
17
|
-
validateLifecycleConfig as validateLifecycleConfigModel,
|
|
18
|
-
validateRunnerManifest,
|
|
19
13
|
validateServiceConfig as validateServiceConfigModel,
|
|
20
14
|
validateTelemetryConfig as validateTelemetryConfigModel,
|
|
21
|
-
validateTemplateConfig as validateTemplateConfigModel,
|
|
22
15
|
} from "./model.mjs";
|
|
23
16
|
|
|
24
|
-
const RUNNER_MANIFEST = "runner.manifest.json";
|
|
25
17
|
const TESTKIT_CONFIG = "testkit.config.json";
|
|
26
18
|
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
27
19
|
export function parseDotenv(filePath) {
|
|
@@ -31,20 +23,20 @@ export function parseDotenv(filePath) {
|
|
|
31
23
|
|
|
32
24
|
export function getServiceNames(cwd) {
|
|
33
25
|
const dir = cwd || process.cwd();
|
|
34
|
-
const
|
|
35
|
-
if (!fs.existsSync(
|
|
36
|
-
const
|
|
37
|
-
if (!isObject(
|
|
38
|
-
return Object.keys(
|
|
26
|
+
const configPath = path.join(dir, TESTKIT_CONFIG);
|
|
27
|
+
if (!fs.existsSync(configPath)) return [];
|
|
28
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
29
|
+
if (!isObject(config.services)) return [];
|
|
30
|
+
return Object.keys(config.services);
|
|
39
31
|
}
|
|
40
32
|
|
|
41
33
|
export function loadConfigs(opts = {}) {
|
|
42
34
|
const productDir = resolveProductDir(process.cwd(), opts.dir);
|
|
43
|
-
const runner = loadRunnerManifest(productDir);
|
|
44
35
|
const config = loadTestkitConfig(productDir);
|
|
45
|
-
validateConfigCoverage(
|
|
36
|
+
validateConfigCoverage(config);
|
|
37
|
+
const discoveredSuites = discoverSuites(productDir, config.services);
|
|
46
38
|
|
|
47
|
-
const serviceEntries = Object.entries(
|
|
39
|
+
const serviceEntries = Object.entries(config.services);
|
|
48
40
|
const filtered = opts.service
|
|
49
41
|
? serviceEntries.filter(([name]) => name === opts.service)
|
|
50
42
|
: serviceEntries;
|
|
@@ -54,14 +46,8 @@ export function loadConfigs(opts = {}) {
|
|
|
54
46
|
throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
|
|
55
47
|
}
|
|
56
48
|
|
|
57
|
-
return filtered.map(([name,
|
|
58
|
-
const
|
|
59
|
-
if (!serviceConfig) {
|
|
60
|
-
throw new Error(
|
|
61
|
-
`Service "${name}" exists in ${RUNNER_MANIFEST} but is missing from ${TESTKIT_CONFIG}`
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
49
|
+
return filtered.map(([name, serviceConfig]) => {
|
|
50
|
+
const suites = discoveredSuites[name] || {};
|
|
65
51
|
const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
|
|
66
52
|
const serviceEnv = loadServiceEnv(productDir, serviceConfig);
|
|
67
53
|
const selectedBackend = resolvedDatabase?.selectedBackend;
|
|
@@ -69,7 +55,7 @@ export function loadConfigs(opts = {}) {
|
|
|
69
55
|
const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
|
|
70
56
|
validateMergedService(
|
|
71
57
|
name,
|
|
72
|
-
|
|
58
|
+
suites,
|
|
73
59
|
serviceConfig,
|
|
74
60
|
resolvedDatabase,
|
|
75
61
|
resolvedMigrate,
|
|
@@ -82,7 +68,7 @@ export function loadConfigs(opts = {}) {
|
|
|
82
68
|
productDir,
|
|
83
69
|
stateDir: path.join(productDir, ".testkit", name),
|
|
84
70
|
telemetry: normalizeTelemetryConfig(config.telemetry),
|
|
85
|
-
suites
|
|
71
|
+
suites,
|
|
86
72
|
testkit: {
|
|
87
73
|
...serviceConfig,
|
|
88
74
|
database: resolvedDatabase,
|
|
@@ -128,13 +114,6 @@ export function resolveServiceCwd(productDir, maybeRelative) {
|
|
|
128
114
|
return path.resolve(productDir, maybeRelative || ".");
|
|
129
115
|
}
|
|
130
116
|
|
|
131
|
-
function loadRunnerManifest(productDir) {
|
|
132
|
-
const manifestPath = path.join(productDir, RUNNER_MANIFEST);
|
|
133
|
-
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
134
|
-
validateRunnerManifest(raw, RUNNER_MANIFEST, manifestPath);
|
|
135
|
-
return raw;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
117
|
function loadTestkitConfig(productDir) {
|
|
139
118
|
const configPath = path.join(productDir, TESTKIT_CONFIG);
|
|
140
119
|
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
@@ -154,8 +133,8 @@ function loadTestkitConfig(productDir) {
|
|
|
154
133
|
return raw;
|
|
155
134
|
}
|
|
156
135
|
|
|
157
|
-
function validateConfigCoverage(
|
|
158
|
-
return validateConfigCoverageModel(
|
|
136
|
+
function validateConfigCoverage(config) {
|
|
137
|
+
return validateConfigCoverageModel(config, TESTKIT_CONFIG);
|
|
159
138
|
}
|
|
160
139
|
|
|
161
140
|
function resolveProductDir(cwd, explicitDir) {
|
|
@@ -170,7 +149,7 @@ function resolveProductDir(cwd, explicitDir) {
|
|
|
170
149
|
}
|
|
171
150
|
|
|
172
151
|
function ensureProductFiles(dir) {
|
|
173
|
-
const missing = [
|
|
152
|
+
const missing = [TESTKIT_CONFIG].filter(
|
|
174
153
|
(file) => !fs.existsSync(path.join(dir, file))
|
|
175
154
|
);
|
|
176
155
|
if (missing.length > 0) {
|
|
@@ -182,18 +161,16 @@ function ensureProductFiles(dir) {
|
|
|
182
161
|
|
|
183
162
|
function validateMergedService(
|
|
184
163
|
name,
|
|
185
|
-
|
|
164
|
+
suites,
|
|
186
165
|
serviceConfig,
|
|
187
166
|
resolvedDatabase,
|
|
188
167
|
resolvedMigrate,
|
|
189
168
|
resolvedSeed,
|
|
190
169
|
productDir
|
|
191
170
|
) {
|
|
192
|
-
const usesLocalExecution = Object.
|
|
193
|
-
|
|
194
|
-
(suite) =>
|
|
195
|
-
(suite.framework && suite.framework !== "k6") ||
|
|
196
|
-
!isDalSuiteType(suite, runnerService, suites)
|
|
171
|
+
const usesLocalExecution = Object.entries(suites).some(([suiteType, discoveredSuites]) =>
|
|
172
|
+
discoveredSuites.some(
|
|
173
|
+
(suite) => (suite.framework && suite.framework !== "k6") || suiteType !== "dal"
|
|
197
174
|
)
|
|
198
175
|
);
|
|
199
176
|
|
|
@@ -249,15 +226,6 @@ function validateMergedService(
|
|
|
249
226
|
}
|
|
250
227
|
|
|
251
228
|
}
|
|
252
|
-
|
|
253
|
-
function validateServiceConfig(name, service, configPath) {
|
|
254
|
-
return validateServiceConfigModel(name, service, configPath);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function validateTelemetryConfig(telemetry, configPath) {
|
|
258
|
-
return validateTelemetryConfigModel(telemetry, configPath, TESTKIT_CONFIG);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
229
|
function loadServiceEnv(productDir, serviceConfig) {
|
|
262
230
|
const env = {};
|
|
263
231
|
for (const envFile of getServiceEnvFiles(serviceConfig)) {
|
|
@@ -274,34 +242,10 @@ function resolveSelectedDatabase(name, serviceConfig) {
|
|
|
274
242
|
return resolveSelectedDatabaseModel(name, serviceConfig);
|
|
275
243
|
}
|
|
276
244
|
|
|
277
|
-
function validateDatabaseProviderConfig(name, db, label) {
|
|
278
|
-
return validateDatabaseProviderConfigModel(name, db, label);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function validateTemplateConfig(name, template, label) {
|
|
282
|
-
return validateTemplateConfigModel(name, template, label);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function normalizeTemplateConfig(template) {
|
|
286
|
-
return normalizeTemplateConfigModel(template);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function validateLifecycleConfig(name, value, label) {
|
|
290
|
-
return validateLifecycleConfigModel(name, value, label);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
245
|
function resolveLifecycleConfig(value, selectedBackend) {
|
|
294
246
|
return resolveLifecycleConfigModel(value, selectedBackend);
|
|
295
247
|
}
|
|
296
248
|
|
|
297
|
-
function requireString(obj, key, label) {
|
|
298
|
-
return requireStringModel(obj, key, label);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function isDalSuiteType(suite, runnerService, suitesForType) {
|
|
302
|
-
return isDalSuiteTypeModel(suite, runnerService, suitesForType);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
249
|
function isObject(value) {
|
|
306
250
|
return isObjectModel(value);
|
|
307
251
|
}
|
|
@@ -309,7 +253,3 @@ function isObject(value) {
|
|
|
309
253
|
function normalizeTelemetryConfig(telemetry) {
|
|
310
254
|
return normalizeTelemetryConfigModel(telemetry);
|
|
311
255
|
}
|
|
312
|
-
|
|
313
|
-
function validateHttpUrl(value, label) {
|
|
314
|
-
return validateHttpUrlModel(value, label);
|
|
315
|
-
}
|
package/lib/config/model.mjs
CHANGED
|
@@ -110,43 +110,23 @@ export function validateRunnerManifest(raw, manifestName = "runner.manifest.json
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
export function validateConfigCoverage(
|
|
113
|
-
runner,
|
|
114
113
|
config,
|
|
115
|
-
configName = "testkit.config.json"
|
|
116
|
-
manifestName = "runner.manifest.json"
|
|
114
|
+
configName = "testkit.config.json"
|
|
117
115
|
) {
|
|
118
116
|
for (const serviceName of Object.keys(config.services || {})) {
|
|
119
|
-
if (!runner.services[serviceName]) {
|
|
120
|
-
throw new Error(
|
|
121
|
-
`Service "${serviceName}" exists in ${configName} but not in ${manifestName}`
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
117
|
for (const depName of config.services[serviceName].dependsOn || []) {
|
|
126
118
|
if (!config.services[depName]) {
|
|
127
119
|
throw new Error(
|
|
128
120
|
`Service "${serviceName}" depends on "${depName}", but ${depName} is missing from ${configName}`
|
|
129
121
|
);
|
|
130
122
|
}
|
|
131
|
-
if (!runner.services[depName]) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`Service "${serviceName}" depends on "${depName}", but ${depName} is missing from ${manifestName}`
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
123
|
}
|
|
137
124
|
|
|
138
125
|
const databaseFrom = config.services[serviceName].databaseFrom;
|
|
139
|
-
if (databaseFrom) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
if (!runner.services[databaseFrom]) {
|
|
146
|
-
throw new Error(
|
|
147
|
-
`Service "${serviceName}" databaseFrom "${databaseFrom}" is missing from ${manifestName}`
|
|
148
|
-
);
|
|
149
|
-
}
|
|
126
|
+
if (databaseFrom && !config.services[databaseFrom]) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Service "${serviceName}" databaseFrom "${databaseFrom}" is missing from ${configName}`
|
|
129
|
+
);
|
|
150
130
|
}
|
|
151
131
|
}
|
|
152
132
|
}
|
|
@@ -231,6 +211,12 @@ export function validateServiceConfig(name, service, configPath) {
|
|
|
231
211
|
throw new Error(`Service "${name}" local.env must be an object`);
|
|
232
212
|
}
|
|
233
213
|
}
|
|
214
|
+
|
|
215
|
+
if (service.discovery !== undefined) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Service "${name}" cannot define discovery. Testkit discovers *.testkit.ts files from the filesystem automatically.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
234
220
|
}
|
|
235
221
|
|
|
236
222
|
export function validateTelemetryConfig(
|
|
@@ -60,11 +60,6 @@ QUX='zap'
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it("validates config coverage", () => {
|
|
63
|
-
const runner = {
|
|
64
|
-
services: {
|
|
65
|
-
api: { suites: {} },
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
63
|
const config = {
|
|
69
64
|
services: {
|
|
70
65
|
frontend: {
|
|
@@ -73,8 +68,8 @@ QUX='zap'
|
|
|
73
68
|
},
|
|
74
69
|
};
|
|
75
70
|
|
|
76
|
-
expect(() => validateConfigCoverage(
|
|
77
|
-
'Service "frontend"
|
|
71
|
+
expect(() => validateConfigCoverage(config)).toThrow(
|
|
72
|
+
'Service "frontend" depends on "api", but api is missing from testkit.config.json'
|
|
78
73
|
);
|
|
79
74
|
});
|
|
80
75
|
|
|
@@ -98,6 +93,12 @@ QUX='zap'
|
|
|
98
93
|
}, "testkit.config.json")
|
|
99
94
|
).toThrow("database.backends is no longer supported");
|
|
100
95
|
|
|
96
|
+
expect(() =>
|
|
97
|
+
validateServiceConfig("api", {
|
|
98
|
+
discovery: {},
|
|
99
|
+
}, "testkit.config.json")
|
|
100
|
+
).toThrow("cannot define discovery");
|
|
101
|
+
|
|
101
102
|
expect(() =>
|
|
102
103
|
validateTelemetryConfig(
|
|
103
104
|
{
|
package/lib/runner/index.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import net from "net";
|
|
5
|
+
import { pathToFileURL } from "url";
|
|
5
6
|
import { execa, execaCommand } from "execa";
|
|
6
7
|
import { bundleK6File } from "../bundler/index.mjs";
|
|
7
8
|
import { resolveK6Binary, resolveServiceCwd } from "../config/index.mjs";
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
import {
|
|
46
47
|
addTrackerError as addTrackerErrorModel,
|
|
47
48
|
buildRunArtifact as buildRunArtifactModel,
|
|
49
|
+
buildStatusArtifact as buildStatusArtifactModel,
|
|
48
50
|
buildServiceTrackers as buildServiceTrackersModel,
|
|
49
51
|
finalizeServiceResult as finalizeServiceResultModel,
|
|
50
52
|
formatDuration as formatDurationModel,
|
|
@@ -97,13 +99,21 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
97
99
|
const configMap = new Map(allConfigs.map((config) => [config.name, config]));
|
|
98
100
|
const startedAt = Date.now();
|
|
99
101
|
const telemetry = configs[0]?.telemetry || null;
|
|
102
|
+
const productDir = configs[0]?.productDir || process.cwd();
|
|
103
|
+
const metadata = {
|
|
104
|
+
git: collectGitMetadata(productDir),
|
|
105
|
+
host: {
|
|
106
|
+
hostname: safeHostname(),
|
|
107
|
+
username: safeUsername(),
|
|
108
|
+
},
|
|
109
|
+
testkitVersion: readPackageMetadata().version,
|
|
110
|
+
};
|
|
100
111
|
const servicePlans = collectServicePlans(configs, configMap, suiteType, suiteNames, opts);
|
|
101
112
|
const trackers = buildServiceTrackers(servicePlans, startedAt);
|
|
102
113
|
const executedPlans = servicePlans.filter((plan) => !plan.skipped);
|
|
103
114
|
let workerCount = 0;
|
|
104
115
|
|
|
105
116
|
if (executedPlans.length > 0) {
|
|
106
|
-
const productDir = executedPlans[0].config.productDir;
|
|
107
117
|
const timings = loadTimings(productDir);
|
|
108
118
|
const graphs = buildRuntimeGraphs(executedPlans);
|
|
109
119
|
const queue = buildTaskQueue(executedPlans, graphs, timings);
|
|
@@ -137,7 +147,7 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
137
147
|
finalizeServiceResult(trackers.get(config.name), startedAt, finishedAt)
|
|
138
148
|
);
|
|
139
149
|
const artifact = buildRunArtifact({
|
|
140
|
-
productDir
|
|
150
|
+
productDir,
|
|
141
151
|
results,
|
|
142
152
|
startedAt,
|
|
143
153
|
finishedAt,
|
|
@@ -145,12 +155,30 @@ export async function runAll(configs, suiteType, suiteNames, opts, allConfigs =
|
|
|
145
155
|
workerCount,
|
|
146
156
|
suiteType,
|
|
147
157
|
suiteNames,
|
|
158
|
+
fileNames: opts.fileNames || [],
|
|
148
159
|
framework: opts.framework || "all",
|
|
149
160
|
shard: opts.shard || null,
|
|
150
161
|
serviceFilter: configs.length === 1 ? configs[0].name : null,
|
|
162
|
+
metadata,
|
|
151
163
|
});
|
|
152
164
|
|
|
153
|
-
writeRunArtifact(
|
|
165
|
+
writeRunArtifact(productDir, artifact);
|
|
166
|
+
if (opts.writeStatus) {
|
|
167
|
+
writeStatusArtifact(
|
|
168
|
+
productDir,
|
|
169
|
+
buildStatusArtifact({
|
|
170
|
+
productDir,
|
|
171
|
+
results,
|
|
172
|
+
suiteType,
|
|
173
|
+
suiteNames,
|
|
174
|
+
fileNames: opts.fileNames || [],
|
|
175
|
+
framework: opts.framework || "all",
|
|
176
|
+
shard: opts.shard || null,
|
|
177
|
+
serviceFilter: configs.length === 1 ? configs[0].name : null,
|
|
178
|
+
metadata,
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
}
|
|
154
182
|
|
|
155
183
|
printRunSummary(results, finishedAt - startedAt);
|
|
156
184
|
await reportTelemetry(telemetry, artifact);
|
|
@@ -201,13 +229,13 @@ function collectServicePlans(configs, configMap, suiteType, suiteNames, opts) {
|
|
|
201
229
|
return configs.map((config) => {
|
|
202
230
|
console.log(`\n══ ${config.name} ══`);
|
|
203
231
|
const suites = applyShard(
|
|
204
|
-
collectSuites(config, suiteType, suiteNames, opts.framework),
|
|
232
|
+
collectSuites(config, suiteType, suiteNames, opts.framework, opts.fileNames || []),
|
|
205
233
|
opts.shard
|
|
206
234
|
);
|
|
207
235
|
|
|
208
236
|
if (suites.length === 0) {
|
|
209
237
|
console.log(
|
|
210
|
-
`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} — skipping`
|
|
238
|
+
`No test files for ${config.name} type="${suiteType}" suites=${suiteNames.join(",") || "all"} files=${(opts.fileNames || []).join(",") || "all"} — skipping`
|
|
211
239
|
);
|
|
212
240
|
return {
|
|
213
241
|
config,
|
|
@@ -618,10 +646,11 @@ async function runPlaywrightBatch(targetConfig, batch) {
|
|
|
618
646
|
const requestedFiles = batch.tasks.map((task) =>
|
|
619
647
|
path.relative(cwd, path.join(targetConfig.productDir, task.file))
|
|
620
648
|
);
|
|
649
|
+
const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles);
|
|
621
650
|
const startedAt = Date.now();
|
|
622
651
|
const result = await execa(
|
|
623
652
|
"npx",
|
|
624
|
-
["playwright", "test", "--reporter=json", ...requestedFiles],
|
|
653
|
+
["playwright", "test", "--config", playwrightConfigPath, "--reporter=json", ...requestedFiles],
|
|
625
654
|
{
|
|
626
655
|
cwd,
|
|
627
656
|
env: buildPlaywrightEnv(targetConfig, local.baseUrl),
|
|
@@ -724,8 +753,8 @@ function resolveRuntimeConfigs(targetConfig, configMap) {
|
|
|
724
753
|
return resolveRuntimeConfigsModel(targetConfig, configMap);
|
|
725
754
|
}
|
|
726
755
|
|
|
727
|
-
function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
728
|
-
return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter);
|
|
756
|
+
function collectSuites(config, suiteType, suiteNames, frameworkFilter, fileNames = []) {
|
|
757
|
+
return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter, fileNames);
|
|
729
758
|
}
|
|
730
759
|
|
|
731
760
|
function applyShard(suites, shard) {
|
|
@@ -891,9 +920,11 @@ function buildRunArtifact({
|
|
|
891
920
|
workerCount,
|
|
892
921
|
suiteType,
|
|
893
922
|
suiteNames,
|
|
923
|
+
fileNames,
|
|
894
924
|
framework,
|
|
895
925
|
shard,
|
|
896
926
|
serviceFilter,
|
|
927
|
+
metadata,
|
|
897
928
|
}) {
|
|
898
929
|
return buildRunArtifactModel({
|
|
899
930
|
productDir,
|
|
@@ -904,17 +935,11 @@ function buildRunArtifact({
|
|
|
904
935
|
workerCount,
|
|
905
936
|
suiteType,
|
|
906
937
|
suiteNames,
|
|
938
|
+
fileNames,
|
|
907
939
|
framework,
|
|
908
940
|
shard,
|
|
909
941
|
serviceFilter,
|
|
910
|
-
metadata
|
|
911
|
-
git: collectGitMetadata(productDir),
|
|
912
|
-
host: {
|
|
913
|
-
hostname: safeHostname(),
|
|
914
|
-
username: safeUsername(),
|
|
915
|
-
},
|
|
916
|
-
testkitVersion: readPackageMetadata().version,
|
|
917
|
-
},
|
|
942
|
+
metadata,
|
|
918
943
|
});
|
|
919
944
|
}
|
|
920
945
|
|
|
@@ -924,6 +949,73 @@ function writeRunArtifact(productDir, artifact) {
|
|
|
924
949
|
fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
|
|
925
950
|
}
|
|
926
951
|
|
|
952
|
+
function buildStatusArtifact({
|
|
953
|
+
productDir,
|
|
954
|
+
results,
|
|
955
|
+
metadata,
|
|
956
|
+
}) {
|
|
957
|
+
return buildStatusArtifactModel({
|
|
958
|
+
productDir,
|
|
959
|
+
results,
|
|
960
|
+
metadata,
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function writeStatusArtifact(productDir, artifact) {
|
|
965
|
+
fs.writeFileSync(
|
|
966
|
+
path.join(productDir, "testkit.status.json"),
|
|
967
|
+
`${JSON.stringify(artifact, null, 2)}\n`
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles) {
|
|
972
|
+
const stateDir = targetConfig.stateDir || path.join(targetConfig.productDir, ".testkit");
|
|
973
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
974
|
+
const configPath = path.join(stateDir, "playwright.testkit.config.mjs");
|
|
975
|
+
const baseConfigPath = findPlaywrightConfig(cwd);
|
|
976
|
+
const normalizedFiles = requestedFiles.map(normalizePathSeparators);
|
|
977
|
+
|
|
978
|
+
let source = "";
|
|
979
|
+
if (baseConfigPath) {
|
|
980
|
+
source = `import baseConfig from ${JSON.stringify(pathToFileURL(baseConfigPath).href)};\n` +
|
|
981
|
+
`const resolvedBase = typeof baseConfig === "function" ? await baseConfig() : baseConfig;\n` +
|
|
982
|
+
`export default {\n` +
|
|
983
|
+
` ...(resolvedBase || {}),\n` +
|
|
984
|
+
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
985
|
+
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
986
|
+
`};\n`;
|
|
987
|
+
} else {
|
|
988
|
+
source =
|
|
989
|
+
`export default {\n` +
|
|
990
|
+
` testDir: ${JSON.stringify(cwd)},\n` +
|
|
991
|
+
` testMatch: ${JSON.stringify(normalizedFiles)},\n` +
|
|
992
|
+
`};\n`;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
fs.writeFileSync(configPath, source);
|
|
996
|
+
return configPath;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function findPlaywrightConfig(cwd) {
|
|
1000
|
+
const candidates = [
|
|
1001
|
+
"playwright.config.ts",
|
|
1002
|
+
"playwright.config.mts",
|
|
1003
|
+
"playwright.config.js",
|
|
1004
|
+
"playwright.config.mjs",
|
|
1005
|
+
"playwright.config.cjs",
|
|
1006
|
+
"playwright.config.cts",
|
|
1007
|
+
];
|
|
1008
|
+
|
|
1009
|
+
for (const candidate of candidates) {
|
|
1010
|
+
const candidatePath = path.join(cwd, candidate);
|
|
1011
|
+
if (fs.existsSync(candidatePath)) {
|
|
1012
|
+
return candidatePath;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
return null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
927
1019
|
function summarizeDbBackend(results) {
|
|
928
1020
|
return summarizeDbBackendModel(results);
|
|
929
1021
|
}
|
package/lib/runner/planning.mjs
CHANGED
|
@@ -34,24 +34,31 @@ export function resolveRuntimeConfigs(targetConfig, configMap) {
|
|
|
34
34
|
return ordered;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
37
|
+
export function collectSuites(config, suiteType, suiteNames, frameworkFilter, fileNames = []) {
|
|
38
38
|
const types =
|
|
39
39
|
suiteType === "all"
|
|
40
40
|
? orderedTypes(Object.keys(config.suites))
|
|
41
41
|
: [suiteType === "int" ? "integration" : suiteType];
|
|
42
42
|
|
|
43
43
|
const selectedNames = new Set(suiteNames);
|
|
44
|
+
const selectedFiles = new Set(fileNames.map(normalizePathSeparators));
|
|
44
45
|
const suites = [];
|
|
45
46
|
let orderIndex = 0;
|
|
46
47
|
|
|
47
48
|
for (const type of types) {
|
|
48
49
|
for (const suite of config.suites[type] || []) {
|
|
49
50
|
const framework = suite.framework || "k6";
|
|
51
|
+
const files =
|
|
52
|
+
selectedFiles.size === 0
|
|
53
|
+
? suite.files
|
|
54
|
+
: suite.files.filter((file) => selectedFiles.has(normalizePathSeparators(file)));
|
|
50
55
|
if (selectedNames.size > 0 && !selectedNames.has(suite.name)) continue;
|
|
51
56
|
if (frameworkFilter && frameworkFilter !== "all" && framework !== frameworkFilter) continue;
|
|
57
|
+
if (files.length === 0) continue;
|
|
52
58
|
|
|
53
59
|
suites.push({
|
|
54
60
|
...suite,
|
|
61
|
+
files,
|
|
55
62
|
framework,
|
|
56
63
|
type,
|
|
57
64
|
orderIndex,
|
|
@@ -59,8 +66,8 @@ export function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
|
59
66
|
weight:
|
|
60
67
|
suite.testkit?.weight ||
|
|
61
68
|
(framework === "playwright"
|
|
62
|
-
? Math.max(2,
|
|
63
|
-
: Math.max(1,
|
|
69
|
+
? Math.max(2, files.length)
|
|
70
|
+
: Math.max(1, files.length)),
|
|
64
71
|
maxFileConcurrency:
|
|
65
72
|
framework === "k6" ? suite.testkit?.maxFileConcurrency || 1 : 1,
|
|
66
73
|
});
|
|
@@ -260,6 +267,10 @@ export function compareGraphsForAssignment(left, right) {
|
|
|
260
267
|
return left.key.localeCompare(right.key);
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
function normalizePathSeparators(filePath) {
|
|
271
|
+
return String(filePath).split("\\").join("/");
|
|
272
|
+
}
|
|
273
|
+
|
|
263
274
|
export function buildGraphDirName(runtimeNames) {
|
|
264
275
|
const slug = runtimeNames.map(slugSegment).join("__");
|
|
265
276
|
return slug.length > 0 ? slug : "graph";
|
|
@@ -42,10 +42,17 @@ describe("runner-planning", () => {
|
|
|
42
42
|
const config = makeConfig("api", {
|
|
43
43
|
suites: {
|
|
44
44
|
integration: [
|
|
45
|
-
{ name: "health", files: ["health.
|
|
45
|
+
{ name: "health", files: ["tests/api/integration/health.int.testkit.ts"] },
|
|
46
46
|
],
|
|
47
47
|
e2e: [
|
|
48
|
-
{
|
|
48
|
+
{
|
|
49
|
+
name: "auth",
|
|
50
|
+
framework: "playwright",
|
|
51
|
+
files: [
|
|
52
|
+
"tests/frontend/e2e/auth.pw.testkit.ts",
|
|
53
|
+
"tests/frontend/e2e/signup.pw.testkit.ts",
|
|
54
|
+
],
|
|
55
|
+
},
|
|
49
56
|
],
|
|
50
57
|
},
|
|
51
58
|
});
|
|
@@ -62,6 +69,19 @@ describe("runner-planning", () => {
|
|
|
62
69
|
framework: "playwright",
|
|
63
70
|
weight: 2,
|
|
64
71
|
});
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
collectSuites(
|
|
75
|
+
config,
|
|
76
|
+
"all",
|
|
77
|
+
[],
|
|
78
|
+
"all",
|
|
79
|
+
["tests/frontend/e2e/signup.pw.testkit.ts"]
|
|
80
|
+
)[0]
|
|
81
|
+
).toMatchObject({
|
|
82
|
+
name: "auth",
|
|
83
|
+
files: ["tests/frontend/e2e/signup.pw.testkit.ts"],
|
|
84
|
+
});
|
|
65
85
|
});
|
|
66
86
|
|
|
67
87
|
it("applies shards, builds graphs, queues tasks, and claims batches", () => {
|
package/lib/runner/results.mjs
CHANGED
|
@@ -31,11 +31,34 @@ export function buildServiceTrackers(servicePlans, startedAt) {
|
|
|
31
31
|
completedFileCount: 0,
|
|
32
32
|
failedFiles: [],
|
|
33
33
|
failedFileSet: new Set(),
|
|
34
|
-
fileResults:
|
|
35
|
-
|
|
34
|
+
fileResults: suite.files.map((file) => ({
|
|
35
|
+
path: normalizePathSeparators(file),
|
|
36
|
+
failed: false,
|
|
37
|
+
durationMs: 0,
|
|
38
|
+
error: null,
|
|
39
|
+
status: "not_run",
|
|
40
|
+
})),
|
|
41
|
+
fileResultsByPath: new Map(
|
|
42
|
+
suite.files.map((file) => {
|
|
43
|
+
const normalizedPath = normalizePathSeparators(file);
|
|
44
|
+
return [
|
|
45
|
+
normalizedPath,
|
|
46
|
+
{
|
|
47
|
+
path: normalizedPath,
|
|
48
|
+
failed: false,
|
|
49
|
+
durationMs: 0,
|
|
50
|
+
error: null,
|
|
51
|
+
status: "not_run",
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
})
|
|
55
|
+
),
|
|
36
56
|
durationMs: 0,
|
|
37
57
|
error: null,
|
|
38
58
|
}));
|
|
59
|
+
for (const suite of suites) {
|
|
60
|
+
suite.fileResults = [...suite.fileResultsByPath.values()];
|
|
61
|
+
}
|
|
39
62
|
|
|
40
63
|
trackers.set(plan.config.name, {
|
|
41
64
|
name: plan.config.name,
|
|
@@ -73,12 +96,14 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
|
|
|
73
96
|
existingFileResult.failed = outcome.failed;
|
|
74
97
|
existingFileResult.durationMs = outcome.durationMs;
|
|
75
98
|
existingFileResult.error = outcome.error;
|
|
99
|
+
existingFileResult.status = outcome.failed ? "failed" : "passed";
|
|
76
100
|
} else {
|
|
77
101
|
const fileResult = {
|
|
78
102
|
path: normalizedPath,
|
|
79
103
|
failed: outcome.failed,
|
|
80
104
|
durationMs: outcome.durationMs,
|
|
81
105
|
error: outcome.error,
|
|
106
|
+
status: outcome.failed ? "failed" : "passed",
|
|
82
107
|
};
|
|
83
108
|
suite.fileResultsByPath.set(normalizedPath, fileResult);
|
|
84
109
|
suite.fileResults.push(fileResult);
|
|
@@ -164,6 +189,7 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
|
164
189
|
.map((file) => ({
|
|
165
190
|
path: file.path,
|
|
166
191
|
failed: file.failed,
|
|
192
|
+
status: file.status,
|
|
167
193
|
durationMs: file.durationMs,
|
|
168
194
|
error: file.error,
|
|
169
195
|
})),
|
|
@@ -172,6 +198,66 @@ export function finalizeServiceResult(tracker, startedAt, finishedAt) {
|
|
|
172
198
|
};
|
|
173
199
|
}
|
|
174
200
|
|
|
201
|
+
export function buildStatusArtifact({
|
|
202
|
+
productDir,
|
|
203
|
+
results,
|
|
204
|
+
metadata,
|
|
205
|
+
}) {
|
|
206
|
+
const executedResults = results.filter((result) => !result.skipped);
|
|
207
|
+
const tests = [];
|
|
208
|
+
|
|
209
|
+
for (const result of executedResults) {
|
|
210
|
+
for (const suite of result.suites) {
|
|
211
|
+
for (const file of suite.files) {
|
|
212
|
+
tests.push({
|
|
213
|
+
service: result.name,
|
|
214
|
+
type: suite.type,
|
|
215
|
+
framework: suite.framework,
|
|
216
|
+
path: file.path,
|
|
217
|
+
status: file.status,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
tests.sort(
|
|
224
|
+
(left, right) =>
|
|
225
|
+
left.service.localeCompare(right.service) ||
|
|
226
|
+
left.type.localeCompare(right.type) ||
|
|
227
|
+
left.path.localeCompare(right.path)
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const summary = {
|
|
231
|
+
services: {
|
|
232
|
+
total: executedResults.length,
|
|
233
|
+
passed: executedResults.filter((result) => !result.failed).length,
|
|
234
|
+
failed: executedResults.filter((result) => result.failed).length,
|
|
235
|
+
},
|
|
236
|
+
tests: {
|
|
237
|
+
total: tests.length,
|
|
238
|
+
passed: tests.filter((test) => test.status === "passed").length,
|
|
239
|
+
failed: tests.filter((test) => test.status === "failed").length,
|
|
240
|
+
notRun: tests.filter((test) => test.status === "not_run").length,
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
schemaVersion: 1,
|
|
246
|
+
source: "testkit",
|
|
247
|
+
notice: "Generated file. Do not edit manually.",
|
|
248
|
+
product: {
|
|
249
|
+
name: path.basename(productDir),
|
|
250
|
+
},
|
|
251
|
+
git: {
|
|
252
|
+
branch: metadata.git?.branch || null,
|
|
253
|
+
commitSha: metadata.git?.commitSha || null,
|
|
254
|
+
},
|
|
255
|
+
testkitVersion: metadata.testkitVersion,
|
|
256
|
+
summary,
|
|
257
|
+
tests,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
175
261
|
export function buildRunArtifact({
|
|
176
262
|
productDir,
|
|
177
263
|
results,
|
|
@@ -181,6 +267,7 @@ export function buildRunArtifact({
|
|
|
181
267
|
workerCount,
|
|
182
268
|
suiteType,
|
|
183
269
|
suiteNames,
|
|
270
|
+
fileNames,
|
|
184
271
|
framework,
|
|
185
272
|
shard,
|
|
186
273
|
serviceFilter,
|
|
@@ -213,6 +300,7 @@ export function buildRunArtifact({
|
|
|
213
300
|
dbBackend,
|
|
214
301
|
suiteType,
|
|
215
302
|
suiteNames,
|
|
303
|
+
fileNames,
|
|
216
304
|
framework,
|
|
217
305
|
shard,
|
|
218
306
|
serviceFilter,
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
addTrackerError,
|
|
4
4
|
buildRunArtifact,
|
|
5
|
+
buildStatusArtifact,
|
|
5
6
|
buildServiceTrackers,
|
|
6
7
|
finalizeServiceResult,
|
|
7
8
|
formatDuration,
|
|
@@ -68,6 +69,7 @@ describe("runner-results", () => {
|
|
|
68
69
|
{
|
|
69
70
|
path: "tests/health.js",
|
|
70
71
|
failed: true,
|
|
72
|
+
status: "failed",
|
|
71
73
|
durationMs: 250,
|
|
72
74
|
error: "boom",
|
|
73
75
|
},
|
|
@@ -111,6 +113,7 @@ describe("runner-results", () => {
|
|
|
111
113
|
workerCount: 1,
|
|
112
114
|
suiteType: "all",
|
|
113
115
|
suiteNames: [],
|
|
116
|
+
fileNames: [],
|
|
114
117
|
framework: "all",
|
|
115
118
|
shard: null,
|
|
116
119
|
serviceFilter: null,
|
|
@@ -141,4 +144,84 @@ describe("runner-results", () => {
|
|
|
141
144
|
).toBe("1/3 suites passed, 1 not run");
|
|
142
145
|
expect(formatError(new Error("boom"))).toBe("boom");
|
|
143
146
|
});
|
|
147
|
+
|
|
148
|
+
it("builds deterministic status artifacts", () => {
|
|
149
|
+
const status = buildStatusArtifact({
|
|
150
|
+
productDir: "/tmp/my-product",
|
|
151
|
+
results: [
|
|
152
|
+
{
|
|
153
|
+
name: "api",
|
|
154
|
+
failed: true,
|
|
155
|
+
skipped: false,
|
|
156
|
+
suites: [
|
|
157
|
+
{
|
|
158
|
+
name: "health",
|
|
159
|
+
type: "integration",
|
|
160
|
+
framework: "k6",
|
|
161
|
+
files: [
|
|
162
|
+
{ path: "tests/api/integration/a.int.testkit.ts", status: "passed" },
|
|
163
|
+
{ path: "tests/api/integration/b.int.testkit.ts", status: "failed" },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
suiteType: "int",
|
|
170
|
+
suiteNames: ["health"],
|
|
171
|
+
fileNames: ["tests/api/integration/b.int.testkit.ts"],
|
|
172
|
+
framework: "k6",
|
|
173
|
+
shard: null,
|
|
174
|
+
serviceFilter: "api",
|
|
175
|
+
metadata: {
|
|
176
|
+
git: {
|
|
177
|
+
branch: "main",
|
|
178
|
+
commitSha: "abc123",
|
|
179
|
+
},
|
|
180
|
+
testkitVersion: "0.1.20",
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(status).toEqual({
|
|
185
|
+
schemaVersion: 1,
|
|
186
|
+
source: "testkit",
|
|
187
|
+
notice: "Generated file. Do not edit manually.",
|
|
188
|
+
product: {
|
|
189
|
+
name: "my-product",
|
|
190
|
+
},
|
|
191
|
+
git: {
|
|
192
|
+
branch: "main",
|
|
193
|
+
commitSha: "abc123",
|
|
194
|
+
},
|
|
195
|
+
testkitVersion: "0.1.20",
|
|
196
|
+
summary: {
|
|
197
|
+
services: {
|
|
198
|
+
total: 1,
|
|
199
|
+
passed: 0,
|
|
200
|
+
failed: 1,
|
|
201
|
+
},
|
|
202
|
+
tests: {
|
|
203
|
+
total: 2,
|
|
204
|
+
passed: 1,
|
|
205
|
+
failed: 1,
|
|
206
|
+
notRun: 0,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
tests: [
|
|
210
|
+
{
|
|
211
|
+
service: "api",
|
|
212
|
+
type: "integration",
|
|
213
|
+
framework: "k6",
|
|
214
|
+
path: "tests/api/integration/a.int.testkit.ts",
|
|
215
|
+
status: "passed",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
service: "api",
|
|
219
|
+
type: "integration",
|
|
220
|
+
framework: "k6",
|
|
221
|
+
path: "tests/api/integration/b.int.testkit.ts",
|
|
222
|
+
status: "failed",
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
});
|
|
144
227
|
});
|
package/lib/runtime/index.mjs
CHANGED
|
@@ -3,7 +3,6 @@ import fs from "fs";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
|
|
6
|
-
const RUNNER_MANIFEST = "runner.manifest.json";
|
|
7
6
|
const TESTKIT_CONFIG = "testkit.config.json";
|
|
8
7
|
const DEFAULT_RUNTIME_DIR = path.join("tests", "_testkit");
|
|
9
8
|
const METADATA_FILE = ".runtime-manifest.json";
|
|
@@ -124,7 +123,7 @@ function resolveProductDir(cwd, explicitDir) {
|
|
|
124
123
|
}
|
|
125
124
|
|
|
126
125
|
function ensureProductFiles(dir) {
|
|
127
|
-
const missing = [
|
|
126
|
+
const missing = [TESTKIT_CONFIG].filter(
|
|
128
127
|
(file) => !fs.existsSync(path.join(dir, file))
|
|
129
128
|
);
|
|
130
129
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "CLI for running
|
|
3
|
+
"version": "0.1.22",
|
|
4
|
+
"description": "CLI for discovering and running local test suites across k6 and Playwright",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./lib/index.mjs",
|