@elench/testkit 0.1.36 → 0.1.38

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.
@@ -101,7 +101,10 @@ function normalizeServiceConfig({
101
101
  }) {
102
102
  const local = normalizeLocalConfig(name, explicitService, discoveredService, productDir);
103
103
  const envFiles = inferEnvFiles(productDir, explicitService, local);
104
- const serviceEnv = loadServiceEnv(productDir, envFiles);
104
+ const serviceEnv = {
105
+ ...loadServiceEnv(productDir, envFiles),
106
+ ...(explicitService.env || {}),
107
+ };
105
108
  const database = normalizeDatabaseConfig(explicitService, name);
106
109
  const migrate = normalizeLifecycle(explicitService.migrate);
107
110
  const seed = normalizeLifecycle(explicitService.seed);
@@ -151,7 +154,10 @@ function normalizeServiceConfig({
151
154
  }
152
155
 
153
156
  function normalizeLocalConfig(name, explicitService, discoveredService, productDir) {
154
- if (explicitService.local) {
157
+ if (Object.prototype.hasOwnProperty.call(explicitService, "local")) {
158
+ if (explicitService.local === false) {
159
+ return undefined;
160
+ }
155
161
  return {
156
162
  ...explicitService.local,
157
163
  cwd: explicitService.local.cwd || ".",
@@ -7,6 +7,7 @@ import {
7
7
  } from "../timing/index.mjs";
8
8
 
9
9
  const TIMINGS_FILENAME = "timings.json";
10
+ const RESULT_ARTIFACTS_DIRNAME = "artifacts";
10
11
 
11
12
  export function writeRunArtifact(productDir, artifact) {
12
13
  const resultsDir = path.join(productDir, ".testkit", "results");
@@ -21,6 +22,65 @@ export function writeStatusArtifact(productDir, artifact) {
21
22
  );
22
23
  }
23
24
 
25
+ export function resetResultArtifacts(productDir) {
26
+ fs.rmSync(path.join(productDir, ".testkit", "results", RESULT_ARTIFACTS_DIRNAME), {
27
+ recursive: true,
28
+ force: true,
29
+ });
30
+ }
31
+
32
+ export function persistTaskArtifacts(productDir, task, emittedArtifacts) {
33
+ if (!Array.isArray(emittedArtifacts) || emittedArtifacts.length === 0) return [];
34
+
35
+ const artifactsDir = path.join(
36
+ productDir,
37
+ ".testkit",
38
+ "results",
39
+ RESULT_ARTIFACTS_DIRNAME,
40
+ sanitizePathSegment(task.serviceName || "service")
41
+ );
42
+ fs.mkdirSync(artifactsDir, { recursive: true });
43
+
44
+ return emittedArtifacts.map((artifact, index) => {
45
+ const fileName = `task-${task.id}-${String(index + 1).padStart(2, "0")}-${sanitizePathSegment(artifact.name || "artifact")}.json`;
46
+ const relativePath = path.join(
47
+ ".testkit",
48
+ "results",
49
+ RESULT_ARTIFACTS_DIRNAME,
50
+ sanitizePathSegment(task.serviceName || "service"),
51
+ fileName
52
+ );
53
+ const absolutePath = path.join(productDir, relativePath);
54
+ const payload = {
55
+ schemaVersion: 1,
56
+ source: "testkit-runtime-artifact",
57
+ service: task.serviceName,
58
+ suite: {
59
+ key: task.suiteKey,
60
+ name: task.suiteName,
61
+ type: task.type,
62
+ },
63
+ file: task.file,
64
+ taskId: task.id,
65
+ index,
66
+ name: artifact.name,
67
+ kind: artifact.kind || null,
68
+ summary: artifact.summary || null,
69
+ contentType: artifact.contentType || "application/json",
70
+ emittedAt: artifact.emittedAt || null,
71
+ data: artifact.data,
72
+ };
73
+ fs.writeFileSync(absolutePath, `${JSON.stringify(payload, null, 2)}\n`);
74
+ return {
75
+ name: payload.name,
76
+ kind: payload.kind,
77
+ summary: payload.summary,
78
+ contentType: payload.contentType,
79
+ path: normalizePath(relativePath),
80
+ };
81
+ });
82
+ }
83
+
24
84
  export function loadTimings(productDir) {
25
85
  const filePath = path.join(productDir, ".testkit", TIMINGS_FILENAME);
26
86
  if (!fs.existsSync(filePath)) {
@@ -41,3 +101,15 @@ export function saveTimings(productDir, timings, updates) {
41
101
  fs.mkdirSync(rootDir, { recursive: true });
42
102
  fs.writeFileSync(path.join(rootDir, TIMINGS_FILENAME), JSON.stringify(next, null, 2));
43
103
  }
104
+
105
+ function sanitizePathSegment(value) {
106
+ return String(value)
107
+ .trim()
108
+ .toLowerCase()
109
+ .replace(/[^a-z0-9._-]+/g, "-")
110
+ .replace(/^-+|-+$/g, "") || "artifact";
111
+ }
112
+
113
+ function normalizePath(filePath) {
114
+ return filePath.split(path.sep).join("/");
115
+ }
@@ -3,6 +3,8 @@ import path from "path";
3
3
  import { execa } from "execa";
4
4
  import { bundleK6File } from "../bundler/index.mjs";
5
5
  import { resolveK6Binary } from "../config/index.mjs";
6
+ import { persistTaskArtifacts } from "./artifacts.mjs";
7
+ import { RUNTIME_ARTIFACT_MARKER } from "../runtime-src/k6/artifacts.js";
6
8
  import { determineDefaultRuntimeFailure } from "./default-runtime-errors.mjs";
7
9
  import { formatBatchDescriptor } from "./formatting.mjs";
8
10
  import { buildExecutionEnv } from "./template.mjs";
@@ -85,10 +87,17 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
85
87
  }
86
88
  );
87
89
 
88
- if (result.stdout) process.stdout.write(result.stdout);
89
- if (result.stderr) process.stderr.write(result.stderr);
90
+ const stdout = parseDefaultRuntimeOutput(result.stdout || "");
91
+ const stderr = parseDefaultRuntimeOutput(result.stderr || "");
92
+ if (stdout.visibleOutput) process.stdout.write(stdout.visibleOutput);
93
+ if (stderr.visibleOutput) process.stderr.write(stderr.visibleOutput);
90
94
 
91
95
  const summary = readDefaultRuntimeSummary(summaryFile);
96
+ const runtimeArtifacts = persistTaskArtifacts(
97
+ targetConfig.productDir,
98
+ task,
99
+ [...stdout.artifacts, ...stderr.artifacts]
100
+ );
92
101
  const runtimeError = determineDefaultRuntimeFailure(result, summary, firstLine);
93
102
  const finishedAt = Date.now();
94
103
 
@@ -99,6 +108,7 @@ export async function runDefaultRuntimeTask(targetConfig, task, args, lifecycle,
99
108
  durationMs: finishedAt - startedAt,
100
109
  startedAt,
101
110
  finishedAt,
111
+ artifacts: runtimeArtifacts,
102
112
  };
103
113
  }
104
114
 
@@ -117,3 +127,42 @@ export function readDefaultRuntimeSummary(filePath) {
117
127
  return null;
118
128
  }
119
129
  }
130
+
131
+ function parseDefaultRuntimeOutput(output) {
132
+ if (!output) {
133
+ return {
134
+ visibleOutput: "",
135
+ artifacts: [],
136
+ };
137
+ }
138
+
139
+ const visibleLines = [];
140
+ const artifacts = [];
141
+ for (const line of output.split(/\r?\n/)) {
142
+ const rawPayload = extractArtifactPayload(line);
143
+ if (rawPayload !== null) {
144
+ try {
145
+ artifacts.push(JSON.parse(decodeURIComponent(rawPayload)));
146
+ } catch {
147
+ visibleLines.push(line);
148
+ }
149
+ continue;
150
+ }
151
+ visibleLines.push(line);
152
+ }
153
+
154
+ return {
155
+ visibleOutput: visibleLines.join("\n"),
156
+ artifacts,
157
+ };
158
+ }
159
+
160
+ function extractArtifactPayload(line) {
161
+ if (line.startsWith(RUNTIME_ARTIFACT_MARKER)) {
162
+ return line.slice(RUNTIME_ARTIFACT_MARKER.length);
163
+ }
164
+
165
+ const k6ConsoleMatch = line.match(/msg="TESTKIT_ARTIFACT:(.*)"(?:\s+source=console)?$/);
166
+ if (!k6ConsoleMatch) return null;
167
+ return k6ConsoleMatch[1];
168
+ }
@@ -16,7 +16,13 @@ import {
16
16
  } from "./results.mjs";
17
17
  import { buildRunArtifact, buildStatusArtifact } from "./reporting.mjs";
18
18
  import { buildRunSummaryLines, formatError } from "./formatting.mjs";
19
- import { loadTimings, saveTimings, writeRunArtifact, writeStatusArtifact } from "./artifacts.mjs";
19
+ import {
20
+ loadTimings,
21
+ resetResultArtifacts,
22
+ saveTimings,
23
+ writeRunArtifact,
24
+ writeStatusArtifact,
25
+ } from "./artifacts.mjs";
20
26
  import {
21
27
  cleanupRunById,
22
28
  cleanupRuns,
@@ -39,6 +45,7 @@ export async function runAll(configs, typeValues, suiteSelectors, opts, allConfi
39
45
  const telemetry = configs[0]?.telemetry || null;
40
46
  const productDir = configs[0]?.productDir || process.cwd();
41
47
  await cleanupStaleRuns(productDir);
48
+ resetResultArtifacts(productDir);
42
49
  const metadata = {
43
50
  git: collectGitMetadata(productDir),
44
51
  host: {
@@ -45,6 +45,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
45
45
  error: null,
46
46
  reason: null,
47
47
  status: "not_run",
48
+ artifacts: [],
48
49
  },
49
50
  ];
50
51
  }),
@@ -57,6 +58,7 @@ export function buildServiceTrackers(servicePlans, startedAt) {
57
58
  error: null,
58
59
  reason: file.reason,
59
60
  status: "skipped",
61
+ artifacts: [],
60
62
  },
61
63
  ]),
62
64
  ]),
@@ -118,6 +120,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
118
120
  existingFileResult.error = outcome.error;
119
121
  existingFileResult.reason = outcome.reason || null;
120
122
  existingFileResult.status = status;
123
+ existingFileResult.artifacts = Array.isArray(outcome.artifacts) ? outcome.artifacts : [];
121
124
  } else {
122
125
  suite.fileResultsByPath.set(normalizedPath, {
123
126
  path: normalizedPath,
@@ -126,6 +129,7 @@ export function recordTaskOutcome(trackers, task, outcome, finishedAt = Date.now
126
129
  error: outcome.error,
127
130
  reason: outcome.reason || null,
128
131
  status,
132
+ artifacts: Array.isArray(outcome.artifacts) ? outcome.artifacts : [],
129
133
  });
130
134
  }
131
135
  if (status === "failed" && !suite.failedFileSet.has(task.file)) {
@@ -243,6 +247,9 @@ function finalizeSuite(suite) {
243
247
  durationMs: file.durationMs,
244
248
  error: file.error,
245
249
  reason: file.reason,
250
+ ...(Array.isArray(file.artifacts) && file.artifacts.length > 0
251
+ ? { artifacts: file.artifacts }
252
+ : {}),
246
253
  }));
247
254
 
248
255
  return {
@@ -5,6 +5,29 @@ export function readDatabaseUrl(stateDir) {
5
5
  return readStateValue(path.join(stateDir, "database_url"));
6
6
  }
7
7
 
8
+ export function readDatabaseInfo(stateDir) {
9
+ return parseDatabaseUrl(readDatabaseUrl(stateDir));
10
+ }
11
+
12
+ export function parseDatabaseUrl(databaseUrl) {
13
+ if (!databaseUrl) return null;
14
+
15
+ try {
16
+ const parsed = new URL(databaseUrl);
17
+ const port = Number(parsed.port || "5432");
18
+ return {
19
+ url: databaseUrl,
20
+ host: parsed.hostname,
21
+ port: Number.isInteger(port) && port > 0 ? port : 5432,
22
+ database: parsed.pathname.replace(/^\//, ""),
23
+ user: decodeURIComponent(parsed.username || ""),
24
+ password: decodeURIComponent(parsed.password || ""),
25
+ };
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
8
31
  export function readStateValue(filePath) {
9
32
  if (!fs.existsSync(filePath)) return null;
10
33
  return fs.readFileSync(filePath, "utf8").trim();
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import { readDatabaseInfo } from "./state-io.mjs";
2
3
 
3
4
  const PORT_STRIDE = 100;
4
5
 
@@ -6,6 +7,14 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
6
7
  const portMap = buildPortMap(runtimeConfigs, workerId);
7
8
  const baseUrlByService = new Map();
8
9
  const readyUrlByService = new Map();
10
+ const stateDirByService = new Map();
11
+
12
+ for (const config of runtimeConfigs) {
13
+ stateDirByService.set(
14
+ config.name,
15
+ resolveServiceStateDir(workerStateDir, targetConfig.name, config)
16
+ );
17
+ }
9
18
 
10
19
  for (const config of runtimeConfigs) {
11
20
  if (!config.testkit.local) continue;
@@ -16,6 +25,7 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
16
25
  portMap,
17
26
  baseUrlByService,
18
27
  readyUrlByService,
28
+ stateDirByService,
19
29
  })
20
30
  );
21
31
  readyUrlByService.set(
@@ -25,6 +35,7 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
25
35
  portMap,
26
36
  baseUrlByService,
27
37
  readyUrlByService,
38
+ stateDirByService,
28
39
  })
29
40
  );
30
41
  }
@@ -51,6 +62,7 @@ export function resolveWorkerRuntimeConfigs(targetConfig, runtimeConfigs, worker
51
62
  portMap,
52
63
  baseUrlByService,
53
64
  readyUrlByService,
65
+ stateDirByService,
54
66
  urlMappings
55
67
  )
56
68
  );
@@ -90,6 +102,7 @@ export function resolveWorkerConfig(
90
102
  portMap,
91
103
  baseUrlByService,
92
104
  readyUrlByService,
105
+ stateDirByService,
93
106
  urlMappings
94
107
  ) {
95
108
  const stateDir = resolveServiceStateDir(workerStateDir, targetConfig.name, config);
@@ -101,6 +114,7 @@ export function resolveWorkerConfig(
101
114
  portMap,
102
115
  baseUrlByService,
103
116
  readyUrlByService,
117
+ stateDirByService,
104
118
  urlMappings,
105
119
  };
106
120
 
@@ -143,12 +157,7 @@ export function resolveWorkerConfig(
143
157
  port: portMap.get(config.name) || config.testkit.local.port,
144
158
  baseUrl: baseUrlByService.get(config.name),
145
159
  readyUrl: readyUrlByService.get(config.name),
146
- env: Object.fromEntries(
147
- Object.entries(config.testkit.local.env || {}).map(([key, value]) => [
148
- key,
149
- finalizeString(String(value), context),
150
- ])
151
- ),
160
+ env: { ...(config.testkit.local.env || {}) },
152
161
  }
153
162
  : undefined;
154
163
 
@@ -163,6 +172,7 @@ export function resolveWorkerConfig(
163
172
  database,
164
173
  migrate,
165
174
  seed,
175
+ templateContext: context,
166
176
  local,
167
177
  },
168
178
  };
@@ -182,10 +192,11 @@ export function getWorkerServiceStateDir(workerStateDir, targetName, serviceName
182
192
 
183
193
  export function buildExecutionEnv(config, extraEnv = {}, processEnv = process.env) {
184
194
  const inheritedEnv = { ...processEnv };
195
+ const templateContext = config.testkit?.templateContext;
185
196
  const env = {
186
197
  ...inheritedEnv,
187
- ...(config.testkit.serviceEnv || {}),
188
- ...extraEnv,
198
+ ...resolveEnvTemplates(config.testkit.serviceEnv || {}, templateContext),
199
+ ...resolveEnvTemplates(extraEnv, templateContext),
189
200
  TESTKIT_ACTIVE: "1",
190
201
  ...(config.workerId ? { TESTKIT_WORKER_ID: String(config.workerId) } : {}),
191
202
  };
@@ -266,12 +277,61 @@ export function resolveTemplateString(value, context) {
266
277
  }
267
278
  return readyUrl;
268
279
  }
280
+ case "dbUrl":
281
+ case "dbHost":
282
+ case "dbPort":
283
+ case "dbName":
284
+ case "dbUser":
285
+ case "dbPassword": {
286
+ const serviceName = arg || context.serviceName;
287
+ return resolveDatabaseTemplateValue(token, serviceName, context);
288
+ }
269
289
  default:
270
290
  throw new Error(`Unsupported template token "{${token}${arg ? `:${arg}` : ""}}"`);
271
291
  }
272
292
  });
273
293
  }
274
294
 
295
+ function resolveEnvTemplates(values, templateContext) {
296
+ return Object.fromEntries(
297
+ Object.entries(values || {}).map(([key, value]) => [
298
+ key,
299
+ typeof value === "string" && templateContext ? finalizeString(value, templateContext) : value,
300
+ ])
301
+ );
302
+ }
303
+
304
+ function resolveDatabaseTemplateValue(token, serviceName, context) {
305
+ const stateDir = context.stateDirByService?.get(serviceName);
306
+ if (!stateDir) {
307
+ throw new Error(`Unknown database placeholder for service "${serviceName}"`);
308
+ }
309
+
310
+ const info = readDatabaseInfo(stateDir);
311
+ if (!info) {
312
+ throw new Error(
313
+ `Database placeholder "{${token}:${serviceName}}" is unavailable before "${serviceName}" database preparation`
314
+ );
315
+ }
316
+
317
+ switch (token) {
318
+ case "dbUrl":
319
+ return info.url;
320
+ case "dbHost":
321
+ return info.host;
322
+ case "dbPort":
323
+ return String(info.port);
324
+ case "dbName":
325
+ return info.database;
326
+ case "dbUser":
327
+ return info.user;
328
+ case "dbPassword":
329
+ return info.password;
330
+ default:
331
+ throw new Error(`Unsupported database placeholder "{${token}:${serviceName}}"`);
332
+ }
333
+ }
334
+
275
335
  export function rewriteUrlPort(rawUrl, port) {
276
336
  try {
277
337
  const original = new URL(rawUrl);
@@ -1,3 +1,6 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
1
4
  import { describe, expect, it } from "vitest";
2
5
  import {
3
6
  buildExecutionEnv,
@@ -51,6 +54,14 @@ describe("runner-template", () => {
51
54
  });
52
55
 
53
56
  it("resolves template strings and URL rewrites", () => {
57
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-template-"));
58
+ const apiStateDir = path.join(tmpDir, "api");
59
+ fs.mkdirSync(apiStateDir, { recursive: true });
60
+ fs.writeFileSync(
61
+ path.join(apiStateDir, "database_url"),
62
+ "postgres://testkit:testkit@127.0.0.1:55432/onix_db"
63
+ );
64
+
54
65
  const context = {
55
66
  workerId: 2,
56
67
  targetName: "frontend",
@@ -62,11 +73,15 @@ describe("runner-template", () => {
62
73
  ]),
63
74
  baseUrlByService: new Map([["api", "http://127.0.0.1:3100"]]),
64
75
  readyUrlByService: new Map([["api", "http://127.0.0.1:3100/health"]]),
76
+ stateDirByService: new Map([["api", apiStateDir]]),
65
77
  urlMappings: [["http://api:3000", "http://127.0.0.1:3100"]],
66
78
  };
67
79
 
68
80
  expect(resolveTemplateString("{worker}:{target}:{service}", context)).toBe("2:frontend:frontend");
69
81
  expect(resolveTemplateString("{baseUrl:api}", context)).toBe("http://127.0.0.1:3100");
82
+ expect(resolveTemplateString("{dbHost:api}:{dbPort:api}/{dbName:api}", context)).toBe(
83
+ "127.0.0.1:55432/onix_db"
84
+ );
70
85
  expect(finalizeString("API={baseUrl:api} OLD=http://api:3000", context)).toBe(
71
86
  "API=http://127.0.0.1:3100 OLD=http://127.0.0.1:3100"
72
87
  );
@@ -76,6 +91,7 @@ describe("runner-template", () => {
76
91
  });
77
92
 
78
93
  it("builds worker runtime configs and execution env", () => {
94
+ const workerStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-worker-"));
79
95
  const api = makeRuntimeConfig("api", {
80
96
  cwd: ".",
81
97
  start: "npm run api",
@@ -98,26 +114,29 @@ describe("runner-template", () => {
98
114
  readyUrl: "http://127.0.0.1:{port}",
99
115
  env: {
100
116
  NEXT_PUBLIC_API_URL: "{baseUrl:api}",
117
+ ONIX_DB_HOST: "{dbHost:api}",
101
118
  },
102
119
  });
103
120
 
104
- const resolved = resolveWorkerRuntimeConfigs(frontend, [api, frontend], 2, "/tmp/w2");
121
+ const resolved = resolveWorkerRuntimeConfigs(frontend, [api, frontend], 2, workerStateDir);
105
122
  expect(resolved[0].testkit.local.port).toBe(3100);
106
- expect(resolved[1].testkit.local.env.NEXT_PUBLIC_API_URL).toBe("http://127.0.0.1:3100");
107
- expect(resolveServiceStateDir("/tmp/w2", "frontend", api)).toBe("/tmp/w2/deps/api");
108
- expect(getWorkerServiceStateDir("/tmp/w2", "frontend", "frontend")).toBe("/tmp/w2");
123
+ expect(resolved[1].testkit.local.env.NEXT_PUBLIC_API_URL).toBe("{baseUrl:api}");
124
+ expect(resolveServiceStateDir(workerStateDir, "frontend", api)).toBe(
125
+ `${workerStateDir}/deps/api`
126
+ );
127
+ expect(getWorkerServiceStateDir(workerStateDir, "frontend", "frontend")).toBe(workerStateDir);
128
+
129
+ fs.mkdirSync(path.join(workerStateDir, "deps", "api"), { recursive: true });
130
+ fs.writeFileSync(
131
+ path.join(workerStateDir, "deps", "api", "database_url"),
132
+ "postgres://testkit:testkit@127.0.0.1:55432/onix_db"
133
+ );
109
134
 
110
135
  expect(
111
136
  buildExecutionEnv(
137
+ resolved[1],
112
138
  {
113
- workerId: 2,
114
- testkit: {
115
- serviceEnv: {
116
- API_KEY: "secret",
117
- },
118
- },
119
- },
120
- {
139
+ ...resolved[1].testkit.local.env,
121
140
  DATABASE_URL: "gone",
122
141
  },
123
142
  {
@@ -126,7 +145,8 @@ describe("runner-template", () => {
126
145
  )
127
146
  ).toEqual({
128
147
  PATH: "/usr/bin",
129
- API_KEY: "secret",
148
+ NEXT_PUBLIC_API_URL: "http://127.0.0.1:3100",
149
+ ONIX_DB_HOST: "127.0.0.1",
130
150
  TESTKIT_ACTIVE: "1",
131
151
  TESTKIT_WORKER_ID: "2",
132
152
  });
@@ -23,6 +23,12 @@ export interface RuntimeOptions {
23
23
  thresholds?: Record<string, unknown>;
24
24
  }
25
25
 
26
+ export interface RuntimeArtifactOptions {
27
+ contentType?: string;
28
+ kind?: string;
29
+ summary?: string;
30
+ }
31
+
26
32
  export interface RuntimeEnv {
27
33
  BASE: string;
28
34
  MACHINE_ID?: string;
@@ -133,6 +139,11 @@ export declare const http: RuntimeHttpClient;
133
139
 
134
140
  export declare function file(data: unknown, filename?: string, contentType?: string): unknown;
135
141
  export declare function json<T = unknown>(response: Pick<RuntimeResponse, "body">): T;
142
+ export declare function emitArtifact(
143
+ name: string,
144
+ data: unknown,
145
+ options?: RuntimeArtifactOptions
146
+ ): void;
136
147
  export declare function contains<T extends Record<string, unknown>>(
137
148
  rows: T[],
138
149
  field: keyof T | string,
@@ -10,6 +10,9 @@ export function file(data, filename, contentType) {
10
10
  return rawHttp.file(data, filename, contentType);
11
11
  }
12
12
 
13
+ export {
14
+ emitArtifact,
15
+ } from "../runtime-src/k6/artifacts.js";
13
16
  export {
14
17
  allMatch,
15
18
  contains,
@@ -0,0 +1,36 @@
1
+ export const RUNTIME_ARTIFACT_MARKER = "TESTKIT_ARTIFACT:";
2
+
3
+ export function emitArtifact(name, data, options = {}) {
4
+ const normalizedName = normalizeArtifactName(name);
5
+ const payload = encodeURIComponent(
6
+ JSON.stringify({
7
+ name: normalizedName,
8
+ kind: normalizeOptionalString(options.kind),
9
+ summary: normalizeOptionalString(options.summary),
10
+ contentType: normalizeContentType(options.contentType),
11
+ data,
12
+ emittedAt: new Date().toISOString(),
13
+ })
14
+ );
15
+ console.log(
16
+ `${RUNTIME_ARTIFACT_MARKER}${payload}`
17
+ );
18
+ }
19
+
20
+ function normalizeArtifactName(name) {
21
+ if (typeof name !== "string" || name.trim().length === 0) {
22
+ throw new Error("emitArtifact(name, data) requires a non-empty artifact name");
23
+ }
24
+ return name.trim();
25
+ }
26
+
27
+ function normalizeOptionalString(value) {
28
+ if (value === undefined || value === null) return null;
29
+ const normalized = String(value).trim();
30
+ return normalized.length > 0 ? normalized : null;
31
+ }
32
+
33
+ function normalizeContentType(value) {
34
+ const normalized = normalizeOptionalString(value);
35
+ return normalized || "application/json";
36
+ }
@@ -40,9 +40,10 @@ export interface ServiceConfig {
40
40
  discovery?: {
41
41
  roots?: string[];
42
42
  };
43
+ env?: Record<string, string>;
43
44
  envFile?: string;
44
45
  envFiles?: string[];
45
- local?: {
46
+ local?: false | {
46
47
  baseUrl: string;
47
48
  cwd?: string;
48
49
  env?: Record<string, string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "CLI for discovering and running local HTTP, DAL, and Playwright test suites",
5
5
  "type": "module",
6
6
  "types": "./lib/index.d.ts",