@piwitests/reporter 0.56.0 → 0.58.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/cli/index.js
CHANGED
|
@@ -873,6 +873,8 @@ var PIWI_ENV_KEYS = {
|
|
|
873
873
|
codeReachRoots: "PIWI_CODE_REACH_ROOTS",
|
|
874
874
|
coverageBrowser: "PIWI_COVERAGE_BROWSER",
|
|
875
875
|
coverageServer: "PIWI_COVERAGE_SERVER",
|
|
876
|
+
externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
|
|
877
|
+
externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
|
|
876
878
|
captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
|
|
877
879
|
captureResources: "PIWI_CAPTURE_RESOURCES",
|
|
878
880
|
leakCheck: "PIWI_LEAK_CHECK",
|
|
@@ -1137,13 +1139,18 @@ var ASSERTION_MATCHERS = [
|
|
|
1137
1139
|
"toBeHidden",
|
|
1138
1140
|
"toBeEnabled",
|
|
1139
1141
|
"toBeDisabled",
|
|
1140
|
-
"toHaveURL"
|
|
1142
|
+
"toHaveURL",
|
|
1143
|
+
"toContainText",
|
|
1144
|
+
"toHaveCount",
|
|
1145
|
+
"toBeChecked"
|
|
1141
1146
|
];
|
|
1142
1147
|
var VALUE_MATCHERS = /* @__PURE__ */ new Set([
|
|
1143
1148
|
"toHaveText",
|
|
1144
1149
|
"toHaveValue",
|
|
1145
1150
|
"toHaveAccessibleName",
|
|
1146
|
-
"toHaveURL"
|
|
1151
|
+
"toHaveURL",
|
|
1152
|
+
"toContainText",
|
|
1153
|
+
"toHaveCount"
|
|
1147
1154
|
]);
|
|
1148
1155
|
|
|
1149
1156
|
// ../core/src/function-match.ts
|
|
@@ -1833,7 +1840,7 @@ function renderAssertStep(step, index, ctx) {
|
|
|
1833
1840
|
});
|
|
1834
1841
|
return [` // Step ${index + 1}: ${matcher} without an expected value.`];
|
|
1835
1842
|
}
|
|
1836
|
-
arg = quote(matcher === "toHaveURL" ? urlForCode(assertion.expected, ctx) : assertion.expected);
|
|
1843
|
+
arg = matcher === "toHaveCount" ? String(Number.parseInt(assertion.expected, 10) || 0) : quote(matcher === "toHaveURL" ? urlForCode(assertion.expected, ctx) : assertion.expected);
|
|
1837
1844
|
}
|
|
1838
1845
|
const subject = matcher === "toHaveURL" ? ctx.page : locatorForStep(step, index, ctx);
|
|
1839
1846
|
let line = ` await expect(${subject})${not}.${matcher}(${arg});`;
|
|
@@ -1846,10 +1853,25 @@ function renderAssertStep(step, index, ctx) {
|
|
|
1846
1853
|
function fileNames(value) {
|
|
1847
1854
|
return (value ?? "").split("\n").map((name) => name.trim().replace(/^.*[\\/]/, "")).filter(Boolean);
|
|
1848
1855
|
}
|
|
1856
|
+
function renderManualStep(step, index, ctx) {
|
|
1857
|
+
const description = commentText(step.manual?.description ?? "") || "a step to do by hand";
|
|
1858
|
+
ctx.warnings.push({
|
|
1859
|
+
step: index,
|
|
1860
|
+
code: "manual-step",
|
|
1861
|
+
detail: description,
|
|
1862
|
+
message: `Step ${index + 1} cannot be written as code: ${description}`
|
|
1863
|
+
});
|
|
1864
|
+
const lines = [` // Manual step: ${description}`];
|
|
1865
|
+
const call = step.manual?.call ? commentText(step.manual.call) : "";
|
|
1866
|
+
const location = step.manual?.location ? commentText(step.manual.location) : "";
|
|
1867
|
+
if (call || location) lines.push(` // ${[call, location ? `at ${location}` : ""].filter(Boolean).join(" ")}`);
|
|
1868
|
+
return lines;
|
|
1869
|
+
}
|
|
1849
1870
|
function renderRawStep(step, index, ctx) {
|
|
1850
1871
|
const { page } = ctx;
|
|
1851
1872
|
if (step.action === "goto") return [` await ${page}.goto(${quote(urlForCode(step.value ?? step.pageUrl, ctx))});`];
|
|
1852
1873
|
if (step.action === "assert" || step.action === "assertVisible") return renderAssertStep(step, index, ctx);
|
|
1874
|
+
if (step.action === "manual") return renderManualStep(step, index, ctx);
|
|
1853
1875
|
if (step.action === "press" && !step.target) {
|
|
1854
1876
|
return [` await ${page}.keyboard.press(${quote(step.value ?? "Enter")});`];
|
|
1855
1877
|
}
|
|
@@ -2060,6 +2082,7 @@ function expectFailLine(options) {
|
|
|
2060
2082
|
return reason ? ` test.fail(); // ${reason}` : " test.fail();";
|
|
2061
2083
|
}
|
|
2062
2084
|
function samePage(a, b) {
|
|
2085
|
+
if (!a || !b) return true;
|
|
2063
2086
|
const ka = pageKey(a);
|
|
2064
2087
|
const kb = pageKey(b);
|
|
2065
2088
|
return ka === null || kb === null || ka === kb;
|
|
@@ -2111,7 +2134,7 @@ function renderSpec(session, options = {}) {
|
|
|
2111
2134
|
const pageStarts = [];
|
|
2112
2135
|
const enterPageOf = (index) => {
|
|
2113
2136
|
const url = steps[index]?.pageUrl;
|
|
2114
|
-
if (options.testSteps !== "page" || url
|
|
2137
|
+
if (options.testSteps !== "page" || !url) return;
|
|
2115
2138
|
const last = pageStarts[pageStarts.length - 1];
|
|
2116
2139
|
if (!last || !samePage(last.url, url)) pageStarts.push({ line: bodyLines.length, url });
|
|
2117
2140
|
};
|
|
@@ -2123,7 +2146,7 @@ function renderSpec(session, options = {}) {
|
|
|
2123
2146
|
const pattern = pageUrlPattern(next.pageUrl);
|
|
2124
2147
|
enterPageOf(last + 1);
|
|
2125
2148
|
if (pattern) bodyLines.push(` await expect(${page}).toHaveURL(${pattern});`);
|
|
2126
|
-
const acts = next.action !== "assert" && next.action !== "assertVisible";
|
|
2149
|
+
const acts = next.action !== "assert" && next.action !== "assertVisible" && next.action !== "manual";
|
|
2127
2150
|
const chosen = acts && next.target ? chooseLocator(next.target, options) : null;
|
|
2128
2151
|
if (chosen) bodyLines.push(` await expect(${page}.${chosen.text}).toHaveCount(1);`);
|
|
2129
2152
|
};
|
|
@@ -2225,7 +2248,11 @@ function renderSpec(session, options = {}) {
|
|
|
2225
2248
|
}
|
|
2226
2249
|
|
|
2227
2250
|
// ../core/src/steps.ts
|
|
2228
|
-
var STEPS_VERSION =
|
|
2251
|
+
var STEPS_VERSION = 2;
|
|
2252
|
+
var VERSION_2_MATCHERS = /* @__PURE__ */ new Set(["toContainText", "toHaveCount", "toBeChecked"]);
|
|
2253
|
+
function stepsVersionFor(steps) {
|
|
2254
|
+
return steps.some((s) => s.action === "manual" || !!s.assertion && VERSION_2_MATCHERS.has(s.assertion.matcher)) ? 2 : 1;
|
|
2255
|
+
}
|
|
2229
2256
|
var STEPS_LIMITS = {
|
|
2230
2257
|
steps: 200,
|
|
2231
2258
|
alternatives: 10,
|
|
@@ -2252,7 +2279,8 @@ var ACTIONS = /* @__PURE__ */ new Set([
|
|
|
2252
2279
|
"setInputFiles",
|
|
2253
2280
|
"dragTo",
|
|
2254
2281
|
"assertVisible",
|
|
2255
|
-
"assert"
|
|
2282
|
+
"assert",
|
|
2283
|
+
"manual"
|
|
2256
2284
|
]);
|
|
2257
2285
|
function originOf2(url) {
|
|
2258
2286
|
try {
|
|
@@ -2277,7 +2305,7 @@ function toAbsolute(url, origin) {
|
|
|
2277
2305
|
function toStepsDocument(session, meta = {}) {
|
|
2278
2306
|
const origin = originOf2(session.startUrl) ?? originOf2(session.steps[0]?.pageUrl ?? "");
|
|
2279
2307
|
return {
|
|
2280
|
-
v:
|
|
2308
|
+
v: stepsVersionFor(session.steps),
|
|
2281
2309
|
title: meta.title ?? null,
|
|
2282
2310
|
origin,
|
|
2283
2311
|
recordedAt: session.startedAt,
|
|
@@ -2386,6 +2414,9 @@ function checkAssertion(c, v, path24) {
|
|
|
2386
2414
|
return void 0;
|
|
2387
2415
|
}
|
|
2388
2416
|
const expected = c.text(v.expected, `${path24}.expected`, STEPS_LIMITS.valueLength, !VALUE_MATCHERS.has(matcher));
|
|
2417
|
+
if (matcher === "toHaveCount" && expected != null && !/^\d{1,6}$/.test(expected)) {
|
|
2418
|
+
c.fail(`${path24}.expected`, "must be a whole number of elements on a toHaveCount assertion");
|
|
2419
|
+
}
|
|
2389
2420
|
return {
|
|
2390
2421
|
matcher,
|
|
2391
2422
|
expected: VALUE_MATCHERS.has(matcher) ? expected : null,
|
|
@@ -2394,6 +2425,19 @@ function checkAssertion(c, v, path24) {
|
|
|
2394
2425
|
note: c.text(v.note, `${path24}.note`, STEPS_LIMITS.valueLength, true)
|
|
2395
2426
|
};
|
|
2396
2427
|
}
|
|
2428
|
+
function checkManual(c, v, path24) {
|
|
2429
|
+
if (!isObject(v)) {
|
|
2430
|
+
c.fail(path24, "is required on a manual step");
|
|
2431
|
+
return void 0;
|
|
2432
|
+
}
|
|
2433
|
+
const description = c.text(v.description, `${path24}.description`, STEPS_LIMITS.valueLength, false);
|
|
2434
|
+
if (description != null && !description.trim()) c.fail(`${path24}.description`, "must not be empty");
|
|
2435
|
+
return {
|
|
2436
|
+
description: description ?? "",
|
|
2437
|
+
location: c.text(v.location, `${path24}.location`, STEPS_LIMITS.textLength, true),
|
|
2438
|
+
call: c.text(v.call, `${path24}.call`, STEPS_LIMITS.textLength, true)
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2397
2441
|
function checkViewports(c, v, stepCount) {
|
|
2398
2442
|
if (v == null) return [];
|
|
2399
2443
|
if (!Array.isArray(v)) {
|
|
@@ -2466,6 +2510,9 @@ function checkStep(c, v, path24) {
|
|
|
2466
2510
|
step.assertion = { matcher: "toBeVisible", expected: null, actual: null, negated: false, note: null };
|
|
2467
2511
|
} else if (action === "assert") {
|
|
2468
2512
|
step.assertion = checkAssertion(c, v.assertion, `${path24}.assertion`);
|
|
2513
|
+
} else if (action === "manual") {
|
|
2514
|
+
step.value = null;
|
|
2515
|
+
step.manual = checkManual(c, v.manual, `${path24}.manual`);
|
|
2469
2516
|
}
|
|
2470
2517
|
return step;
|
|
2471
2518
|
}
|
|
@@ -2481,8 +2528,8 @@ function parseSteps(input) {
|
|
|
2481
2528
|
if (!isObject(value)) return { ok: false, errors: ["a steps document must be a JSON object"] };
|
|
2482
2529
|
const c = new Checker();
|
|
2483
2530
|
const legacy = value.v == null && typeof value.startUrl === "string";
|
|
2484
|
-
if (!legacy && value.v !== STEPS_VERSION) {
|
|
2485
|
-
return { ok: false, errors: [`v: this reads
|
|
2531
|
+
if (!legacy && value.v !== 1 && value.v !== STEPS_VERSION) {
|
|
2532
|
+
return { ok: false, errors: [`v: this reads versions 1 to ${STEPS_VERSION}, not ${JSON.stringify(value.v)}`] };
|
|
2486
2533
|
}
|
|
2487
2534
|
if (!Array.isArray(value.steps)) return { ok: false, errors: ["steps: must be a list"] };
|
|
2488
2535
|
if (value.steps.length > STEPS_LIMITS.steps) {
|
|
@@ -2505,7 +2552,7 @@ function parseSteps(input) {
|
|
|
2505
2552
|
if (originText && !origin) c.fail("origin", "must be an http(s) origin");
|
|
2506
2553
|
const viewports = checkViewports(c, value.viewports, steps.length);
|
|
2507
2554
|
doc = {
|
|
2508
|
-
v:
|
|
2555
|
+
v: stepsVersionFor(steps),
|
|
2509
2556
|
title: c.text(value.title, "title", STEPS_LIMITS.textLength, true),
|
|
2510
2557
|
origin,
|
|
2511
2558
|
recordedAt: value.recordedAt == null ? 0 : c.number(value.recordedAt, "recordedAt"),
|
|
@@ -4832,7 +4879,7 @@ Connection:
|
|
|
4832
4879
|
--server-url <url> Dashboard URL (env PIWI_DASHBOARD_URL)
|
|
4833
4880
|
--api-key <key> API key (env PIWI_API_KEY)
|
|
4834
4881
|
|
|
4835
|
-
Policy (at least one is required):
|
|
4882
|
+
Policy (at least one rule or warn-only flag is required):
|
|
4836
4883
|
--require-tag <tags> Comma-separated; every test carrying the tag must pass
|
|
4837
4884
|
--max-failed <n> Fail when more than n tests failed
|
|
4838
4885
|
--max-new-regressions <n> Fail when more than n tests newly started failing
|
|
@@ -4845,7 +4892,7 @@ Policy (at least one is required):
|
|
|
4845
4892
|
--max-new-leaks <n> Fail when more than n of those were never seen on the base branch
|
|
4846
4893
|
|
|
4847
4894
|
Warn-only (reported, never fails the build):
|
|
4848
|
-
--max-uncovered-changes <n> Warn when more than n changed files
|
|
4895
|
+
--max-uncovered-changes <n> Warn when more than n changed files are uncovered changes: no test reached them
|
|
4849
4896
|
--min-patch-coverage <percent> Warn when fewer of the changed lines ran, in the commit's combined coverage
|
|
4850
4897
|
|
|
4851
4898
|
Other:
|
|
@@ -127,6 +127,8 @@ var PIWI_ENV_KEYS = {
|
|
|
127
127
|
codeReachRoots: "PIWI_CODE_REACH_ROOTS",
|
|
128
128
|
coverageBrowser: "PIWI_COVERAGE_BROWSER",
|
|
129
129
|
coverageServer: "PIWI_COVERAGE_SERVER",
|
|
130
|
+
externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
|
|
131
|
+
externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
|
|
130
132
|
captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
|
|
131
133
|
captureResources: "PIWI_CAPTURE_RESOURCES",
|
|
132
134
|
leakCheck: "PIWI_LEAK_CHECK",
|
|
@@ -217,6 +219,13 @@ function resolveOptions(raw) {
|
|
|
217
219
|
coverage.browser = coverageBrowser;
|
|
218
220
|
if (coverage.server === void 0 && coverageServer) coverage.server = coverageServer;
|
|
219
221
|
if (coverage.browser !== void 0 || coverage.server !== void 0) mergedRaw.coverage = coverage;
|
|
222
|
+
const externalId = { ...mergedRaw.externalId };
|
|
223
|
+
const externalIdPattern = env[PIWI_ENV_KEYS.externalIdPattern];
|
|
224
|
+
const externalIdOnDuplicate = env[PIWI_ENV_KEYS.externalIdOnDuplicate];
|
|
225
|
+
if (externalId.pattern === void 0 && externalIdPattern) externalId.pattern = externalIdPattern;
|
|
226
|
+
if (externalId.onDuplicate === void 0 && externalIdOnDuplicate)
|
|
227
|
+
externalId.onDuplicate = externalIdOnDuplicate;
|
|
228
|
+
if (Object.keys(externalId).length > 0) mergedRaw.externalId = externalId;
|
|
220
229
|
const opts = { ...DEFAULTS, ...mergedRaw };
|
|
221
230
|
if (env[PIWI_ENV_KEYS.verbose] !== void 0) opts.verbose = env[PIWI_ENV_KEYS.verbose] === "true";
|
|
222
231
|
return opts;
|
package/dist/index.d.ts
CHANGED
|
@@ -129,6 +129,33 @@ interface PiwiDashboardOptions {
|
|
|
129
129
|
*/
|
|
130
130
|
server?: 'off' | 'files' | 'functions' | 'lines';
|
|
131
131
|
};
|
|
132
|
+
/**
|
|
133
|
+
* Match each test to its history on the dashboard by an external ID, the ID the test has in your own test
|
|
134
|
+
* management tool, so a test keeps its history when it is renamed or moved. A test declares its ID with a
|
|
135
|
+
* `piwi:id` annotation (`{ type: 'piwi:id', description: '12526' }`), which wins, or the ID is read from its title
|
|
136
|
+
* with `pattern`. Two tests declaring the same ID are reported before the first test runs, and matched by file,
|
|
137
|
+
* describe path and title instead.
|
|
138
|
+
*/
|
|
139
|
+
externalId?: {
|
|
140
|
+
/**
|
|
141
|
+
* Where the ID sits in a test's own title (not its describe path): `/^(\d+), /` reads `12526` from
|
|
142
|
+
* `'12526, Log in with Email'`. The ID is the first capture group, or the whole match without a group. A string
|
|
143
|
+
* is compiled with `new RegExp(value)`; one that does not compile turns external IDs off for the run, with an
|
|
144
|
+
* error, but never fails it. Can also be set with `PIWI_EXTERNAL_ID_PATTERN`.
|
|
145
|
+
*/
|
|
146
|
+
pattern?: RegExp | string;
|
|
147
|
+
/**
|
|
148
|
+
* Show the title without its ID on the dashboard, next to the ID. The whole match of `pattern` is removed from
|
|
149
|
+
* the title shown; the title stored and matched on stays whole. Defaults to `false`.
|
|
150
|
+
*/
|
|
151
|
+
stripFromTitle?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* What two tests declaring the same ID do: `'report'` lists them in the console and on the run page; `'fail'`
|
|
154
|
+
* also fails the run once its tests have run. Either way the run matches them by file, describe path and title.
|
|
155
|
+
* Defaults to `'report'`. Can also be set with `PIWI_EXTERNAL_ID_ON_DUPLICATE`.
|
|
156
|
+
*/
|
|
157
|
+
onDuplicate?: 'report' | 'fail';
|
|
158
|
+
};
|
|
132
159
|
/**
|
|
133
160
|
* Capture server-side spans for each API/document request the test makes,
|
|
134
161
|
* read from the `X-Piwi-Trace` response header emitted by a Piwi
|
|
@@ -457,6 +484,8 @@ declare class PiwiDashboardReporter {
|
|
|
457
484
|
private instanceId;
|
|
458
485
|
private shardInfo;
|
|
459
486
|
private metadata;
|
|
487
|
+
/** Each planned test's external ID and the IDs two tests share, settled in `onBegin`. */
|
|
488
|
+
private externalIds;
|
|
460
489
|
private enabled;
|
|
461
490
|
/**
|
|
462
491
|
* True under `playwright test --list`, where Playwright still constructs this
|
|
@@ -515,6 +544,13 @@ declare class PiwiDashboardReporter {
|
|
|
515
544
|
printsToStdio(): boolean;
|
|
516
545
|
/** Playwright reporter hook: called once at the start of the test run */
|
|
517
546
|
onBegin(config: FullConfig, suite: Suite): void;
|
|
547
|
+
/**
|
|
548
|
+
* Settle each planned test's external ID before any test runs, name the IDs two tests share, and record them on
|
|
549
|
+
* the run so the dashboard lists them too.
|
|
550
|
+
*/
|
|
551
|
+
private planExternalIds;
|
|
552
|
+
/** The external ID fields of a test's payload: `null` tells the dashboard the test has none to match on. */
|
|
553
|
+
private externalIdFields;
|
|
518
554
|
/** Playwright reporter hook: called when an individual test begins */
|
|
519
555
|
onTestBegin(test: TestCase, result: TestResult): void;
|
|
520
556
|
/** Track suite-level setup steps (beforeAll/afterAll) not tied to any test */
|
|
@@ -554,8 +590,14 @@ declare class PiwiDashboardReporter {
|
|
|
554
590
|
* interrupted) is resolved once from the overall run status by the caller.
|
|
555
591
|
*/
|
|
556
592
|
private materializeUnrunTests;
|
|
557
|
-
/**
|
|
558
|
-
|
|
593
|
+
/**
|
|
594
|
+
* Playwright reporter hook: called when the full test run finishes. Fails the run when two tests share an external
|
|
595
|
+
* ID and `externalId.onDuplicate` is `'fail'`.
|
|
596
|
+
*/
|
|
597
|
+
onEnd(result: FullResult): Promise<{
|
|
598
|
+
status?: FullResult['status'];
|
|
599
|
+
} | void>;
|
|
600
|
+
private finishRun;
|
|
559
601
|
}
|
|
560
602
|
|
|
561
603
|
interface ResolveSelectionResult {
|
package/dist/index.js
CHANGED
|
@@ -128,6 +128,8 @@ var PIWI_ENV_KEYS = {
|
|
|
128
128
|
codeReachRoots: "PIWI_CODE_REACH_ROOTS",
|
|
129
129
|
coverageBrowser: "PIWI_COVERAGE_BROWSER",
|
|
130
130
|
coverageServer: "PIWI_COVERAGE_SERVER",
|
|
131
|
+
externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
|
|
132
|
+
externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
|
|
131
133
|
captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
|
|
132
134
|
captureResources: "PIWI_CAPTURE_RESOURCES",
|
|
133
135
|
leakCheck: "PIWI_LEAK_CHECK",
|
|
@@ -247,6 +249,13 @@ function resolveOptions(raw) {
|
|
|
247
249
|
coverage.browser = coverageBrowser;
|
|
248
250
|
if (coverage.server === void 0 && coverageServer) coverage.server = coverageServer;
|
|
249
251
|
if (coverage.browser !== void 0 || coverage.server !== void 0) mergedRaw.coverage = coverage;
|
|
252
|
+
const externalId = { ...mergedRaw.externalId };
|
|
253
|
+
const externalIdPattern = env[PIWI_ENV_KEYS.externalIdPattern];
|
|
254
|
+
const externalIdOnDuplicate = env[PIWI_ENV_KEYS.externalIdOnDuplicate];
|
|
255
|
+
if (externalId.pattern === void 0 && externalIdPattern) externalId.pattern = externalIdPattern;
|
|
256
|
+
if (externalId.onDuplicate === void 0 && externalIdOnDuplicate)
|
|
257
|
+
externalId.onDuplicate = externalIdOnDuplicate;
|
|
258
|
+
if (Object.keys(externalId).length > 0) mergedRaw.externalId = externalId;
|
|
250
259
|
const opts = { ...DEFAULTS, ...mergedRaw };
|
|
251
260
|
if (env[PIWI_ENV_KEYS.verbose] !== void 0) opts.verbose = env[PIWI_ENV_KEYS.verbose] === "true";
|
|
252
261
|
return opts;
|
|
@@ -652,6 +661,9 @@ function toWireTestCase(tc) {
|
|
|
652
661
|
tags: rest.tags ?? null,
|
|
653
662
|
locks: rest.locks ?? null,
|
|
654
663
|
testMeta: rest.testMeta ?? null,
|
|
664
|
+
// Kept `undefined` when unset (a `begin` event): `null` would tell the dashboard the test has no ID.
|
|
665
|
+
externalId: rest.externalId,
|
|
666
|
+
displayTitle: rest.displayTitle ?? null,
|
|
655
667
|
locatorSnapshots: rest.locatorSnapshots || null,
|
|
656
668
|
locatorPages: rest.locatorPages || null,
|
|
657
669
|
codeReach: rest.codeReach || null,
|
|
@@ -4549,6 +4561,30 @@ function runFilesOf(answer) {
|
|
|
4549
4561
|
}
|
|
4550
4562
|
return files;
|
|
4551
4563
|
}
|
|
4564
|
+
function instanceOf(answer) {
|
|
4565
|
+
const instance = isRecord2(answer) ? answer.instance : null;
|
|
4566
|
+
if (!isRecord2(instance) || typeof instance.id !== "string" || !REQUEST_ID.test(instance.id)) return null;
|
|
4567
|
+
const startedAt = typeof instance.startedAt === "string" && !Number.isNaN(Date.parse(instance.startedAt)) ? new Date(instance.startedAt).toISOString() : null;
|
|
4568
|
+
return { id: instance.id, startedAt };
|
|
4569
|
+
}
|
|
4570
|
+
function processKeyOf(id) {
|
|
4571
|
+
const dot = id.lastIndexOf(".");
|
|
4572
|
+
return dot > 0 ? id.slice(0, dot) : null;
|
|
4573
|
+
}
|
|
4574
|
+
function withTestCoverage(files, tests) {
|
|
4575
|
+
if (!tests || tests.files.length === 0) return files;
|
|
4576
|
+
const byKey = new Map(files.map((file) => [`${file.runtime}\0${file.path}`, { ...file }]));
|
|
4577
|
+
for (const ran of tests.files) {
|
|
4578
|
+
const key = `${ran.runtime}\0${ran.path}`;
|
|
4579
|
+
const file = byKey.get(key) ?? { path: ran.path, runtime: ran.runtime };
|
|
4580
|
+
const functions = normalizeFunctionRanges([...file.functions ?? [], ...ran.functions ?? []]);
|
|
4581
|
+
const lines = normalizeLineRanges([...file.lines ?? [], ...ran.lines ?? []]);
|
|
4582
|
+
if (functions.length > 0) file.functions = functions;
|
|
4583
|
+
if (lines.length > 0) file.lines = lines;
|
|
4584
|
+
byKey.set(key, file);
|
|
4585
|
+
}
|
|
4586
|
+
return [...byKey.values()];
|
|
4587
|
+
}
|
|
4552
4588
|
function isCollectAnswer(answer) {
|
|
4553
4589
|
return isRecord2(answer) && answer.v === 1 && Array.isArray(answer.groups);
|
|
4554
4590
|
}
|
|
@@ -4600,11 +4636,16 @@ var ServerCoverageCollector = class {
|
|
|
4600
4636
|
}
|
|
4601
4637
|
/**
|
|
4602
4638
|
* The run-level coverage of an origin, with each server file's executable
|
|
4603
|
-
* lines at `lines` granularity
|
|
4639
|
+
* lines at `lines` granularity, and the lines and functions the tests'
|
|
4640
|
+
* coverage named added to it. The gap says why part of it is missing: the
|
|
4641
|
+
* backend restarted during the run (its answer names another process than
|
|
4642
|
+
* the run's first request ids), or it did not answer with run-level coverage.
|
|
4604
4643
|
*/
|
|
4605
4644
|
async fetchRun(origin) {
|
|
4606
4645
|
const state = this.stateOf(origin);
|
|
4607
4646
|
const query = `?granularity=${encodeURIComponent(this.options.granularity)}`;
|
|
4647
|
+
let answer = null;
|
|
4648
|
+
let failure = null;
|
|
4608
4649
|
for (const base of this.pathBases(origin)) {
|
|
4609
4650
|
const outcome = await this.call(
|
|
4610
4651
|
`${origin}${base}${RUN_PATH}${query}`,
|
|
@@ -4612,21 +4653,47 @@ var ServerCoverageCollector = class {
|
|
|
4612
4653
|
this.limits.runTimeoutMs
|
|
4613
4654
|
);
|
|
4614
4655
|
if (outcome.kind === "failed") {
|
|
4615
|
-
|
|
4616
|
-
|
|
4656
|
+
failure = outcome.reason;
|
|
4657
|
+
break;
|
|
4617
4658
|
}
|
|
4618
4659
|
const answered = outcome.ok ? runFilesOf(outcome.json) : null;
|
|
4619
4660
|
if (answered) {
|
|
4620
4661
|
state.base = base;
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
fileChecksums(isRecord2(outcome.json) ? outcome.json.files : null)
|
|
4624
|
-
);
|
|
4625
|
-
return this.options.blobs ? this.options.blobs.withBlobs(files.files) : files.files;
|
|
4662
|
+
answer = { files: answered, json: outcome.json };
|
|
4663
|
+
break;
|
|
4626
4664
|
}
|
|
4627
4665
|
}
|
|
4628
|
-
this.
|
|
4629
|
-
|
|
4666
|
+
const gap = answer ? this.restartGap(origin, state, instanceOf(answer.json)) : { origin, reason: "unreachable" };
|
|
4667
|
+
if (!answer) failure ??= this.noEndpoint(origin, RUN_PATH);
|
|
4668
|
+
if (gap && !this.closer.signal.aborted) this.logger.warn(this.gapMessage(gap, failure));
|
|
4669
|
+
const checksums = fileChecksums(answer?.json.files ?? null, new Map(state.testChecksums));
|
|
4670
|
+
const files = await this.verifyChecksums(withTestCoverage(answer?.files ?? [], state.testCoverage), checksums);
|
|
4671
|
+
let out = files.files;
|
|
4672
|
+
try {
|
|
4673
|
+
if (this.options.blobs) out = await this.options.blobs.withBlobs(out);
|
|
4674
|
+
} catch {
|
|
4675
|
+
}
|
|
4676
|
+
return { files: out, gap };
|
|
4677
|
+
}
|
|
4678
|
+
/**
|
|
4679
|
+
* The gap of an origin whose run-level coverage `instance` answered: a
|
|
4680
|
+
* restart when it, or a collection answer, names another process than the
|
|
4681
|
+
* run's first request ids; null when the backend names no process.
|
|
4682
|
+
*/
|
|
4683
|
+
restartGap(origin, state, instance) {
|
|
4684
|
+
if (!instance) return null;
|
|
4685
|
+
const keys = [...state.processKeys];
|
|
4686
|
+
const others = [...state.instances.values()].filter((known) => known.id !== instance.id);
|
|
4687
|
+
if (keys.every((key) => key === instance.id) && others.length === 0) return null;
|
|
4688
|
+
const at = keys[0] === instance.id ? others[others.length - 1]?.startedAt ?? null : instance.startedAt;
|
|
4689
|
+
return { origin, reason: "restarted", ...at ? { at } : {} };
|
|
4690
|
+
}
|
|
4691
|
+
gapMessage(gap, failure) {
|
|
4692
|
+
if (gap.reason === "unreachable") {
|
|
4693
|
+
return `Could not fetch the run's server coverage from ${gap.origin} (${failure}): the run's server coverage is marked incomplete, and keeps the lines the tests' coverage named.`;
|
|
4694
|
+
}
|
|
4695
|
+
const when = gap.at ? ` at about ${gap.at}` : "";
|
|
4696
|
+
return `The backend at ${gap.origin} restarted during the run${when}: the run's server coverage is marked incomplete. It keeps the lines the tests' coverage named; what the earlier process ran outside a request is lost.`;
|
|
4630
4697
|
}
|
|
4631
4698
|
/** Abort every call in flight and fail every later one; the tests still waiting resolve incomplete. */
|
|
4632
4699
|
close() {
|
|
@@ -4648,7 +4715,11 @@ var ServerCoverageCollector = class {
|
|
|
4648
4715
|
timer: null,
|
|
4649
4716
|
base: null,
|
|
4650
4717
|
limit: createLimiter(this.limits.concurrency),
|
|
4651
|
-
warned: false
|
|
4718
|
+
warned: false,
|
|
4719
|
+
processKeys: /* @__PURE__ */ new Set(),
|
|
4720
|
+
instances: /* @__PURE__ */ new Map(),
|
|
4721
|
+
testCoverage: null,
|
|
4722
|
+
testChecksums: /* @__PURE__ */ new Map()
|
|
4652
4723
|
};
|
|
4653
4724
|
this.originStates.set(origin, state);
|
|
4654
4725
|
}
|
|
@@ -4718,6 +4789,13 @@ var ServerCoverageCollector = class {
|
|
|
4718
4789
|
}
|
|
4719
4790
|
/** Send one call and hand each group's answer to its test. */
|
|
4720
4791
|
async send(origin, groups) {
|
|
4792
|
+
const state = this.stateOf(origin);
|
|
4793
|
+
for (const group of groups) {
|
|
4794
|
+
for (const id of group.ids) {
|
|
4795
|
+
const key = processKeyOf(id);
|
|
4796
|
+
if (key !== null) state.processKeys.add(key);
|
|
4797
|
+
}
|
|
4798
|
+
}
|
|
4721
4799
|
let answer = null;
|
|
4722
4800
|
try {
|
|
4723
4801
|
const body = JSON.stringify({
|
|
@@ -4728,6 +4806,8 @@ var ServerCoverageCollector = class {
|
|
|
4728
4806
|
} catch (error) {
|
|
4729
4807
|
this.logger.debug(`Server coverage collection from ${origin} failed: ${errorMessage(error)}`);
|
|
4730
4808
|
}
|
|
4809
|
+
const instance = instanceOf(answer);
|
|
4810
|
+
if (instance && !state.instances.has(instance.id)) state.instances.set(instance.id, instance);
|
|
4731
4811
|
const answered = /* @__PURE__ */ new Map();
|
|
4732
4812
|
for (const group of answer?.groups ?? []) {
|
|
4733
4813
|
if (isRecord2(group) && typeof group.key === "string") answered.set(group.key, group);
|
|
@@ -4739,9 +4819,11 @@ var ServerCoverageCollector = class {
|
|
|
4739
4819
|
} else {
|
|
4740
4820
|
if (Array.isArray(result.missing) && result.missing.length > 0) group.entry.incomplete = true;
|
|
4741
4821
|
fileChecksums(result.files, group.entry.checksums);
|
|
4822
|
+
fileChecksums(result.files, state.testChecksums);
|
|
4742
4823
|
const capture = sanitizeCoverage({ v: 1, files: result.files });
|
|
4743
4824
|
if (capture) {
|
|
4744
4825
|
group.entry.captures.push(capture);
|
|
4826
|
+
state.testCoverage = mergeCoverage(state.testCoverage, capture);
|
|
4745
4827
|
if (capture.incomplete) group.entry.incomplete = true;
|
|
4746
4828
|
}
|
|
4747
4829
|
}
|
|
@@ -6032,6 +6114,82 @@ function parseTestMetadata(annotations) {
|
|
|
6032
6114
|
}
|
|
6033
6115
|
return Object.keys(meta).length > 0 ? meta : null;
|
|
6034
6116
|
}
|
|
6117
|
+
var MAX_EXTERNAL_ID_CHARS = 120;
|
|
6118
|
+
function normalizeExternalId(value) {
|
|
6119
|
+
if (typeof value !== "string") return void 0;
|
|
6120
|
+
const trimmed = value.trim();
|
|
6121
|
+
if (!trimmed || trimmed.length > MAX_EXTERNAL_ID_CHARS) return void 0;
|
|
6122
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
6123
|
+
const code = trimmed.charCodeAt(i);
|
|
6124
|
+
if (code < 32 || code === 127) return void 0;
|
|
6125
|
+
}
|
|
6126
|
+
return trimmed;
|
|
6127
|
+
}
|
|
6128
|
+
function parseExternalId(annotations) {
|
|
6129
|
+
if (!Array.isArray(annotations)) return void 0;
|
|
6130
|
+
let id;
|
|
6131
|
+
for (const entry of annotations) {
|
|
6132
|
+
if (!entry || typeof entry !== "object") continue;
|
|
6133
|
+
const { type, description } = entry;
|
|
6134
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== `${PIWI_ANNOTATION_PREFIX}id`) continue;
|
|
6135
|
+
const value = normalizeExternalId(description);
|
|
6136
|
+
if (value) id = value;
|
|
6137
|
+
}
|
|
6138
|
+
return id;
|
|
6139
|
+
}
|
|
6140
|
+
function compileExternalIdPattern(pattern) {
|
|
6141
|
+
if (typeof pattern === "string") return new RegExp(pattern);
|
|
6142
|
+
return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
|
|
6143
|
+
}
|
|
6144
|
+
function readExternalIdFromTitle(title, pattern) {
|
|
6145
|
+
const match = pattern.exec(title);
|
|
6146
|
+
if (!match) return null;
|
|
6147
|
+
const externalId = normalizeExternalId(match.length > 1 ? match[1] : match[0]);
|
|
6148
|
+
if (!externalId) return null;
|
|
6149
|
+
const rest = (title.slice(0, match.index) + title.slice(match.index + match[0].length)).trim();
|
|
6150
|
+
return rest ? { externalId, displayTitle: rest } : { externalId };
|
|
6151
|
+
}
|
|
6152
|
+
var MAX_EXTERNAL_ID_DUPLICATES = 50;
|
|
6153
|
+
var MAX_EXTERNAL_ID_DUPLICATE_TESTS = 10;
|
|
6154
|
+
function duplicateTestKey(test) {
|
|
6155
|
+
return `${test.filePath}\0${(test.suitePath ?? []).join("")}\0${test.title}`;
|
|
6156
|
+
}
|
|
6157
|
+
function readDuplicateTest(raw) {
|
|
6158
|
+
if (!raw || typeof raw !== "object") return null;
|
|
6159
|
+
const { filePath: filePath2, suitePath, title, line } = raw;
|
|
6160
|
+
if (typeof filePath2 !== "string" || typeof title !== "string") return null;
|
|
6161
|
+
const test = { filePath: filePath2, title };
|
|
6162
|
+
if (Array.isArray(suitePath) && suitePath.every((s) => typeof s === "string") && suitePath.length > 0)
|
|
6163
|
+
test.suitePath = suitePath;
|
|
6164
|
+
if (typeof line === "number" && Number.isFinite(line)) test.line = line;
|
|
6165
|
+
return test;
|
|
6166
|
+
}
|
|
6167
|
+
function mergeExternalIdDuplicates(...lists) {
|
|
6168
|
+
const byId = /* @__PURE__ */ new Map();
|
|
6169
|
+
for (const list of lists) {
|
|
6170
|
+
if (!Array.isArray(list)) continue;
|
|
6171
|
+
for (const entry of list) {
|
|
6172
|
+
if (!entry || typeof entry !== "object") continue;
|
|
6173
|
+
const externalId = normalizeExternalId(entry.externalId);
|
|
6174
|
+
const tests = entry.tests;
|
|
6175
|
+
if (!externalId || !Array.isArray(tests)) continue;
|
|
6176
|
+
const read = tests.map(readDuplicateTest).filter((test) => test !== null);
|
|
6177
|
+
if (read.length === 0) continue;
|
|
6178
|
+
let known = byId.get(externalId);
|
|
6179
|
+
if (!known) {
|
|
6180
|
+
if (byId.size >= MAX_EXTERNAL_ID_DUPLICATES) continue;
|
|
6181
|
+
known = /* @__PURE__ */ new Map();
|
|
6182
|
+
byId.set(externalId, known);
|
|
6183
|
+
}
|
|
6184
|
+
for (const test of read) {
|
|
6185
|
+
if (known.size >= MAX_EXTERNAL_ID_DUPLICATE_TESTS) break;
|
|
6186
|
+
const key = duplicateTestKey(test);
|
|
6187
|
+
if (!known.has(key)) known.set(key, test);
|
|
6188
|
+
}
|
|
6189
|
+
}
|
|
6190
|
+
}
|
|
6191
|
+
return [...byId].map(([externalId, tests]) => ({ externalId, tests: [...tests.values()] }));
|
|
6192
|
+
}
|
|
6035
6193
|
|
|
6036
6194
|
// src/internal/collect/test-meta.ts
|
|
6037
6195
|
function collectTestTags(test) {
|
|
@@ -6045,6 +6203,84 @@ function collectTestMetadata(annotations) {
|
|
|
6045
6203
|
return parseTestMetadata(annotations);
|
|
6046
6204
|
}
|
|
6047
6205
|
|
|
6206
|
+
// src/internal/collect/external-ids.ts
|
|
6207
|
+
function naturalKey(test) {
|
|
6208
|
+
return `${test.filePath}\0${test.suitePath.join("")}\0${test.title}`;
|
|
6209
|
+
}
|
|
6210
|
+
function duplicateTest(test) {
|
|
6211
|
+
return {
|
|
6212
|
+
filePath: test.filePath,
|
|
6213
|
+
...test.suitePath.length ? { suitePath: test.suitePath } : {},
|
|
6214
|
+
title: test.title,
|
|
6215
|
+
line: test.line
|
|
6216
|
+
};
|
|
6217
|
+
}
|
|
6218
|
+
function planExternalIds(tests, options = {}) {
|
|
6219
|
+
let pattern = null;
|
|
6220
|
+
let patternError;
|
|
6221
|
+
if (options.pattern !== void 0 && options.pattern !== "") {
|
|
6222
|
+
try {
|
|
6223
|
+
pattern = compileExternalIdPattern(options.pattern);
|
|
6224
|
+
} catch (error) {
|
|
6225
|
+
patternError = error instanceof Error ? error.message : String(error);
|
|
6226
|
+
}
|
|
6227
|
+
}
|
|
6228
|
+
const found = /* @__PURE__ */ new Map();
|
|
6229
|
+
const holders = /* @__PURE__ */ new Map();
|
|
6230
|
+
for (const test of tests) {
|
|
6231
|
+
let id = null;
|
|
6232
|
+
const declared = parseExternalId(test.annotations);
|
|
6233
|
+
if (declared) id = { externalId: declared };
|
|
6234
|
+
else if (pattern) {
|
|
6235
|
+
const read = readExternalIdFromTitle(test.title, pattern);
|
|
6236
|
+
if (read) id = options.stripFromTitle ? read : { externalId: read.externalId };
|
|
6237
|
+
}
|
|
6238
|
+
if (!id) continue;
|
|
6239
|
+
found.set(test.testId, id);
|
|
6240
|
+
let keys = holders.get(id.externalId);
|
|
6241
|
+
if (!keys) holders.set(id.externalId, keys = /* @__PURE__ */ new Map());
|
|
6242
|
+
const key = naturalKey(test);
|
|
6243
|
+
if (!keys.has(key)) keys.set(key, test);
|
|
6244
|
+
}
|
|
6245
|
+
const duplicates = [];
|
|
6246
|
+
const duplicateIds = /* @__PURE__ */ new Set();
|
|
6247
|
+
for (const [externalId, keys] of holders) {
|
|
6248
|
+
if (keys.size < 2) continue;
|
|
6249
|
+
duplicateIds.add(externalId);
|
|
6250
|
+
duplicates.push({ externalId, tests: [...keys.values()].map(duplicateTest) });
|
|
6251
|
+
}
|
|
6252
|
+
const byTestId = /* @__PURE__ */ new Map();
|
|
6253
|
+
for (const [testId, id] of found) if (!duplicateIds.has(id.externalId)) byTestId.set(testId, id);
|
|
6254
|
+
return {
|
|
6255
|
+
byTestId,
|
|
6256
|
+
duplicates: mergeExternalIdDuplicates(duplicates),
|
|
6257
|
+
...patternError ? { patternError } : {}
|
|
6258
|
+
};
|
|
6259
|
+
}
|
|
6260
|
+
function formatExternalIdDuplicates(duplicates, onDuplicate) {
|
|
6261
|
+
if (duplicates.length === 0) return [];
|
|
6262
|
+
const lines = [];
|
|
6263
|
+
for (const { externalId, tests } of duplicates) {
|
|
6264
|
+
lines.push(
|
|
6265
|
+
`External ID ${externalId} is declared by ${tests.length} tests; this run matches them by file, describe path and title:`
|
|
6266
|
+
);
|
|
6267
|
+
for (const test of tests) {
|
|
6268
|
+
const where = test.line != null ? `${test.filePath}:${test.line}` : test.filePath;
|
|
6269
|
+
lines.push(` ${where} ${[...test.suitePath ?? [], test.title].join(" \u203A ")}`);
|
|
6270
|
+
}
|
|
6271
|
+
}
|
|
6272
|
+
lines.push(
|
|
6273
|
+
onDuplicate === "fail" ? "Give each test its own ID: the run fails while two tests share one (externalId.onDuplicate: fail)." : "Give each test its own ID, or set externalId.onDuplicate: 'fail' to fail the run instead."
|
|
6274
|
+
);
|
|
6275
|
+
return lines;
|
|
6276
|
+
}
|
|
6277
|
+
function resolveOnDuplicate(raw) {
|
|
6278
|
+
if (raw === void 0 || raw === null || raw === "") return { onDuplicate: "report" };
|
|
6279
|
+
const value = String(raw).trim().toLowerCase();
|
|
6280
|
+
if (value === "report" || value === "fail") return { onDuplicate: value };
|
|
6281
|
+
return { onDuplicate: "report", invalid: String(raw) };
|
|
6282
|
+
}
|
|
6283
|
+
|
|
6048
6284
|
// ../core/src/error-text.ts
|
|
6049
6285
|
function joinErrorMessages(errors) {
|
|
6050
6286
|
if (!errors || errors.length === 0) return null;
|
|
@@ -10496,17 +10732,26 @@ var CoverageCollection = class {
|
|
|
10496
10732
|
}
|
|
10497
10733
|
/**
|
|
10498
10734
|
* Send the run-level coverage of every origin, and the browser's, to the
|
|
10499
|
-
* dashboard run, in requests within its limits
|
|
10500
|
-
*
|
|
10735
|
+
* dashboard run, in requests within its limits; the first names the origins
|
|
10736
|
+
* whose server coverage is incomplete (`incomplete`), and is sent for them
|
|
10737
|
+
* alone when no file is. A failure is logged once, never thrown.
|
|
10501
10738
|
*/
|
|
10502
10739
|
async sendRunCoverage(httpClient, runId, auth) {
|
|
10503
10740
|
if (!this.collector && !this.browserBlobs) return;
|
|
10504
10741
|
this.fetchRunCoverage();
|
|
10505
|
-
const
|
|
10742
|
+
const runs = await Promise.all(this.runFiles.values());
|
|
10743
|
+
const server = runs.flatMap((result) => result?.files ?? []);
|
|
10744
|
+
const gaps = runs.flatMap((result) => result?.gap ? [result.gap] : []);
|
|
10506
10745
|
const files = [...server, ...await this.browserRunFiles()];
|
|
10507
|
-
|
|
10746
|
+
const chunks = chunkRunCoverage(files);
|
|
10747
|
+
if (chunks.length === 0 && gaps.length > 0) chunks.push([]);
|
|
10748
|
+
for (const [i, chunk] of chunks.entries()) {
|
|
10508
10749
|
try {
|
|
10509
|
-
await httpClient.putJSON(
|
|
10750
|
+
await httpClient.putJSON(
|
|
10751
|
+
`/api/test-runs/${runId}/coverage`,
|
|
10752
|
+
{ v: 1, files: chunk, ...i === 0 && gaps.length > 0 ? { incomplete: gaps } : {} },
|
|
10753
|
+
auth
|
|
10754
|
+
);
|
|
10510
10755
|
} catch (error) {
|
|
10511
10756
|
this.logger.warn(`Could not send the run's coverage to the dashboard: ${errorMessage(error)}`);
|
|
10512
10757
|
return;
|
|
@@ -10553,6 +10798,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10553
10798
|
this.instanceId = "";
|
|
10554
10799
|
this.shardInfo = null;
|
|
10555
10800
|
this.metadata = {};
|
|
10801
|
+
/** Each planned test's external ID and the IDs two tests share, settled in `onBegin`. */
|
|
10802
|
+
this.externalIds = null;
|
|
10556
10803
|
this.isFullRun = true;
|
|
10557
10804
|
this.filterDetails = null;
|
|
10558
10805
|
/** Configured `maxFailures` (0 = unlimited) — disambiguates an interrupted run's unrun reason. */
|
|
@@ -10701,6 +10948,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10701
10948
|
this.metadata = flakeRunMetadata(stamp, this.metadata);
|
|
10702
10949
|
}
|
|
10703
10950
|
this.plannedTests = suite.allTests();
|
|
10951
|
+
this.planExternalIds();
|
|
10704
10952
|
this.shardInfo = resolveShardInfo(config);
|
|
10705
10953
|
if (this.shardInfo) {
|
|
10706
10954
|
this.logger.info(`Shard ${this.shardInfo.current}/${this.shardInfo.total} detected`);
|
|
@@ -10745,6 +10993,44 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10745
10993
|
this.plannedTests.length
|
|
10746
10994
|
);
|
|
10747
10995
|
}
|
|
10996
|
+
/**
|
|
10997
|
+
* Settle each planned test's external ID before any test runs, name the IDs two tests share, and record them on
|
|
10998
|
+
* the run so the dashboard lists them too.
|
|
10999
|
+
*/
|
|
11000
|
+
planExternalIds() {
|
|
11001
|
+
const options = this.options.externalId ?? {};
|
|
11002
|
+
const plan = planExternalIds(
|
|
11003
|
+
this.plannedTests.map((test) => ({
|
|
11004
|
+
testId: test.id,
|
|
11005
|
+
filePath: testFile(test, this.testPathBase),
|
|
11006
|
+
suitePath: this.metadataCollector.getSuiteInfo(test).suitePath ?? [],
|
|
11007
|
+
title: test.title,
|
|
11008
|
+
line: test.location.line,
|
|
11009
|
+
annotations: test.annotations ?? []
|
|
11010
|
+
})),
|
|
11011
|
+
options
|
|
11012
|
+
);
|
|
11013
|
+
if (plan.patternError) {
|
|
11014
|
+
this.logger.error(
|
|
11015
|
+
`externalId.pattern is not a valid regular expression, so no ID is read from titles: ${plan.patternError}`
|
|
11016
|
+
);
|
|
11017
|
+
}
|
|
11018
|
+
const { onDuplicate, invalid } = resolveOnDuplicate(options.onDuplicate);
|
|
11019
|
+
if (invalid !== void 0) {
|
|
11020
|
+
this.logger.warn(
|
|
11021
|
+
`externalId.onDuplicate is "${invalid}"; expected 'report' or 'fail', so duplicates are reported.`
|
|
11022
|
+
);
|
|
11023
|
+
}
|
|
11024
|
+
for (const line of formatExternalIdDuplicates(plan.duplicates, onDuplicate)) this.logger.warn(line);
|
|
11025
|
+
if (plan.duplicates.length > 0) this.metadata = { ...this.metadata, externalIdDuplicates: plan.duplicates };
|
|
11026
|
+
this.externalIds = plan;
|
|
11027
|
+
}
|
|
11028
|
+
/** The external ID fields of a test's payload: `null` tells the dashboard the test has none to match on. */
|
|
11029
|
+
externalIdFields(test) {
|
|
11030
|
+
if (!this.externalIds) return {};
|
|
11031
|
+
const id = this.externalIds.byTestId.get(test.id);
|
|
11032
|
+
return { externalId: id?.externalId ?? null, displayTitle: id?.displayTitle ?? null };
|
|
11033
|
+
}
|
|
10748
11034
|
/** Playwright reporter hook: called when an individual test begins */
|
|
10749
11035
|
onTestBegin(test, result) {
|
|
10750
11036
|
this.lastTestWatch?.begin(test.id);
|
|
@@ -10872,6 +11158,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10872
11158
|
tags: tags.length ? tags : null,
|
|
10873
11159
|
locks: locks.length ? locks : null,
|
|
10874
11160
|
testMeta: collectTestMetadata(annotations),
|
|
11161
|
+
...this.externalIdFields(test),
|
|
10875
11162
|
// An annotation-less skip reclassified to `didnotrun` is a serial-group
|
|
10876
11163
|
// cascade: an earlier test failed and Playwright skipped the rest.
|
|
10877
11164
|
didNotRunReason: status === "didnotrun" ? "previous-failure" : null,
|
|
@@ -11074,6 +11361,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
11074
11361
|
tags: tags.length ? tags : null,
|
|
11075
11362
|
locks: locks.length ? locks : null,
|
|
11076
11363
|
testMeta: collectTestMetadata(declaredAnnotations),
|
|
11364
|
+
...this.externalIdFields(test),
|
|
11077
11365
|
didNotRunReason: reason,
|
|
11078
11366
|
expectedStatus: test.expectedStatus ?? null
|
|
11079
11367
|
};
|
|
@@ -11085,8 +11373,23 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
11085
11373
|
}
|
|
11086
11374
|
}
|
|
11087
11375
|
}
|
|
11088
|
-
/**
|
|
11376
|
+
/**
|
|
11377
|
+
* Playwright reporter hook: called when the full test run finishes. Fails the run when two tests share an external
|
|
11378
|
+
* ID and `externalId.onDuplicate` is `'fail'`.
|
|
11379
|
+
*/
|
|
11089
11380
|
async onEnd(result) {
|
|
11381
|
+
const duplicates = this.externalIds?.duplicates.length ?? 0;
|
|
11382
|
+
const failsOnDuplicates = duplicates > 0 && resolveOnDuplicate(this.options.externalId?.onDuplicate).onDuplicate === "fail";
|
|
11383
|
+
const overridden = failsOnDuplicates && result?.status === "passed";
|
|
11384
|
+
await this.finishRun(overridden ? { ...result, status: "failed" } : result);
|
|
11385
|
+
if (failsOnDuplicates) {
|
|
11386
|
+
this.logger.error(
|
|
11387
|
+
`Failing the run: ${duplicates} external ID${duplicates === 1 ? " is" : "s are"} declared by more than one test.`
|
|
11388
|
+
);
|
|
11389
|
+
return { status: "failed" };
|
|
11390
|
+
}
|
|
11391
|
+
}
|
|
11392
|
+
async finishRun(result) {
|
|
11090
11393
|
if (this.listMode || !this.enabled) return;
|
|
11091
11394
|
if (this.startTime === null) {
|
|
11092
11395
|
this.logger.debug("The run stopped before any test began \u2014 nothing to report.");
|
|
@@ -2648,6 +2648,8 @@ var PIWI_ENV_KEYS = {
|
|
|
2648
2648
|
codeReachRoots: "PIWI_CODE_REACH_ROOTS",
|
|
2649
2649
|
coverageBrowser: "PIWI_COVERAGE_BROWSER",
|
|
2650
2650
|
coverageServer: "PIWI_COVERAGE_SERVER",
|
|
2651
|
+
externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
|
|
2652
|
+
externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
|
|
2651
2653
|
captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
|
|
2652
2654
|
captureResources: "PIWI_CAPTURE_RESOURCES",
|
|
2653
2655
|
leakCheck: "PIWI_LEAK_CHECK",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piwitests/reporter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "Playwright reporter that streams results, traces and HTML reports to a Piwi Dashboard instance",
|
|
5
5
|
"url": "https://github.com/PiwiTests/platform",
|
|
6
6
|
"homepage": "https://piwitests.dev",
|
|
@@ -21,8 +21,9 @@ view, or the reporter CLI with `PIWI_DASHBOARD_URL` / `PIWI_API_KEY` /
|
|
|
21
21
|
## Steps
|
|
22
22
|
|
|
23
23
|
1. **Find the gaps.** On a change, call `get_change_coverage` for the current
|
|
24
|
-
branch first — the
|
|
25
|
-
|
|
24
|
+
branch first — the files whose `reach` is `unreached` (the uncovered
|
|
25
|
+
changes) and the `lines.notRun` of the files it did reach are what the
|
|
26
|
+
change left untested. Otherwise call `list_scenario_gaps` for the project. Filter to what you are
|
|
26
27
|
working on with `class`, `feature`, `minScore` or `pr`. Read the classes:
|
|
27
28
|
- **blind-spot** — no trusted test reaches it (a route, a page, a control, a
|
|
28
29
|
new error path).
|