@elench/testkit 0.1.19 → 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 +21 -30
- 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 +42 -82
- package/lib/config/index.test.mjs +36 -4
- package/lib/config/model.mjs +25 -25
- package/lib/config/model.test.mjs +10 -7
- package/lib/runner/index.mjs +112 -19
- 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,15 +1,11 @@
|
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
sudo apt-get install -y jq
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
For DAL suites, `@elench/testkit` ships its own `k6` SQL binary.
|
|
7
|
+
`@elench/testkit` ships its own `k6` binary and uses it for both HTTP and DAL suites by default.
|
|
8
|
+
If you need to force a different binary, set `TESTKIT_K6_BIN` to an absolute or relative path.
|
|
13
9
|
|
|
14
10
|
Database isolation uses Docker-managed local Postgres containers. `testkit` creates and reuses template databases automatically, then clones per-worker databases from those templates for fast reruns. The default image is `pgvector/pgvector:pg16`.
|
|
15
11
|
|
|
@@ -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
|
|
@@ -77,6 +79,12 @@ const suite = defineHttpSuite({ auth: clerkSessionAuth }, ({ req, setupData }) =
|
|
|
77
79
|
`testkit` bundles these imports before invoking k6, so tests do not need
|
|
78
80
|
generated `_testkit` files or direct package-manager path imports.
|
|
79
81
|
|
|
82
|
+
If you need to override the packaged `k6` binary for local environment reasons:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
TESTKIT_K6_BIN=/path/to/k6 npx @elench/testkit int
|
|
86
|
+
```
|
|
87
|
+
|
|
80
88
|
Legacy compatibility:
|
|
81
89
|
|
|
82
90
|
- `testkit runtime install`
|
|
@@ -94,8 +102,9 @@ npx @elench/testkit --dir my-product api int -s health
|
|
|
94
102
|
|
|
95
103
|
## How it works
|
|
96
104
|
|
|
97
|
-
1. **Discovery** — reads `
|
|
98
|
-
|
|
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
|
|
99
108
|
Per-service `.env` files declared in config are loaded when present.
|
|
100
109
|
3. **Database** — provisions Docker-managed local Postgres when a service declares one
|
|
101
110
|
4. **Seed** — runs optional product seed commands against the provisioned database
|
|
@@ -105,16 +114,17 @@ npx @elench/testkit --dir my-product api int -s health
|
|
|
105
114
|
|
|
106
115
|
Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
|
|
107
116
|
|
|
108
|
-
`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.
|
|
109
118
|
|
|
110
119
|
## File roles
|
|
111
120
|
|
|
112
|
-
- `runner.manifest.json`: canonical test inventory
|
|
113
121
|
- `testkit.config.json`: local execution and provisioning config
|
|
122
|
+
- `testkit.status.json`: optional deterministic run snapshot written by `--write-status`
|
|
114
123
|
|
|
115
124
|
`testkit.config.json` can also declare:
|
|
116
125
|
|
|
117
126
|
- `telemetry` for optional generic HTTP result upload
|
|
127
|
+
- `discovery.include` / `discovery.exclude` for per-service test discovery
|
|
118
128
|
- `envFile` / `envFiles` for service-specific environment loading
|
|
119
129
|
- `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
|
|
120
130
|
- `database.provider` for local Postgres settings
|
|
@@ -139,24 +149,6 @@ Use `--shard <i/n>` to split the selected suite set deterministically before wor
|
|
|
139
149
|
|
|
140
150
|
`testkit` also writes `.testkit/timings.json` and uses those file timings on later runs for longest-first balancing.
|
|
141
151
|
|
|
142
|
-
## Suite metadata
|
|
143
|
-
|
|
144
|
-
`runner.manifest.json` remains the source of truth for suites. Optional per-suite `testkit` metadata can tune execution:
|
|
145
|
-
|
|
146
|
-
```json
|
|
147
|
-
{
|
|
148
|
-
"name": "health",
|
|
149
|
-
"files": ["tests/example.js"],
|
|
150
|
-
"testkit": {
|
|
151
|
-
"maxFileConcurrency": 2,
|
|
152
|
-
"weight": 3
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
- `maxFileConcurrency`: k6-only opt-in for batching multiple files from the same suite onto one worker
|
|
158
|
-
- `weight`: optional fallback scheduling weight when no file timing history exists yet
|
|
159
|
-
|
|
160
152
|
## Schema
|
|
161
153
|
|
|
162
154
|
See [testkit-config-schema.md](testkit-config-schema.md).
|
|
@@ -173,5 +165,4 @@ npm run test:system
|
|
|
173
165
|
`test:system` runs real end-to-end fixtures from `test/fixtures/system/`. It requires:
|
|
174
166
|
|
|
175
167
|
- Docker with a running daemon
|
|
176
|
-
- `k6` on `PATH`
|
|
177
168
|
- Playwright Chromium browser assets available to `@playwright/test`
|
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,28 +1,21 @@
|
|
|
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";
|
|
18
|
+
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
26
19
|
export function parseDotenv(filePath) {
|
|
27
20
|
if (!fs.existsSync(filePath)) return {};
|
|
28
21
|
return parseDotenvString(fs.readFileSync(filePath, "utf8"));
|
|
@@ -30,20 +23,19 @@ export function parseDotenv(filePath) {
|
|
|
30
23
|
|
|
31
24
|
export function getServiceNames(cwd) {
|
|
32
25
|
const dir = cwd || process.cwd();
|
|
33
|
-
const
|
|
34
|
-
if (!fs.existsSync(
|
|
35
|
-
const
|
|
36
|
-
if (!isObject(
|
|
37
|
-
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);
|
|
38
31
|
}
|
|
39
32
|
|
|
40
33
|
export function loadConfigs(opts = {}) {
|
|
41
34
|
const productDir = resolveProductDir(process.cwd(), opts.dir);
|
|
42
|
-
const runner = loadRunnerManifest(productDir);
|
|
43
35
|
const config = loadTestkitConfig(productDir);
|
|
44
|
-
validateConfigCoverage(
|
|
36
|
+
validateConfigCoverage(config);
|
|
45
37
|
|
|
46
|
-
const serviceEntries = Object.entries(
|
|
38
|
+
const serviceEntries = Object.entries(config.services);
|
|
47
39
|
const filtered = opts.service
|
|
48
40
|
? serviceEntries.filter(([name]) => name === opts.service)
|
|
49
41
|
: serviceEntries;
|
|
@@ -53,14 +45,8 @@ export function loadConfigs(opts = {}) {
|
|
|
53
45
|
throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
|
|
54
46
|
}
|
|
55
47
|
|
|
56
|
-
return filtered.map(([name,
|
|
57
|
-
const
|
|
58
|
-
if (!serviceConfig) {
|
|
59
|
-
throw new Error(
|
|
60
|
-
`Service "${name}" exists in ${RUNNER_MANIFEST} but is missing from ${TESTKIT_CONFIG}`
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
48
|
+
return filtered.map(([name, serviceConfig]) => {
|
|
49
|
+
const suites = discoverServiceSuites(productDir, name, serviceConfig);
|
|
64
50
|
const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
|
|
65
51
|
const serviceEnv = loadServiceEnv(productDir, serviceConfig);
|
|
66
52
|
const selectedBackend = resolvedDatabase?.selectedBackend;
|
|
@@ -68,7 +54,7 @@ export function loadConfigs(opts = {}) {
|
|
|
68
54
|
const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
|
|
69
55
|
validateMergedService(
|
|
70
56
|
name,
|
|
71
|
-
|
|
57
|
+
suites,
|
|
72
58
|
serviceConfig,
|
|
73
59
|
resolvedDatabase,
|
|
74
60
|
resolvedMigrate,
|
|
@@ -81,7 +67,7 @@ export function loadConfigs(opts = {}) {
|
|
|
81
67
|
productDir,
|
|
82
68
|
stateDir: path.join(productDir, ".testkit", name),
|
|
83
69
|
telemetry: normalizeTelemetryConfig(config.telemetry),
|
|
84
|
-
suites
|
|
70
|
+
suites,
|
|
85
71
|
testkit: {
|
|
86
72
|
...serviceConfig,
|
|
87
73
|
database: resolvedDatabase,
|
|
@@ -94,24 +80,37 @@ export function loadConfigs(opts = {}) {
|
|
|
94
80
|
});
|
|
95
81
|
}
|
|
96
82
|
|
|
97
|
-
export function
|
|
83
|
+
export function resolveK6Binary() {
|
|
84
|
+
const override = process.env[TESTKIT_K6_BIN]?.trim();
|
|
85
|
+
if (override) {
|
|
86
|
+
const isPathLike =
|
|
87
|
+
path.isAbsolute(override) ||
|
|
88
|
+
override.includes(path.sep) ||
|
|
89
|
+
override.includes(path.posix.sep) ||
|
|
90
|
+
override.includes(path.win32.sep);
|
|
91
|
+
const overridePath = isPathLike ? path.resolve(process.cwd(), override) : override;
|
|
92
|
+
|
|
93
|
+
if (isPathLike && !fs.existsSync(overridePath)) {
|
|
94
|
+
throw new Error(`${TESTKIT_K6_BIN} points to a missing file: ${overridePath}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return overridePath;
|
|
98
|
+
}
|
|
99
|
+
|
|
98
100
|
const thisFile = fileURLToPath(import.meta.url);
|
|
99
101
|
const abs = path.resolve(path.dirname(thisFile), "..", "..", "vendor", "k6");
|
|
100
102
|
if (!fs.existsSync(abs)) {
|
|
101
|
-
throw new Error(`Bundled
|
|
103
|
+
throw new Error(`Bundled k6 binary not found: ${abs}`);
|
|
102
104
|
}
|
|
103
105
|
return abs;
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
export function
|
|
107
|
-
return
|
|
108
|
+
export function resolveDalBinary() {
|
|
109
|
+
return resolveK6Binary();
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
113
|
-
validateRunnerManifest(raw, RUNNER_MANIFEST, manifestPath);
|
|
114
|
-
return raw;
|
|
112
|
+
export function resolveServiceCwd(productDir, maybeRelative) {
|
|
113
|
+
return path.resolve(productDir, maybeRelative || ".");
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
function loadTestkitConfig(productDir) {
|
|
@@ -133,8 +132,8 @@ function loadTestkitConfig(productDir) {
|
|
|
133
132
|
return raw;
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
function validateConfigCoverage(
|
|
137
|
-
return validateConfigCoverageModel(
|
|
135
|
+
function validateConfigCoverage(config) {
|
|
136
|
+
return validateConfigCoverageModel(config, TESTKIT_CONFIG);
|
|
138
137
|
}
|
|
139
138
|
|
|
140
139
|
function resolveProductDir(cwd, explicitDir) {
|
|
@@ -149,7 +148,7 @@ function resolveProductDir(cwd, explicitDir) {
|
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
function ensureProductFiles(dir) {
|
|
152
|
-
const missing = [
|
|
151
|
+
const missing = [TESTKIT_CONFIG].filter(
|
|
153
152
|
(file) => !fs.existsSync(path.join(dir, file))
|
|
154
153
|
);
|
|
155
154
|
if (missing.length > 0) {
|
|
@@ -161,18 +160,16 @@ function ensureProductFiles(dir) {
|
|
|
161
160
|
|
|
162
161
|
function validateMergedService(
|
|
163
162
|
name,
|
|
164
|
-
|
|
163
|
+
suites,
|
|
165
164
|
serviceConfig,
|
|
166
165
|
resolvedDatabase,
|
|
167
166
|
resolvedMigrate,
|
|
168
167
|
resolvedSeed,
|
|
169
168
|
productDir
|
|
170
169
|
) {
|
|
171
|
-
const usesLocalExecution = Object.
|
|
172
|
-
|
|
173
|
-
(suite) =>
|
|
174
|
-
(suite.framework && suite.framework !== "k6") ||
|
|
175
|
-
!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"
|
|
176
173
|
)
|
|
177
174
|
);
|
|
178
175
|
|
|
@@ -228,15 +225,6 @@ function validateMergedService(
|
|
|
228
225
|
}
|
|
229
226
|
|
|
230
227
|
}
|
|
231
|
-
|
|
232
|
-
function validateServiceConfig(name, service, configPath) {
|
|
233
|
-
return validateServiceConfigModel(name, service, configPath);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function validateTelemetryConfig(telemetry, configPath) {
|
|
237
|
-
return validateTelemetryConfigModel(telemetry, configPath, TESTKIT_CONFIG);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
228
|
function loadServiceEnv(productDir, serviceConfig) {
|
|
241
229
|
const env = {};
|
|
242
230
|
for (const envFile of getServiceEnvFiles(serviceConfig)) {
|
|
@@ -253,34 +241,10 @@ function resolveSelectedDatabase(name, serviceConfig) {
|
|
|
253
241
|
return resolveSelectedDatabaseModel(name, serviceConfig);
|
|
254
242
|
}
|
|
255
243
|
|
|
256
|
-
function validateDatabaseProviderConfig(name, db, label) {
|
|
257
|
-
return validateDatabaseProviderConfigModel(name, db, label);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function validateTemplateConfig(name, template, label) {
|
|
261
|
-
return validateTemplateConfigModel(name, template, label);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function normalizeTemplateConfig(template) {
|
|
265
|
-
return normalizeTemplateConfigModel(template);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function validateLifecycleConfig(name, value, label) {
|
|
269
|
-
return validateLifecycleConfigModel(name, value, label);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
244
|
function resolveLifecycleConfig(value, selectedBackend) {
|
|
273
245
|
return resolveLifecycleConfigModel(value, selectedBackend);
|
|
274
246
|
}
|
|
275
247
|
|
|
276
|
-
function requireString(obj, key, label) {
|
|
277
|
-
return requireStringModel(obj, key, label);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function isDalSuiteType(suite, runnerService, suitesForType) {
|
|
281
|
-
return isDalSuiteTypeModel(suite, runnerService, suitesForType);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
248
|
function isObject(value) {
|
|
285
249
|
return isObjectModel(value);
|
|
286
250
|
}
|
|
@@ -288,7 +252,3 @@ function isObject(value) {
|
|
|
288
252
|
function normalizeTelemetryConfig(telemetry) {
|
|
289
253
|
return normalizeTelemetryConfigModel(telemetry);
|
|
290
254
|
}
|
|
291
|
-
|
|
292
|
-
function validateHttpUrl(value, label) {
|
|
293
|
-
return validateHttpUrlModel(value, label);
|
|
294
|
-
}
|
|
@@ -1,12 +1,44 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
import os from "os";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { resolveDalBinary, resolveK6Binary } from "./index.mjs";
|
|
6
|
+
|
|
7
|
+
const originalK6Bin = process.env.TESTKIT_K6_BIN;
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
if (originalK6Bin === undefined) {
|
|
11
|
+
delete process.env.TESTKIT_K6_BIN;
|
|
12
|
+
} else {
|
|
13
|
+
process.env.TESTKIT_K6_BIN = originalK6Bin;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
5
16
|
|
|
6
17
|
describe("config-index", () => {
|
|
7
|
-
it("resolves the bundled
|
|
8
|
-
const binaryPath =
|
|
18
|
+
it("resolves the bundled k6 binary from the package root", () => {
|
|
19
|
+
const binaryPath = resolveK6Binary();
|
|
9
20
|
expect(path.basename(binaryPath)).toBe("k6");
|
|
10
21
|
expect(fs.existsSync(binaryPath)).toBe(true);
|
|
11
22
|
});
|
|
23
|
+
|
|
24
|
+
it("keeps resolveDalBinary aligned with the shared k6 resolver", () => {
|
|
25
|
+
expect(resolveDalBinary()).toBe(resolveK6Binary());
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("uses TESTKIT_K6_BIN when provided with a relative path", () => {
|
|
29
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-k6-"));
|
|
30
|
+
const binPath = path.join(tempDir, "k6-custom");
|
|
31
|
+
fs.writeFileSync(binPath, "#!/usr/bin/env bash\nexit 0\n");
|
|
32
|
+
process.env.TESTKIT_K6_BIN = path.relative(process.cwd(), binPath);
|
|
33
|
+
|
|
34
|
+
expect(resolveK6Binary()).toBe(binPath);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("throws a clear error when TESTKIT_K6_BIN points at a missing path", () => {
|
|
38
|
+
process.env.TESTKIT_K6_BIN = "./definitely-missing-k6-bin";
|
|
39
|
+
|
|
40
|
+
expect(() => resolveK6Binary()).toThrow(
|
|
41
|
+
/TESTKIT_K6_BIN points to a missing file/
|
|
42
|
+
);
|
|
43
|
+
});
|
|
12
44
|
});
|
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,9 +2,10 @@ 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
|
-
import {
|
|
8
|
+
import { resolveK6Binary, resolveServiceCwd } from "../config/index.mjs";
|
|
8
9
|
import {
|
|
9
10
|
cleanupOrphanedLocalInfrastructure,
|
|
10
11
|
destroyRuntimeDatabase,
|
|
@@ -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,
|
|
@@ -521,6 +549,7 @@ async function runHttpK6Batch(targetConfig, batch) {
|
|
|
521
549
|
|
|
522
550
|
async function runHttpK6Task(targetConfig, task, baseUrl) {
|
|
523
551
|
const absFile = path.join(targetConfig.productDir, task.file);
|
|
552
|
+
const k6Binary = resolveK6Binary();
|
|
524
553
|
const bundledFile = await bundleK6File({
|
|
525
554
|
productDir: targetConfig.productDir,
|
|
526
555
|
serviceName: targetConfig.name,
|
|
@@ -529,7 +558,7 @@ async function runHttpK6Task(targetConfig, task, baseUrl) {
|
|
|
529
558
|
console.log(`·· ${targetConfig.workerLabel}:${task.suiteName} → ${task.file}`);
|
|
530
559
|
const startedAt = Date.now();
|
|
531
560
|
try {
|
|
532
|
-
await execa(
|
|
561
|
+
await execa(k6Binary, ["run", "--address", "127.0.0.1:0", "-e", `BASE_URL=${baseUrl}`, bundledFile], {
|
|
533
562
|
cwd: targetConfig.productDir,
|
|
534
563
|
env: buildExecutionEnv(targetConfig),
|
|
535
564
|
stdio: "inherit",
|
|
@@ -567,7 +596,7 @@ async function runDalBatch(targetConfig, batch) {
|
|
|
567
596
|
|
|
568
597
|
async function runDalTask(targetConfig, task, databaseUrl) {
|
|
569
598
|
const absFile = path.join(targetConfig.productDir, task.file);
|
|
570
|
-
const k6Binary =
|
|
599
|
+
const k6Binary = resolveK6Binary();
|
|
571
600
|
const bundledFile = await bundleK6File({
|
|
572
601
|
productDir: targetConfig.productDir,
|
|
573
602
|
serviceName: targetConfig.name,
|
|
@@ -617,10 +646,11 @@ async function runPlaywrightBatch(targetConfig, batch) {
|
|
|
617
646
|
const requestedFiles = batch.tasks.map((task) =>
|
|
618
647
|
path.relative(cwd, path.join(targetConfig.productDir, task.file))
|
|
619
648
|
);
|
|
649
|
+
const playwrightConfigPath = ensurePlaywrightTestConfig(targetConfig, cwd, requestedFiles);
|
|
620
650
|
const startedAt = Date.now();
|
|
621
651
|
const result = await execa(
|
|
622
652
|
"npx",
|
|
623
|
-
["playwright", "test", "--reporter=json", ...requestedFiles],
|
|
653
|
+
["playwright", "test", "--config", playwrightConfigPath, "--reporter=json", ...requestedFiles],
|
|
624
654
|
{
|
|
625
655
|
cwd,
|
|
626
656
|
env: buildPlaywrightEnv(targetConfig, local.baseUrl),
|
|
@@ -723,8 +753,8 @@ function resolveRuntimeConfigs(targetConfig, configMap) {
|
|
|
723
753
|
return resolveRuntimeConfigsModel(targetConfig, configMap);
|
|
724
754
|
}
|
|
725
755
|
|
|
726
|
-
function collectSuites(config, suiteType, suiteNames, frameworkFilter) {
|
|
727
|
-
return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter);
|
|
756
|
+
function collectSuites(config, suiteType, suiteNames, frameworkFilter, fileNames = []) {
|
|
757
|
+
return collectSuitesModel(config, suiteType, suiteNames, frameworkFilter, fileNames);
|
|
728
758
|
}
|
|
729
759
|
|
|
730
760
|
function applyShard(suites, shard) {
|
|
@@ -890,9 +920,11 @@ function buildRunArtifact({
|
|
|
890
920
|
workerCount,
|
|
891
921
|
suiteType,
|
|
892
922
|
suiteNames,
|
|
923
|
+
fileNames,
|
|
893
924
|
framework,
|
|
894
925
|
shard,
|
|
895
926
|
serviceFilter,
|
|
927
|
+
metadata,
|
|
896
928
|
}) {
|
|
897
929
|
return buildRunArtifactModel({
|
|
898
930
|
productDir,
|
|
@@ -903,17 +935,11 @@ function buildRunArtifact({
|
|
|
903
935
|
workerCount,
|
|
904
936
|
suiteType,
|
|
905
937
|
suiteNames,
|
|
938
|
+
fileNames,
|
|
906
939
|
framework,
|
|
907
940
|
shard,
|
|
908
941
|
serviceFilter,
|
|
909
|
-
metadata
|
|
910
|
-
git: collectGitMetadata(productDir),
|
|
911
|
-
host: {
|
|
912
|
-
hostname: safeHostname(),
|
|
913
|
-
username: safeUsername(),
|
|
914
|
-
},
|
|
915
|
-
testkitVersion: readPackageMetadata().version,
|
|
916
|
-
},
|
|
942
|
+
metadata,
|
|
917
943
|
});
|
|
918
944
|
}
|
|
919
945
|
|
|
@@ -923,6 +949,73 @@ function writeRunArtifact(productDir, artifact) {
|
|
|
923
949
|
fs.writeFileSync(path.join(resultsDir, "latest.json"), JSON.stringify(artifact, null, 2));
|
|
924
950
|
}
|
|
925
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
|
+
|
|
926
1019
|
function summarizeDbBackend(results) {
|
|
927
1020
|
return summarizeDbBackendModel(results);
|
|
928
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",
|