@usejourney/k6-adapter 0.1.5 → 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 +25 -1
- package/dist/index.js +614 -177
- package/package.json +2 -2
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 {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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 " +
|
|
162
|
-
toEqual(expected) { if (!deepEqual(value, expected)) fail("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") {
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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 " +
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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;
|
|
517
|
+
if (entry !== undefined) { slot.value = entry.value; return finish(); }
|
|
280
518
|
}
|
|
281
|
-
|
|
519
|
+
const prevSlot = __outputSlot;
|
|
520
|
+
__outputSlot = slot;
|
|
521
|
+
const ran = __tryFinally(() => {
|
|
282
522
|
const childNodes = __collectNodes(handle.body, input);
|
|
283
|
-
group(
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
302
|
-
export
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
314
|
-
`;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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 ?
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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.
|
|
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.
|
|
31
|
+
"@usejourney/core": "0.2.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsup src/index.ts --format esm --dts --clean",
|