@elench/testkit 0.1.36 → 0.1.37

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.
@@ -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 {
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
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",