@elench/testkit 0.1.26 → 0.1.27
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 +103 -82
- package/lib/bundler/index.mjs +46 -7
- package/lib/cli/index.mjs +3 -32
- package/lib/config/discovery.mjs +209 -54
- package/lib/config/discovery.test.mjs +57 -28
- package/lib/config/index.mjs +297 -154
- package/lib/config/setup-loader.mjs +98 -0
- package/lib/index.d.ts +1 -0
- package/lib/package.test.mjs +5 -0
- package/lib/runner/template.mjs +1 -1
- package/lib/runtime-src/k6/http.js +1 -0
- package/lib/runtime-src/k6/suite.js +66 -23
- package/lib/setup/index.d.ts +104 -0
- package/lib/setup/index.mjs +292 -0
- package/lib/setup/runtime.mjs +79 -0
- package/package.json +5 -1
- package/lib/config/model.mjs +0 -320
- package/lib/config/model.test.mjs +0 -163
- package/lib/runtime-manager/index.mjs +0 -190
package/lib/config/index.mjs
CHANGED
|
@@ -1,84 +1,55 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
isObject as isObjectModel,
|
|
8
|
-
normalizeTelemetryConfig as normalizeTelemetryConfigModel,
|
|
9
|
-
parseDotenvString,
|
|
10
|
-
resolveLifecycleConfig as resolveLifecycleConfigModel,
|
|
11
|
-
resolveSelectedDatabase as resolveSelectedDatabaseModel,
|
|
12
|
-
validateConfigCoverage as validateConfigCoverageModel,
|
|
13
|
-
validateServiceConfig as validateServiceConfigModel,
|
|
14
|
-
validateTelemetryConfig as validateTelemetryConfigModel,
|
|
15
|
-
} from "./model.mjs";
|
|
16
|
-
|
|
17
|
-
const TESTKIT_CONFIG = "testkit.config.json";
|
|
4
|
+
import { discoverProject } from "./discovery.mjs";
|
|
5
|
+
import { loadTestkitSetup } from "./setup-loader.mjs";
|
|
6
|
+
|
|
18
7
|
const TESTKIT_K6_BIN = "TESTKIT_K6_BIN";
|
|
8
|
+
const DEFAULT_LOCAL_IMAGE = "pgvector/pgvector:pg16";
|
|
9
|
+
const DEFAULT_LOCAL_USER = "testkit";
|
|
10
|
+
const DEFAULT_LOCAL_PASSWORD = "testkit";
|
|
11
|
+
|
|
19
12
|
export function parseDotenv(filePath) {
|
|
20
13
|
if (!fs.existsSync(filePath)) return {};
|
|
21
14
|
return parseDotenvString(fs.readFileSync(filePath, "utf8"));
|
|
22
15
|
}
|
|
23
16
|
|
|
24
|
-
export function
|
|
25
|
-
const dir = cwd || process.cwd();
|
|
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);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function loadConfigs(opts = {}) {
|
|
17
|
+
export async function loadConfigs(opts = {}) {
|
|
34
18
|
const productDir = resolveProductDir(process.cwd(), opts.dir);
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
19
|
+
const { setup, setupFile } = await loadTestkitSetup(productDir);
|
|
20
|
+
const explicitServices = setup.services || {};
|
|
21
|
+
const discovery = discoverProject(productDir, explicitServices);
|
|
22
|
+
const serviceNames = new Set([
|
|
23
|
+
...Object.keys(explicitServices),
|
|
24
|
+
...Object.keys(discovery.suitesByService),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const configs = [...serviceNames]
|
|
28
|
+
.sort((left, right) => left.localeCompare(right))
|
|
29
|
+
.map((name) =>
|
|
30
|
+
normalizeServiceConfig({
|
|
31
|
+
name,
|
|
32
|
+
productDir,
|
|
33
|
+
setup,
|
|
34
|
+
setupFile,
|
|
35
|
+
explicitService: explicitServices[name] || {},
|
|
36
|
+
discoveredService: discovery.services[name] || null,
|
|
37
|
+
suites: discovery.suitesByService[name] || {},
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
validateConfigCoverage(configs);
|
|
38
42
|
|
|
39
|
-
const serviceEntries = Object.entries(config.services);
|
|
40
43
|
const filtered = opts.service
|
|
41
|
-
?
|
|
42
|
-
:
|
|
44
|
+
? configs.filter((config) => config.name === opts.service)
|
|
45
|
+
: configs;
|
|
43
46
|
|
|
44
47
|
if (opts.service && filtered.length === 0) {
|
|
45
|
-
const available =
|
|
48
|
+
const available = configs.map((config) => config.name).join(", ");
|
|
46
49
|
throw new Error(`Service "${opts.service}" not found. Available: ${available}`);
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
return filtered
|
|
50
|
-
const suites = discoveredSuites[name] || {};
|
|
51
|
-
const resolvedDatabase = resolveSelectedDatabase(name, serviceConfig);
|
|
52
|
-
const serviceEnv = loadServiceEnv(productDir, serviceConfig);
|
|
53
|
-
const selectedBackend = resolvedDatabase?.selectedBackend;
|
|
54
|
-
const resolvedMigrate = resolveLifecycleConfig(serviceConfig.migrate, selectedBackend);
|
|
55
|
-
const resolvedSeed = resolveLifecycleConfig(serviceConfig.seed, selectedBackend);
|
|
56
|
-
validateMergedService(
|
|
57
|
-
name,
|
|
58
|
-
suites,
|
|
59
|
-
serviceConfig,
|
|
60
|
-
resolvedDatabase,
|
|
61
|
-
resolvedMigrate,
|
|
62
|
-
resolvedSeed,
|
|
63
|
-
productDir
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
name,
|
|
68
|
-
productDir,
|
|
69
|
-
stateDir: path.join(productDir, ".testkit", name),
|
|
70
|
-
telemetry: normalizeTelemetryConfig(config.telemetry),
|
|
71
|
-
suites,
|
|
72
|
-
testkit: {
|
|
73
|
-
...serviceConfig,
|
|
74
|
-
database: resolvedDatabase,
|
|
75
|
-
migrate: resolvedMigrate,
|
|
76
|
-
seed: resolvedSeed,
|
|
77
|
-
envFiles: getServiceEnvFiles(serviceConfig),
|
|
78
|
-
serviceEnv,
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
});
|
|
52
|
+
return filtered;
|
|
82
53
|
}
|
|
83
54
|
|
|
84
55
|
export function resolveK6Binary() {
|
|
@@ -114,142 +85,314 @@ export function resolveServiceCwd(productDir, maybeRelative) {
|
|
|
114
85
|
return path.resolve(productDir, maybeRelative || ".");
|
|
115
86
|
}
|
|
116
87
|
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
88
|
+
function normalizeServiceConfig({
|
|
89
|
+
name,
|
|
90
|
+
productDir,
|
|
91
|
+
setup,
|
|
92
|
+
setupFile,
|
|
93
|
+
explicitService,
|
|
94
|
+
discoveredService,
|
|
95
|
+
suites,
|
|
96
|
+
}) {
|
|
97
|
+
const local = normalizeLocalConfig(name, explicitService, discoveredService, productDir);
|
|
98
|
+
const envFiles = inferEnvFiles(productDir, explicitService, local);
|
|
99
|
+
const serviceEnv = loadServiceEnv(productDir, envFiles);
|
|
100
|
+
const database = normalizeDatabaseConfig(explicitService, name);
|
|
101
|
+
const migrate = normalizeLifecycle(explicitService.migrate);
|
|
102
|
+
const seed = normalizeLifecycle(explicitService.seed);
|
|
103
|
+
|
|
104
|
+
if (!explicitService.databaseFrom && !database && (migrate || seed)) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Service "${name}" defines migrate/seed hooks but no database. Add localDatabase(...) in testkit.setup.ts.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
validateServiceConfig({
|
|
111
|
+
name,
|
|
112
|
+
local,
|
|
113
|
+
database,
|
|
114
|
+
databaseFrom: explicitService.databaseFrom,
|
|
115
|
+
migrate,
|
|
116
|
+
seed,
|
|
117
|
+
dependsOn: explicitService.dependsOn || [],
|
|
118
|
+
suites,
|
|
119
|
+
productDir,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
name,
|
|
124
|
+
productDir,
|
|
125
|
+
setupFile,
|
|
126
|
+
stateDir: path.join(productDir, ".testkit", name),
|
|
127
|
+
telemetry: normalizeTelemetryConfig(setup.telemetry),
|
|
128
|
+
suites,
|
|
129
|
+
testkit: {
|
|
130
|
+
dependsOn: explicitService.dependsOn || [],
|
|
131
|
+
database,
|
|
132
|
+
databaseFrom: explicitService.databaseFrom,
|
|
133
|
+
envFiles,
|
|
134
|
+
serviceEnv,
|
|
135
|
+
migrate,
|
|
136
|
+
seed,
|
|
137
|
+
local,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
|
|
143
|
+
if (explicitService.local) {
|
|
144
|
+
return {
|
|
145
|
+
...explicitService.local,
|
|
146
|
+
cwd: explicitService.local.cwd || ".",
|
|
147
|
+
env: explicitService.local.env || {},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const inferredCwd = discoveredService?.inferredLocalCwd || ".";
|
|
152
|
+
const detected = inferLocalRuntime(productDir, inferredCwd);
|
|
153
|
+
if (!detected) return undefined;
|
|
154
|
+
|
|
155
|
+
return detected;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function inferLocalRuntime(productDir, cwd) {
|
|
159
|
+
const absoluteCwd = resolveServiceCwd(productDir, cwd);
|
|
160
|
+
if (!fs.existsSync(absoluteCwd)) return undefined;
|
|
120
161
|
|
|
121
|
-
if (
|
|
122
|
-
|
|
162
|
+
if (detectNextApp(absoluteCwd)) {
|
|
163
|
+
return {
|
|
164
|
+
cwd,
|
|
165
|
+
start: "exec ./node_modules/.bin/next dev -p {port}",
|
|
166
|
+
port: 3000,
|
|
167
|
+
baseUrl: "http://127.0.0.1:{port}",
|
|
168
|
+
readyUrl: "http://127.0.0.1:{port}",
|
|
169
|
+
env: {},
|
|
170
|
+
};
|
|
123
171
|
}
|
|
124
172
|
|
|
125
|
-
if (
|
|
126
|
-
|
|
173
|
+
if (fs.existsSync(path.join(absoluteCwd, "cmd", "server"))) {
|
|
174
|
+
return {
|
|
175
|
+
cwd,
|
|
176
|
+
start: "exec go run ./cmd/server",
|
|
177
|
+
port: 3000,
|
|
178
|
+
baseUrl: "http://127.0.0.1:{port}",
|
|
179
|
+
readyUrl: "http://127.0.0.1:{port}/health",
|
|
180
|
+
env: {},
|
|
181
|
+
};
|
|
127
182
|
}
|
|
128
183
|
|
|
129
|
-
|
|
130
|
-
|
|
184
|
+
if (fs.existsSync(path.join(absoluteCwd, "package.json")) && fs.existsSync(path.join(absoluteCwd, "src"))) {
|
|
185
|
+
return {
|
|
186
|
+
cwd,
|
|
187
|
+
start: "exec ./node_modules/.bin/tsx watch src/index.ts",
|
|
188
|
+
port: 3000,
|
|
189
|
+
baseUrl: "http://127.0.0.1:{port}",
|
|
190
|
+
readyUrl: "http://127.0.0.1:{port}/health",
|
|
191
|
+
env: {},
|
|
192
|
+
};
|
|
131
193
|
}
|
|
132
194
|
|
|
133
|
-
return
|
|
195
|
+
return undefined;
|
|
134
196
|
}
|
|
135
197
|
|
|
136
|
-
function
|
|
137
|
-
|
|
198
|
+
function normalizeDatabaseConfig(explicitService, serviceName) {
|
|
199
|
+
if (explicitService.databaseFrom) return undefined;
|
|
200
|
+
if (!explicitService.database) return undefined;
|
|
201
|
+
|
|
202
|
+
const database =
|
|
203
|
+
explicitService.database.provider === "local"
|
|
204
|
+
? explicitService.database
|
|
205
|
+
: {
|
|
206
|
+
provider: "local",
|
|
207
|
+
...explicitService.database,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
...database,
|
|
212
|
+
provider: "local",
|
|
213
|
+
selectedBackend: "local",
|
|
214
|
+
reset: database.reset !== false,
|
|
215
|
+
image: database.image || DEFAULT_LOCAL_IMAGE,
|
|
216
|
+
user: database.user || DEFAULT_LOCAL_USER,
|
|
217
|
+
password: database.password || DEFAULT_LOCAL_PASSWORD,
|
|
218
|
+
template: {
|
|
219
|
+
inputs: Array.isArray(database.template?.inputs) ? [...database.template.inputs] : [],
|
|
220
|
+
},
|
|
221
|
+
serviceName,
|
|
222
|
+
};
|
|
138
223
|
}
|
|
139
224
|
|
|
140
|
-
function
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return dir;
|
|
225
|
+
function normalizeLifecycle(value) {
|
|
226
|
+
if (!value) return undefined;
|
|
227
|
+
if (!value.cmd && !value.testkitCmd) {
|
|
228
|
+
throw new Error("Lifecycle config requires cmd or testkitCmd");
|
|
145
229
|
}
|
|
146
230
|
|
|
147
|
-
|
|
148
|
-
|
|
231
|
+
return {
|
|
232
|
+
cmd: value.testkitCmd || value.cmd,
|
|
233
|
+
cwd: value.testkitCwd || value.cwd,
|
|
234
|
+
};
|
|
149
235
|
}
|
|
150
236
|
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
`Expected ${missing.join(" and ")} in ${dir}. Either cd into a product directory or use --dir.`
|
|
158
|
-
);
|
|
237
|
+
function inferEnvFiles(productDir, explicitService, local) {
|
|
238
|
+
if (explicitService.envFile || explicitService.envFiles) {
|
|
239
|
+
const files = [];
|
|
240
|
+
if (explicitService.envFile) files.push(explicitService.envFile);
|
|
241
|
+
if (Array.isArray(explicitService.envFiles)) files.push(...explicitService.envFiles);
|
|
242
|
+
return files;
|
|
159
243
|
}
|
|
244
|
+
|
|
245
|
+
const candidates = [];
|
|
246
|
+
const serviceCwd = local?.cwd || ".";
|
|
247
|
+
if (serviceCwd !== ".") {
|
|
248
|
+
candidates.push(path.posix.join(serviceCwd, ".env.testkit"));
|
|
249
|
+
candidates.push(path.posix.join(serviceCwd, ".env"));
|
|
250
|
+
}
|
|
251
|
+
candidates.push(".env.testkit");
|
|
252
|
+
candidates.push(".env");
|
|
253
|
+
|
|
254
|
+
return [...new Set(candidates)]
|
|
255
|
+
.map((candidate) => candidate.split(path.sep).join("/"))
|
|
256
|
+
.filter((candidate) => fs.existsSync(resolveServiceCwd(productDir, candidate)));
|
|
160
257
|
}
|
|
161
258
|
|
|
162
|
-
function
|
|
259
|
+
function loadServiceEnv(productDir, envFiles) {
|
|
260
|
+
const env = {};
|
|
261
|
+
for (const envFile of envFiles) {
|
|
262
|
+
Object.assign(env, parseDotenv(resolveServiceCwd(productDir, envFile)));
|
|
263
|
+
}
|
|
264
|
+
return env;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function validateConfigCoverage(configs) {
|
|
268
|
+
const names = new Set(configs.map((config) => config.name));
|
|
269
|
+
for (const config of configs) {
|
|
270
|
+
for (const depName of config.testkit.dependsOn || []) {
|
|
271
|
+
if (!names.has(depName)) {
|
|
272
|
+
throw new Error(`Service "${config.name}" depends on "${depName}", but ${depName} is not defined`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const databaseFrom = config.testkit.databaseFrom;
|
|
276
|
+
if (databaseFrom && !names.has(databaseFrom)) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`Service "${config.name}" databaseFrom "${databaseFrom}" is not defined`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function validateServiceConfig({
|
|
163
285
|
name,
|
|
286
|
+
local,
|
|
287
|
+
database,
|
|
288
|
+
databaseFrom,
|
|
289
|
+
migrate,
|
|
290
|
+
seed,
|
|
291
|
+
dependsOn,
|
|
164
292
|
suites,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
resolvedMigrate,
|
|
168
|
-
resolvedSeed,
|
|
169
|
-
productDir
|
|
170
|
-
) {
|
|
293
|
+
productDir,
|
|
294
|
+
}) {
|
|
171
295
|
const usesLocalExecution = Object.entries(suites).some(([suiteType, discoveredSuites]) =>
|
|
172
296
|
discoveredSuites.some(
|
|
173
297
|
(suite) => (suite.framework && suite.framework !== "k6") || suiteType !== "dal"
|
|
174
298
|
)
|
|
175
299
|
);
|
|
176
300
|
|
|
177
|
-
if (usesLocalExecution && !
|
|
301
|
+
if (usesLocalExecution && !local) {
|
|
178
302
|
throw new Error(
|
|
179
|
-
`Service "${name}" defines non-DAL suites but
|
|
303
|
+
`Service "${name}" defines non-DAL suites but no local runtime could be resolved. Add it in testkit.setup.ts.`
|
|
180
304
|
);
|
|
181
305
|
}
|
|
182
306
|
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
if (dep === name) {
|
|
186
|
-
throw new Error(`Service "${name}" cannot depend on itself`);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
307
|
+
if (database && databaseFrom) {
|
|
308
|
+
throw new Error(`Service "${name}" cannot define both database and databaseFrom`);
|
|
189
309
|
}
|
|
190
310
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
resolvedDatabase.template.inputs.length === 0
|
|
195
|
-
) {
|
|
196
|
-
throw new Error(
|
|
197
|
-
`Service "${name}" uses local database provisioning with migrations or seeds, so database.template.inputs must list the files/directories that define the template cache`
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (serviceConfig.local?.cwd) {
|
|
202
|
-
const cwdPath = resolveServiceCwd(productDir, serviceConfig.local.cwd);
|
|
203
|
-
if (!fs.existsSync(cwdPath)) {
|
|
204
|
-
throw new Error(
|
|
205
|
-
`Service "${name}" local.cwd does not exist: ${serviceConfig.local.cwd}`
|
|
206
|
-
);
|
|
311
|
+
for (const depName of dependsOn || []) {
|
|
312
|
+
if (depName === name) {
|
|
313
|
+
throw new Error(`Service "${name}" cannot depend on itself`);
|
|
207
314
|
}
|
|
208
315
|
}
|
|
209
316
|
|
|
210
|
-
if (
|
|
211
|
-
|
|
212
|
-
if (!fs.existsSync(cwdPath)) {
|
|
213
|
-
throw new Error(
|
|
214
|
-
`Service "${name}" migrate.cwd does not exist: ${resolvedMigrate.cwd}`
|
|
215
|
-
);
|
|
216
|
-
}
|
|
317
|
+
if (local?.cwd) {
|
|
318
|
+
ensureExistingPath(productDir, local.cwd, `Service "${name}" local.cwd`);
|
|
217
319
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const cwdPath = resolveServiceCwd(productDir, resolvedSeed.cwd);
|
|
221
|
-
if (!fs.existsSync(cwdPath)) {
|
|
222
|
-
throw new Error(
|
|
223
|
-
`Service "${name}" seed.cwd does not exist: ${resolvedSeed.cwd}`
|
|
224
|
-
);
|
|
225
|
-
}
|
|
320
|
+
if (migrate?.cwd) {
|
|
321
|
+
ensureExistingPath(productDir, migrate.cwd, `Service "${name}" migrate.cwd`);
|
|
226
322
|
}
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
function loadServiceEnv(productDir, serviceConfig) {
|
|
230
|
-
const env = {};
|
|
231
|
-
for (const envFile of getServiceEnvFiles(serviceConfig)) {
|
|
232
|
-
Object.assign(env, parseDotenv(resolveServiceCwd(productDir, envFile)));
|
|
323
|
+
if (seed?.cwd) {
|
|
324
|
+
ensureExistingPath(productDir, seed.cwd, `Service "${name}" seed.cwd`);
|
|
233
325
|
}
|
|
234
|
-
return env;
|
|
235
326
|
}
|
|
236
327
|
|
|
237
|
-
function
|
|
238
|
-
|
|
328
|
+
function ensureExistingPath(productDir, relativePath, label) {
|
|
329
|
+
const absolute = resolveServiceCwd(productDir, relativePath);
|
|
330
|
+
if (!fs.existsSync(absolute)) {
|
|
331
|
+
throw new Error(`${label} does not exist: ${relativePath}`);
|
|
332
|
+
}
|
|
239
333
|
}
|
|
240
334
|
|
|
241
|
-
function
|
|
242
|
-
|
|
335
|
+
function normalizeTelemetryConfig(telemetry) {
|
|
336
|
+
if (!telemetry) return null;
|
|
337
|
+
if (telemetry.endpoint) {
|
|
338
|
+
let parsed;
|
|
339
|
+
try {
|
|
340
|
+
parsed = new URL(telemetry.endpoint);
|
|
341
|
+
} catch {
|
|
342
|
+
throw new Error("testkit.setup telemetry.endpoint must be a valid URL");
|
|
343
|
+
}
|
|
344
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
345
|
+
throw new Error("testkit.setup telemetry.endpoint must use http or https");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (telemetry.enabled === true) {
|
|
349
|
+
if (!telemetry.endpoint) {
|
|
350
|
+
throw new Error("testkit.setup telemetry.endpoint is required when telemetry.enabled is true");
|
|
351
|
+
}
|
|
352
|
+
if (!telemetry.tokenEnv) {
|
|
353
|
+
throw new Error("testkit.setup telemetry.tokenEnv is required when telemetry.enabled is true");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
enabled: telemetry.enabled === true,
|
|
358
|
+
endpoint: telemetry.endpoint,
|
|
359
|
+
tokenEnv: telemetry.tokenEnv,
|
|
360
|
+
timeoutMs: telemetry.timeoutMs || 3000,
|
|
361
|
+
};
|
|
243
362
|
}
|
|
244
363
|
|
|
245
|
-
function
|
|
246
|
-
|
|
364
|
+
function resolveProductDir(cwd, explicitDir) {
|
|
365
|
+
const dir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
|
|
366
|
+
if (!fs.existsSync(dir)) {
|
|
367
|
+
throw new Error(`Product directory does not exist: ${dir}`);
|
|
368
|
+
}
|
|
369
|
+
return dir;
|
|
247
370
|
}
|
|
248
371
|
|
|
249
|
-
function
|
|
250
|
-
|
|
372
|
+
function parseDotenvString(source) {
|
|
373
|
+
const env = {};
|
|
374
|
+
for (const line of String(source).split("\n")) {
|
|
375
|
+
const trimmed = line.trim();
|
|
376
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
377
|
+
const eq = trimmed.indexOf("=");
|
|
378
|
+
if (eq === -1) continue;
|
|
379
|
+
const key = trimmed.slice(0, eq).trim();
|
|
380
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
381
|
+
if (
|
|
382
|
+
(val.startsWith("'") && val.endsWith("'")) ||
|
|
383
|
+
(val.startsWith("\"") && val.endsWith("\""))
|
|
384
|
+
) {
|
|
385
|
+
val = val.slice(1, -1);
|
|
386
|
+
}
|
|
387
|
+
env[key] = val;
|
|
388
|
+
}
|
|
389
|
+
return env;
|
|
251
390
|
}
|
|
252
391
|
|
|
253
|
-
function
|
|
254
|
-
return
|
|
392
|
+
function detectNextApp(cwd) {
|
|
393
|
+
return (
|
|
394
|
+
fs.existsSync(path.join(cwd, "next.config.js")) ||
|
|
395
|
+
fs.existsSync(path.join(cwd, "next.config.mjs")) ||
|
|
396
|
+
fs.existsSync(path.join(cwd, "next.config.ts"))
|
|
397
|
+
);
|
|
255
398
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { build } from "esbuild";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
8
|
+
const ROOT_ENTRY = path.join(PACKAGE_ROOT, "lib", "index.mjs");
|
|
9
|
+
const SETUP_ENTRY = path.join(PACKAGE_ROOT, "lib", "setup", "index.mjs");
|
|
10
|
+
const RUNTIME_ENTRY = path.join(PACKAGE_ROOT, "lib", "runtime", "index.mjs");
|
|
11
|
+
const SETUP_FILES = [
|
|
12
|
+
"testkit.setup.ts",
|
|
13
|
+
"testkit.setup.mts",
|
|
14
|
+
"testkit.setup.mjs",
|
|
15
|
+
"testkit.setup.js",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export function findSetupFile(productDir) {
|
|
19
|
+
for (const candidate of SETUP_FILES) {
|
|
20
|
+
const absolute = path.join(productDir, candidate);
|
|
21
|
+
if (fs.existsSync(absolute)) {
|
|
22
|
+
return absolute;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadTestkitSetup(productDir) {
|
|
29
|
+
const setupFile = findSetupFile(productDir);
|
|
30
|
+
if (!setupFile) {
|
|
31
|
+
return {
|
|
32
|
+
setup: {},
|
|
33
|
+
setupFile: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const bundleDir = path.join(productDir, ".testkit", "_setup");
|
|
38
|
+
fs.mkdirSync(bundleDir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
const cacheKey = buildSetupCacheKey(setupFile);
|
|
41
|
+
const outputFile = path.join(bundleDir, `setup-${cacheKey.slice(0, 12)}.mjs`);
|
|
42
|
+
|
|
43
|
+
await build({
|
|
44
|
+
absWorkingDir: productDir,
|
|
45
|
+
bundle: true,
|
|
46
|
+
entryPoints: [setupFile],
|
|
47
|
+
format: "esm",
|
|
48
|
+
legalComments: "none",
|
|
49
|
+
outfile: outputFile,
|
|
50
|
+
platform: "node",
|
|
51
|
+
sourcemap: "inline",
|
|
52
|
+
target: "es2020",
|
|
53
|
+
plugins: [testkitSetupAliasPlugin()],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const imported = await import(`${pathToFileURL(outputFile).href}?v=${cacheKey}`);
|
|
57
|
+
const setup = imported.default || imported.setup || {};
|
|
58
|
+
if (!setup || typeof setup !== "object") {
|
|
59
|
+
throw new Error(`testkit setup file must export an object: ${path.basename(setupFile)}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
setup,
|
|
64
|
+
setupFile,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildSetupCacheKey(setupFile) {
|
|
69
|
+
const content = fs.readFileSync(setupFile, "utf8");
|
|
70
|
+
return crypto.createHash("sha256").update(setupFile).update("\0").update(content).digest("hex");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function testkitSetupAliasPlugin() {
|
|
74
|
+
return {
|
|
75
|
+
name: "testkit-setup-alias",
|
|
76
|
+
setup(buildApi) {
|
|
77
|
+
buildApi.onResolve({ filter: /^@elench\/testkit(?:\/.*)?$/ }, (args) => ({
|
|
78
|
+
namespace: "file",
|
|
79
|
+
path: resolvePackageSubpath(args.path),
|
|
80
|
+
}));
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolvePackageSubpath(specifier) {
|
|
86
|
+
const subpath = specifier.slice("@elench/testkit".length);
|
|
87
|
+
if (!subpath) return ROOT_ENTRY;
|
|
88
|
+
if (subpath === "/setup") return SETUP_ENTRY;
|
|
89
|
+
if (subpath === "/runtime") {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"testkit.setup.ts may not import @elench/testkit/runtime. " +
|
|
92
|
+
"Use @elench/testkit/setup runtime helpers instead."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (subpath === "/runtime/index.mjs") return RUNTIME_ENTRY;
|
|
96
|
+
|
|
97
|
+
throw new Error(`Unsupported @elench/testkit import "${specifier}" while loading testkit setup`);
|
|
98
|
+
}
|
package/lib/index.d.ts
CHANGED
package/lib/package.test.mjs
CHANGED
|
@@ -14,11 +14,16 @@ describe("package metadata", () => {
|
|
|
14
14
|
types: "./lib/index.d.ts",
|
|
15
15
|
default: "./lib/index.mjs",
|
|
16
16
|
});
|
|
17
|
+
expect(packageJson.exports["./setup"]).toEqual({
|
|
18
|
+
types: "./lib/setup/index.d.ts",
|
|
19
|
+
default: "./lib/setup/index.mjs",
|
|
20
|
+
});
|
|
17
21
|
expect(packageJson.exports["./runtime"]).toEqual({
|
|
18
22
|
types: "./lib/runtime/index.d.ts",
|
|
19
23
|
default: "./lib/runtime/index.mjs",
|
|
20
24
|
});
|
|
21
25
|
expect(fs.existsSync(path.join(rootDir, "lib", "index.d.ts"))).toBe(true);
|
|
26
|
+
expect(fs.existsSync(path.join(rootDir, "lib", "setup", "index.d.ts"))).toBe(true);
|
|
22
27
|
expect(fs.existsSync(path.join(rootDir, "lib", "runtime", "index.d.ts"))).toBe(true);
|
|
23
28
|
});
|
|
24
29
|
});
|
package/lib/runner/template.mjs
CHANGED
|
@@ -72,7 +72,7 @@ export function buildPortMap(runtimeConfigs, workerId) {
|
|
|
72
72
|
if (existing) {
|
|
73
73
|
throw new Error(
|
|
74
74
|
`Worker port collision: services "${existing}" and "${config.name}" both resolve to ${actualPort}. ` +
|
|
75
|
-
`Assign distinct local.port/baseUrl ports in testkit.
|
|
75
|
+
`Assign distinct local.port/baseUrl ports in testkit.setup.ts.`
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
seen.set(actualPort, config.name);
|