@qualflare/cucumberjs 0.1.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/LICENSE +190 -0
- package/README.md +104 -0
- package/dist/formatter/index.cjs +1441 -0
- package/dist/formatter/index.cjs.map +1 -0
- package/dist/formatter/index.d.cts +21 -0
- package/dist/formatter/index.d.ts +21 -0
- package/dist/formatter/index.js +1410 -0
- package/dist/formatter/index.js.map +1 -0
- package/dist/index.cjs +116 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +229 -0
- package/dist/index.d.ts +229 -0
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -0
|
@@ -0,0 +1,1410 @@
|
|
|
1
|
+
// src/formatter/formatter.ts
|
|
2
|
+
import { Formatter } from "@cucumber/cucumber";
|
|
3
|
+
|
|
4
|
+
// src/config/ci-detect.ts
|
|
5
|
+
import * as ciInfo from "ci-info";
|
|
6
|
+
function parsePositiveInt(raw) {
|
|
7
|
+
if (!raw) {
|
|
8
|
+
return void 0;
|
|
9
|
+
}
|
|
10
|
+
const n = Number.parseInt(raw, 10);
|
|
11
|
+
return Number.isFinite(n) && n >= 1 ? n : void 0;
|
|
12
|
+
}
|
|
13
|
+
function nonEmpty(raw) {
|
|
14
|
+
return raw && raw.length > 0 ? raw : void 0;
|
|
15
|
+
}
|
|
16
|
+
var PROVIDERS = [
|
|
17
|
+
{
|
|
18
|
+
detect: (env) => env.GITHUB_ACTIONS === "true",
|
|
19
|
+
providerName: "GitHub Actions",
|
|
20
|
+
buildNumber: (env) => nonEmpty(env.GITHUB_RUN_NUMBER),
|
|
21
|
+
runUrl: (env) => env.GITHUB_SERVER_URL && env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID ? `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}` : void 0,
|
|
22
|
+
prNumber: (env) => {
|
|
23
|
+
const match = /^refs\/pull\/(\d+)\/merge$/.exec(env.GITHUB_REF ?? "");
|
|
24
|
+
return match ? parsePositiveInt(match[1]) : void 0;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
detect: (env) => env.GITLAB_CI === "true",
|
|
29
|
+
providerName: "GitLab CI",
|
|
30
|
+
buildNumber: (env) => nonEmpty(env.CI_PIPELINE_IID),
|
|
31
|
+
runUrl: (env) => nonEmpty(env.CI_PIPELINE_URL),
|
|
32
|
+
prNumber: (env) => parsePositiveInt(env.CI_MERGE_REQUEST_IID)
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
detect: (env) => env.CIRCLECI === "true",
|
|
36
|
+
providerName: "CircleCI",
|
|
37
|
+
buildNumber: (env) => nonEmpty(env.CIRCLE_BUILD_NUM),
|
|
38
|
+
runUrl: (env) => nonEmpty(env.CIRCLE_BUILD_URL),
|
|
39
|
+
prNumber: (env) => parsePositiveInt(env.CIRCLE_PR_NUMBER)
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
detect: (env) => env.BUILDKITE === "true",
|
|
43
|
+
providerName: "Buildkite",
|
|
44
|
+
buildNumber: (env) => nonEmpty(env.BUILDKITE_BUILD_NUMBER),
|
|
45
|
+
runUrl: (env) => nonEmpty(env.BUILDKITE_BUILD_URL),
|
|
46
|
+
prNumber: (env) => {
|
|
47
|
+
const raw = env.BUILDKITE_PULL_REQUEST;
|
|
48
|
+
if (!raw || raw === "false") {
|
|
49
|
+
return void 0;
|
|
50
|
+
}
|
|
51
|
+
return parsePositiveInt(raw);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
// Jenkins has no simple `JENKINS=true`-style flag; JENKINS_URL is always
|
|
56
|
+
// set by the Jenkins agent and is the conventional detection signal.
|
|
57
|
+
detect: (env) => Boolean(env.JENKINS_URL),
|
|
58
|
+
providerName: "Jenkins",
|
|
59
|
+
buildNumber: (env) => nonEmpty(env.BUILD_NUMBER),
|
|
60
|
+
runUrl: (env) => nonEmpty(env.BUILD_URL)
|
|
61
|
+
// Jenkins has no standardized PR-number env var across its many PR
|
|
62
|
+
// plugins (Multibranch, GitHub Branch Source, etc.) — deliberately
|
|
63
|
+
// omitted rather than guessing at a plugin-specific variable.
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
detect: (env) => env.TF_BUILD === "True" || env.TF_BUILD === "true",
|
|
67
|
+
providerName: "Azure Pipelines",
|
|
68
|
+
buildNumber: (env) => nonEmpty(env.BUILD_BUILDID),
|
|
69
|
+
runUrl: (env) => {
|
|
70
|
+
const collectionUri = env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI;
|
|
71
|
+
const project = env.SYSTEM_TEAMPROJECT;
|
|
72
|
+
const buildId = env.BUILD_BUILDID;
|
|
73
|
+
if (!collectionUri || !project || !buildId) {
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
return `${collectionUri.replace(/\/+$/, "")}/${encodeURIComponent(project)}/_build/results?buildId=${buildId}`;
|
|
77
|
+
},
|
|
78
|
+
prNumber: (env) => parsePositiveInt(env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER)
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
detect: (env) => Boolean(env.BITBUCKET_BUILD_NUMBER),
|
|
82
|
+
providerName: "Bitbucket Pipelines",
|
|
83
|
+
buildNumber: (env) => nonEmpty(env.BITBUCKET_BUILD_NUMBER),
|
|
84
|
+
runUrl: (env) => {
|
|
85
|
+
const origin = env.BITBUCKET_GIT_HTTP_ORIGIN;
|
|
86
|
+
if (!origin) {
|
|
87
|
+
return void 0;
|
|
88
|
+
}
|
|
89
|
+
const resultsId = env.BITBUCKET_PIPELINE_UUID ?? env.BITBUCKET_BUILD_NUMBER;
|
|
90
|
+
return resultsId ? `${origin}/addon/pipelines/home#!/results/${resultsId}` : void 0;
|
|
91
|
+
},
|
|
92
|
+
prNumber: (env) => parsePositiveInt(env.BITBUCKET_PR_ID)
|
|
93
|
+
}
|
|
94
|
+
];
|
|
95
|
+
function detectCi(env = process.env) {
|
|
96
|
+
const provider = PROVIDERS.find((p) => p.detect(env));
|
|
97
|
+
if (provider) {
|
|
98
|
+
const result = { ciProvider: provider.providerName };
|
|
99
|
+
const buildNumber = provider.buildNumber?.(env);
|
|
100
|
+
if (buildNumber !== void 0) result.ciBuildNumber = buildNumber;
|
|
101
|
+
const runUrl = provider.runUrl?.(env);
|
|
102
|
+
if (runUrl !== void 0) result.ciRunUrl = runUrl;
|
|
103
|
+
const prNumber = provider.prNumber?.(env);
|
|
104
|
+
if (prNumber !== void 0) result.ciPrNumber = prNumber;
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
if (ciInfo.name) {
|
|
108
|
+
return { ciProvider: ciInfo.name };
|
|
109
|
+
}
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/config/git-detect.ts
|
|
114
|
+
import { execFileSync } from "child_process";
|
|
115
|
+
var defaultExecGit = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
116
|
+
function firstEnv(env, ...names) {
|
|
117
|
+
for (const name2 of names) {
|
|
118
|
+
const value = env[name2];
|
|
119
|
+
if (value) {
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return void 0;
|
|
124
|
+
}
|
|
125
|
+
function detectBranchFromGit(exec, cwd) {
|
|
126
|
+
try {
|
|
127
|
+
const out = exec(["symbolic-ref", "--short", "-q", "HEAD"], cwd).trim();
|
|
128
|
+
return out || void 0;
|
|
129
|
+
} catch {
|
|
130
|
+
return void 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function detectCommitFromGit(exec, cwd) {
|
|
134
|
+
try {
|
|
135
|
+
const out = exec(["rev-parse", "HEAD"], cwd).trim();
|
|
136
|
+
return out || void 0;
|
|
137
|
+
} catch {
|
|
138
|
+
return void 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function detectGit(env = process.env, cwd = process.cwd(), exec = defaultExecGit) {
|
|
142
|
+
const branch = firstEnv(env, "GIT_BRANCH", "GITHUB_REF_NAME", "CI_COMMIT_REF_NAME", "BITBUCKET_BRANCH") ?? detectBranchFromGit(exec, cwd);
|
|
143
|
+
const commit = firstEnv(env, "GIT_COMMIT", "GITHUB_SHA", "CI_COMMIT_SHA", "BITBUCKET_COMMIT") ?? detectCommitFromGit(exec, cwd);
|
|
144
|
+
const result = {};
|
|
145
|
+
if (branch !== void 0) result.branch = branch;
|
|
146
|
+
if (commit !== void 0) result.commit = commit;
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/config/resolve-config.ts
|
|
151
|
+
function firstEnv2(...names) {
|
|
152
|
+
for (const name2 of names) {
|
|
153
|
+
const value = process.env[name2];
|
|
154
|
+
if (value !== void 0 && value !== "") {
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return void 0;
|
|
159
|
+
}
|
|
160
|
+
function envBool(...names) {
|
|
161
|
+
const raw = firstEnv2(...names);
|
|
162
|
+
if (raw === void 0) {
|
|
163
|
+
return void 0;
|
|
164
|
+
}
|
|
165
|
+
return raw === "true" || raw === "1";
|
|
166
|
+
}
|
|
167
|
+
function envInt(...names) {
|
|
168
|
+
const raw = firstEnv2(...names);
|
|
169
|
+
if (raw === void 0) {
|
|
170
|
+
return void 0;
|
|
171
|
+
}
|
|
172
|
+
const parsed = Number.parseInt(raw, 10);
|
|
173
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
174
|
+
}
|
|
175
|
+
var QualflareConfigError = class extends Error {
|
|
176
|
+
constructor(message) {
|
|
177
|
+
super(message);
|
|
178
|
+
this.name = "QualflareConfigError";
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
function resolveConfig(options, deps = {}) {
|
|
182
|
+
const doDetectGit = deps.detectGit ?? detectGit;
|
|
183
|
+
const doDetectCi = deps.detectCi ?? detectCi;
|
|
184
|
+
const enabled = options.enabled ?? envBool("QUALFLARE_ENABLED") ?? true;
|
|
185
|
+
const token = options.token ?? firstEnv2("QUALFLARE_TOKEN", "QF_TOKEN") ?? "";
|
|
186
|
+
if (enabled && token === "") {
|
|
187
|
+
throw new QualflareConfigError(
|
|
188
|
+
"qualflare-cucumberjs: no token configured. Set the `token` format option or the QUALFLARE_TOKEN (or QF_TOKEN) environment variable, or pass `enabled: false` to disable this formatter."
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const milestoneRaw = options.milestone !== void 0 ? options.milestone : envInt("QUALFLARE_MILESTONE", "QF_MILESTONE");
|
|
192
|
+
const milestone = milestoneRaw !== void 0 && milestoneRaw !== null && milestoneRaw >= 1 ? milestoneRaw : null;
|
|
193
|
+
const envBranch = firstEnv2("QUALFLARE_BRANCH", "QF_BRANCH");
|
|
194
|
+
const envCommit = firstEnv2("QUALFLARE_COMMIT", "QF_COMMIT");
|
|
195
|
+
const needsGitDetection = options.branch === void 0 && envBranch === void 0 || options.commit === void 0 && envCommit === void 0;
|
|
196
|
+
const detectedGit = needsGitDetection ? doDetectGit() : {};
|
|
197
|
+
const branch = options.branch !== void 0 ? options.branch : envBranch ?? detectedGit.branch ?? null;
|
|
198
|
+
const commit = options.commit !== void 0 ? options.commit : envCommit ?? detectedGit.commit ?? null;
|
|
199
|
+
const detectedCi = doDetectCi();
|
|
200
|
+
const ciProvider = options.ciProvider ?? detectedCi.ciProvider;
|
|
201
|
+
const ciBuildNumber = options.ciBuildNumber ?? detectedCi.ciBuildNumber;
|
|
202
|
+
const ciRunUrl = options.ciRunUrl ?? detectedCi.ciRunUrl;
|
|
203
|
+
const ciPrNumber = options.ciPrNumber ?? detectedCi.ciPrNumber;
|
|
204
|
+
return {
|
|
205
|
+
token,
|
|
206
|
+
apiEndpoint: options.apiEndpoint ?? firstEnv2("QUALFLARE_API_ENDPOINT") ?? "https://api.qualflare.com",
|
|
207
|
+
// `||` (truthy check), not `??`, for these three REQUIRED-non-empty wire
|
|
208
|
+
// fields — an explicit `''` option must not silently win over the
|
|
209
|
+
// default (the server rejects an empty `environment`, and since
|
|
210
|
+
// `failOnUploadError` defaults `false`, that would fail the entire
|
|
211
|
+
// upload with no visible error by default). Ported verbatim from
|
|
212
|
+
// qualflare-cypress, where this was found via deep adversarial review.
|
|
213
|
+
environment: (options.environment || void 0) ?? firstEnv2("QUALFLARE_ENVIRONMENT", "QF_ENVIRONMENT") ?? "development",
|
|
214
|
+
language: (options.language || void 0) ?? firstEnv2("QUALFLARE_LANGUAGE", "QF_LANGUAGE") ?? "en-US",
|
|
215
|
+
milestone,
|
|
216
|
+
branch,
|
|
217
|
+
commit,
|
|
218
|
+
platform: options.platform ?? "web",
|
|
219
|
+
framework: options.framework || "cucumber",
|
|
220
|
+
os: options.os,
|
|
221
|
+
browser: options.browser,
|
|
222
|
+
properties: options.properties,
|
|
223
|
+
ciProvider,
|
|
224
|
+
ciBuildNumber,
|
|
225
|
+
ciRunUrl,
|
|
226
|
+
ciPrNumber,
|
|
227
|
+
timeoutMs: options.timeoutMs ?? envInt("QUALFLARE_TIMEOUT_MS") ?? 12e4,
|
|
228
|
+
retry: {
|
|
229
|
+
max: options.retry?.max ?? envInt("QUALFLARE_RETRY_MAX", "QF_RETRY_MAX") ?? 3,
|
|
230
|
+
baseDelayMs: options.retry?.baseDelayMs ?? envInt("QUALFLARE_RETRY_BASE_DELAY_MS") ?? 1e3,
|
|
231
|
+
maxDelayMs: options.retry?.maxDelayMs ?? envInt("QUALFLARE_RETRY_MAX_DELAY_MS") ?? 3e4
|
|
232
|
+
},
|
|
233
|
+
failOnUploadError: options.failOnUploadError ?? envBool("QUALFLARE_FAIL_ON_UPLOAD_ERROR") ?? false,
|
|
234
|
+
attachScreenshots: options.attachScreenshots ?? envBool("QUALFLARE_ATTACH_SCREENSHOTS") ?? true,
|
|
235
|
+
includeStepHooks: options.includeStepHooks ?? envBool("QUALFLARE_INCLUDE_STEP_HOOKS") ?? false,
|
|
236
|
+
maxAttachmentBytes: options.maxAttachmentBytes ?? envInt("QUALFLARE_MAX_ATTACHMENT_BYTES") ?? 15e5,
|
|
237
|
+
maxTotalAttachmentBytes: options.maxTotalAttachmentBytes ?? envInt("QUALFLARE_MAX_TOTAL_ATTACHMENT_BYTES") ?? 75e4,
|
|
238
|
+
debug: options.debug ?? envBool("QUALFLARE_DEBUG", "QF_DEBUG") ?? false,
|
|
239
|
+
enabled
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/http/client.ts
|
|
244
|
+
import { request } from "undici";
|
|
245
|
+
|
|
246
|
+
// src/shared/constants.ts
|
|
247
|
+
var RESERVED_MESSAGE_MEDIA_TYPE = "application/vnd.qualflare.message+json";
|
|
248
|
+
var HEADER_TOKEN = "QF_TOKEN";
|
|
249
|
+
var HEADER_IDEMPOTENCY_KEY = "Idempotency-Key";
|
|
250
|
+
var HEADER_CONTENT_TYPE = "Content-Type";
|
|
251
|
+
var HEADER_ACCEPT = "Accept";
|
|
252
|
+
var HEADER_USER_AGENT = "User-Agent";
|
|
253
|
+
var MAX_SUITES_PER_LAUNCH = 2e3;
|
|
254
|
+
var MAX_CASES_PER_SUITE = 5e3;
|
|
255
|
+
var MAX_TAGS_PER_CASE = 64;
|
|
256
|
+
var MAX_IDEMPOTENCY_KEY_CHARS = 255;
|
|
257
|
+
var MAX_STEPS_PER_TEST_ATTEMPT = 300;
|
|
258
|
+
|
|
259
|
+
// src/shared/logger.ts
|
|
260
|
+
var PREFIX = "[qualflare-cucumberjs]";
|
|
261
|
+
var logger = {
|
|
262
|
+
debug(...args) {
|
|
263
|
+
console.debug(PREFIX, ...args);
|
|
264
|
+
},
|
|
265
|
+
info(...args) {
|
|
266
|
+
console.log(PREFIX, ...args);
|
|
267
|
+
},
|
|
268
|
+
warn(...args) {
|
|
269
|
+
console.warn(PREFIX, ...args);
|
|
270
|
+
},
|
|
271
|
+
error(...args) {
|
|
272
|
+
console.error(PREFIX, ...args);
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// src/http/backoff.ts
|
|
277
|
+
function computeDelay(attempt, baseDelayMs, maxDelayMs, retryAfterMs) {
|
|
278
|
+
const exponential = baseDelayMs * 2 ** Math.max(0, attempt - 1);
|
|
279
|
+
const jittered = Math.random() * Math.min(exponential, maxDelayMs);
|
|
280
|
+
const floor = retryAfterMs !== void 0 ? retryAfterMs : 0;
|
|
281
|
+
return Math.min(Math.max(jittered, floor), maxDelayMs);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/http/errors.ts
|
|
285
|
+
function friendlyHint(code) {
|
|
286
|
+
switch (code) {
|
|
287
|
+
case "environment.not_found":
|
|
288
|
+
return "Environment not found. Check the `environment` option or create it in Qualflare.";
|
|
289
|
+
case "milestone.not_found":
|
|
290
|
+
return "Milestone not found. Check the `milestone` option or its sequence number in Qualflare.";
|
|
291
|
+
case "common.validation_failed":
|
|
292
|
+
return "Validation failed. Check the request data below.";
|
|
293
|
+
default:
|
|
294
|
+
return void 0;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function actionHint(statusCode) {
|
|
298
|
+
switch (statusCode) {
|
|
299
|
+
case 401:
|
|
300
|
+
return "the configured token is missing or invalid \u2014 check `token`/QUALFLARE_TOKEN";
|
|
301
|
+
case 403:
|
|
302
|
+
return "the token lacks access to this project";
|
|
303
|
+
case 402:
|
|
304
|
+
return "a plan limit was reached \u2014 check your Qualflare subscription";
|
|
305
|
+
default:
|
|
306
|
+
return void 0;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function renderFields(fields) {
|
|
310
|
+
if (!fields || fields.length === 0) {
|
|
311
|
+
return void 0;
|
|
312
|
+
}
|
|
313
|
+
return fields.map((f) => {
|
|
314
|
+
const rule = f.rule ? ` (${f.rule})` : "";
|
|
315
|
+
const msg = f.message ? `: ${f.message}` : "";
|
|
316
|
+
return `${f.field}${rule}${msg}`;
|
|
317
|
+
}).join("; ");
|
|
318
|
+
}
|
|
319
|
+
var QualflareApiError = class extends Error {
|
|
320
|
+
code;
|
|
321
|
+
statusCode;
|
|
322
|
+
requestId;
|
|
323
|
+
fields;
|
|
324
|
+
constructor(init) {
|
|
325
|
+
const parts = [init.message];
|
|
326
|
+
const fieldsRendered = renderFields(init.fields);
|
|
327
|
+
if (fieldsRendered) {
|
|
328
|
+
parts.push(`fields: ${fieldsRendered}`);
|
|
329
|
+
}
|
|
330
|
+
const hint = actionHint(init.statusCode);
|
|
331
|
+
if (hint) {
|
|
332
|
+
parts.push(`(${hint})`);
|
|
333
|
+
}
|
|
334
|
+
if (init.requestId) {
|
|
335
|
+
parts.push(`[request_id: ${init.requestId}]`);
|
|
336
|
+
}
|
|
337
|
+
super(parts.join(" \u2014 "), init.cause !== void 0 ? { cause: init.cause } : void 0);
|
|
338
|
+
this.name = "QualflareApiError";
|
|
339
|
+
this.code = init.code;
|
|
340
|
+
this.statusCode = init.statusCode;
|
|
341
|
+
this.requestId = init.requestId;
|
|
342
|
+
this.fields = init.fields;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function buildApiError(statusCode, body) {
|
|
346
|
+
const code = body?.code;
|
|
347
|
+
const message = body?.message || friendlyHint(code) || body?.error || `request failed with status ${statusCode}`;
|
|
348
|
+
return new QualflareApiError({
|
|
349
|
+
message,
|
|
350
|
+
code,
|
|
351
|
+
statusCode,
|
|
352
|
+
requestId: body?.request_id,
|
|
353
|
+
fields: body?.fields
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/http/idempotency.ts
|
|
358
|
+
import { randomUUID } from "crypto";
|
|
359
|
+
function newIdempotencyKey() {
|
|
360
|
+
const key = randomUUID();
|
|
361
|
+
if (key.length > MAX_IDEMPOTENCY_KEY_CHARS) {
|
|
362
|
+
return key.slice(0, MAX_IDEMPOTENCY_KEY_CHARS);
|
|
363
|
+
}
|
|
364
|
+
return key;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/http/client.ts
|
|
368
|
+
var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
|
|
369
|
+
function redactToken(token) {
|
|
370
|
+
return token.length > 0 ? "***REDACTED***" : "(none)";
|
|
371
|
+
}
|
|
372
|
+
var QualflareHttpClient = class {
|
|
373
|
+
constructor(opts) {
|
|
374
|
+
this.opts = opts;
|
|
375
|
+
}
|
|
376
|
+
opts;
|
|
377
|
+
async send(collect) {
|
|
378
|
+
const url = `${this.opts.endpoint.replace(/\/+$/, "")}/api/v1/collect`;
|
|
379
|
+
const idempotencyKey = newIdempotencyKey();
|
|
380
|
+
const body = JSON.stringify(collect);
|
|
381
|
+
const maxAttempts = Math.max(1, this.opts.retry.max + 1);
|
|
382
|
+
let lastError;
|
|
383
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
384
|
+
if (this.opts.debug) {
|
|
385
|
+
logger.debug(
|
|
386
|
+
`POST ${url} (attempt ${attempt}/${maxAttempts}, QF_TOKEN: ${redactToken(this.opts.token)})`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
let statusCode;
|
|
390
|
+
let responseBody;
|
|
391
|
+
let responseHeaders;
|
|
392
|
+
try {
|
|
393
|
+
const res = await request(url, {
|
|
394
|
+
method: "POST",
|
|
395
|
+
headers: {
|
|
396
|
+
[HEADER_TOKEN]: this.opts.token,
|
|
397
|
+
[HEADER_CONTENT_TYPE]: "application/json",
|
|
398
|
+
[HEADER_ACCEPT]: "application/json",
|
|
399
|
+
[HEADER_USER_AGENT]: this.opts.userAgent,
|
|
400
|
+
[HEADER_IDEMPOTENCY_KEY]: idempotencyKey
|
|
401
|
+
},
|
|
402
|
+
body,
|
|
403
|
+
maxRedirections: 0,
|
|
404
|
+
signal: AbortSignal.timeout(this.opts.timeoutMs)
|
|
405
|
+
});
|
|
406
|
+
statusCode = res.statusCode;
|
|
407
|
+
responseHeaders = res.headers;
|
|
408
|
+
responseBody = await res.body.text();
|
|
409
|
+
} catch (err) {
|
|
410
|
+
lastError = err;
|
|
411
|
+
if (this.opts.debug) {
|
|
412
|
+
logger.debug(`attempt ${attempt} transport error: ${err?.message ?? err}`);
|
|
413
|
+
}
|
|
414
|
+
if (attempt < maxAttempts) {
|
|
415
|
+
await sleep(computeDelay(attempt, this.opts.retry.baseDelayMs, this.opts.retry.maxDelayMs));
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
throw new QualflareApiError({
|
|
419
|
+
message: `failed to send request to ${url}`,
|
|
420
|
+
cause: err
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (this.opts.debug) {
|
|
424
|
+
logger.debug(`attempt ${attempt} response: ${statusCode}`);
|
|
425
|
+
}
|
|
426
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
427
|
+
return parseSuccess(responseBody);
|
|
428
|
+
}
|
|
429
|
+
const parsedError = parseErrorBody(responseBody);
|
|
430
|
+
if (!RETRYABLE_STATUS_CODES.has(statusCode) || attempt === maxAttempts) {
|
|
431
|
+
throw buildApiError(statusCode, parsedError);
|
|
432
|
+
}
|
|
433
|
+
const retryAfterMs = parseRetryAfter(responseHeaders["retry-after"]);
|
|
434
|
+
const delay = computeDelay(
|
|
435
|
+
attempt,
|
|
436
|
+
this.opts.retry.baseDelayMs,
|
|
437
|
+
this.opts.retry.maxDelayMs,
|
|
438
|
+
retryAfterMs
|
|
439
|
+
);
|
|
440
|
+
if (this.opts.debug) {
|
|
441
|
+
logger.debug(`retrying after ${Math.round(delay)}ms (status ${statusCode})`);
|
|
442
|
+
}
|
|
443
|
+
await sleep(delay);
|
|
444
|
+
}
|
|
445
|
+
throw lastError instanceof Error ? lastError : new QualflareApiError({ message: "request failed for an unknown reason" });
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
function parseSuccess(responseBody) {
|
|
449
|
+
try {
|
|
450
|
+
const parsed = JSON.parse(responseBody);
|
|
451
|
+
if (typeof parsed.seq !== "number") {
|
|
452
|
+
throw new Error('response body missing numeric "seq"');
|
|
453
|
+
}
|
|
454
|
+
return parsed;
|
|
455
|
+
} catch (err) {
|
|
456
|
+
throw new QualflareApiError({
|
|
457
|
+
message: "server returned a success status but an unparseable body",
|
|
458
|
+
cause: err
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function parseErrorBody(responseBody) {
|
|
463
|
+
if (!responseBody) {
|
|
464
|
+
return void 0;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
return JSON.parse(responseBody);
|
|
468
|
+
} catch {
|
|
469
|
+
return void 0;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function parseRetryAfter(value) {
|
|
473
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
474
|
+
if (!raw) {
|
|
475
|
+
return void 0;
|
|
476
|
+
}
|
|
477
|
+
const seconds = Number(raw);
|
|
478
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : void 0;
|
|
479
|
+
}
|
|
480
|
+
function sleep(ms) {
|
|
481
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/config/version.ts
|
|
485
|
+
var PACKAGE_VERSION = "0.1.0";
|
|
486
|
+
|
|
487
|
+
// src/formatter/attachment-budget.ts
|
|
488
|
+
import * as fs from "fs";
|
|
489
|
+
import * as path from "path";
|
|
490
|
+
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".mov", ".avi", ".mkv"]);
|
|
491
|
+
var AttachmentBudget = class {
|
|
492
|
+
constructor(maxTotalBytes) {
|
|
493
|
+
this.maxTotalBytes = maxTotalBytes;
|
|
494
|
+
}
|
|
495
|
+
maxTotalBytes;
|
|
496
|
+
used = 0;
|
|
497
|
+
/** Atomically checks-and-reserves `bytes` against the remaining budget.
|
|
498
|
+
* Returns false (reserving nothing) if it would exceed the total. */
|
|
499
|
+
tryReserve(bytes) {
|
|
500
|
+
if (this.used + bytes > this.maxTotalBytes) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
this.used += bytes;
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
get usedBytes() {
|
|
507
|
+
return this.used;
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
function isVideoLike(mimeType, filePath) {
|
|
511
|
+
if (mimeType?.toLowerCase().startsWith("video/")) {
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
if (filePath && VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
|
|
515
|
+
return true;
|
|
516
|
+
}
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
function readAttachmentFile(filePath, maxAttachmentBytes, budget) {
|
|
520
|
+
let size;
|
|
521
|
+
try {
|
|
522
|
+
size = fs.statSync(filePath).size;
|
|
523
|
+
} catch (err) {
|
|
524
|
+
return { skipped: true, reason: `could not stat file: ${err.message}` };
|
|
525
|
+
}
|
|
526
|
+
if (size > maxAttachmentBytes) {
|
|
527
|
+
return {
|
|
528
|
+
skipped: true,
|
|
529
|
+
reason: `${size} bytes exceeds the configured per-attachment cap of ${maxAttachmentBytes} bytes`
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
if (!budget.tryReserve(size)) {
|
|
533
|
+
return {
|
|
534
|
+
skipped: true,
|
|
535
|
+
reason: `would exceed this run's total attachment budget (${budget.usedBytes} bytes already used)`
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
const content = fs.readFileSync(filePath).toString("base64");
|
|
540
|
+
return { skipped: false, content };
|
|
541
|
+
} catch (err) {
|
|
542
|
+
return { skipped: true, reason: `could not read file: ${err.message}` };
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
function resolvePendingAttachment(pending, config, budget) {
|
|
546
|
+
if (!config.attachScreenshots) {
|
|
547
|
+
return void 0;
|
|
548
|
+
}
|
|
549
|
+
if (isVideoLike(pending.mimeType, pending.path)) {
|
|
550
|
+
logger.warn(
|
|
551
|
+
`refusing to attach "${pending.name}": video attachments are not supported yet (${pending.path ?? pending.mimeType ?? "unknown"}).`
|
|
552
|
+
);
|
|
553
|
+
return void 0;
|
|
554
|
+
}
|
|
555
|
+
if (pending.content !== void 0) {
|
|
556
|
+
const bytes = Buffer.byteLength(pending.content, "base64");
|
|
557
|
+
if (bytes > config.maxAttachmentBytes) {
|
|
558
|
+
logger.warn(
|
|
559
|
+
`skipping attachment "${pending.name}": ${bytes} bytes exceeds the configured per-attachment cap of ${config.maxAttachmentBytes} bytes`
|
|
560
|
+
);
|
|
561
|
+
return void 0;
|
|
562
|
+
}
|
|
563
|
+
if (!budget.tryReserve(bytes)) {
|
|
564
|
+
logger.warn(
|
|
565
|
+
`skipping attachment "${pending.name}": would exceed this run's total attachment budget (${budget.usedBytes} bytes already used)`
|
|
566
|
+
);
|
|
567
|
+
return void 0;
|
|
568
|
+
}
|
|
569
|
+
return { name: pending.name, mimeType: pending.mimeType, content: pending.content, stepIndex: pending.stepIndex };
|
|
570
|
+
}
|
|
571
|
+
if (pending.path) {
|
|
572
|
+
const result = readAttachmentFile(pending.path, config.maxAttachmentBytes, budget);
|
|
573
|
+
if (result.skipped) {
|
|
574
|
+
logger.warn(`skipping attachment "${pending.name}" (${pending.path}): ${result.reason}`);
|
|
575
|
+
return void 0;
|
|
576
|
+
}
|
|
577
|
+
return {
|
|
578
|
+
name: pending.name,
|
|
579
|
+
mimeType: pending.mimeType,
|
|
580
|
+
content: result.content,
|
|
581
|
+
path: pending.path,
|
|
582
|
+
stepIndex: pending.stepIndex
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return void 0;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// src/formatter/attempt-tracker.ts
|
|
589
|
+
import {
|
|
590
|
+
getWorstTestStepResult
|
|
591
|
+
} from "@cucumber/messages";
|
|
592
|
+
|
|
593
|
+
// src/shared/duration.ts
|
|
594
|
+
var NS_PER_MS = 1e6;
|
|
595
|
+
var NS_PER_SECOND = 1e9;
|
|
596
|
+
function msToNs(ms) {
|
|
597
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
598
|
+
return 0;
|
|
599
|
+
}
|
|
600
|
+
return Math.round(ms * NS_PER_MS);
|
|
601
|
+
}
|
|
602
|
+
function messageDurationToNs(duration) {
|
|
603
|
+
if (!duration || !Number.isFinite(duration.seconds) || !Number.isFinite(duration.nanos)) {
|
|
604
|
+
return 0;
|
|
605
|
+
}
|
|
606
|
+
const total = duration.seconds * NS_PER_SECOND + duration.nanos;
|
|
607
|
+
return total > 0 ? Math.round(total) : 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/formatter/case-builder.ts
|
|
611
|
+
function combineSteps(realSteps, manualSteps) {
|
|
612
|
+
if (realSteps.length === 0 && manualSteps.length === 0) {
|
|
613
|
+
return void 0;
|
|
614
|
+
}
|
|
615
|
+
const offsetManual = manualSteps.map((record) => {
|
|
616
|
+
const step = {
|
|
617
|
+
name: record.name,
|
|
618
|
+
status: record.status,
|
|
619
|
+
duration: msToNs(record.durationMs ?? 0)
|
|
620
|
+
};
|
|
621
|
+
if (record.error) step.error = record.error;
|
|
622
|
+
if (record.parentIndex !== void 0) step.parentIndex = record.parentIndex + realSteps.length;
|
|
623
|
+
if (record.parameters && record.parameters.length > 0) step.parameters = record.parameters;
|
|
624
|
+
return step;
|
|
625
|
+
});
|
|
626
|
+
return [...realSteps, ...offsetManual];
|
|
627
|
+
}
|
|
628
|
+
function collapseAttempts(attempts) {
|
|
629
|
+
if (attempts.length === 0) {
|
|
630
|
+
throw new Error("collapseAttempts: at least one attempt is required");
|
|
631
|
+
}
|
|
632
|
+
const final = attempts[attempts.length - 1];
|
|
633
|
+
const retryCount = attempts.length - 1;
|
|
634
|
+
const isFlaky = retryCount > 0 && final.status === "passed" && attempts.some((a) => a.status !== "passed");
|
|
635
|
+
const duration = attempts.reduce((sum, a) => sum + a.duration, 0);
|
|
636
|
+
return {
|
|
637
|
+
status: final.status,
|
|
638
|
+
duration,
|
|
639
|
+
retryCount,
|
|
640
|
+
isFlaky,
|
|
641
|
+
error: final.status === "passed" ? void 0 : final.error,
|
|
642
|
+
steps: combineSteps(final.steps, final.manualSteps),
|
|
643
|
+
labels: final.labels,
|
|
644
|
+
links: final.links,
|
|
645
|
+
tags: final.tags,
|
|
646
|
+
description: final.description,
|
|
647
|
+
priority: final.priority,
|
|
648
|
+
properties: final.properties,
|
|
649
|
+
attachments: final.attachments
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function buildCase(uri, pickle, collapsed, gherkin) {
|
|
653
|
+
const entry = gherkin.get(uri);
|
|
654
|
+
const scenarioId = pickle.astNodeIds[0];
|
|
655
|
+
const scenarioEntry = scenarioId ? entry?.scenarioById.get(scenarioId) : void 0;
|
|
656
|
+
const featureName = entry?.featureName;
|
|
657
|
+
const ruleName = scenarioEntry?.ruleName;
|
|
658
|
+
const className = ruleName ? `${featureName ?? ""} > ${ruleName}` : featureName;
|
|
659
|
+
const labels = [...collapsed.labels];
|
|
660
|
+
if (featureName) {
|
|
661
|
+
labels.push({ name: "feature", value: featureName });
|
|
662
|
+
}
|
|
663
|
+
if (ruleName) {
|
|
664
|
+
labels.push({ name: "rule", value: ruleName });
|
|
665
|
+
}
|
|
666
|
+
const properties = { ...examplesRowProperties(scenarioEntry, pickle), ...collapsed.properties };
|
|
667
|
+
const staticTags = pickle.tags.map((t) => t.name.replace(/^@/, ""));
|
|
668
|
+
const tags = [.../* @__PURE__ */ new Set([...staticTags, ...collapsed.tags])].slice(0, MAX_TAGS_PER_CASE);
|
|
669
|
+
const kase = {
|
|
670
|
+
// Stable across runs (unchanged unless the file is edited), and
|
|
671
|
+
// includes the source line specifically so two identically-named
|
|
672
|
+
// Scenarios in one feature file can never collide — the exact bug
|
|
673
|
+
// that's open upstream in allure-js (allure-framework/allure-js#1502).
|
|
674
|
+
id: `${uri}:${pickle.location?.line ?? 0}#${pickle.name}`,
|
|
675
|
+
name: pickle.name,
|
|
676
|
+
status: collapsed.status,
|
|
677
|
+
duration: collapsed.duration,
|
|
678
|
+
retryCount: collapsed.retryCount || void 0,
|
|
679
|
+
isFlaky: collapsed.isFlaky || void 0,
|
|
680
|
+
error: collapsed.error,
|
|
681
|
+
tags: tags.length > 0 ? tags : void 0,
|
|
682
|
+
steps: collapsed.steps,
|
|
683
|
+
labels: labels.length > 0 ? labels : void 0,
|
|
684
|
+
links: collapsed.links.length > 0 ? collapsed.links : void 0,
|
|
685
|
+
// `||`, not `??`: cucumber-js's `Scenario.description` is always a
|
|
686
|
+
// defined string, `''` when the Gherkin source has none — `??` would
|
|
687
|
+
// never fall through and every scenario without one would send an
|
|
688
|
+
// empty-string description instead of omitting the field (found via a
|
|
689
|
+
// real tarball-installed smoke test, not just reasoning about types).
|
|
690
|
+
description: collapsed.description || scenarioEntry?.scenario.description || void 0,
|
|
691
|
+
priority: collapsed.priority,
|
|
692
|
+
properties: Object.keys(properties).length > 0 ? properties : void 0,
|
|
693
|
+
attachments: collapsed.attachments.length > 0 ? collapsed.attachments : void 0
|
|
694
|
+
};
|
|
695
|
+
if (className) {
|
|
696
|
+
kase.className = className;
|
|
697
|
+
}
|
|
698
|
+
return { uri, case: kase };
|
|
699
|
+
}
|
|
700
|
+
function examplesRowProperties(scenarioEntry, pickle) {
|
|
701
|
+
if (!scenarioEntry) {
|
|
702
|
+
return {};
|
|
703
|
+
}
|
|
704
|
+
const rowAstIds = new Set(pickle.astNodeIds);
|
|
705
|
+
for (const examples of scenarioEntry.scenario.examples) {
|
|
706
|
+
const header = examples.tableHeader?.cells;
|
|
707
|
+
if (!header) {
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const row = examples.tableBody.find((r) => rowAstIds.has(r.id));
|
|
711
|
+
if (!row) {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const properties = {};
|
|
715
|
+
header.forEach((cell, i) => {
|
|
716
|
+
const value = row.cells[i]?.value;
|
|
717
|
+
if (cell.value && value !== void 0) {
|
|
718
|
+
properties[cell.value] = value;
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
return properties;
|
|
722
|
+
}
|
|
723
|
+
return {};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// src/formatter/step-mapper.ts
|
|
727
|
+
import { TestStepResultStatus } from "@cucumber/messages";
|
|
728
|
+
function mapStatus(status) {
|
|
729
|
+
switch (status) {
|
|
730
|
+
case TestStepResultStatus.PASSED:
|
|
731
|
+
return "passed";
|
|
732
|
+
case TestStepResultStatus.FAILED:
|
|
733
|
+
return "failed";
|
|
734
|
+
case TestStepResultStatus.SKIPPED:
|
|
735
|
+
return "skipped";
|
|
736
|
+
case TestStepResultStatus.PENDING:
|
|
737
|
+
return "pending";
|
|
738
|
+
case TestStepResultStatus.UNDEFINED:
|
|
739
|
+
case TestStepResultStatus.AMBIGUOUS:
|
|
740
|
+
case TestStepResultStatus.UNKNOWN:
|
|
741
|
+
default:
|
|
742
|
+
return "error";
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
function formatError(result) {
|
|
746
|
+
return result.message || result.exception?.stackTrace || result.exception?.message;
|
|
747
|
+
}
|
|
748
|
+
function pickleStepArgumentToParameters(argument) {
|
|
749
|
+
if (!argument) {
|
|
750
|
+
return void 0;
|
|
751
|
+
}
|
|
752
|
+
const params = [];
|
|
753
|
+
if (argument.docString) {
|
|
754
|
+
params.push({ name: "docString", value: argument.docString.content });
|
|
755
|
+
}
|
|
756
|
+
if (argument.dataTable) {
|
|
757
|
+
const rows = argument.dataTable.rows.map((row) => row.cells.map((cell) => cell.value));
|
|
758
|
+
params.push({ name: "dataTable", value: JSON.stringify(rows) });
|
|
759
|
+
}
|
|
760
|
+
return params.length > 0 ? params : void 0;
|
|
761
|
+
}
|
|
762
|
+
function mapPickleStep(uri, pickleStep, result, gherkin) {
|
|
763
|
+
const resolved = gherkin.resolveKeyword(uri, pickleStep.astNodeIds);
|
|
764
|
+
const step = {
|
|
765
|
+
name: pickleStep.text,
|
|
766
|
+
status: mapStatus(result.status),
|
|
767
|
+
duration: messageDurationToNs(result.duration)
|
|
768
|
+
};
|
|
769
|
+
if (resolved?.keyword) {
|
|
770
|
+
step.keyword = resolved.keyword.trim();
|
|
771
|
+
}
|
|
772
|
+
const error = formatError(result);
|
|
773
|
+
if (error) {
|
|
774
|
+
step.error = error;
|
|
775
|
+
}
|
|
776
|
+
const parameters = pickleStepArgumentToParameters(pickleStep.argument);
|
|
777
|
+
if (parameters) {
|
|
778
|
+
step.parameters = parameters;
|
|
779
|
+
}
|
|
780
|
+
return step;
|
|
781
|
+
}
|
|
782
|
+
var HOOK_LABELS = {
|
|
783
|
+
before: "Before",
|
|
784
|
+
after: "After",
|
|
785
|
+
beforeStep: "BeforeStep",
|
|
786
|
+
afterStep: "AfterStep",
|
|
787
|
+
beforeAll: "BeforeAll",
|
|
788
|
+
afterAll: "AfterAll"
|
|
789
|
+
};
|
|
790
|
+
function mapHookStep(hook, result) {
|
|
791
|
+
const label = HOOK_LABELS[hook.kind];
|
|
792
|
+
const step = {
|
|
793
|
+
name: hook.name || `${label} hook`,
|
|
794
|
+
keyword: label,
|
|
795
|
+
status: mapStatus(result.status),
|
|
796
|
+
duration: messageDurationToNs(result.duration)
|
|
797
|
+
};
|
|
798
|
+
const error = formatError(result);
|
|
799
|
+
if (error) {
|
|
800
|
+
step.error = error;
|
|
801
|
+
}
|
|
802
|
+
return step;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/formatter/attempt-tracker.ts
|
|
806
|
+
var AttemptTracker = class {
|
|
807
|
+
constructor(hookIndex, gherkin, config, attachmentBudget) {
|
|
808
|
+
this.hookIndex = hookIndex;
|
|
809
|
+
this.gherkin = gherkin;
|
|
810
|
+
this.config = config;
|
|
811
|
+
this.attachmentBudget = attachmentBudget;
|
|
812
|
+
}
|
|
813
|
+
hookIndex;
|
|
814
|
+
gherkin;
|
|
815
|
+
config;
|
|
816
|
+
attachmentBudget;
|
|
817
|
+
byTestCaseId = /* @__PURE__ */ new Map();
|
|
818
|
+
byTestCaseStartedId = /* @__PURE__ */ new Map();
|
|
819
|
+
begin(e, testCase, pickle) {
|
|
820
|
+
const record = {
|
|
821
|
+
testCase,
|
|
822
|
+
pickle,
|
|
823
|
+
attempt: e.attempt,
|
|
824
|
+
startedAtMs: timestampMs(e.timestamp),
|
|
825
|
+
steps: [],
|
|
826
|
+
stepResults: [],
|
|
827
|
+
stepIndexByTestStepId: /* @__PURE__ */ new Map(),
|
|
828
|
+
manualSteps: [],
|
|
829
|
+
manualStepStack: [],
|
|
830
|
+
labels: [],
|
|
831
|
+
links: [],
|
|
832
|
+
tags: [],
|
|
833
|
+
properties: {},
|
|
834
|
+
attachments: [],
|
|
835
|
+
stepCapWarned: false
|
|
836
|
+
};
|
|
837
|
+
this.byTestCaseStartedId.set(e.id, record);
|
|
838
|
+
const attempts = this.byTestCaseId.get(testCase.id) ?? [];
|
|
839
|
+
attempts.push(record);
|
|
840
|
+
this.byTestCaseId.set(testCase.id, attempts);
|
|
841
|
+
}
|
|
842
|
+
stepStarted(e) {
|
|
843
|
+
const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
|
|
844
|
+
if (!record) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
record.currentTestStepId = e.testStepId;
|
|
848
|
+
}
|
|
849
|
+
stepFinished(e) {
|
|
850
|
+
const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
|
|
851
|
+
if (!record) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
record.currentTestStepId = void 0;
|
|
855
|
+
record.stepResults.push(e.testStepResult);
|
|
856
|
+
const testStep = record.testCase.testSteps.find((s) => s.id === e.testStepId);
|
|
857
|
+
if (!testStep) {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
let step;
|
|
861
|
+
if (testStep.pickleStepId) {
|
|
862
|
+
const pickleStep = record.pickle.steps.find((s) => s.id === testStep.pickleStepId);
|
|
863
|
+
if (pickleStep) {
|
|
864
|
+
step = mapPickleStep(record.pickle.uri, pickleStep, e.testStepResult, this.gherkin);
|
|
865
|
+
}
|
|
866
|
+
} else if (testStep.hookId) {
|
|
867
|
+
const hook = this.hookIndex.get(testStep.hookId);
|
|
868
|
+
if (hook && (hook.kind === "before" || hook.kind === "after")) {
|
|
869
|
+
step = mapHookStep(hook, e.testStepResult);
|
|
870
|
+
} else if (hook && (hook.kind === "beforeStep" || hook.kind === "afterStep") && this.config.includeStepHooks) {
|
|
871
|
+
step = mapHookStep(hook, e.testStepResult);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
if (!step) {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (record.steps.length >= MAX_STEPS_PER_TEST_ATTEMPT) {
|
|
878
|
+
if (!record.stepCapWarned) {
|
|
879
|
+
record.stepCapWarned = true;
|
|
880
|
+
logger.warn(
|
|
881
|
+
`reached the ${MAX_STEPS_PER_TEST_ATTEMPT}-step-per-attempt cap \u2014 further steps in this scenario attempt will not be uploaded.`
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
record.stepIndexByTestStepId.set(e.testStepId, record.steps.length);
|
|
887
|
+
record.steps.push(step);
|
|
888
|
+
}
|
|
889
|
+
/** Handles both a real user `World.attach()` call (becomes a wire
|
|
890
|
+
* `Attachment`) and a `qualflare.*()` reserved-media-type message
|
|
891
|
+
* (unwrapped and applied as a model mutation instead). */
|
|
892
|
+
attachment(e) {
|
|
893
|
+
if (!e.testCaseStartedId) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
|
|
897
|
+
if (!record) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (e.mediaType === RESERVED_MESSAGE_MEDIA_TYPE) {
|
|
901
|
+
let message;
|
|
902
|
+
try {
|
|
903
|
+
message = JSON.parse(e.contentEncoding === "BASE64" ? Buffer.from(e.body, "base64").toString("utf8") : e.body);
|
|
904
|
+
} catch {
|
|
905
|
+
logger.warn("received a malformed qualflare runtime message \u2014 ignoring it.");
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
this.applyRuntimeMessage(record, message, e.testStepId);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const stepIndex = this.resolveStepIndex(record, e.testStepId);
|
|
912
|
+
const content = e.contentEncoding === "BASE64" ? e.body : Buffer.from(e.body, "utf8").toString("base64");
|
|
913
|
+
const resolved = resolvePendingAttachment(
|
|
914
|
+
{ name: e.fileName || "attachment", mimeType: e.mediaType, content, stepIndex },
|
|
915
|
+
this.config,
|
|
916
|
+
this.attachmentBudget
|
|
917
|
+
);
|
|
918
|
+
if (resolved) {
|
|
919
|
+
record.attachments.push(resolved);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
/** Resolves which step index an attachment (real or `qualflare.*()`
|
|
923
|
+
* message) belongs to. `stepIndexByTestStepId` only gets an entry once a
|
|
924
|
+
* step is FINISHED — but `World.attach()` (the only channel available,
|
|
925
|
+
* used by both a real user attachment and every `qualflare.*()` call) is
|
|
926
|
+
* always called from WITHIN a step's body, i.e. strictly BETWEEN that
|
|
927
|
+
* step's `testStepStarted` and `testStepFinished`. So the map lookup
|
|
928
|
+
* alone can never resolve the step that's currently attaching — this was
|
|
929
|
+
* a real bug found in self-review, caught by a unit test that attaches
|
|
930
|
+
* mid-step instead of only after it finishes. While a step is in flight,
|
|
931
|
+
* `record.steps.length` (the array's CURRENT length, before that step has
|
|
932
|
+
* been pushed) is exactly the index it will occupy once it does finish —
|
|
933
|
+
* single-threaded, event-ordered execution guarantees nothing else can be
|
|
934
|
+
* pushed in between. */
|
|
935
|
+
resolveStepIndex(record, testStepId) {
|
|
936
|
+
if (testStepId === void 0) {
|
|
937
|
+
return void 0;
|
|
938
|
+
}
|
|
939
|
+
if (record.currentTestStepId === testStepId) {
|
|
940
|
+
return record.steps.length;
|
|
941
|
+
}
|
|
942
|
+
return record.stepIndexByTestStepId.get(testStepId);
|
|
943
|
+
}
|
|
944
|
+
applyRuntimeMessage(record, message, testStepId) {
|
|
945
|
+
switch (message.type) {
|
|
946
|
+
case "label":
|
|
947
|
+
record.labels.push({ name: message.name, value: message.value });
|
|
948
|
+
return;
|
|
949
|
+
case "link":
|
|
950
|
+
record.links.push({ type: message.linkType ?? "custom", name: message.name, url: message.url });
|
|
951
|
+
return;
|
|
952
|
+
case "tag":
|
|
953
|
+
record.tags.push(...message.tags);
|
|
954
|
+
return;
|
|
955
|
+
case "description":
|
|
956
|
+
record.description = message.text;
|
|
957
|
+
return;
|
|
958
|
+
case "priority":
|
|
959
|
+
record.priority = message.value;
|
|
960
|
+
return;
|
|
961
|
+
case "parameter": {
|
|
962
|
+
const openStepIndex = record.manualStepStack[record.manualStepStack.length - 1];
|
|
963
|
+
if (openStepIndex !== void 0) {
|
|
964
|
+
const step = record.manualSteps[openStepIndex];
|
|
965
|
+
step.parameters = step.parameters ?? [];
|
|
966
|
+
step.parameters.push({ name: message.name, value: message.value, masked: message.masked });
|
|
967
|
+
} else {
|
|
968
|
+
record.properties[message.name] = message.value ?? "";
|
|
969
|
+
}
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
case "attachment": {
|
|
973
|
+
const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
|
|
974
|
+
const resolved = resolvePendingAttachment(
|
|
975
|
+
{ name: message.name, mimeType: message.mimeType, content: message.contentBase64, stepIndex },
|
|
976
|
+
this.config,
|
|
977
|
+
this.attachmentBudget
|
|
978
|
+
);
|
|
979
|
+
if (resolved) {
|
|
980
|
+
record.attachments.push(resolved);
|
|
981
|
+
}
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
case "attachment_from_file": {
|
|
985
|
+
const stepIndex = testStepId !== void 0 ? record.stepIndexByTestStepId.get(testStepId) : void 0;
|
|
986
|
+
const resolved = resolvePendingAttachment(
|
|
987
|
+
{ name: message.name, mimeType: message.mimeType, path: message.path, stepIndex },
|
|
988
|
+
this.config,
|
|
989
|
+
this.attachmentBudget
|
|
990
|
+
);
|
|
991
|
+
if (resolved) {
|
|
992
|
+
record.attachments.push(resolved);
|
|
993
|
+
}
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
case "step_start": {
|
|
997
|
+
const parentIndex = record.manualStepStack.length > 0 ? record.manualStepStack[record.manualStepStack.length - 1] : void 0;
|
|
998
|
+
const step = { name: message.name, status: "passed", startedAt: message.timestamp, parentIndex };
|
|
999
|
+
record.manualStepStack.push(record.manualSteps.length);
|
|
1000
|
+
record.manualSteps.push(step);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
case "step_stop": {
|
|
1004
|
+
const openIndex = record.manualStepStack.pop();
|
|
1005
|
+
if (openIndex === void 0) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const step = record.manualSteps[openIndex];
|
|
1009
|
+
step.status = message.status;
|
|
1010
|
+
step.error = message.error;
|
|
1011
|
+
step.durationMs = Math.max(0, message.timestamp - step.startedAt);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/** Returns the collapsed result once all attempts of this logical
|
|
1017
|
+
* scenario have arrived, or `undefined` if more attempts are coming
|
|
1018
|
+
* (`willBeRetried === true`). */
|
|
1019
|
+
finish(e) {
|
|
1020
|
+
const record = this.byTestCaseStartedId.get(e.testCaseStartedId);
|
|
1021
|
+
this.byTestCaseStartedId.delete(e.testCaseStartedId);
|
|
1022
|
+
if (!record) {
|
|
1023
|
+
return void 0;
|
|
1024
|
+
}
|
|
1025
|
+
if (e.willBeRetried) {
|
|
1026
|
+
return void 0;
|
|
1027
|
+
}
|
|
1028
|
+
const testCaseId = record.testCase.id;
|
|
1029
|
+
const attempts = this.byTestCaseId.get(testCaseId) ?? [record];
|
|
1030
|
+
this.byTestCaseId.delete(testCaseId);
|
|
1031
|
+
const snapshots = attempts.map((a) => {
|
|
1032
|
+
const worst = a.stepResults.length > 0 ? getWorstTestStepResult(a.stepResults) : void 0;
|
|
1033
|
+
const duration = a.stepResults.reduce((sum, r) => sum + messageDurationToNs(r.duration), 0);
|
|
1034
|
+
return {
|
|
1035
|
+
status: worst ? mapStatus(worst.status) : "passed",
|
|
1036
|
+
duration,
|
|
1037
|
+
// `message` first — see `step-mapper.ts`'s `formatError()` doc
|
|
1038
|
+
// comment for why (verified empirically across the peer-dependency
|
|
1039
|
+
// range; `exception.stackTrace` is not version-safe).
|
|
1040
|
+
error: worst?.message || worst?.exception?.stackTrace || worst?.exception?.message,
|
|
1041
|
+
steps: a.steps,
|
|
1042
|
+
manualSteps: a.manualSteps,
|
|
1043
|
+
labels: a.labels,
|
|
1044
|
+
links: a.links,
|
|
1045
|
+
tags: a.tags,
|
|
1046
|
+
description: a.description,
|
|
1047
|
+
priority: a.priority,
|
|
1048
|
+
properties: a.properties,
|
|
1049
|
+
attachments: a.attachments
|
|
1050
|
+
};
|
|
1051
|
+
});
|
|
1052
|
+
return {
|
|
1053
|
+
uri: record.pickle.uri,
|
|
1054
|
+
pickleId: record.testCase.pickleId,
|
|
1055
|
+
collapsed: collapseAttempts(snapshots)
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
function timestampMs(ts) {
|
|
1060
|
+
return ts.seconds * 1e3 + Math.floor(ts.nanos / 1e6);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// src/formatter/collect-builder.ts
|
|
1064
|
+
import * as os from "os";
|
|
1065
|
+
function resolveOs(config) {
|
|
1066
|
+
if (config.os) {
|
|
1067
|
+
return config.os;
|
|
1068
|
+
}
|
|
1069
|
+
return `${os.type()} ${os.release()}`;
|
|
1070
|
+
}
|
|
1071
|
+
function buildCollectPayload(suites, config) {
|
|
1072
|
+
return {
|
|
1073
|
+
framework: config.framework,
|
|
1074
|
+
platform: config.platform,
|
|
1075
|
+
os: resolveOs(config),
|
|
1076
|
+
browser: config.browser ?? "",
|
|
1077
|
+
branch: config.branch,
|
|
1078
|
+
commit: config.commit,
|
|
1079
|
+
environment: config.environment,
|
|
1080
|
+
language: config.language,
|
|
1081
|
+
milestone: config.milestone,
|
|
1082
|
+
metadata: {
|
|
1083
|
+
version: PACKAGE_VERSION,
|
|
1084
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1085
|
+
cliName: "qualflare-cucumberjs"
|
|
1086
|
+
},
|
|
1087
|
+
properties: config.properties,
|
|
1088
|
+
suites,
|
|
1089
|
+
ciProvider: config.ciProvider,
|
|
1090
|
+
ciBuildNumber: config.ciBuildNumber,
|
|
1091
|
+
ciRunUrl: config.ciRunUrl,
|
|
1092
|
+
ciPrNumber: config.ciPrNumber
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/formatter/gherkin-index.ts
|
|
1097
|
+
var GherkinIndex = class {
|
|
1098
|
+
byUri = /* @__PURE__ */ new Map();
|
|
1099
|
+
add(doc) {
|
|
1100
|
+
if (!doc.uri || !doc.feature) {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const entry = {
|
|
1104
|
+
featureName: doc.feature.name || void 0,
|
|
1105
|
+
stepMap: /* @__PURE__ */ new Map(),
|
|
1106
|
+
scenarioById: /* @__PURE__ */ new Map()
|
|
1107
|
+
};
|
|
1108
|
+
for (const child of doc.feature.children) {
|
|
1109
|
+
if (child.background) {
|
|
1110
|
+
this.indexSteps(entry, child.background.steps);
|
|
1111
|
+
}
|
|
1112
|
+
if (child.scenario) {
|
|
1113
|
+
entry.scenarioById.set(child.scenario.id, { scenario: child.scenario });
|
|
1114
|
+
this.indexSteps(entry, child.scenario.steps);
|
|
1115
|
+
}
|
|
1116
|
+
if (child.rule) {
|
|
1117
|
+
for (const ruleChild of child.rule.children) {
|
|
1118
|
+
if (ruleChild.background) {
|
|
1119
|
+
this.indexSteps(entry, ruleChild.background.steps);
|
|
1120
|
+
}
|
|
1121
|
+
if (ruleChild.scenario) {
|
|
1122
|
+
entry.scenarioById.set(ruleChild.scenario.id, {
|
|
1123
|
+
scenario: ruleChild.scenario,
|
|
1124
|
+
ruleName: child.rule.name || void 0
|
|
1125
|
+
});
|
|
1126
|
+
this.indexSteps(entry, ruleChild.scenario.steps);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
this.byUri.set(doc.uri, entry);
|
|
1132
|
+
}
|
|
1133
|
+
indexSteps(entry, steps) {
|
|
1134
|
+
for (const step of steps) {
|
|
1135
|
+
entry.stepMap.set(step.id, { keyword: step.keyword, text: step.text });
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
get(uri) {
|
|
1139
|
+
return this.byUri.get(uri);
|
|
1140
|
+
}
|
|
1141
|
+
/** Resolves the literal Given/When/Then/And/But keyword text for a
|
|
1142
|
+
* compiled `PickleStep`, by walking its `astNodeIds` back to the first
|
|
1143
|
+
* one present in this feature's `stepMap` (a Background step referenced
|
|
1144
|
+
* by a scenario has exactly one AST id; a step reusing a parameter type
|
|
1145
|
+
* from an outline row can have more than one — the first match is always
|
|
1146
|
+
* the step's own defining AST node). */
|
|
1147
|
+
resolveKeyword(uri, astNodeIds) {
|
|
1148
|
+
const entry = this.byUri.get(uri);
|
|
1149
|
+
if (!entry) {
|
|
1150
|
+
return void 0;
|
|
1151
|
+
}
|
|
1152
|
+
for (const id of astNodeIds) {
|
|
1153
|
+
const found = entry.stepMap.get(id);
|
|
1154
|
+
if (found) {
|
|
1155
|
+
return found;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
return void 0;
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
// src/formatter/hook-index.ts
|
|
1163
|
+
function buildHookIndex(supportCodeLibrary) {
|
|
1164
|
+
const index = /* @__PURE__ */ new Map();
|
|
1165
|
+
for (const def of supportCodeLibrary.beforeTestCaseHookDefinitions) {
|
|
1166
|
+
index.set(def.id, { kind: "before", name: def.name || void 0 });
|
|
1167
|
+
}
|
|
1168
|
+
for (const def of supportCodeLibrary.afterTestCaseHookDefinitions) {
|
|
1169
|
+
index.set(def.id, { kind: "after", name: def.name || void 0 });
|
|
1170
|
+
}
|
|
1171
|
+
for (const def of supportCodeLibrary.beforeTestStepHookDefinitions) {
|
|
1172
|
+
index.set(def.id, { kind: "beforeStep" });
|
|
1173
|
+
}
|
|
1174
|
+
for (const def of supportCodeLibrary.afterTestStepHookDefinitions) {
|
|
1175
|
+
index.set(def.id, { kind: "afterStep" });
|
|
1176
|
+
}
|
|
1177
|
+
for (const def of supportCodeLibrary.beforeTestRunHookDefinitions) {
|
|
1178
|
+
index.set(def.id, { kind: "beforeAll" });
|
|
1179
|
+
}
|
|
1180
|
+
for (const def of supportCodeLibrary.afterTestRunHookDefinitions) {
|
|
1181
|
+
index.set(def.id, { kind: "afterAll" });
|
|
1182
|
+
}
|
|
1183
|
+
return index;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/formatter/run-hook-tracker.ts
|
|
1187
|
+
var RunHookTracker = class {
|
|
1188
|
+
/** `testRunHookStartedId` -> the hook it started, so `finish()` can look
|
|
1189
|
+
* up its kind/name. `TestRunHookFinished.result.duration` already gives
|
|
1190
|
+
* an accurate duration directly — no start/finish timestamp delta needed. */
|
|
1191
|
+
started = /* @__PURE__ */ new Map();
|
|
1192
|
+
failed = [];
|
|
1193
|
+
start(e) {
|
|
1194
|
+
this.started.set(e.id, e.hookId);
|
|
1195
|
+
}
|
|
1196
|
+
finish(e, hookIndex) {
|
|
1197
|
+
const hookId = this.started.get(e.testRunHookStartedId);
|
|
1198
|
+
this.started.delete(e.testRunHookStartedId);
|
|
1199
|
+
const status = mapStatus(e.result.status);
|
|
1200
|
+
if (status === "passed" || status === "skipped") {
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
const hook = hookId ? hookIndex.get(hookId) : void 0;
|
|
1204
|
+
const label = hook?.kind === "afterAll" ? "AfterAll hook" : "BeforeAll hook";
|
|
1205
|
+
this.failed.push({
|
|
1206
|
+
id: `global-hook:${e.testRunHookStartedId}`,
|
|
1207
|
+
name: hook?.name || label,
|
|
1208
|
+
status,
|
|
1209
|
+
duration: messageDurationToNs(e.result.duration),
|
|
1210
|
+
// `result.message` first — see `step-mapper.ts`'s `formatError()` doc
|
|
1211
|
+
// comment for why (verified empirically to be the version-safe field
|
|
1212
|
+
// across the peer-dependency range; `exception.stackTrace` is not).
|
|
1213
|
+
error: e.result.message || e.result.exception?.stackTrace || e.result.exception?.message
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
/** Returns `undefined` if no run-hook failed — see the class doc comment
|
|
1217
|
+
* for why a passing BeforeAll/AfterAll produces no Case at all. */
|
|
1218
|
+
buildSuite() {
|
|
1219
|
+
if (this.failed.length === 0) {
|
|
1220
|
+
return void 0;
|
|
1221
|
+
}
|
|
1222
|
+
return {
|
|
1223
|
+
name: "(global hooks)",
|
|
1224
|
+
category: "bdd",
|
|
1225
|
+
duration: this.failed.reduce((sum, c) => sum + c.duration, 0),
|
|
1226
|
+
cases: this.failed
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
// src/formatter/suite-builder.ts
|
|
1232
|
+
import * as path2 from "path";
|
|
1233
|
+
function groupIntoSuites(cases, cwd, extraSuite) {
|
|
1234
|
+
const byUri = /* @__PURE__ */ new Map();
|
|
1235
|
+
for (const { uri, case: kase } of cases) {
|
|
1236
|
+
const bucket = byUri.get(uri);
|
|
1237
|
+
if (bucket) {
|
|
1238
|
+
bucket.push(kase);
|
|
1239
|
+
} else {
|
|
1240
|
+
byUri.set(uri, [kase]);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const suites = [];
|
|
1244
|
+
for (const [uri, kases] of byUri) {
|
|
1245
|
+
if (kases.length > MAX_CASES_PER_SUITE) {
|
|
1246
|
+
logger.warn(
|
|
1247
|
+
`feature file "${uri}" reported ${kases.length} scenarios \u2014 only the first ${MAX_CASES_PER_SUITE} will be uploaded (server cap).`
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
suites.push({
|
|
1251
|
+
name: relativizeUri(uri, cwd),
|
|
1252
|
+
category: "bdd",
|
|
1253
|
+
duration: kases.reduce((sum, c) => sum + c.duration, 0),
|
|
1254
|
+
cases: kases.slice(0, MAX_CASES_PER_SUITE)
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
if (extraSuite) {
|
|
1258
|
+
suites.push(extraSuite);
|
|
1259
|
+
}
|
|
1260
|
+
if (suites.length > MAX_SUITES_PER_LAUNCH) {
|
|
1261
|
+
logger.warn(
|
|
1262
|
+
`this run reported ${suites.length} feature-file suites \u2014 only the first ${MAX_SUITES_PER_LAUNCH} will be uploaded (server cap).`
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
return suites.slice(0, MAX_SUITES_PER_LAUNCH);
|
|
1266
|
+
}
|
|
1267
|
+
function relativizeUri(uri, cwd) {
|
|
1268
|
+
let normalized = uri;
|
|
1269
|
+
if (normalized.startsWith("file://")) {
|
|
1270
|
+
normalized = new URL(normalized).pathname;
|
|
1271
|
+
}
|
|
1272
|
+
if (path2.isAbsolute(normalized)) {
|
|
1273
|
+
normalized = path2.relative(cwd, normalized);
|
|
1274
|
+
}
|
|
1275
|
+
return normalized.split(path2.sep).join("/");
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/formatter/formatter.ts
|
|
1279
|
+
var QualflareCucumberFormatter = class extends Formatter {
|
|
1280
|
+
config;
|
|
1281
|
+
gherkin = new GherkinIndex();
|
|
1282
|
+
hookIndex;
|
|
1283
|
+
pickleIndex = /* @__PURE__ */ new Map();
|
|
1284
|
+
testCaseIndex = /* @__PURE__ */ new Map();
|
|
1285
|
+
attachmentBudget;
|
|
1286
|
+
attemptTracker;
|
|
1287
|
+
runHookTracker = new RunHookTracker();
|
|
1288
|
+
finishedCases = [];
|
|
1289
|
+
constructor(options) {
|
|
1290
|
+
super(options);
|
|
1291
|
+
this.config = resolveConfig(options.parsedArgvOptions);
|
|
1292
|
+
this.hookIndex = buildHookIndex(options.supportCodeLibrary);
|
|
1293
|
+
this.attachmentBudget = new AttachmentBudget(this.config.maxTotalAttachmentBytes);
|
|
1294
|
+
this.attemptTracker = new AttemptTracker(this.hookIndex, this.gherkin, this.config, this.attachmentBudget);
|
|
1295
|
+
if (!this.config.enabled) {
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
options.eventBroadcaster.on("envelope", (envelope) => this.onEnvelope(envelope));
|
|
1299
|
+
}
|
|
1300
|
+
onEnvelope(envelope) {
|
|
1301
|
+
try {
|
|
1302
|
+
this.dispatch(envelope);
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
logger.error("failed to process a cucumber-js event:", err);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
dispatch(envelope) {
|
|
1308
|
+
if (envelope.gherkinDocument) {
|
|
1309
|
+
this.onGherkinDocument(envelope.gherkinDocument);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (envelope.pickle) {
|
|
1313
|
+
this.pickleIndex.set(envelope.pickle.id, envelope.pickle);
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
if (envelope.testCase) {
|
|
1317
|
+
this.testCaseIndex.set(envelope.testCase.id, envelope.testCase);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (envelope.testCaseStarted) {
|
|
1321
|
+
const testCase = this.testCaseIndex.get(envelope.testCaseStarted.testCaseId);
|
|
1322
|
+
const pickle = testCase ? this.pickleIndex.get(testCase.pickleId) : void 0;
|
|
1323
|
+
if (testCase && pickle) {
|
|
1324
|
+
this.attemptTracker.begin(envelope.testCaseStarted, testCase, pickle);
|
|
1325
|
+
} else {
|
|
1326
|
+
logger.warn(
|
|
1327
|
+
`could not resolve testCase/pickle for testCaseStarted "${envelope.testCaseStarted.id}" \u2014 this scenario attempt will not be uploaded.`
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
if (envelope.testStepStarted) {
|
|
1333
|
+
this.attemptTracker.stepStarted(envelope.testStepStarted);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
if (envelope.testStepFinished) {
|
|
1337
|
+
this.attemptTracker.stepFinished(envelope.testStepFinished);
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
if (envelope.attachment) {
|
|
1341
|
+
this.attemptTracker.attachment(envelope.attachment);
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (envelope.testCaseFinished) {
|
|
1345
|
+
const finished = this.attemptTracker.finish(envelope.testCaseFinished);
|
|
1346
|
+
if (finished) {
|
|
1347
|
+
const pickle = this.pickleIndex.get(finished.pickleId);
|
|
1348
|
+
if (pickle) {
|
|
1349
|
+
this.finishedCases.push(buildCase(finished.uri, pickle, finished.collapsed, this.gherkin));
|
|
1350
|
+
} else {
|
|
1351
|
+
logger.warn(`could not resolve pickle "${finished.pickleId}" for a finished scenario \u2014 it will not be uploaded.`);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (envelope.testRunHookStarted) {
|
|
1357
|
+
this.runHookTracker.start(envelope.testRunHookStarted);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (envelope.testRunHookFinished) {
|
|
1361
|
+
this.runHookTracker.finish(envelope.testRunHookFinished, this.hookIndex);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
onGherkinDocument(doc) {
|
|
1366
|
+
this.gherkin.add(doc);
|
|
1367
|
+
}
|
|
1368
|
+
async finished() {
|
|
1369
|
+
try {
|
|
1370
|
+
if (this.config.enabled) {
|
|
1371
|
+
await this.uploadResults();
|
|
1372
|
+
}
|
|
1373
|
+
} finally {
|
|
1374
|
+
await super.finished();
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async uploadResults() {
|
|
1378
|
+
const suites = groupIntoSuites(this.finishedCases, this.cwd, this.runHookTracker.buildSuite());
|
|
1379
|
+
if (suites.length === 0) {
|
|
1380
|
+
if (this.config.debug) {
|
|
1381
|
+
logger.debug("no scenarios reported \u2014 skipping upload.");
|
|
1382
|
+
}
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
const payload = buildCollectPayload(suites, this.config);
|
|
1386
|
+
const client = new QualflareHttpClient({
|
|
1387
|
+
endpoint: this.config.apiEndpoint,
|
|
1388
|
+
token: this.config.token,
|
|
1389
|
+
timeoutMs: this.config.timeoutMs,
|
|
1390
|
+
retry: this.config.retry,
|
|
1391
|
+
userAgent: `qualflare-cucumberjs/${PACKAGE_VERSION}`,
|
|
1392
|
+
debug: this.config.debug
|
|
1393
|
+
});
|
|
1394
|
+
try {
|
|
1395
|
+
const result = await client.send(payload);
|
|
1396
|
+
if (this.config.debug) {
|
|
1397
|
+
logger.debug(`uploaded launch #${result.seq}.`);
|
|
1398
|
+
}
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
if (this.config.failOnUploadError) {
|
|
1401
|
+
throw err;
|
|
1402
|
+
}
|
|
1403
|
+
logger.error("failed to upload results to Qualflare:", err);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
export {
|
|
1408
|
+
QualflareCucumberFormatter as default
|
|
1409
|
+
};
|
|
1410
|
+
//# sourceMappingURL=index.js.map
|