@elench/testkit 0.1.20 → 0.1.21
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 +13 -27
- package/lib/cli/index.mjs +4 -0
- package/lib/config/discovery.mjs +156 -0
- package/lib/config/discovery.test.mjs +44 -0
- package/lib/config/index.mjs +19 -80
- package/lib/config/model.mjs +25 -25
- package/lib/config/model.test.mjs +10 -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,8 +102,9 @@ npx @elench/testkit --dir my-product api int -s health
|
|
|
100
102
|
|
|
101
103
|
## How it works
|
|
102
104
|
|
|
103
|
-
1. **Discovery** — reads `
|
|
104
|
-
|
|
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`
|
|
107
|
+
2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, database, and discovery 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
|
|
107
110
|
4. **Seed** — runs optional product seed commands against the provisioned database
|
|
@@ -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
|
+
- `discovery.include` / `discovery.exclude` for per-service test discovery
|
|
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,156 @@
|
|
|
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
|
+
export function discoverServiceSuites(productDir, serviceName, serviceConfig) {
|
|
13
|
+
const include = serviceConfig.discovery?.include || [];
|
|
14
|
+
const exclude = serviceConfig.discovery?.exclude || [];
|
|
15
|
+
const grouped = {};
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
|
|
18
|
+
for (const pattern of include) {
|
|
19
|
+
const normalizedPattern = normalizePath(pattern);
|
|
20
|
+
const patternInfo = compilePattern(normalizedPattern);
|
|
21
|
+
const rootDir = path.resolve(productDir, patternInfo.baseDir);
|
|
22
|
+
if (!fs.existsSync(rootDir)) continue;
|
|
23
|
+
|
|
24
|
+
for (const relativeFile of walkFiles(productDir, rootDir)) {
|
|
25
|
+
const normalizedFile = normalizePath(relativeFile);
|
|
26
|
+
if (!patternInfo.regex.test(normalizedFile)) continue;
|
|
27
|
+
if (exclude.some((candidate) => compilePattern(normalizePath(candidate)).regex.test(normalizedFile))) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (seen.has(normalizedFile)) continue;
|
|
31
|
+
|
|
32
|
+
const rule = inferRule(normalizedFile);
|
|
33
|
+
if (!rule) continue;
|
|
34
|
+
|
|
35
|
+
const typeSuites = grouped[rule.type] || [];
|
|
36
|
+
typeSuites.push({
|
|
37
|
+
name: buildSuiteName(normalizedFile, rule.suffix, patternInfo.baseDir),
|
|
38
|
+
files: [normalizedFile],
|
|
39
|
+
framework: rule.framework,
|
|
40
|
+
});
|
|
41
|
+
grouped[rule.type] = typeSuites;
|
|
42
|
+
seen.add(normalizedFile);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const suites of Object.values(grouped)) {
|
|
47
|
+
suites.sort((left, right) => left.files[0].localeCompare(right.files[0]));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return grouped;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function inferRule(filePath) {
|
|
54
|
+
return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildSuiteName(filePath, suffix, baseDir) {
|
|
58
|
+
const normalizedBase = normalizePath(baseDir);
|
|
59
|
+
const relativeToBase =
|
|
60
|
+
normalizedBase === "." || normalizedBase.length === 0
|
|
61
|
+
? filePath
|
|
62
|
+
: path.posix.relative(normalizedBase, filePath);
|
|
63
|
+
const candidate = relativeToBase.length > 0 ? relativeToBase : path.posix.basename(filePath);
|
|
64
|
+
return candidate.slice(0, -suffix.length);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function walkFiles(productDir, rootDir) {
|
|
68
|
+
const rootStats = fs.statSync(rootDir);
|
|
69
|
+
if (rootStats.isFile()) {
|
|
70
|
+
return [normalizePath(path.relative(productDir, rootDir))];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const files = [];
|
|
74
|
+
const queue = [rootDir];
|
|
75
|
+
|
|
76
|
+
while (queue.length > 0) {
|
|
77
|
+
const current = queue.pop();
|
|
78
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
79
|
+
const absolute = path.join(current, entry.name);
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
queue.push(absolute);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!entry.isFile()) continue;
|
|
85
|
+
files.push(normalizePath(path.relative(productDir, absolute)));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function compilePattern(pattern) {
|
|
93
|
+
const baseDir = patternBase(pattern);
|
|
94
|
+
return {
|
|
95
|
+
baseDir,
|
|
96
|
+
regex: globToRegExp(pattern),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function patternBase(pattern) {
|
|
101
|
+
const parts = pattern.split("/");
|
|
102
|
+
const baseParts = [];
|
|
103
|
+
|
|
104
|
+
for (const part of parts) {
|
|
105
|
+
if (/[?*\[]/.test(part)) break;
|
|
106
|
+
baseParts.push(part);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return baseParts.length > 0 ? baseParts.join("/") : ".";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function globToRegExp(pattern) {
|
|
113
|
+
let source = "^";
|
|
114
|
+
|
|
115
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
116
|
+
const current = pattern[index];
|
|
117
|
+
const next = pattern[index + 1];
|
|
118
|
+
const afterNext = pattern[index + 2];
|
|
119
|
+
|
|
120
|
+
if (current === "*" && next === "*" && afterNext === "/") {
|
|
121
|
+
source += "(?:.*/)?";
|
|
122
|
+
index += 2;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (current === "*" && next === "*") {
|
|
127
|
+
source += ".*";
|
|
128
|
+
index += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (current === "*") {
|
|
133
|
+
source += "[^/]*";
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (current === "?") {
|
|
138
|
+
source += "[^/]";
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if ("\\.[]{}()+-^$|".includes(current)) {
|
|
143
|
+
source += `\\${current}`;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
source += current;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
source += "$";
|
|
151
|
+
return new RegExp(source);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizePath(value) {
|
|
155
|
+
return value.split(path.sep).join("/");
|
|
156
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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 { discoverServiceSuites } from "./discovery.mjs";
|
|
6
|
+
|
|
7
|
+
const cleanups = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
while (cleanups.length > 0) {
|
|
11
|
+
cleanups.pop()();
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("config-discovery", () => {
|
|
16
|
+
it("discovers exact-file includes with stable suite names", () => {
|
|
17
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
|
|
18
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
19
|
+
|
|
20
|
+
const filePath = path.join(
|
|
21
|
+
productDir,
|
|
22
|
+
"tests",
|
|
23
|
+
"api",
|
|
24
|
+
"integration",
|
|
25
|
+
"health.int.testkit.ts"
|
|
26
|
+
);
|
|
27
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
28
|
+
fs.writeFileSync(filePath, "export {};\n");
|
|
29
|
+
|
|
30
|
+
const suites = discoverServiceSuites(productDir, "api", {
|
|
31
|
+
discovery: {
|
|
32
|
+
include: ["tests/api/integration/health.int.testkit.ts"],
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(suites.integration).toEqual([
|
|
37
|
+
{
|
|
38
|
+
name: "health",
|
|
39
|
+
files: ["tests/api/integration/health.int.testkit.ts"],
|
|
40
|
+
framework: "k6",
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
});
|
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 { discoverServiceSuites } 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,19 @@ 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);
|
|
46
37
|
|
|
47
|
-
const serviceEntries = Object.entries(
|
|
38
|
+
const serviceEntries = Object.entries(config.services);
|
|
48
39
|
const filtered = opts.service
|
|
49
40
|
? serviceEntries.filter(([name]) => name === opts.service)
|
|
50
41
|
: serviceEntries;
|
|
@@ -54,14 +45,8 @@ export function loadConfigs(opts = {}) {
|
|
|
54
45
|
throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
|
|
55
46
|
}
|
|
56
47
|
|
|
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
|
-
|
|
48
|
+
return filtered.map(([name, serviceConfig]) => {
|
|
49
|
+
const suites = discoverServiceSuites(productDir, name, serviceConfig);
|
|
65
50
|
const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
|
|
66
51
|
const serviceEnv = loadServiceEnv(productDir, serviceConfig);
|
|
67
52
|
const selectedBackend = resolvedDatabase?.selectedBackend;
|
|
@@ -69,7 +54,7 @@ export function loadConfigs(opts = {}) {
|
|
|
69
54
|
const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
|
|
70
55
|
validateMergedService(
|
|
71
56
|
name,
|
|
72
|
-
|
|
57
|
+
suites,
|
|
73
58
|
serviceConfig,
|
|
74
59
|
resolvedDatabase,
|
|
75
60
|
resolvedMigrate,
|
|
@@ -82,7 +67,7 @@ export function loadConfigs(opts = {}) {
|
|
|
82
67
|
productDir,
|
|
83
68
|
stateDir: path.join(productDir, ".testkit", name),
|
|
84
69
|
telemetry: normalizeTelemetryConfig(config.telemetry),
|
|
85
|
-
suites
|
|
70
|
+
suites,
|
|
86
71
|
testkit: {
|
|
87
72
|
...serviceConfig,
|
|
88
73
|
database: resolvedDatabase,
|
|
@@ -128,13 +113,6 @@ export function resolveServiceCwd(productDir, maybeRelative) {
|
|
|
128
113
|
return path.resolve(productDir, maybeRelative || ".");
|
|
129
114
|
}
|
|
130
115
|
|
|
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
116
|
function loadTestkitConfig(productDir) {
|
|
139
117
|
const configPath = path.join(productDir, TESTKIT_CONFIG);
|
|
140
118
|
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
@@ -154,8 +132,8 @@ function loadTestkitConfig(productDir) {
|
|
|
154
132
|
return raw;
|
|
155
133
|
}
|
|
156
134
|
|
|
157
|
-
function validateConfigCoverage(
|
|
158
|
-
return validateConfigCoverageModel(
|
|
135
|
+
function validateConfigCoverage(config) {
|
|
136
|
+
return validateConfigCoverageModel(config, TESTKIT_CONFIG);
|
|
159
137
|
}
|
|
160
138
|
|
|
161
139
|
function resolveProductDir(cwd, explicitDir) {
|
|
@@ -170,7 +148,7 @@ function resolveProductDir(cwd, explicitDir) {
|
|
|
170
148
|
}
|
|
171
149
|
|
|
172
150
|
function ensureProductFiles(dir) {
|
|
173
|
-
const missing = [
|
|
151
|
+
const missing = [TESTKIT_CONFIG].filter(
|
|
174
152
|
(file) => !fs.existsSync(path.join(dir, file))
|
|
175
153
|
);
|
|
176
154
|
if (missing.length > 0) {
|
|
@@ -182,18 +160,16 @@ function ensureProductFiles(dir) {
|
|
|
182
160
|
|
|
183
161
|
function validateMergedService(
|
|
184
162
|
name,
|
|
185
|
-
|
|
163
|
+
suites,
|
|
186
164
|
serviceConfig,
|
|
187
165
|
resolvedDatabase,
|
|
188
166
|
resolvedMigrate,
|
|
189
167
|
resolvedSeed,
|
|
190
168
|
productDir
|
|
191
169
|
) {
|
|
192
|
-
const usesLocalExecution = Object.
|
|
193
|
-
|
|
194
|
-
(suite) =>
|
|
195
|
-
(suite.framework && suite.framework !== "k6") ||
|
|
196
|
-
!isDalSuiteType(suite, runnerService, suites)
|
|
170
|
+
const usesLocalExecution = Object.entries(suites).some(([suiteType, discoveredSuites]) =>
|
|
171
|
+
discoveredSuites.some(
|
|
172
|
+
(suite) => (suite.framework && suite.framework !== "k6") || suiteType !== "dal"
|
|
197
173
|
)
|
|
198
174
|
);
|
|
199
175
|
|
|
@@ -249,15 +225,6 @@ function validateMergedService(
|
|
|
249
225
|
}
|
|
250
226
|
|
|
251
227
|
}
|
|
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
228
|
function loadServiceEnv(productDir, serviceConfig) {
|
|
262
229
|
const env = {};
|
|
263
230
|
for (const envFile of getServiceEnvFiles(serviceConfig)) {
|
|
@@ -274,34 +241,10 @@ function resolveSelectedDatabase(name, serviceConfig) {
|
|
|
274
241
|
return resolveSelectedDatabaseModel(name, serviceConfig);
|
|
275
242
|
}
|
|
276
243
|
|
|
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
244
|
function resolveLifecycleConfig(value, selectedBackend) {
|
|
294
245
|
return resolveLifecycleConfigModel(value, selectedBackend);
|
|
295
246
|
}
|
|
296
247
|
|
|
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
248
|
function isObject(value) {
|
|
306
249
|
return isObjectModel(value);
|
|
307
250
|
}
|
|
@@ -309,7 +252,3 @@ function isObject(value) {
|
|
|
309
252
|
function normalizeTelemetryConfig(telemetry) {
|
|
310
253
|
return normalizeTelemetryConfigModel(telemetry);
|
|
311
254
|
}
|
|
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,26 @@ 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
|
+
if (!isObject(service.discovery)) {
|
|
217
|
+
throw new Error(`Service "${name}" discovery must be an object`);
|
|
218
|
+
}
|
|
219
|
+
if (
|
|
220
|
+
service.discovery.include !== undefined &&
|
|
221
|
+
(!Array.isArray(service.discovery.include) ||
|
|
222
|
+
service.discovery.include.some((value) => typeof value !== "string" || value.length === 0))
|
|
223
|
+
) {
|
|
224
|
+
throw new Error(`Service "${name}" discovery.include must be an array of glob strings`);
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
service.discovery.exclude !== undefined &&
|
|
228
|
+
(!Array.isArray(service.discovery.exclude) ||
|
|
229
|
+
service.discovery.exclude.some((value) => typeof value !== "string" || value.length === 0))
|
|
230
|
+
) {
|
|
231
|
+
throw new Error(`Service "${name}" discovery.exclude must be an array of glob strings`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
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,14 @@ 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
|
+
include: "tests/**/*.int.testkit.ts",
|
|
100
|
+
},
|
|
101
|
+
}, "testkit.config.json")
|
|
102
|
+
).toThrow("discovery.include must be an array of glob strings");
|
|
103
|
+
|
|
101
104
|
expect(() =>
|
|
102
105
|
validateTelemetryConfig(
|
|
103
106
|
{
|
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.21",
|
|
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",
|