@usejourney/k6-adapter 0.1.4 → 0.2.0

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/dist/index.d.ts CHANGED
@@ -11,11 +11,35 @@ interface ExportK6Options {
11
11
  * adapter does not pull a runtime dep on core. JSON-serialized verbatim.
12
12
  */
13
13
  k6Options?: Record<string, unknown>;
14
+ /**
15
+ * Opt in to custom k6 metrics. Every step records a Trend
16
+ * `<prefix>_<step_slug>_duration` (`res.timings.duration`, ms) and a Counter
17
+ * `<prefix>_<step_slug>_tps`; every completed iteration records
18
+ * `<prefix>_flow_duration` (sum of its step durations) and `<prefix>_flow_tps`.
19
+ * A step's `metric` option overrides its slug (`false` skips it).
20
+ */
21
+ metricsPrefix?: string;
22
+ /**
23
+ * Stop the iteration's remaining steps when a step's `assert` fails,
24
+ * mirroring `journey run`. Default: the failure is recorded as a failed
25
+ * k6 check and the iteration carries on.
26
+ */
27
+ haltOnFailure?: boolean;
28
+ /**
29
+ * Project root that relative `fileFrom()` paths resolve against. Defaults to
30
+ * the nearest ancestor of the journey file holding `journey.config.json`,
31
+ * else `process.cwd()`.
32
+ */
33
+ projectDir?: string;
14
34
  }
15
35
  interface ExportK6Result {
16
36
  outFile: string;
17
37
  source: string;
18
38
  }
39
+ /** Nearest ancestor of `from` (inclusive) containing `journey.config.json`. */
40
+ declare function findProjectDir(from: string): string | undefined;
41
+ /** String-literal `fileFrom("…")` paths in a module's source. */
42
+ declare function scanFileFromLiterals(source: string): string[];
19
43
  declare function exportToK6(opts: ExportK6Options): Promise<ExportK6Result>;
20
44
 
21
- export { type ExportK6Options, type ExportK6Result, exportToK6 };
45
+ export { type ExportK6Options, type ExportK6Result, exportToK6, findProjectDir, scanFileFromLiterals };
package/dist/index.js CHANGED
@@ -1,84 +1,22 @@
1
1
  // src/index.ts
2
- import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
3
- import { basename, dirname as dirname2, resolve as resolve2 } from "path";
4
- import { transform } from "esbuild";
5
-
6
- // src/inline.ts
7
- import { readFile } from "fs/promises";
8
- import { dirname, isAbsolute, resolve } from "path";
9
- var IMPORT_RE = /^\s*import\s+[^;]*?from\s+["']([^"']+)["'];?\s*$/gm;
10
- function stripCoreImports(source) {
11
- return source.replace(IMPORT_RE, (match, specifier) => {
12
- if (specifier === "@usejourney/core") return "";
13
- return match;
14
- });
15
- }
16
- function stripTypeImports(source) {
17
- return source.replace(/^\s*import\s+type\s+[^;]*?from\s+["'][^"']+["'];?\s*$/gm, "");
18
- }
19
- function findRelativeImports(source, fromFile) {
20
- const out = [];
21
- IMPORT_RE.lastIndex = 0;
22
- let m;
23
- while (m = IMPORT_RE.exec(source)) {
24
- const spec = m[1];
25
- if (!spec.startsWith(".")) continue;
26
- const base = dirname(fromFile);
27
- const abs = isAbsolute(spec) ? spec : resolve(base, spec);
28
- const withExt = abs.endsWith(".ts") || abs.endsWith(".js") ? abs : null;
29
- const candidates = withExt ? [withExt] : [`${abs}.ts`, `${abs}.js`, abs.replace(/\.js$/, ".ts")];
30
- out.push({ specifier: spec, sourcePath: candidates[0] });
31
- }
32
- return out;
33
- }
34
- async function inlineRelativeImports(journeySource, journeyPath, visited = /* @__PURE__ */ new Set()) {
35
- const imports = findRelativeImports(journeySource, journeyPath);
36
- let out = journeySource;
37
- for (const imp of imports) {
38
- const tryPaths = imp.sourcePath.endsWith(".ts") || imp.sourcePath.endsWith(".js") ? [imp.sourcePath, imp.sourcePath.replace(/\.js$/, ".ts")] : [`${imp.sourcePath}.ts`, `${imp.sourcePath}.js`];
39
- let resolved;
40
- for (const p of tryPaths) {
41
- try {
42
- const content = await readFile(p, "utf8");
43
- resolved = { path: p, content };
44
- break;
45
- } catch {
46
- }
47
- }
48
- const importLineRe = new RegExp(
49
- `^\\s*import\\s+[^;]*?from\\s+["']${imp.specifier.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}["'];?\\s*$`,
50
- "m"
51
- );
52
- if (!resolved) continue;
53
- if (visited.has(resolved.path)) {
54
- out = out.replace(importLineRe, "");
55
- continue;
56
- }
57
- visited.add(resolved.path);
58
- const cleaned = stripCoreImports(stripTypeImports(resolved.content));
59
- const recursed = await inlineRelativeImports(cleaned, resolved.path, visited);
60
- const converted = recursed.replace(/^\s*export\s+(const|function|class)\s+/gm, "$1 ");
61
- out = out.replace(
62
- importLineRe,
63
- `
64
- // ----- inlined from ${imp.specifier} -----
65
- ${converted}
66
- // ----- end inlined -----
67
- `
68
- );
69
- }
70
- return out;
71
- }
2
+ import { existsSync } from "fs";
3
+ import { mkdir, readFile, writeFile } from "fs/promises";
4
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "path";
5
+ import { build } from "esbuild";
72
6
 
73
7
  // src/shim.ts
74
- var SHIM_SOURCE = `import http from "k6/http";
8
+ var SHIM_MODULE_SOURCE = `import http from "k6/http";
75
9
  import { check, group } from "k6";
10
+ import { Trend, Counter } from "k6/metrics";
76
11
 
77
- const __BASE_URL = __ENV.JOURNEY_BASE_URL || "";
12
+ // k6 reads JOURNEY_BASE_URL; BASE_URL (the name environments/*.json use for
13
+ // \`journey run\`) is the fallback.
14
+ const __BASE_URL = __ENV.JOURNEY_BASE_URL || __ENV.BASE_URL || "";
78
15
  const __journeys = [];
79
16
  let __currentNodes = null;
80
17
  let __outputSlot = null;
81
18
  const __MAX_SUB_DEPTH = 8;
19
+ const __config = { metricsPrefix: null, haltOnFailure: false, projectRoot: "./", files: {} };
82
20
 
83
21
  // Sub-journey output cache \u2014 mirrors the runtime's MemorySubJourneyCache.
84
22
  // Scope is per-VU: k6 gives each VU its own JS runtime, and module-scoped state
@@ -101,10 +39,35 @@ function __cacheSet(key, value, ttlMs) {
101
39
  __subCache[key] = ttlMs !== undefined ? { value: value, expiresAt: Date.now() + ttlMs } : { value: value };
102
40
  }
103
41
 
42
+ // ---- maybe-async helpers -------------------------------------------------
43
+ function __isThenable(v) {
44
+ return v !== null && (typeof v === "object" || typeof v === "function") && typeof v.then === "function";
45
+ }
46
+ function __then(v, fn) {
47
+ return __isThenable(v) ? v.then(fn) : fn(v);
48
+ }
49
+ function __lazy(v) {
50
+ return typeof v === "function" ? v() : v;
51
+ }
52
+ function __tryFinally(body, fin) {
53
+ let r;
54
+ try { r = body(); } catch (e) { fin(); throw e; }
55
+ if (__isThenable(r)) return r.then((v) => { fin(); return v; }, (e) => { fin(); throw e; });
56
+ fin();
57
+ return r;
58
+ }
59
+ // Run fn; report { ok, error } synchronously when possible, else as a promise.
60
+ function __attempt(fn) {
61
+ let r;
62
+ try { r = fn(); } catch (e) { return { ok: false, error: e }; }
63
+ if (__isThenable(r)) return r.then(() => ({ ok: true }), (e) => ({ ok: false, error: e }));
64
+ return { ok: true };
65
+ }
66
+
104
67
  // Minimal no-op stand-in for zod. Reusable journeys declare \`inputs\` / \`outputs\`
105
68
  // schemas via \`z\`, but k6 has no zod runtime and the schemas are not enforced
106
69
  // in exported scripts \u2014 every access or call returns the same chainable proxy.
107
- const z = new Proxy(function () {}, {
70
+ export const z = new Proxy(function () {}, {
108
71
  get() { return z; },
109
72
  apply() { return z; },
110
73
  construct() { return z; },
@@ -112,7 +75,7 @@ const z = new Proxy(function () {}, {
112
75
 
113
76
  // Sub-journey cacheKey / cacheTtlMs / cache opts ARE honored \u2014 see __runSub
114
77
  // and the per-VU note on __subCache above.
115
- function journey(name, optsOrBody, maybeBody) {
78
+ export function journey(name, optsOrBody, maybeBody) {
116
79
  const body = typeof optsOrBody === "function" ? optsOrBody : maybeBody;
117
80
  const opts = typeof optsOrBody === "function" ? null : optsOrBody;
118
81
  if (opts && opts.reusable === true) {
@@ -121,30 +84,49 @@ function journey(name, optsOrBody, maybeBody) {
121
84
  __journeys.push({ name, body });
122
85
  }
123
86
 
124
- function step(name, opts) {
87
+ export function step(name, opts) {
125
88
  if (!__currentNodes) {
126
89
  throw new Error("step(" + JSON.stringify(name) + ") called outside a journey(...) body");
127
90
  }
128
91
  __currentNodes.push({ kind: "step", name, opts });
129
92
  }
130
93
 
131
- function invokeJourney(handle, opts) {
94
+ export function invokeJourney(handle, opts) {
132
95
  if (!__currentNodes) {
133
96
  throw new Error("invokeJourney(...) called outside a journey(...) body");
134
97
  }
135
98
  __currentNodes.push({ kind: "sub", handle, opts: opts || {} });
136
99
  }
137
100
 
138
- function output(value) {
101
+ export function output(value) {
139
102
  if (__outputSlot) __outputSlot.value = value;
140
103
  }
141
104
 
142
- function env(key) {
105
+ export function env(key) {
143
106
  const v = __ENV[key];
144
107
  if (v === undefined) throw new Error("env: missing " + key);
145
108
  return v;
146
109
  }
147
110
 
111
+ export function tryEnv(key) {
112
+ return __ENV[key];
113
+ }
114
+
115
+ export function isEndpointRef(e) {
116
+ return e !== null && typeof e === "object" && typeof e.operationId === "string";
117
+ }
118
+
119
+ export class AssertionError extends Error {
120
+ constructor(message) {
121
+ super(message);
122
+ this.name = "AssertionError";
123
+ }
124
+ }
125
+
126
+ function __fmt(v) {
127
+ try { return JSON.stringify(v); } catch (e) { return String(v); }
128
+ }
129
+
148
130
  function deepEqual(a, b) {
149
131
  if (Object.is(a, b)) return true;
150
132
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
@@ -155,31 +137,211 @@ function deepEqual(a, b) {
155
137
  return true;
156
138
  }
157
139
 
158
- function expect(value) {
159
- const fail = (msg) => { const e = new Error(msg); e.name = "AssertionError"; throw e; };
140
+ // Mirrors @usejourney/core's expect() \u2014 same matchers, same failure messages.
141
+ export function expect(value) {
142
+ const fail = (msg) => { throw new AssertionError(msg); };
143
+ const num = (m) => { if (typeof value !== "number") fail(m + " is only supported on numbers (got " + typeof value + ")"); };
160
144
  return {
161
- toBe(expected) { if (!Object.is(value, expected)) fail("expected " + JSON.stringify(value) + " to be " + JSON.stringify(expected)); },
162
- toEqual(expected) { if (!deepEqual(value, expected)) fail("expected " + JSON.stringify(value) + " to equal " + JSON.stringify(expected)); },
145
+ toBe(expected) { if (!Object.is(value, expected)) fail("expected " + __fmt(value) + " to be " + __fmt(expected)); },
146
+ toEqual(expected) { if (!deepEqual(value, expected)) fail("expected " + __fmt(value) + " to equal " + __fmt(expected)); },
163
147
  toBeDefined() { if (value === undefined) fail("expected value to be defined"); },
164
148
  toContain(expected) {
165
- if (typeof value === "string") { if (!value.includes(expected)) fail("expected string to contain " + JSON.stringify(expected)); return; }
166
- if (Array.isArray(value)) { if (!value.some((v) => deepEqual(v, expected))) fail("expected array to contain " + JSON.stringify(expected)); return; }
167
- fail("toContain only supports strings and arrays");
149
+ if (typeof value === "string") {
150
+ if (typeof expected !== "string" || !value.includes(expected)) fail("expected " + __fmt(value) + " to contain " + __fmt(expected));
151
+ return;
152
+ }
153
+ if (Array.isArray(value)) { if (!value.some((v) => deepEqual(v, expected))) fail("expected array to contain " + __fmt(expected)); return; }
154
+ fail("toContain is only supported on strings and arrays");
168
155
  },
169
156
  toMatch(expected) {
170
- if (typeof value !== "string") fail("toMatch only supports strings");
157
+ if (typeof value !== "string") fail("toMatch is only supported on strings");
171
158
  const re = typeof expected === "string" ? new RegExp(expected) : expected;
172
- if (!re.test(value)) fail("expected " + JSON.stringify(value) + " to match " + re);
159
+ if (!re.test(value)) fail("expected " + __fmt(value) + " to match " + re);
160
+ },
161
+ toBeGreaterThan(n) { num("toBeGreaterThan"); if (!(value > n)) fail("expected " + __fmt(value) + " to be greater than " + n); },
162
+ toBeGreaterThanOrEqual(n) { num("toBeGreaterThanOrEqual"); if (!(value >= n)) fail("expected " + __fmt(value) + " to be greater than or equal to " + n); },
163
+ toBeLessThan(n) { num("toBeLessThan"); if (!(value < n)) fail("expected " + __fmt(value) + " to be less than " + n); },
164
+ toBeLessThanOrEqual(n) { num("toBeLessThanOrEqual"); if (!(value <= n)) fail("expected " + __fmt(value) + " to be less than or equal to " + n); },
165
+ toHaveLength(n) {
166
+ const len = value === null || value === undefined ? undefined : value.length;
167
+ if (typeof len !== "number") fail("toHaveLength is only supported on strings, arrays, and objects with a numeric .length");
168
+ if (len !== n) fail("expected " + __fmt(value) + " to have length " + n + ", got " + len);
173
169
  },
174
170
  };
175
171
  }
176
172
 
177
- function __parseBody(res) {
178
- const ct = (res.headers && (res.headers["Content-Type"] || res.headers["content-type"])) || "";
173
+ // ---- files / multipart ---------------------------------------------------
174
+ // fileFrom() paths are project-root relative. The export pre-loads every
175
+ // string-literal fileFrom("\u2026") path with open(\u2026, "b") in the init context and
176
+ // hands the bytes over through __configure({ files }).
177
+ function __isFileRef(v) { return v !== null && typeof v === "object" && v.__journeyFile === true; }
178
+ function __isMultipart(v) { return v !== null && typeof v === "object" && v.__journeyMultipart === true; }
179
+ function __basename(p) { const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\\\")); return i === -1 ? p : p.slice(i + 1); }
180
+ function __isAbsPath(p) { return p.charAt(0) === "/" || /^[A-Za-z]:[\\\\/]/.test(p); }
181
+
182
+ function __loadFile(path) {
183
+ if (Object.prototype.hasOwnProperty.call(__config.files, path)) return __config.files[path];
184
+ let bytes;
185
+ try {
186
+ bytes = open(__isAbsPath(path) ? path : __config.projectRoot + path, "b");
187
+ } catch (e) {
188
+ throw new Error(
189
+ "fileFrom(" + JSON.stringify(path) + "): the file was not pre-loaded at k6 init time and can't be opened now (" +
190
+ (e && e.message ? e.message : e) + "). Pass a string literal to fileFrom() so \`journey export k6\` can pre-load it, " +
191
+ "or call fileFrom() at module top level.",
192
+ );
193
+ }
194
+ __config.files[path] = bytes;
195
+ return bytes;
196
+ }
197
+
198
+ export function fileFrom(path, contentType, filename) {
199
+ if (typeof path !== "string" || path.length === 0) throw new TypeError("fileFrom(path): path must be a non-empty string");
200
+ // At init time (module top level) this opens the file eagerly; in a VU it
201
+ // only succeeds for paths the export pre-loaded.
202
+ __loadFile(path);
203
+ const ref = { __journeyFile: true, path: path };
204
+ if (contentType !== undefined) ref.contentType = contentType;
205
+ if (filename !== undefined) ref.filename = filename;
206
+ return ref;
207
+ }
208
+
209
+ export function multipart(parts) {
210
+ if (parts === null || typeof parts !== "object") throw new TypeError("multipart(parts): parts must be an object");
211
+ const copy = {};
212
+ for (const k of Object.keys(parts)) copy[k] = parts[k];
213
+ return { __journeyMultipart: true, parts: copy };
214
+ }
215
+
216
+ export function isFileRef(v) { return __isFileRef(v); }
217
+ export function isMultipartBody(v) { return __isMultipart(v); }
218
+
219
+ function __headerKey(h, name) {
220
+ const lower = name.toLowerCase();
221
+ for (const k of Object.keys(h)) if (k.toLowerCase() === lower) return k;
222
+ return undefined;
223
+ }
224
+ function __defaultHeader(h, name, value) {
225
+ if (__headerKey(h, name) === undefined) h[name] = value;
226
+ }
227
+ function __toArrayBuffer(v) {
228
+ if (v instanceof ArrayBuffer) return v;
229
+ return v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength);
230
+ }
231
+
232
+ // Encode a resolved step body for http.request, defaulting Content-Type the
233
+ // way @usejourney/core's buildRequest does. Mutates \`headers\`.
234
+ function __encodeBody(body, headers) {
235
+ if (body === undefined) return null;
236
+ if (typeof body === "string") { __defaultHeader(headers, "Content-Type", "application/json"); return body; }
237
+ if (__isMultipart(body)) {
238
+ // k6 sets multipart/form-data with its own boundary; a caller-supplied
239
+ // Content-Type can't carry it, so drop it (core does the same).
240
+ const ctKey = __headerKey(headers, "Content-Type");
241
+ if (ctKey !== undefined) delete headers[ctKey];
242
+ const names = Object.keys(body.parts);
243
+ const out = {};
244
+ let hasFile = false;
245
+ for (const k of names) {
246
+ const v = body.parts[k];
247
+ if (__isFileRef(v)) {
248
+ hasFile = true;
249
+ out[k] = http.file(__loadFile(v.path), v.filename || __basename(v.path), v.contentType || "application/octet-stream");
250
+ } else {
251
+ out[k] = String(v);
252
+ }
253
+ }
254
+ if (hasFile) return out; // object body with a file \u2192 k6 sends multipart/form-data
255
+ // Text-only: k6 would url-encode an object body, so build multipart by hand.
256
+ const boundary = "----JourneyBoundary" + Math.random().toString(36).slice(2);
257
+ let s = "";
258
+ for (const k of names) {
259
+ s += "--" + boundary + "\\r\\nContent-Disposition: form-data; name=\\"" + k + "\\"\\r\\n\\r\\n" + out[k] + "\\r\\n";
260
+ }
261
+ headers["Content-Type"] = "multipart/form-data; boundary=" + boundary;
262
+ return s + "--" + boundary + "--\\r\\n";
263
+ }
264
+ if (__isFileRef(body)) {
265
+ __defaultHeader(headers, "Content-Type", body.contentType || "application/octet-stream");
266
+ return __loadFile(body.path);
267
+ }
268
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
269
+ __defaultHeader(headers, "Content-Type", "application/octet-stream");
270
+ return __toArrayBuffer(body);
271
+ }
272
+ __defaultHeader(headers, "Content-Type", "application/json");
273
+ return JSON.stringify(body);
274
+ }
275
+
276
+ // ---- fetch ---------------------------------------------------------------
277
+ // Minimal WHATWG-fetch stand-in for helpers that \`import { fetch } from
278
+ // "@usejourney/core"\`. Backed by the synchronous http.request; resolves to a
279
+ // Response-like object. Rejects (like fetch) only on network errors.
280
+ function __headersLike(raw) {
281
+ const lower = {};
282
+ if (raw) for (const k of Object.keys(raw)) lower[k.toLowerCase()] = String(raw[k]);
283
+ return {
284
+ get(name) { const v = lower[String(name).toLowerCase()]; return v === undefined ? null : v; },
285
+ has(name) { return Object.prototype.hasOwnProperty.call(lower, String(name).toLowerCase()); },
286
+ forEach(cb) { for (const k of Object.keys(lower)) cb(lower[k], k); },
287
+ keys() { return Object.keys(lower)[Symbol.iterator](); },
288
+ entries() { return Object.keys(lower).map((k) => [k, lower[k]])[Symbol.iterator](); },
289
+ };
290
+ }
291
+ function __plainHeaders(h) {
292
+ const out = {};
293
+ if (!h) return out;
294
+ if (typeof h.forEach === "function" && !Array.isArray(h)) { h.forEach((v, k) => { out[k] = v; }); return out; }
295
+ if (Array.isArray(h)) { for (const pair of h) out[pair[0]] = pair[1]; return out; }
296
+ for (const k of Object.keys(h)) out[k] = h[k];
297
+ return out;
298
+ }
299
+
300
+ export function fetch(input, init) {
301
+ try {
302
+ const opts = init || {};
303
+ const url = typeof input === "string" ? input : input && input.url ? input.url : String(input);
304
+ const method = String(opts.method || (input && input.method) || "GET").toUpperCase();
305
+ const headers = __plainHeaders(opts.headers);
306
+ let body = opts.body === undefined || opts.body === null ? null : opts.body;
307
+ if (body !== null && typeof body !== "string") {
308
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) body = __toArrayBuffer(body);
309
+ else throw new TypeError("fetch (k6 export): only string and binary bodies are supported");
310
+ }
311
+ const res = http.request(method, url, body, { headers: headers });
312
+ if (res.status === 0 && res.error) return Promise.reject(new TypeError("fetch failed: " + res.error));
313
+ const text = res.body === null || res.body === undefined ? "" : typeof res.body === "string" ? res.body : String(res.body);
314
+ const response = {
315
+ status: res.status,
316
+ ok: res.status >= 200 && res.status < 300,
317
+ statusText: res.status_text || "",
318
+ url: res.url || url,
319
+ redirected: false,
320
+ headers: __headersLike(res.headers),
321
+ text() { return Promise.resolve(text); },
322
+ json() { try { return Promise.resolve(JSON.parse(text)); } catch (e) { return Promise.reject(e); } },
323
+ clone() { return response; },
324
+ };
325
+ return Promise.resolve(response);
326
+ } catch (e) {
327
+ return Promise.reject(e);
328
+ }
329
+ }
330
+
331
+ // ---- request execution ---------------------------------------------------
332
+ function __parseBody(res, headers) {
333
+ const ct = headers["content-type"] || "";
179
334
  if (ct.indexOf("json") !== -1) { try { return res.json(); } catch (e) { return null; } }
180
335
  return res.body;
181
336
  }
182
337
 
338
+ // Response header keys are lowercased, matching fetch() in the core runtime.
339
+ function __lowerHeaders(h) {
340
+ const out = {};
341
+ if (h) for (const k of Object.keys(h)) out[k.toLowerCase()] = h[k];
342
+ return out;
343
+ }
344
+
183
345
  function __interpolate(path, params) {
184
346
  if (!params) return path;
185
347
  let out = path;
@@ -212,37 +374,113 @@ function __collectNodes(body, input) {
212
374
  return nodes;
213
375
  }
214
376
 
377
+ // ---- custom metrics (opt-in via --metrics-prefix) ------------------------
378
+ const __metrics = {};
379
+ const __warnedMetrics = {};
380
+ let __flowMetrics = null;
381
+ let __flowDuration = 0;
382
+
383
+ function __slug(s) {
384
+ return String(s).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
385
+ }
386
+ // Metric base name (<prefix>_<slug>) for a step, or null when it opted out.
387
+ function __metricName(stepName, opts) {
388
+ const m = opts ? opts.metric : undefined;
389
+ if (m === false) return null;
390
+ const slug = typeof m === "string" && m !== "" ? m.replace(/[^A-Za-z0-9_]+/g, "_") : __slug(stepName);
391
+ return __config.metricsPrefix + "_" + (slug || "step");
392
+ }
393
+ function __declareMetric(name) {
394
+ if (__metrics[name]) return;
395
+ __metrics[name] = { duration: new Trend(name + "_duration"), tps: new Counter(name + "_tps") };
396
+ }
397
+ // k6 only allows metric declaration in the init context, but steps are only
398
+ // known by evaluating journey bodies. Dry-collect every journey (and, best
399
+ // effort, every sub-journey) at init to declare a Trend + Counter per step.
400
+ function __declareNodes(nodes, depth) {
401
+ for (let i = 0; i < nodes.length; i++) {
402
+ const n = nodes[i];
403
+ if (n.kind === "step") {
404
+ const name = __metricName(n.name, n.opts);
405
+ if (name) __declareMetric(name);
406
+ continue;
407
+ }
408
+ if (depth >= __MAX_SUB_DEPTH) continue;
409
+ let input;
410
+ try { input = __lazy(n.opts.inputs); } catch (e) { input = undefined; }
411
+ if (__isThenable(input)) input = undefined;
412
+ try {
413
+ __declareNodes(__collectNodes(n.handle.body, input), depth + 1);
414
+ } catch (e) {
415
+ console.warn("[journey] metrics: could not pre-declare metrics for sub-journey " + JSON.stringify(n.handle.name) + " (" + (e && e.message ? e.message : e) + ")");
416
+ }
417
+ }
418
+ }
419
+ export function __declareMetrics() {
420
+ if (!__config.metricsPrefix) return;
421
+ __flowMetrics = {
422
+ duration: new Trend(__config.metricsPrefix + "_flow_duration"),
423
+ tps: new Counter(__config.metricsPrefix + "_flow_tps"),
424
+ };
425
+ for (let i = 0; i < __journeys.length; i++) {
426
+ const j = __journeys[i];
427
+ try {
428
+ __declareNodes(__collectNodes(j.body, undefined), 0);
429
+ } catch (e) {
430
+ console.warn("[journey] metrics: could not pre-declare metrics for journey " + JSON.stringify(j.name) + " (" + (e && e.message ? e.message : e) + ")");
431
+ }
432
+ }
433
+ }
434
+ function __recordStep(stepName, opts, res) {
435
+ if (!__config.metricsPrefix) return;
436
+ const d = res.timings ? res.timings.duration : 0;
437
+ __flowDuration += d;
438
+ const name = __metricName(stepName, opts);
439
+ if (!name) return;
440
+ const m = __metrics[name];
441
+ if (!m) {
442
+ if (!__warnedMetrics[name]) {
443
+ __warnedMetrics[name] = true;
444
+ console.warn("[journey] metrics: " + name + "_* was not declared at init (step only discovered at run time); not recorded");
445
+ }
446
+ return;
447
+ }
448
+ m.duration.add(d);
449
+ m.tps.add(1);
450
+ }
451
+
452
+ // Thrown to stop the iteration when an assert fails under --halt-on-failure.
453
+ const __HALT = { __journeyHalt: true };
454
+
215
455
  function __executeStep(current, journeyName) {
216
456
  const opts = current.opts;
217
- const headers = typeof opts.headers === "function" ? opts.headers() : (opts.headers || {});
218
- const body = typeof opts.body === "function" ? opts.body() : opts.body;
219
- // params and query are lazy in the core runtime \u2014 resolve a function form
220
- // before interpolating the path / building the query string, like headers/body.
221
- const params = typeof opts.params === "function" ? opts.params() : opts.params;
222
- const query = typeof opts.query === "function" ? opts.query() : opts.query;
457
+ // Same resolution order as the core runtime: headers, query, body, params.
458
+ return __then(__lazy(opts.headers), (headers) =>
459
+ __then(__lazy(opts.query), (query) =>
460
+ __then(__lazy(opts.body), (body) =>
461
+ __then(__lazy(opts.params), (params) => __send(current, journeyName, headers, query, body, params)))));
462
+ }
463
+
464
+ function __send(current, journeyName, headers, query, body, params) {
465
+ const opts = current.opts;
223
466
  const path = __interpolate(opts.endpoint.path, params);
224
467
  const base = opts.endpoint.baseUrl || __BASE_URL;
225
468
  const url = base + path + __buildQuery(query);
226
469
  const requestHeaders = __mergeHeaders(headers);
227
- if (body !== undefined && !("Content-Type" in requestHeaders) && !("content-type" in requestHeaders)) {
228
- requestHeaders["Content-Type"] = "application/json";
229
- }
230
- const res = http.request(
231
- opts.endpoint.method,
232
- url,
233
- body !== undefined ? (typeof body === "string" ? body : JSON.stringify(body)) : null,
234
- { headers: requestHeaders },
235
- );
236
- const wrapped = { status: res.status, headers: res.headers, body: __parseBody(res) };
237
- if (opts.assert) {
238
- const name = journeyName + " \u203A " + current.name;
239
- check(wrapped, {
240
- [name]: (r) => {
241
- try { opts.assert(r); return true; } catch (e) { console.error(name + ": " + (e && e.message ? e.message : e)); return false; }
242
- },
243
- });
244
- }
245
- if (opts.after) opts.after(wrapped);
470
+ const payload = __encodeBody(body, requestHeaders);
471
+ const res = http.request(opts.endpoint.method, url, payload, { headers: requestHeaders });
472
+ __recordStep(current.name, opts, res);
473
+ const resHeaders = __lowerHeaders(res.headers);
474
+ const wrapped = { status: res.status, headers: resHeaders, body: __parseBody(res, resHeaders) };
475
+ const afterHook = () => (opts.after ? opts.after(wrapped) : undefined);
476
+ if (!opts.assert) return afterHook();
477
+ const name = journeyName + " \u203A " + current.name;
478
+ return __then(__attempt(() => opts.assert(wrapped)), (r) => {
479
+ if (!r.ok) console.error(name + ": " + (r.error && r.error.message ? r.error.message : r.error));
480
+ check(wrapped, { [name]: () => r.ok });
481
+ if (!r.ok && __config.haltOnFailure) throw __HALT;
482
+ return afterHook();
483
+ });
246
484
  }
247
485
 
248
486
  // Inline a sub-journey node under a k6 group() named after the child journey.
@@ -255,89 +493,288 @@ function __runSub(node, depth) {
255
493
  const handle = node.handle;
256
494
  const opts = node.opts || {};
257
495
  const label = opts.name || handle.name;
258
- let input = opts.inputs;
259
- if (typeof input === "function") input = input();
260
-
261
- // Cache active only when a cacheKey is supplied and not disabled \u2014 mirrors
262
- // the core runtime. Composite key is childName:resolvedKey.
263
- let resolvedKey;
264
- if (opts.cacheKey !== undefined) {
265
- resolvedKey = typeof opts.cacheKey === "function" ? opts.cacheKey(input) : opts.cacheKey;
266
- }
267
- const cacheKeyStr =
268
- !__cacheOff && resolvedKey !== undefined && opts.cache !== "off"
269
- ? handle.name + ":" + resolvedKey
270
- : undefined;
271
-
272
- const prevSlot = __outputSlot;
273
- const slot = { value: undefined };
274
- __outputSlot = slot;
275
- try {
276
- let hit = false;
496
+ return __then(__lazy(opts.inputs), (input) => {
497
+ // Cache active only when a cacheKey is supplied and not disabled \u2014 mirrors
498
+ // the core runtime. Composite key is childName:resolvedKey.
499
+ let resolvedKey;
500
+ if (opts.cacheKey !== undefined) {
501
+ resolvedKey = typeof opts.cacheKey === "function" ? opts.cacheKey(input) : opts.cacheKey;
502
+ }
503
+ const cacheKeyStr =
504
+ !__cacheOff && resolvedKey !== undefined && opts.cache !== "off"
505
+ ? handle.name + ":" + resolvedKey
506
+ : undefined;
507
+
508
+ const slot = { value: undefined };
509
+ const finish = () => {
510
+ if (opts.assert) {
511
+ return __then(opts.assert(slot.value), () => (opts.after ? opts.after(slot.value) : undefined));
512
+ }
513
+ return opts.after ? opts.after(slot.value) : undefined;
514
+ };
277
515
  if (cacheKeyStr !== undefined) {
278
516
  const entry = __cacheGet(cacheKeyStr);
279
- if (entry !== undefined) { slot.value = entry.value; hit = true; }
517
+ if (entry !== undefined) { slot.value = entry.value; return finish(); }
280
518
  }
281
- if (!hit) {
519
+ const prevSlot = __outputSlot;
520
+ __outputSlot = slot;
521
+ const ran = __tryFinally(() => {
282
522
  const childNodes = __collectNodes(handle.body, input);
283
- group(label, () => { __runNodes(childNodes, handle.name, depth + 1); });
523
+ // group() rejects async functions; a sync callback that returns a
524
+ // promise is accepted (requests after its first await lose the tag).
525
+ return group(label, () => __runNodes(childNodes, handle.name, depth + 1));
526
+ }, () => { __outputSlot = prevSlot; });
527
+ return __then(ran, () => {
284
528
  if (cacheKeyStr !== undefined) __cacheSet(cacheKeyStr, slot.value, opts.cacheTtlMs);
285
- }
286
- } finally {
287
- __outputSlot = prevSlot;
288
- }
289
- if (opts.assert) opts.assert(slot.value);
290
- if (opts.after) opts.after(slot.value);
529
+ return finish();
530
+ });
531
+ });
291
532
  }
292
533
 
293
534
  function __runNodes(nodes, journeyName, depth) {
294
- for (let i = 0; i < nodes.length; i++) {
295
- const node = nodes[i];
296
- if (node.kind === "sub") __runSub(node, depth);
297
- else __executeStep(node, journeyName);
298
- }
535
+ let i = 0;
536
+ const next = () => {
537
+ while (i < nodes.length) {
538
+ const node = nodes[i++];
539
+ const r = node.kind === "sub" ? __runSub(node, depth) : __executeStep(node, journeyName);
540
+ if (__isThenable(r)) return r.then(next);
541
+ }
542
+ return undefined;
543
+ };
544
+ return next();
299
545
  }
300
- `;
301
- var ENTRY_SOURCE = `
302
- export default function () {
303
- for (let i = 0; i < __journeys.length; i++) {
304
- const j = __journeys[i];
305
- const nodes = __collectNodes(j.body, undefined);
306
- __runNodes(nodes, j.name, 0);
307
- }
546
+
547
+ /** Called by the generated entry before any journey module evaluates. */
548
+ export function __configure(cfg) {
549
+ if (cfg.metricsPrefix) __config.metricsPrefix = cfg.metricsPrefix;
550
+ if (cfg.haltOnFailure) __config.haltOnFailure = true;
551
+ if (cfg.projectRoot) __config.projectRoot = cfg.projectRoot;
552
+ if (cfg.files) for (const k of Object.keys(cfg.files)) __config.files[k] = cfg.files[k];
553
+ }
554
+
555
+ /** One k6 iteration: run every registered entry journey in order. */
556
+ export function __iterate() {
557
+ __flowDuration = 0;
558
+ let j = 0;
559
+ const next = () => {
560
+ while (j < __journeys.length) {
561
+ const def = __journeys[j++];
562
+ const r = __runNodes(__collectNodes(def.body, undefined), def.name, 0);
563
+ if (__isThenable(r)) return r.then(next);
564
+ }
565
+ return undefined;
566
+ };
567
+ // Flow metrics are recorded only for iterations that ran every step.
568
+ const done = () => {
569
+ if (__flowMetrics) {
570
+ __flowMetrics.duration.add(__flowDuration);
571
+ __flowMetrics.tps.add(1);
572
+ }
573
+ };
574
+ const halted = (e) => {
575
+ if (e === __HALT) return undefined;
576
+ throw e;
577
+ };
578
+ let r;
579
+ try { r = next(); } catch (e) { return halted(e); }
580
+ if (__isThenable(r)) return r.then(done, halted);
581
+ done();
582
+ return undefined;
308
583
  }
309
584
  `;
310
585
 
311
586
  // src/index.ts
312
587
  var HEADER = `// AUTO-GENERATED BY @usejourney/k6-adapter \u2014 do not edit by hand.
313
- // Usage: JOURNEY_BASE_URL=https://api.example.com k6 run <this-file>
314
- `;
315
- async function exportToK6(opts) {
316
- const journeyPath = resolve2(process.cwd(), opts.journeyFile);
317
- const rawSource = await readFile2(journeyPath, "utf8");
318
- const withoutTypeImports = stripTypeImports(rawSource);
319
- const inlined = await inlineRelativeImports(withoutTypeImports, journeyPath);
320
- const withoutCore = stripCoreImports(inlined);
321
- const { code: userCode } = await transform(withoutCore, {
322
- loader: "ts",
323
- format: "esm"
588
+ // Usage: k6 run -e JOURNEY_BASE_URL=https://api.example.com <this-file>
589
+ // (BASE_URL is accepted as a fallback for JOURNEY_BASE_URL)`;
590
+ var METRIC_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
591
+ var FILES_PLACEHOLDER = "__JOURNEY_PRELOADED_FILES__";
592
+ function findProjectDir(from) {
593
+ let dir = from;
594
+ for (; ; ) {
595
+ if (existsSync(resolve(dir, "journey.config.json"))) return dir;
596
+ const parent = dirname(dir);
597
+ if (parent === dir) return void 0;
598
+ dir = parent;
599
+ }
600
+ }
601
+ function toPosix(p) {
602
+ return sep === "/" ? p : p.split(sep).join("/");
603
+ }
604
+ function relFromScript(scriptDir, abs) {
605
+ const rel = toPosix(relative(scriptDir, abs));
606
+ if (rel === "") return ".";
607
+ if (isAbsolute(rel)) return rel;
608
+ return rel.startsWith("../") || rel === ".." ? rel : `./${rel}`;
609
+ }
610
+ var FILE_FROM_RE = /\bfileFrom\s*\(\s*(?:"([^"\\\n]*)"|'([^'\\\n]*)'|`([^`\\$]*)`)/g;
611
+ function scanFileFromLiterals(source) {
612
+ const out = [];
613
+ FILE_FROM_RE.lastIndex = 0;
614
+ let m;
615
+ while (m = FILE_FROM_RE.exec(source)) out.push(m[1] ?? m[2] ?? m[3]);
616
+ return out;
617
+ }
618
+ function loaderFor(path) {
619
+ if (/\.[cm]?tsx$/.test(path)) return "tsx";
620
+ if (/\.[cm]?ts$/.test(path)) return "ts";
621
+ if (/\.jsx$/.test(path)) return "jsx";
622
+ return "js";
623
+ }
624
+ function journeyPlugin(fileRefs, initSource) {
625
+ return {
626
+ name: "journey-k6",
627
+ setup(b) {
628
+ b.onResolve({ filter: /^@usejourney\/core$/ }, () => ({
629
+ path: "shim",
630
+ namespace: "journey"
631
+ }));
632
+ b.onLoad({ filter: /^shim$/, namespace: "journey" }, () => ({
633
+ contents: SHIM_MODULE_SOURCE,
634
+ loader: "js"
635
+ }));
636
+ b.onResolve({ filter: /^journey:init$/ }, () => ({ path: "init", namespace: "journey" }));
637
+ b.onLoad({ filter: /^init$/, namespace: "journey" }, () => ({
638
+ contents: initSource,
639
+ loader: "js"
640
+ }));
641
+ b.onResolve({ filter: /^k6(\/.*)?$/ }, (args) => ({ path: args.path, external: true }));
642
+ b.onResolve({ filter: /^[^./]/ }, (args) => {
643
+ if (isAbsolute(args.path)) return void 0;
644
+ return {
645
+ errors: [
646
+ {
647
+ text: `unsupported import "${args.path}": k6 exports can only import "@usejourney/core", k6 modules ("k6", "k6/\u2026") and relative project files. Inline the helper you need or replace the package with plain code.`
648
+ }
649
+ ]
650
+ };
651
+ });
652
+ b.onLoad({ filter: /\.[cm]?[jt]sx?$/, namespace: "file" }, async (args) => {
653
+ const contents = await readFile(args.path, "utf8");
654
+ for (const p of scanFileFromLiterals(contents)) {
655
+ if (!fileRefs.has(p)) fileRefs.set(p, args.path);
656
+ }
657
+ return { contents, loader: loaderFor(args.path) };
658
+ });
659
+ }
660
+ };
661
+ }
662
+ function assertNoEntryConflicts(source, journeyPath, hasK6Options) {
663
+ if (/^\s*export\s+default\b/m.test(source) || /\bas\s+default\s*[,}]/.test(source)) {
664
+ throw new Error(
665
+ `${journeyPath}: a journey file cannot \`export default\` \u2014 the k6 export generates the default function. Move it to a helper module or drop the export.`
666
+ );
667
+ }
668
+ const exportsOptions = /^\s*export\s+(?:const|let|var|function)\s+options\b/m.test(source) || /^\s*export\s*\{[^}]*\boptions\b[^}]*\}/m.test(source);
669
+ if (hasK6Options && exportsOptions) {
670
+ throw new Error(
671
+ `${journeyPath}: declares both a journey \`k6\` block and \`export const options\`. k6 options are module-scoped \u2014 keep only one of them.`
672
+ );
673
+ }
674
+ }
675
+ function formatBuildError(err) {
676
+ const errors = err.errors;
677
+ if (!Array.isArray(errors) || errors.length === 0) return err;
678
+ const lines = errors.map((e) => {
679
+ const loc = e.location;
680
+ if (!loc) return e.text;
681
+ const file = isAbsolute(loc.file) ? loc.file : resolve(process.cwd(), loc.file);
682
+ return `${file}:${loc.line}:${loc.column + 1}: ${e.text}
683
+ ${loc.lineText.trim()}`;
324
684
  });
685
+ return new Error(`k6 export failed:
686
+ ${lines.join("\n")}`);
687
+ }
688
+ async function exportToK6(opts) {
689
+ const journeyPath = resolve(process.cwd(), opts.journeyFile);
690
+ const rawSource = await readFile(journeyPath, "utf8");
691
+ assertNoEntryConflicts(rawSource, journeyPath, opts.k6Options !== void 0);
692
+ if (opts.metricsPrefix !== void 0 && !METRIC_PREFIX_RE.test(opts.metricsPrefix)) {
693
+ throw new Error(
694
+ `invalid metrics prefix ${JSON.stringify(opts.metricsPrefix)}: use letters, digits and "_" (not starting with a digit)`
695
+ );
696
+ }
325
697
  const fileBase = basename(journeyPath).replace(/\.journey\.ts$/, "");
326
- const outFile = opts.outFile ? resolve2(process.cwd(), opts.outFile) : opts.outDir ? resolve2(process.cwd(), opts.outDir, `${fileBase}.k6.js`) : resolve2(dirname2(journeyPath), `${fileBase}.k6.js`);
698
+ const outFile = opts.outFile ? resolve(process.cwd(), opts.outFile) : opts.outDir ? resolve(process.cwd(), opts.outDir, `${fileBase}.k6.js`) : resolve(dirname(journeyPath), `${fileBase}.k6.js`);
699
+ const scriptDir = dirname(outFile);
700
+ const projectDir = opts.projectDir ? resolve(process.cwd(), opts.projectDir) : findProjectDir(dirname(journeyPath)) ?? process.cwd();
701
+ const rootRel = relFromScript(scriptDir, projectDir);
702
+ const config = {
703
+ ...opts.metricsPrefix ? { metricsPrefix: opts.metricsPrefix } : {},
704
+ ...opts.haltOnFailure ? { haltOnFailure: true } : {},
705
+ projectRoot: rootRel.endsWith("/") ? rootRel : `${rootRel}/`
706
+ };
707
+ const initSource = `import { __configure } from "@usejourney/core";
708
+ __configure(Object.assign(${JSON.stringify(config)}, { files: ${FILES_PLACEHOLDER} }));
709
+ `;
327
710
  const optionsBlock = opts.k6Options ? `export const options = ${JSON.stringify(opts.k6Options, null, 2)};
328
711
  ` : "";
329
- const source = [
330
- HEADER,
331
- SHIM_SOURCE,
332
- optionsBlock,
333
- "// ===== user journey =====",
334
- userCode.trimStart(),
335
- ENTRY_SOURCE
336
- ].join("\n");
337
- await mkdir(dirname2(outFile), { recursive: true });
712
+ const entrySource = `import "journey:init";
713
+ import { __declareMetrics, __iterate } from "@usejourney/core";
714
+ export * from ${JSON.stringify(journeyPath)};
715
+ ${optionsBlock}
716
+ // Custom metrics must be declared in the k6 init context.
717
+ __declareMetrics();
718
+
719
+ export default function () {
720
+ return __iterate();
721
+ }
722
+ `;
723
+ const fileRefs = /* @__PURE__ */ new Map();
724
+ let source;
725
+ try {
726
+ const result = await build({
727
+ stdin: {
728
+ contents: entrySource,
729
+ resolveDir: dirname(journeyPath),
730
+ sourcefile: "journey-k6-entry.js",
731
+ loader: "js"
732
+ },
733
+ bundle: true,
734
+ write: false,
735
+ format: "esm",
736
+ platform: "neutral",
737
+ target: "es2020",
738
+ charset: "utf8",
739
+ logLevel: "silent",
740
+ // Module markers in the output (`// journeys/x.journey.ts`) are
741
+ // project-relative, so emitted scripts don't leak local paths.
742
+ absWorkingDir: projectDir,
743
+ banner: { js: HEADER },
744
+ // Journey projects carry no tsconfig; don't let an ancestor's
745
+ // (e.g. `verbatimModuleSyntax`) change which imports survive.
746
+ tsconfigRaw: {},
747
+ plugins: [journeyPlugin(fileRefs, initSource)]
748
+ });
749
+ source = result.outputFiles[0].text;
750
+ } catch (err) {
751
+ throw formatBuildError(err);
752
+ }
753
+ const fileEntries = [];
754
+ for (const [p, referrer] of fileRefs) {
755
+ const abs = isAbsolute(p) ? p : resolve(projectDir, p);
756
+ if (!existsSync(abs)) {
757
+ throw new Error(
758
+ `${referrer}: fileFrom(${JSON.stringify(p)}) \u2192 ${abs} does not exist (fileFrom paths are relative to the project root, ${projectDir})`
759
+ );
760
+ }
761
+ fileEntries.push(
762
+ ` ${JSON.stringify(p)}: open(${JSON.stringify(relFromScript(scriptDir, abs))}, "b")`
763
+ );
764
+ }
765
+ const fileMap = fileEntries.length ? `{
766
+ ${fileEntries.join(",\n")}
767
+ }` : "{}";
768
+ if (!source.includes(FILES_PLACEHOLDER)) {
769
+ throw new Error("k6-adapter: internal error \u2014 init block missing from the bundle");
770
+ }
771
+ source = source.replace(FILES_PLACEHOLDER, fileMap);
772
+ await mkdir(dirname(outFile), { recursive: true });
338
773
  await writeFile(outFile, source, "utf8");
339
774
  return { outFile, source };
340
775
  }
341
776
  export {
342
- exportToK6
777
+ exportToK6,
778
+ findProjectDir,
779
+ scanFileFromLiterals
343
780
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usejourney/k6-adapter",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  "esbuild": "^0.21.5"
29
29
  },
30
30
  "devDependencies": {
31
- "@usejourney/core": "0.1.4"
31
+ "@usejourney/core": "0.2.0"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsup src/index.ts --format esm --dts --clean",