@elench/testkit 0.1.21 → 0.1.23
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 +14 -15
- package/lib/bundler/index.mjs +2 -10
- package/lib/bundler/index.test.mjs +8 -7
- package/lib/cli/args.mjs +3 -2
- package/lib/cli/args.test.mjs +2 -1
- package/lib/cli/index.mjs +6 -4
- package/lib/config/discovery.mjs +140 -99
- package/lib/config/discovery.test.mjs +75 -17
- package/lib/config/index.mjs +3 -2
- package/lib/config/model.mjs +3 -17
- package/lib/config/model.test.mjs +2 -4
- package/lib/index.mjs +13 -1
- package/lib/runner/index.mjs +20 -4
- package/lib/runner/results.mjs +16 -5
- package/lib/runner/results.test.mjs +2 -3
- package/lib/runtime/index.mjs +31 -190
- package/lib/runtime-manager/index.mjs +190 -0
- package/package.json +3 -4
- package/lib/k6/checks.mjs +0 -1
- package/lib/k6/dal-suite.mjs +0 -1
- package/lib/k6/dal.mjs +0 -1
- package/lib/k6/http.mjs +0 -1
- package/lib/k6/index.mjs +0 -30
- package/lib/k6/suite.mjs +0 -1
package/README.md
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# @elench/testkit
|
|
2
2
|
|
|
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
|
|
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 HTTP, DAL, and Playwright suites.
|
|
4
4
|
|
|
5
5
|
## Prerequisites
|
|
6
6
|
|
|
7
|
-
`@elench/testkit` ships its own
|
|
8
|
-
If you need to force a different binary, set `TESTKIT_K6_BIN` to an absolute or relative path.
|
|
7
|
+
`@elench/testkit` ships its own execution engine for HTTP and DAL suites. Consumers do not need to install or invoke any separate load-testing binary.
|
|
9
8
|
|
|
10
9
|
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`.
|
|
11
10
|
|
|
@@ -24,7 +23,7 @@ npx @elench/testkit e2e
|
|
|
24
23
|
|
|
25
24
|
# Filter by framework
|
|
26
25
|
npx @elench/testkit --framework playwright
|
|
27
|
-
npx @elench/testkit --framework
|
|
26
|
+
npx @elench/testkit --framework default
|
|
28
27
|
|
|
29
28
|
# Parallelize with isolated worker stacks
|
|
30
29
|
npx @elench/testkit --jobs 3
|
|
@@ -48,9 +47,9 @@ npx @elench/testkit status
|
|
|
48
47
|
npx @elench/testkit destroy
|
|
49
48
|
```
|
|
50
49
|
|
|
51
|
-
##
|
|
50
|
+
## Authoring
|
|
52
51
|
|
|
53
|
-
Consumer
|
|
52
|
+
Consumer tests can import the shared authoring API directly from the package:
|
|
54
53
|
|
|
55
54
|
```js
|
|
56
55
|
import { defineHttpSuite } from "@elench/testkit";
|
|
@@ -76,15 +75,15 @@ const suite = defineHttpSuite({ auth: clerkSessionAuth }, ({ req, setupData }) =
|
|
|
76
75
|
});
|
|
77
76
|
```
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
generated `_testkit` files or direct package-manager path imports.
|
|
78
|
+
Low-level runtime primitives are also available without exposing the underlying engine:
|
|
81
79
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
```bash
|
|
85
|
-
TESTKIT_K6_BIN=/path/to/k6 npx @elench/testkit int
|
|
80
|
+
```js
|
|
81
|
+
import { check, group, http } from "@elench/testkit/runtime";
|
|
86
82
|
```
|
|
87
83
|
|
|
84
|
+
`testkit` bundles these imports before execution, so tests do not need
|
|
85
|
+
generated `_testkit` files, direct package-manager path imports, or any separate engine installation.
|
|
86
|
+
|
|
88
87
|
Legacy compatibility:
|
|
89
88
|
|
|
90
89
|
- `testkit runtime install`
|
|
@@ -104,12 +103,12 @@ npx @elench/testkit --dir my-product api int -s health
|
|
|
104
103
|
|
|
105
104
|
1. **Discovery** — reads `testkit.config.json` for services, then discovers files by suffix:
|
|
106
105
|
`*.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,
|
|
106
|
+
2. **Config** — reads `testkit.config.json` for local runtime, migration, dependency, and database settings
|
|
108
107
|
Per-service `.env` files declared in config are loaded when present.
|
|
109
108
|
3. **Database** — provisions Docker-managed local Postgres when a service declares one
|
|
110
109
|
4. **Seed** — runs optional product seed commands against the provisioned database
|
|
111
110
|
5. **Runtime** — starts required local services, waits for readiness, and injects test env
|
|
112
|
-
6. **Execution** — schedules file-level execution tasks across a global worker pool, reuses warm dependency graphs when possible, bundles
|
|
111
|
+
6. **Execution** — schedules file-level execution tasks across a global worker pool, reuses warm dependency graphs when possible, bundles test files before execution so package imports resolve cleanly, and batches Playwright files per worker
|
|
113
112
|
7. **Cleanup** — stops local processes and preserves `.testkit/` state for inspection or later destroy
|
|
114
113
|
|
|
115
114
|
Each run ends with a compact summary that shows per-service pass/fail status, suite totals, failed suites, and total duration.
|
|
@@ -124,7 +123,7 @@ Each run ends with a compact summary that shows per-service pass/fail status, su
|
|
|
124
123
|
`testkit.config.json` can also declare:
|
|
125
124
|
|
|
126
125
|
- `telemetry` for optional generic HTTP result upload
|
|
127
|
-
- `
|
|
126
|
+
- no test registration in config; `testkit` discovers `*.testkit.ts` files from the filesystem automatically
|
|
128
127
|
- `envFile` / `envFiles` for service-specific environment loading
|
|
129
128
|
- `databaseFrom` for dependency services that must share another service's `DATABASE_URL`
|
|
130
129
|
- `database.provider` for local Postgres settings
|
package/lib/bundler/index.mjs
CHANGED
|
@@ -7,8 +7,7 @@ import { fileURLToPath } from "url";
|
|
|
7
7
|
|
|
8
8
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
9
9
|
const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
|
|
10
|
-
const
|
|
11
|
-
const K6_DIR = path.join(PACKAGE_ROOT, "lib", "k6");
|
|
10
|
+
const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
|
|
12
11
|
const bundleCache = new Map();
|
|
13
12
|
|
|
14
13
|
export async function bundleK6File({
|
|
@@ -82,14 +81,7 @@ function testkitPackageAliasPlugin() {
|
|
|
82
81
|
function resolvePackageSubpath(specifier) {
|
|
83
82
|
const subpath = specifier.slice("@elench/testkit".length);
|
|
84
83
|
if (!subpath) return ROOT_ENTRY;
|
|
85
|
-
if (subpath === "/
|
|
86
|
-
if (subpath.startsWith("/k6/")) {
|
|
87
|
-
const rel = subpath.slice("/k6/".length);
|
|
88
|
-
const candidate = path.join(K6_DIR, `${rel}.mjs`);
|
|
89
|
-
if (fs.existsSync(candidate)) {
|
|
90
|
-
return candidate;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
84
|
+
if (subpath === "/runtime") return RUNTIME_ENTRY;
|
|
93
85
|
|
|
94
86
|
throw new Error(`Unsupported @elench/testkit import "${specifier}" in ${os.platform()}`);
|
|
95
87
|
}
|
|
@@ -12,8 +12,8 @@ afterEach(() => {
|
|
|
12
12
|
}
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
describe("
|
|
16
|
-
it("bundles root package imports for
|
|
15
|
+
describe("runtime bundler", () => {
|
|
16
|
+
it("bundles root and runtime package imports for execution", async () => {
|
|
17
17
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
|
|
18
18
|
cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
|
|
19
19
|
|
|
@@ -21,8 +21,8 @@ describe("k6 bundler", () => {
|
|
|
21
21
|
fs.writeFileSync(
|
|
22
22
|
sourceFile,
|
|
23
23
|
[
|
|
24
|
-
'import { defineHttpSuite
|
|
25
|
-
'import { check } from "
|
|
24
|
+
'import { defineHttpSuite } from "@elench/testkit";',
|
|
25
|
+
'import { check, json } from "@elench/testkit/runtime";',
|
|
26
26
|
"const suite = defineHttpSuite(({ rawReq }) => {",
|
|
27
27
|
' const res = rawReq("GET", "/health");',
|
|
28
28
|
" check(json(res), {",
|
|
@@ -44,10 +44,11 @@ describe("k6 bundler", () => {
|
|
|
44
44
|
|
|
45
45
|
const bundled = fs.readFileSync(bundledFile, "utf8");
|
|
46
46
|
expect(bundled).toContain("defineHttpSuite");
|
|
47
|
-
expect(bundled).toContain('import { check
|
|
47
|
+
expect(bundled).toContain('import { check');
|
|
48
|
+
expect(bundled).toContain('from "k6"');
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
it("bundles
|
|
51
|
+
it("bundles DAL execution through the public package surface", async () => {
|
|
51
52
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-bundle-"));
|
|
52
53
|
cleanups.push(() => fs.rmSync(tmpDir, { force: true, recursive: true }));
|
|
53
54
|
|
|
@@ -55,7 +56,7 @@ describe("k6 bundler", () => {
|
|
|
55
56
|
fs.writeFileSync(
|
|
56
57
|
sourceFile,
|
|
57
58
|
[
|
|
58
|
-
'import { defineDalSuite } from "@elench/testkit
|
|
59
|
+
'import { defineDalSuite } from "@elench/testkit";',
|
|
59
60
|
"const suite = defineDalSuite(({ db }) => {",
|
|
60
61
|
' db.query("SELECT 1");',
|
|
61
62
|
"});",
|
package/lib/cli/args.mjs
CHANGED
|
@@ -22,11 +22,12 @@ export function resolveCliSelection({ first, second, serviceNames }) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function validateFrameworkOption(value) {
|
|
25
|
-
if (!["all", "
|
|
25
|
+
if (!["all", "default", "playwright"].includes(value)) {
|
|
26
26
|
throw new Error(
|
|
27
|
-
`Unknown framework "${value}". Expected one of: all,
|
|
27
|
+
`Unknown framework "${value}". Expected one of: all, default, playwright.`
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
|
+
return value === "default" ? "k6" : value;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export function parseJobsOption(value) {
|
package/lib/cli/args.test.mjs
CHANGED
|
@@ -44,7 +44,8 @@ describe("cli-args", () => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
it("validates framework names", () => {
|
|
47
|
-
expect(
|
|
47
|
+
expect(validateFrameworkOption("playwright")).toBe("playwright");
|
|
48
|
+
expect(validateFrameworkOption("default")).toBe("k6");
|
|
48
49
|
expect(() => validateFrameworkOption("jest")).toThrow("Unknown framework");
|
|
49
50
|
});
|
|
50
51
|
|
package/lib/cli/index.mjs
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
validateFrameworkOption,
|
|
9
9
|
} from "./args.mjs";
|
|
10
10
|
import * as runner from "../runner/index.mjs";
|
|
11
|
-
import * as runtime from "../runtime/index.mjs";
|
|
12
11
|
|
|
13
12
|
export function run() {
|
|
14
13
|
const cli = cac("testkit");
|
|
@@ -18,11 +17,13 @@ export function run() {
|
|
|
18
17
|
.option("--dir <path>", "Explicit product directory")
|
|
19
18
|
.option("--path <path>", "Target runtime path relative to the product directory")
|
|
20
19
|
.option("--strict", "Exit non-zero when runtime status is missing or drifted")
|
|
21
|
-
.action((action, options) => {
|
|
20
|
+
.action(async (action, options) => {
|
|
22
21
|
if (!["install", "status", "update"].includes(action)) {
|
|
23
22
|
throw new Error('Unknown runtime action. Expected one of: install, status, update.');
|
|
24
23
|
}
|
|
25
24
|
|
|
25
|
+
const runtime = await import("../runtime-manager/index.mjs");
|
|
26
|
+
|
|
26
27
|
if (action === "status") {
|
|
27
28
|
const status = runtime.getRuntimeStatus(options);
|
|
28
29
|
console.log(runtime.formatRuntimeStatus(status));
|
|
@@ -47,7 +48,7 @@ export function run() {
|
|
|
47
48
|
default: "1",
|
|
48
49
|
})
|
|
49
50
|
.option("--shard <i/n>", "Run only shard i of n at suite granularity")
|
|
50
|
-
.option("--framework <name>", "Filter by framework (
|
|
51
|
+
.option("--framework <name>", "Filter by framework (default, playwright, all)", {
|
|
51
52
|
default: "all",
|
|
52
53
|
})
|
|
53
54
|
.option("--write-status", "Write a deterministic testkit.status.json snapshot")
|
|
@@ -91,7 +92,7 @@ export function run() {
|
|
|
91
92
|
return;
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
validateFrameworkOption(options.framework);
|
|
95
|
+
const framework = validateFrameworkOption(options.framework);
|
|
95
96
|
|
|
96
97
|
const jobs = parseJobsOption(options.jobs);
|
|
97
98
|
const shard = parseShardOption(options.shard);
|
|
@@ -105,6 +106,7 @@ export function run() {
|
|
|
105
106
|
suiteNames,
|
|
106
107
|
{
|
|
107
108
|
...options,
|
|
109
|
+
framework,
|
|
108
110
|
fileNames,
|
|
109
111
|
jobs,
|
|
110
112
|
shard,
|
package/lib/config/discovery.mjs
CHANGED
|
@@ -9,148 +9,189 @@ const DISCOVERY_RULES = [
|
|
|
9
9
|
{ suffix: ".pw.testkit.ts", type: "e2e", framework: "playwright" },
|
|
10
10
|
];
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
12
|
+
const IGNORED_DIRS = new Set([
|
|
13
|
+
".cache",
|
|
14
|
+
".git",
|
|
15
|
+
".hg",
|
|
16
|
+
".next",
|
|
17
|
+
".nuxt",
|
|
18
|
+
".playwright-browsers",
|
|
19
|
+
".svn",
|
|
20
|
+
".testkit",
|
|
21
|
+
".turbo",
|
|
22
|
+
"build",
|
|
23
|
+
"coverage",
|
|
24
|
+
"dist",
|
|
25
|
+
"node_modules",
|
|
26
|
+
"playwright-report",
|
|
27
|
+
"test-results",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function discoverSuites(productDir, services) {
|
|
31
|
+
const serviceEntries = Object.entries(services || {});
|
|
32
|
+
const rules = serviceEntries.map(([serviceName, serviceConfig]) =>
|
|
33
|
+
buildServiceRule(serviceName, serviceConfig)
|
|
34
|
+
);
|
|
35
|
+
const groupedByService = Object.fromEntries(
|
|
36
|
+
serviceEntries.map(([serviceName]) => [serviceName, {}])
|
|
37
|
+
);
|
|
38
|
+
const discoveredFiles = discoverFiles(productDir);
|
|
39
|
+
const unowned = [];
|
|
40
|
+
const ambiguous = [];
|
|
41
|
+
|
|
42
|
+
for (const filePath of discoveredFiles) {
|
|
43
|
+
const rule = inferRule(filePath);
|
|
44
|
+
if (!rule) continue;
|
|
45
|
+
|
|
46
|
+
const matches = rules
|
|
47
|
+
.filter((serviceRule) => ownsFile(serviceRule, filePath))
|
|
48
|
+
.map((serviceRule) => serviceRule.name);
|
|
49
|
+
|
|
50
|
+
if (matches.length === 0) {
|
|
51
|
+
unowned.push(filePath);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
31
54
|
|
|
32
|
-
|
|
33
|
-
|
|
55
|
+
if (matches.length > 1) {
|
|
56
|
+
ambiguous.push({
|
|
57
|
+
filePath,
|
|
58
|
+
serviceNames: matches.sort((left, right) => left.localeCompare(right)),
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
34
62
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
const serviceName = matches[0];
|
|
64
|
+
const serviceRule = rules.find((candidate) => candidate.name === serviceName);
|
|
65
|
+
const relativeToService = relativeToServiceRoot(serviceRule, filePath);
|
|
66
|
+
const suiteName = deriveSuiteName(relativeToService, rule.suffix);
|
|
67
|
+
const grouped = groupedByService[serviceName];
|
|
68
|
+
const suitesForType = grouped[rule.type] || [];
|
|
69
|
+
let suite = suitesForType.find(
|
|
70
|
+
(candidate) => candidate.name === suiteName && candidate.framework === rule.framework
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!suite) {
|
|
74
|
+
suite = {
|
|
75
|
+
name: suiteName,
|
|
76
|
+
files: [],
|
|
39
77
|
framework: rule.framework,
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
78
|
+
};
|
|
79
|
+
suitesForType.push(suite);
|
|
80
|
+
grouped[rule.type] = suitesForType;
|
|
43
81
|
}
|
|
44
|
-
}
|
|
45
82
|
|
|
46
|
-
|
|
47
|
-
suites.sort((left, right) => left.files[0].localeCompare(right.files[0]));
|
|
83
|
+
suite.files.push(filePath);
|
|
48
84
|
}
|
|
49
85
|
|
|
50
|
-
|
|
51
|
-
|
|
86
|
+
if (unowned.length > 0 || ambiguous.length > 0) {
|
|
87
|
+
throw buildDiscoveryError(unowned, ambiguous);
|
|
88
|
+
}
|
|
52
89
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
90
|
+
for (const grouped of Object.values(groupedByService)) {
|
|
91
|
+
for (const suites of Object.values(grouped)) {
|
|
92
|
+
for (const suite of suites) {
|
|
93
|
+
suite.files.sort((left, right) => left.localeCompare(right));
|
|
94
|
+
}
|
|
95
|
+
suites.sort((left, right) => left.name.localeCompare(right.name));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
56
98
|
|
|
57
|
-
|
|
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);
|
|
99
|
+
return groupedByService;
|
|
65
100
|
}
|
|
66
101
|
|
|
67
|
-
function
|
|
68
|
-
const rootStats = fs.statSync(rootDir);
|
|
69
|
-
if (rootStats.isFile()) {
|
|
70
|
-
return [normalizePath(path.relative(productDir, rootDir))];
|
|
71
|
-
}
|
|
72
|
-
|
|
102
|
+
function discoverFiles(productDir) {
|
|
73
103
|
const files = [];
|
|
74
|
-
const queue = [
|
|
104
|
+
const queue = [productDir];
|
|
75
105
|
|
|
76
106
|
while (queue.length > 0) {
|
|
77
107
|
const current = queue.pop();
|
|
78
108
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
79
|
-
const
|
|
109
|
+
const absolutePath = path.join(current, entry.name);
|
|
110
|
+
|
|
111
|
+
if (entry.isSymbolicLink()) continue;
|
|
112
|
+
|
|
80
113
|
if (entry.isDirectory()) {
|
|
81
|
-
|
|
114
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
115
|
+
queue.push(absolutePath);
|
|
82
116
|
continue;
|
|
83
117
|
}
|
|
118
|
+
|
|
84
119
|
if (!entry.isFile()) continue;
|
|
85
|
-
|
|
120
|
+
|
|
121
|
+
const relativePath = normalizePath(path.relative(productDir, absolutePath));
|
|
122
|
+
if (inferRule(relativePath)) files.push(relativePath);
|
|
86
123
|
}
|
|
87
124
|
}
|
|
88
125
|
|
|
89
126
|
return files.sort((left, right) => left.localeCompare(right));
|
|
90
127
|
}
|
|
91
128
|
|
|
92
|
-
function
|
|
93
|
-
const
|
|
129
|
+
function buildServiceRule(serviceName, serviceConfig) {
|
|
130
|
+
const testPrefix = normalizePath(path.posix.join("tests", serviceName));
|
|
131
|
+
const cwd = normalizePath(serviceConfig?.local?.cwd || ".");
|
|
94
132
|
return {
|
|
95
|
-
|
|
96
|
-
|
|
133
|
+
name: serviceName,
|
|
134
|
+
testPrefix,
|
|
135
|
+
cwdPrefix: cwd === "." ? null : cwd,
|
|
97
136
|
};
|
|
98
137
|
}
|
|
99
138
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
139
|
+
function ownsFile(serviceRule, filePath) {
|
|
140
|
+
if (hasPrefix(filePath, serviceRule.testPrefix)) return true;
|
|
141
|
+
if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) return true;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
103
144
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
145
|
+
function relativeToServiceRoot(serviceRule, filePath) {
|
|
146
|
+
if (hasPrefix(filePath, serviceRule.testPrefix)) {
|
|
147
|
+
return path.posix.relative(serviceRule.testPrefix, filePath);
|
|
107
148
|
}
|
|
108
|
-
|
|
109
|
-
|
|
149
|
+
if (serviceRule.cwdPrefix && hasPrefix(filePath, serviceRule.cwdPrefix)) {
|
|
150
|
+
return path.posix.relative(serviceRule.cwdPrefix, filePath);
|
|
151
|
+
}
|
|
152
|
+
return filePath;
|
|
110
153
|
}
|
|
111
154
|
|
|
112
|
-
function
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
}
|
|
155
|
+
function deriveSuiteName(relativePath, suffix) {
|
|
156
|
+
const parts = relativePath.split("/").filter(Boolean);
|
|
157
|
+
if (parts.length >= 3) return parts[1];
|
|
158
|
+
return path.posix.basename(relativePath, suffix);
|
|
159
|
+
}
|
|
125
160
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
index += 1;
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
161
|
+
function buildDiscoveryError(unowned, ambiguous) {
|
|
162
|
+
const lines = ["Filesystem discovery failed for one or more .testkit.ts files."];
|
|
131
163
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
164
|
+
if (unowned.length > 0) {
|
|
165
|
+
lines.push("");
|
|
166
|
+
lines.push("Unowned test files:");
|
|
167
|
+
for (const filePath of unowned) {
|
|
168
|
+
lines.push(`- ${filePath}`);
|
|
135
169
|
}
|
|
170
|
+
}
|
|
136
171
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
172
|
+
if (ambiguous.length > 0) {
|
|
173
|
+
lines.push("");
|
|
174
|
+
lines.push("Ambiguous test files:");
|
|
175
|
+
for (const entry of ambiguous) {
|
|
176
|
+
lines.push(`- ${entry.filePath} -> ${entry.serviceNames.join(", ")}`);
|
|
140
177
|
}
|
|
178
|
+
}
|
|
141
179
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push('Expected test files to live under "tests/<service>/..." or a non-root service local.cwd directory.');
|
|
182
|
+
return new Error(lines.join("\n"));
|
|
183
|
+
}
|
|
146
184
|
|
|
147
|
-
|
|
148
|
-
|
|
185
|
+
function inferRule(filePath) {
|
|
186
|
+
return DISCOVERY_RULES.find((rule) => filePath.endsWith(rule.suffix)) || null;
|
|
187
|
+
}
|
|
149
188
|
|
|
150
|
-
|
|
151
|
-
return
|
|
189
|
+
function hasPrefix(filePath, prefix) {
|
|
190
|
+
return filePath === prefix || filePath.startsWith(`${prefix}/`);
|
|
152
191
|
}
|
|
153
192
|
|
|
154
193
|
function normalizePath(value) {
|
|
155
|
-
|
|
194
|
+
const normalized = String(value).split(path.sep).join("/");
|
|
195
|
+
if (normalized === "." || normalized === "./") return ".";
|
|
196
|
+
return normalized.replace(/^\.\/+/, "").replace(/\/+$/, "") || ".";
|
|
156
197
|
}
|
|
@@ -2,7 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import os from "os";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
import {
|
|
5
|
+
import { discoverSuites } from "./discovery.mjs";
|
|
6
6
|
|
|
7
7
|
const cleanups = [];
|
|
8
8
|
|
|
@@ -12,33 +12,91 @@ afterEach(() => {
|
|
|
12
12
|
}
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
describe("
|
|
16
|
-
it("discovers
|
|
15
|
+
describe("filesystem-discovery", () => {
|
|
16
|
+
it("discovers tests by convention without config registration", () => {
|
|
17
17
|
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
|
|
18
18
|
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
20
|
+
writeFile(productDir, "tests/api/integration/health.int.testkit.ts");
|
|
21
|
+
writeFile(productDir, "tests/api/integration/auth/me.int.testkit.ts");
|
|
22
|
+
writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
|
|
23
|
+
|
|
24
|
+
const suites = discoverSuites(productDir, {
|
|
25
|
+
api: {
|
|
26
|
+
local: {
|
|
27
|
+
cwd: ".",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
frontend: {
|
|
31
|
+
local: {
|
|
32
|
+
cwd: "frontend",
|
|
33
|
+
},
|
|
33
34
|
},
|
|
34
35
|
});
|
|
35
36
|
|
|
36
|
-
expect(suites.integration).toEqual([
|
|
37
|
+
expect(suites.api.integration).toEqual([
|
|
38
|
+
{
|
|
39
|
+
name: "auth",
|
|
40
|
+
files: ["tests/api/integration/auth/me.int.testkit.ts"],
|
|
41
|
+
framework: "k6",
|
|
42
|
+
},
|
|
37
43
|
{
|
|
38
44
|
name: "health",
|
|
39
45
|
files: ["tests/api/integration/health.int.testkit.ts"],
|
|
40
46
|
framework: "k6",
|
|
41
47
|
},
|
|
42
48
|
]);
|
|
49
|
+
expect(suites.frontend.e2e).toEqual([
|
|
50
|
+
{
|
|
51
|
+
name: "homepage",
|
|
52
|
+
files: ["frontend/e2e/homepage.pw.testkit.ts"],
|
|
53
|
+
framework: "playwright",
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("fails when a discovered file does not map to any configured service", () => {
|
|
59
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
|
|
60
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
61
|
+
|
|
62
|
+
writeFile(productDir, "tests/unknown/integration/health.int.testkit.ts");
|
|
63
|
+
|
|
64
|
+
expect(() =>
|
|
65
|
+
discoverSuites(productDir, {
|
|
66
|
+
api: {
|
|
67
|
+
local: {
|
|
68
|
+
cwd: ".",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
).toThrow("Unowned test files");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("fails when a discovered file maps to multiple services", () => {
|
|
76
|
+
const productDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-discovery-"));
|
|
77
|
+
cleanups.push(() => fs.rmSync(productDir, { recursive: true, force: true }));
|
|
78
|
+
|
|
79
|
+
writeFile(productDir, "frontend/e2e/homepage.pw.testkit.ts");
|
|
80
|
+
|
|
81
|
+
expect(() =>
|
|
82
|
+
discoverSuites(productDir, {
|
|
83
|
+
frontend: {
|
|
84
|
+
local: {
|
|
85
|
+
cwd: "frontend",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
web: {
|
|
89
|
+
local: {
|
|
90
|
+
cwd: "frontend",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
).toThrow("Ambiguous test files");
|
|
43
95
|
});
|
|
44
96
|
});
|
|
97
|
+
|
|
98
|
+
function writeFile(productDir, relativePath) {
|
|
99
|
+
const absolutePath = path.join(productDir, relativePath);
|
|
100
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
101
|
+
fs.writeFileSync(absolutePath, "export {};\n");
|
|
102
|
+
}
|
package/lib/config/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
|
-
import {
|
|
4
|
+
import { discoverSuites } from "./discovery.mjs";
|
|
5
5
|
import {
|
|
6
6
|
getServiceEnvFiles as getServiceEnvFilesModel,
|
|
7
7
|
isObject as isObjectModel,
|
|
@@ -34,6 +34,7 @@ export function loadConfigs(opts = {}) {
|
|
|
34
34
|
const productDir = resolveProductDir(process.cwd(), opts.dir);
|
|
35
35
|
const config = loadTestkitConfig(productDir);
|
|
36
36
|
validateConfigCoverage(config);
|
|
37
|
+
const discoveredSuites = discoverSuites(productDir, config.services);
|
|
37
38
|
|
|
38
39
|
const serviceEntries = Object.entries(config.services);
|
|
39
40
|
const filtered = opts.service
|
|
@@ -46,7 +47,7 @@ export function loadConfigs(opts = {}) {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
return filtered.map(([name, serviceConfig]) => {
|
|
49
|
-
const suites =
|
|
50
|
+
const suites = discoveredSuites[name] || {};
|
|
50
51
|
const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
|
|
51
52
|
const serviceEnv = loadServiceEnv(productDir, serviceConfig);
|
|
52
53
|
const selectedBackend = resolvedDatabase?.selectedBackend;
|