@piwitests/reporter 0.57.0 → 0.59.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 +4 -2
- package/dist/global-setup-module.js +9 -0
- package/dist/index.d.ts +44 -2
- package/dist/index.js +314 -17
- package/dist/internal/capture/capture-fixtures.js +76 -15
- package/dist/internal/capture/pick-on-failure.js +74 -15
- package/package.json +1 -1
- package/templates/skills/write-the-missing-test/SKILL.md +3 -2
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",
|
|
@@ -4877,7 +4879,7 @@ Connection:
|
|
|
4877
4879
|
--server-url <url> Dashboard URL (env PIWI_DASHBOARD_URL)
|
|
4878
4880
|
--api-key <key> API key (env PIWI_API_KEY)
|
|
4879
4881
|
|
|
4880
|
-
Policy (at least one is required):
|
|
4882
|
+
Policy (at least one rule or warn-only flag is required):
|
|
4881
4883
|
--require-tag <tags> Comma-separated; every test carrying the tag must pass
|
|
4882
4884
|
--max-failed <n> Fail when more than n tests failed
|
|
4883
4885
|
--max-new-regressions <n> Fail when more than n tests newly started failing
|
|
@@ -4890,7 +4892,7 @@ Policy (at least one is required):
|
|
|
4890
4892
|
--max-new-leaks <n> Fail when more than n of those were never seen on the base branch
|
|
4891
4893
|
|
|
4892
4894
|
Warn-only (reported, never fails the build):
|
|
4893
|
-
--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
|
|
4894
4896
|
--min-patch-coverage <percent> Warn when fewer of the changed lines ran, in the commit's combined coverage
|
|
4895
4897
|
|
|
4896
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;
|
|
@@ -625,6 +634,7 @@ function toWireTestCase(tc) {
|
|
|
625
634
|
timeout: rest.timeout ?? null,
|
|
626
635
|
error: rest.error,
|
|
627
636
|
retries: rest.retries,
|
|
637
|
+
repeatEachIndex: rest.repeatEachIndex ?? 0,
|
|
628
638
|
attempts: rest.attempts || null,
|
|
629
639
|
workerIndex: rest.workerIndex ?? null,
|
|
630
640
|
shardIndex: rest.shardIndex ?? null,
|
|
@@ -652,6 +662,9 @@ function toWireTestCase(tc) {
|
|
|
652
662
|
tags: rest.tags ?? null,
|
|
653
663
|
locks: rest.locks ?? null,
|
|
654
664
|
testMeta: rest.testMeta ?? null,
|
|
665
|
+
// Kept `undefined` when unset (a `begin` event): `null` would tell the dashboard the test has no ID.
|
|
666
|
+
externalId: rest.externalId,
|
|
667
|
+
displayTitle: rest.displayTitle ?? null,
|
|
655
668
|
locatorSnapshots: rest.locatorSnapshots || null,
|
|
656
669
|
locatorPages: rest.locatorPages || null,
|
|
657
670
|
codeReach: rest.codeReach || null,
|
|
@@ -674,7 +687,13 @@ function isFailingStatus(status) {
|
|
|
674
687
|
function computeDistinctRunCounts(testCases) {
|
|
675
688
|
const final = /* @__PURE__ */ new Map();
|
|
676
689
|
for (const tc of testCases) {
|
|
677
|
-
const key = tc.testId ?? [
|
|
690
|
+
const key = tc.testId ?? [
|
|
691
|
+
tc.title,
|
|
692
|
+
tc.location,
|
|
693
|
+
(tc.suitePath ?? []).join(""),
|
|
694
|
+
browserProjectName(tc.browser),
|
|
695
|
+
tc.repeatEachIndex ?? 0
|
|
696
|
+
].join("\0");
|
|
678
697
|
const status = tc.status ?? "";
|
|
679
698
|
const retries = tc.retries ?? 0;
|
|
680
699
|
const prev = final.get(key);
|
|
@@ -832,6 +851,7 @@ var Uploader = class {
|
|
|
832
851
|
title: testCase.title,
|
|
833
852
|
location: testCase.location,
|
|
834
853
|
retries: testCase.retries ?? 0,
|
|
854
|
+
repeatEachIndex: testCase.repeatEachIndex ?? 0,
|
|
835
855
|
suitePath: testCase.suitePath ?? null
|
|
836
856
|
})
|
|
837
857
|
);
|
|
@@ -6102,6 +6122,82 @@ function parseTestMetadata(annotations) {
|
|
|
6102
6122
|
}
|
|
6103
6123
|
return Object.keys(meta).length > 0 ? meta : null;
|
|
6104
6124
|
}
|
|
6125
|
+
var MAX_EXTERNAL_ID_CHARS = 120;
|
|
6126
|
+
function normalizeExternalId(value) {
|
|
6127
|
+
if (typeof value !== "string") return void 0;
|
|
6128
|
+
const trimmed = value.trim();
|
|
6129
|
+
if (!trimmed || trimmed.length > MAX_EXTERNAL_ID_CHARS) return void 0;
|
|
6130
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
6131
|
+
const code = trimmed.charCodeAt(i);
|
|
6132
|
+
if (code < 32 || code === 127) return void 0;
|
|
6133
|
+
}
|
|
6134
|
+
return trimmed;
|
|
6135
|
+
}
|
|
6136
|
+
function parseExternalId(annotations) {
|
|
6137
|
+
if (!Array.isArray(annotations)) return void 0;
|
|
6138
|
+
let id;
|
|
6139
|
+
for (const entry of annotations) {
|
|
6140
|
+
if (!entry || typeof entry !== "object") continue;
|
|
6141
|
+
const { type, description } = entry;
|
|
6142
|
+
if (typeof type !== "string" || type.trim().toLowerCase() !== `${PIWI_ANNOTATION_PREFIX}id`) continue;
|
|
6143
|
+
const value = normalizeExternalId(description);
|
|
6144
|
+
if (value) id = value;
|
|
6145
|
+
}
|
|
6146
|
+
return id;
|
|
6147
|
+
}
|
|
6148
|
+
function compileExternalIdPattern(pattern) {
|
|
6149
|
+
if (typeof pattern === "string") return new RegExp(pattern);
|
|
6150
|
+
return new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""));
|
|
6151
|
+
}
|
|
6152
|
+
function readExternalIdFromTitle(title, pattern) {
|
|
6153
|
+
const match = pattern.exec(title);
|
|
6154
|
+
if (!match) return null;
|
|
6155
|
+
const externalId = normalizeExternalId(match.length > 1 ? match[1] : match[0]);
|
|
6156
|
+
if (!externalId) return null;
|
|
6157
|
+
const rest = (title.slice(0, match.index) + title.slice(match.index + match[0].length)).trim();
|
|
6158
|
+
return rest ? { externalId, displayTitle: rest } : { externalId };
|
|
6159
|
+
}
|
|
6160
|
+
var MAX_EXTERNAL_ID_DUPLICATES = 50;
|
|
6161
|
+
var MAX_EXTERNAL_ID_DUPLICATE_TESTS = 10;
|
|
6162
|
+
function duplicateTestKey(test) {
|
|
6163
|
+
return `${test.filePath}\0${(test.suitePath ?? []).join("")}\0${test.title}`;
|
|
6164
|
+
}
|
|
6165
|
+
function readDuplicateTest(raw) {
|
|
6166
|
+
if (!raw || typeof raw !== "object") return null;
|
|
6167
|
+
const { filePath: filePath2, suitePath, title, line } = raw;
|
|
6168
|
+
if (typeof filePath2 !== "string" || typeof title !== "string") return null;
|
|
6169
|
+
const test = { filePath: filePath2, title };
|
|
6170
|
+
if (Array.isArray(suitePath) && suitePath.every((s) => typeof s === "string") && suitePath.length > 0)
|
|
6171
|
+
test.suitePath = suitePath;
|
|
6172
|
+
if (typeof line === "number" && Number.isFinite(line)) test.line = line;
|
|
6173
|
+
return test;
|
|
6174
|
+
}
|
|
6175
|
+
function mergeExternalIdDuplicates(...lists) {
|
|
6176
|
+
const byId = /* @__PURE__ */ new Map();
|
|
6177
|
+
for (const list of lists) {
|
|
6178
|
+
if (!Array.isArray(list)) continue;
|
|
6179
|
+
for (const entry of list) {
|
|
6180
|
+
if (!entry || typeof entry !== "object") continue;
|
|
6181
|
+
const externalId = normalizeExternalId(entry.externalId);
|
|
6182
|
+
const tests = entry.tests;
|
|
6183
|
+
if (!externalId || !Array.isArray(tests)) continue;
|
|
6184
|
+
const read = tests.map(readDuplicateTest).filter((test) => test !== null);
|
|
6185
|
+
if (read.length === 0) continue;
|
|
6186
|
+
let known = byId.get(externalId);
|
|
6187
|
+
if (!known) {
|
|
6188
|
+
if (byId.size >= MAX_EXTERNAL_ID_DUPLICATES) continue;
|
|
6189
|
+
known = /* @__PURE__ */ new Map();
|
|
6190
|
+
byId.set(externalId, known);
|
|
6191
|
+
}
|
|
6192
|
+
for (const test of read) {
|
|
6193
|
+
if (known.size >= MAX_EXTERNAL_ID_DUPLICATE_TESTS) break;
|
|
6194
|
+
const key = duplicateTestKey(test);
|
|
6195
|
+
if (!known.has(key)) known.set(key, test);
|
|
6196
|
+
}
|
|
6197
|
+
}
|
|
6198
|
+
}
|
|
6199
|
+
return [...byId].map(([externalId, tests]) => ({ externalId, tests: [...tests.values()] }));
|
|
6200
|
+
}
|
|
6105
6201
|
|
|
6106
6202
|
// src/internal/collect/test-meta.ts
|
|
6107
6203
|
function collectTestTags(test) {
|
|
@@ -6115,6 +6211,84 @@ function collectTestMetadata(annotations) {
|
|
|
6115
6211
|
return parseTestMetadata(annotations);
|
|
6116
6212
|
}
|
|
6117
6213
|
|
|
6214
|
+
// src/internal/collect/external-ids.ts
|
|
6215
|
+
function naturalKey(test) {
|
|
6216
|
+
return `${test.filePath}\0${test.suitePath.join("")}\0${test.title}`;
|
|
6217
|
+
}
|
|
6218
|
+
function duplicateTest(test) {
|
|
6219
|
+
return {
|
|
6220
|
+
filePath: test.filePath,
|
|
6221
|
+
...test.suitePath.length ? { suitePath: test.suitePath } : {},
|
|
6222
|
+
title: test.title,
|
|
6223
|
+
line: test.line
|
|
6224
|
+
};
|
|
6225
|
+
}
|
|
6226
|
+
function planExternalIds(tests, options = {}) {
|
|
6227
|
+
let pattern = null;
|
|
6228
|
+
let patternError;
|
|
6229
|
+
if (options.pattern !== void 0 && options.pattern !== "") {
|
|
6230
|
+
try {
|
|
6231
|
+
pattern = compileExternalIdPattern(options.pattern);
|
|
6232
|
+
} catch (error) {
|
|
6233
|
+
patternError = error instanceof Error ? error.message : String(error);
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6236
|
+
const found = /* @__PURE__ */ new Map();
|
|
6237
|
+
const holders = /* @__PURE__ */ new Map();
|
|
6238
|
+
for (const test of tests) {
|
|
6239
|
+
let id = null;
|
|
6240
|
+
const declared = parseExternalId(test.annotations);
|
|
6241
|
+
if (declared) id = { externalId: declared };
|
|
6242
|
+
else if (pattern) {
|
|
6243
|
+
const read = readExternalIdFromTitle(test.title, pattern);
|
|
6244
|
+
if (read) id = options.stripFromTitle ? read : { externalId: read.externalId };
|
|
6245
|
+
}
|
|
6246
|
+
if (!id) continue;
|
|
6247
|
+
found.set(test.testId, id);
|
|
6248
|
+
let keys = holders.get(id.externalId);
|
|
6249
|
+
if (!keys) holders.set(id.externalId, keys = /* @__PURE__ */ new Map());
|
|
6250
|
+
const key = naturalKey(test);
|
|
6251
|
+
if (!keys.has(key)) keys.set(key, test);
|
|
6252
|
+
}
|
|
6253
|
+
const duplicates = [];
|
|
6254
|
+
const duplicateIds = /* @__PURE__ */ new Set();
|
|
6255
|
+
for (const [externalId, keys] of holders) {
|
|
6256
|
+
if (keys.size < 2) continue;
|
|
6257
|
+
duplicateIds.add(externalId);
|
|
6258
|
+
duplicates.push({ externalId, tests: [...keys.values()].map(duplicateTest) });
|
|
6259
|
+
}
|
|
6260
|
+
const byTestId = /* @__PURE__ */ new Map();
|
|
6261
|
+
for (const [testId, id] of found) if (!duplicateIds.has(id.externalId)) byTestId.set(testId, id);
|
|
6262
|
+
return {
|
|
6263
|
+
byTestId,
|
|
6264
|
+
duplicates: mergeExternalIdDuplicates(duplicates),
|
|
6265
|
+
...patternError ? { patternError } : {}
|
|
6266
|
+
};
|
|
6267
|
+
}
|
|
6268
|
+
function formatExternalIdDuplicates(duplicates, onDuplicate) {
|
|
6269
|
+
if (duplicates.length === 0) return [];
|
|
6270
|
+
const lines = [];
|
|
6271
|
+
for (const { externalId, tests } of duplicates) {
|
|
6272
|
+
lines.push(
|
|
6273
|
+
`External ID ${externalId} is declared by ${tests.length} tests; this run matches them by file, describe path and title:`
|
|
6274
|
+
);
|
|
6275
|
+
for (const test of tests) {
|
|
6276
|
+
const where = test.line != null ? `${test.filePath}:${test.line}` : test.filePath;
|
|
6277
|
+
lines.push(` ${where} ${[...test.suitePath ?? [], test.title].join(" \u203A ")}`);
|
|
6278
|
+
}
|
|
6279
|
+
}
|
|
6280
|
+
lines.push(
|
|
6281
|
+
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."
|
|
6282
|
+
);
|
|
6283
|
+
return lines;
|
|
6284
|
+
}
|
|
6285
|
+
function resolveOnDuplicate(raw) {
|
|
6286
|
+
if (raw === void 0 || raw === null || raw === "") return { onDuplicate: "report" };
|
|
6287
|
+
const value = String(raw).trim().toLowerCase();
|
|
6288
|
+
if (value === "report" || value === "fail") return { onDuplicate: value };
|
|
6289
|
+
return { onDuplicate: "report", invalid: String(raw) };
|
|
6290
|
+
}
|
|
6291
|
+
|
|
6118
6292
|
// ../core/src/error-text.ts
|
|
6119
6293
|
function joinErrorMessages(errors) {
|
|
6120
6294
|
if (!errors || errors.length === 0) return null;
|
|
@@ -9375,6 +9549,7 @@ function caseLocateUrl(serverUrl, runId, test) {
|
|
|
9375
9549
|
`retry=${test.retry}`
|
|
9376
9550
|
];
|
|
9377
9551
|
if (test.browser) params.push(`browser=${encodeURIComponent(test.browser)}`);
|
|
9552
|
+
if (test.repeatEachIndex) params.push(`repeat=${test.repeatEachIndex}`);
|
|
9378
9553
|
return `${serverUrl.replace(/\/+$/, "")}/test-runs/${runId}/locate?${params.join("&")}`;
|
|
9379
9554
|
}
|
|
9380
9555
|
function formatFailureLine(link) {
|
|
@@ -10632,6 +10807,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10632
10807
|
this.instanceId = "";
|
|
10633
10808
|
this.shardInfo = null;
|
|
10634
10809
|
this.metadata = {};
|
|
10810
|
+
/** Each planned test's external ID and the IDs two tests share, settled in `onBegin`. */
|
|
10811
|
+
this.externalIds = null;
|
|
10635
10812
|
this.isFullRun = true;
|
|
10636
10813
|
this.filterDetails = null;
|
|
10637
10814
|
/** Configured `maxFailures` (0 = unlimited) — disambiguates an interrupted run's unrun reason. */
|
|
@@ -10780,6 +10957,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10780
10957
|
this.metadata = flakeRunMetadata(stamp, this.metadata);
|
|
10781
10958
|
}
|
|
10782
10959
|
this.plannedTests = suite.allTests();
|
|
10960
|
+
this.planExternalIds();
|
|
10783
10961
|
this.shardInfo = resolveShardInfo(config);
|
|
10784
10962
|
if (this.shardInfo) {
|
|
10785
10963
|
this.logger.info(`Shard ${this.shardInfo.current}/${this.shardInfo.total} detected`);
|
|
@@ -10824,6 +11002,44 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10824
11002
|
this.plannedTests.length
|
|
10825
11003
|
);
|
|
10826
11004
|
}
|
|
11005
|
+
/**
|
|
11006
|
+
* Settle each planned test's external ID before any test runs, name the IDs two tests share, and record them on
|
|
11007
|
+
* the run so the dashboard lists them too.
|
|
11008
|
+
*/
|
|
11009
|
+
planExternalIds() {
|
|
11010
|
+
const options = this.options.externalId ?? {};
|
|
11011
|
+
const plan = planExternalIds(
|
|
11012
|
+
this.plannedTests.map((test) => ({
|
|
11013
|
+
testId: test.id,
|
|
11014
|
+
filePath: testFile(test, this.testPathBase),
|
|
11015
|
+
suitePath: this.metadataCollector.getSuiteInfo(test).suitePath ?? [],
|
|
11016
|
+
title: test.title,
|
|
11017
|
+
line: test.location.line,
|
|
11018
|
+
annotations: test.annotations ?? []
|
|
11019
|
+
})),
|
|
11020
|
+
options
|
|
11021
|
+
);
|
|
11022
|
+
if (plan.patternError) {
|
|
11023
|
+
this.logger.error(
|
|
11024
|
+
`externalId.pattern is not a valid regular expression, so no ID is read from titles: ${plan.patternError}`
|
|
11025
|
+
);
|
|
11026
|
+
}
|
|
11027
|
+
const { onDuplicate, invalid } = resolveOnDuplicate(options.onDuplicate);
|
|
11028
|
+
if (invalid !== void 0) {
|
|
11029
|
+
this.logger.warn(
|
|
11030
|
+
`externalId.onDuplicate is "${invalid}"; expected 'report' or 'fail', so duplicates are reported.`
|
|
11031
|
+
);
|
|
11032
|
+
}
|
|
11033
|
+
for (const line of formatExternalIdDuplicates(plan.duplicates, onDuplicate)) this.logger.warn(line);
|
|
11034
|
+
if (plan.duplicates.length > 0) this.metadata = { ...this.metadata, externalIdDuplicates: plan.duplicates };
|
|
11035
|
+
this.externalIds = plan;
|
|
11036
|
+
}
|
|
11037
|
+
/** The external ID fields of a test's payload: `null` tells the dashboard the test has none to match on. */
|
|
11038
|
+
externalIdFields(test) {
|
|
11039
|
+
if (!this.externalIds) return {};
|
|
11040
|
+
const id = this.externalIds.byTestId.get(test.id);
|
|
11041
|
+
return { externalId: id?.externalId ?? null, displayTitle: id?.displayTitle ?? null };
|
|
11042
|
+
}
|
|
10827
11043
|
/** Playwright reporter hook: called when an individual test begins */
|
|
10828
11044
|
onTestBegin(test, result) {
|
|
10829
11045
|
this.lastTestWatch?.begin(test.id);
|
|
@@ -10832,6 +11048,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10832
11048
|
type: "begin",
|
|
10833
11049
|
title: test.title,
|
|
10834
11050
|
location: testLocation(test, this.testPathBase),
|
|
11051
|
+
repeatEachIndex: test.repeatEachIndex,
|
|
10835
11052
|
workerIndex: workerIndexOf(result),
|
|
10836
11053
|
shardIndex: this.shardInfo?.current ?? null,
|
|
10837
11054
|
browser: this.metadataCollector.getBrowserConfig(test) || void 0,
|
|
@@ -10939,6 +11156,8 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10939
11156
|
// Playwright reports the same line, so synthesize it.
|
|
10940
11157
|
error: expectedFailureError(result.status, annotations) ?? buildErrorText(result, this.testPathBase),
|
|
10941
11158
|
retries: result.retry,
|
|
11159
|
+
// Each `--repeat-each` repetition is a test of its own, with its own attempts.
|
|
11160
|
+
repeatEachIndex: test.repeatEachIndex,
|
|
10942
11161
|
attempts: attempts.map((a) => ({ ...a })),
|
|
10943
11162
|
workerIndex: workerIndexOf(result),
|
|
10944
11163
|
shardIndex: this.shardInfo?.current ?? null,
|
|
@@ -10951,6 +11170,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
10951
11170
|
tags: tags.length ? tags : null,
|
|
10952
11171
|
locks: locks.length ? locks : null,
|
|
10953
11172
|
testMeta: collectTestMetadata(annotations),
|
|
11173
|
+
...this.externalIdFields(test),
|
|
10954
11174
|
// An annotation-less skip reclassified to `didnotrun` is a serial-group
|
|
10955
11175
|
// cascade: an earlier test failed and Playwright skipped the rest.
|
|
10956
11176
|
didNotRunReason: status === "didnotrun" ? "previous-failure" : null,
|
|
@@ -11024,6 +11244,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
11024
11244
|
file: testFile(test, this.testPathBase),
|
|
11025
11245
|
retry: result.retry,
|
|
11026
11246
|
browser: typeof testCase.browser?.projectName === "string" ? testCase.browser.projectName : null,
|
|
11247
|
+
repeatEachIndex: test.repeatEachIndex,
|
|
11027
11248
|
headline: failureHeadline(testCase.error, testCase.performanceMetrics?.steps)
|
|
11028
11249
|
});
|
|
11029
11250
|
}
|
|
@@ -11142,6 +11363,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
11142
11363
|
timeout: test.timeout ?? null,
|
|
11143
11364
|
error: null,
|
|
11144
11365
|
retries: 0,
|
|
11366
|
+
repeatEachIndex: test.repeatEachIndex,
|
|
11145
11367
|
workerIndex: null,
|
|
11146
11368
|
shardIndex: this.shardInfo?.current ?? null,
|
|
11147
11369
|
startedAt: null,
|
|
@@ -11153,6 +11375,7 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
11153
11375
|
tags: tags.length ? tags : null,
|
|
11154
11376
|
locks: locks.length ? locks : null,
|
|
11155
11377
|
testMeta: collectTestMetadata(declaredAnnotations),
|
|
11378
|
+
...this.externalIdFields(test),
|
|
11156
11379
|
didNotRunReason: reason,
|
|
11157
11380
|
expectedStatus: test.expectedStatus ?? null
|
|
11158
11381
|
};
|
|
@@ -11164,8 +11387,23 @@ var PiwiDashboardReporter = class _PiwiDashboardReporter {
|
|
|
11164
11387
|
}
|
|
11165
11388
|
}
|
|
11166
11389
|
}
|
|
11167
|
-
/**
|
|
11390
|
+
/**
|
|
11391
|
+
* Playwright reporter hook: called when the full test run finishes. Fails the run when two tests share an external
|
|
11392
|
+
* ID and `externalId.onDuplicate` is `'fail'`.
|
|
11393
|
+
*/
|
|
11168
11394
|
async onEnd(result) {
|
|
11395
|
+
const duplicates = this.externalIds?.duplicates.length ?? 0;
|
|
11396
|
+
const failsOnDuplicates = duplicates > 0 && resolveOnDuplicate(this.options.externalId?.onDuplicate).onDuplicate === "fail";
|
|
11397
|
+
const overridden = failsOnDuplicates && result?.status === "passed";
|
|
11398
|
+
await this.finishRun(overridden ? { ...result, status: "failed" } : result);
|
|
11399
|
+
if (failsOnDuplicates) {
|
|
11400
|
+
this.logger.error(
|
|
11401
|
+
`Failing the run: ${duplicates} external ID${duplicates === 1 ? " is" : "s are"} declared by more than one test.`
|
|
11402
|
+
);
|
|
11403
|
+
return { status: "failed" };
|
|
11404
|
+
}
|
|
11405
|
+
}
|
|
11406
|
+
async finishRun(result) {
|
|
11169
11407
|
if (this.listMode || !this.enabled) return;
|
|
11170
11408
|
if (this.startTime === null) {
|
|
11171
11409
|
this.logger.debug("The run stopped before any test began \u2014 nothing to report.");
|
|
@@ -11650,10 +11888,11 @@ function installPickerOverlay(arg) {
|
|
|
11650
11888
|
}
|
|
11651
11889
|
const Z = 2147483600;
|
|
11652
11890
|
const testIdAttr = arg.testIdAttribute || "data-testid";
|
|
11891
|
+
const enterReaches = arg.transport === "global";
|
|
11653
11892
|
const words = {
|
|
11654
11893
|
banner: "Piwi inspector \u2014 click any element to generate locators for it",
|
|
11655
|
-
keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
11656
|
-
keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
11894
|
+
keys: enterReaches ? "Enter picks the focused element \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
11895
|
+
keysHovering: enterReaches ? "click or Enter to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
11657
11896
|
analyzing: "Analyzing element\u2026"
|
|
11658
11897
|
};
|
|
11659
11898
|
for (const key of Object.keys(words)) {
|
|
@@ -11748,6 +11987,16 @@ function installPickerOverlay(arg) {
|
|
|
11748
11987
|
const cls = (el.getAttribute && el.getAttribute("class") || "").split(/\s+/).find((c) => c.length > 1);
|
|
11749
11988
|
return cls ? `locator(${quoteJs(`.${cls}`)})` : tag;
|
|
11750
11989
|
};
|
|
11990
|
+
const parentOf = (node) => {
|
|
11991
|
+
if (node.parentElement) return node.parentElement;
|
|
11992
|
+
const root = node.getRootNode ? node.getRootNode() : null;
|
|
11993
|
+
return root && root !== doc && root.host ? root.host : null;
|
|
11994
|
+
};
|
|
11995
|
+
const targetOf = (e) => {
|
|
11996
|
+
const path32 = typeof e.composedPath === "function" ? e.composedPath() : null;
|
|
11997
|
+
const first = path32 && path32.length ? path32[0] : null;
|
|
11998
|
+
return first && first.nodeType === 1 ? first : e.target;
|
|
11999
|
+
};
|
|
11751
12000
|
const buildChain = (raw) => {
|
|
11752
12001
|
const chain2 = [];
|
|
11753
12002
|
let node = raw;
|
|
@@ -11755,7 +12004,7 @@ function installPickerOverlay(arg) {
|
|
|
11755
12004
|
const tag = (node.tagName || "").toLowerCase();
|
|
11756
12005
|
if (tag === "body" || tag === "html") break;
|
|
11757
12006
|
chain2.push(node);
|
|
11758
|
-
node = node
|
|
12007
|
+
node = parentOf(node);
|
|
11759
12008
|
}
|
|
11760
12009
|
return chain2.length ? chain2 : [raw];
|
|
11761
12010
|
};
|
|
@@ -11846,10 +12095,14 @@ function installPickerOverlay(arg) {
|
|
|
11846
12095
|
}
|
|
11847
12096
|
};
|
|
11848
12097
|
const onMove = (e) => {
|
|
11849
|
-
const raw = e
|
|
12098
|
+
const raw = targetOf(e);
|
|
11850
12099
|
if (!raw || isOwn(raw)) {
|
|
12100
|
+
chain = [];
|
|
12101
|
+
idx = 0;
|
|
12102
|
+
lastRaw = null;
|
|
11851
12103
|
highlight.style.display = "none";
|
|
11852
12104
|
label.style.display = "none";
|
|
12105
|
+
foot.textContent = words.keys;
|
|
11853
12106
|
return;
|
|
11854
12107
|
}
|
|
11855
12108
|
if (raw !== lastRaw) {
|
|
@@ -11928,17 +12181,30 @@ function installPickerOverlay(arg) {
|
|
|
11928
12181
|
const onClick = (e) => {
|
|
11929
12182
|
stop(e);
|
|
11930
12183
|
const el = current();
|
|
11931
|
-
if (!el || isOwn(e
|
|
12184
|
+
if (!el || isOwn(targetOf(e))) return;
|
|
11932
12185
|
reportPicked(el);
|
|
11933
12186
|
};
|
|
12187
|
+
const focused = () => {
|
|
12188
|
+
let el = doc.activeElement;
|
|
12189
|
+
while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
|
|
12190
|
+
const tag = (el && el.tagName || "").toLowerCase();
|
|
12191
|
+
return !el || tag === "body" || tag === "html" || isOwn(el) ? null : el;
|
|
12192
|
+
};
|
|
11934
12193
|
const onKey = (e) => {
|
|
11935
12194
|
if (e.key === "Escape") {
|
|
11936
12195
|
stop(e);
|
|
11937
12196
|
reportSkipped();
|
|
11938
12197
|
return;
|
|
11939
12198
|
}
|
|
12199
|
+
if (e.key === "Enter" && !e.isComposing) {
|
|
12200
|
+
stop(e);
|
|
12201
|
+
const el = current() || focused();
|
|
12202
|
+
if (el) reportPicked(el);
|
|
12203
|
+
return;
|
|
12204
|
+
}
|
|
11940
12205
|
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
11941
12206
|
stop(e);
|
|
12207
|
+
if (!chain.length) return;
|
|
11942
12208
|
if (e.key === "ArrowUp") idx = Math.min(idx + 1, chain.length - 1);
|
|
11943
12209
|
else idx = Math.max(idx - 1, 0);
|
|
11944
12210
|
refresh();
|
|
@@ -12012,12 +12278,43 @@ function showAnchorPicker(arg) {
|
|
|
12012
12278
|
const al = n.getAttribute && n.getAttribute("aria-level");
|
|
12013
12279
|
return al && /^\d+$/.test(al) ? Number(al) : null;
|
|
12014
12280
|
};
|
|
12281
|
+
const parentOf = (n) => {
|
|
12282
|
+
if (n.parentElement) return n.parentElement;
|
|
12283
|
+
const root = n.getRootNode ? n.getRootNode() : null;
|
|
12284
|
+
return root && root !== doc && root.host ? root.host : null;
|
|
12285
|
+
};
|
|
12286
|
+
const shadowRoots = [];
|
|
12287
|
+
const collectRoots = (root) => {
|
|
12288
|
+
const all = root.querySelectorAll("*");
|
|
12289
|
+
for (let i = 0; i < all.length; i++) {
|
|
12290
|
+
const sr = all[i].shadowRoot;
|
|
12291
|
+
if (sr) {
|
|
12292
|
+
shadowRoots.push(sr);
|
|
12293
|
+
collectRoots(sr);
|
|
12294
|
+
}
|
|
12295
|
+
}
|
|
12296
|
+
};
|
|
12297
|
+
try {
|
|
12298
|
+
collectRoots(doc);
|
|
12299
|
+
} catch {
|
|
12300
|
+
shadowRoots.length = 0;
|
|
12301
|
+
}
|
|
12302
|
+
const within = (node2, scope) => {
|
|
12303
|
+
if (scope === doc) return true;
|
|
12304
|
+
for (let n = node2; n; n = parentOf(n)) if (n === scope) return true;
|
|
12305
|
+
return false;
|
|
12306
|
+
};
|
|
12307
|
+
const queryAll = (scope, sel) => {
|
|
12308
|
+
const found = Array.from(scope.querySelectorAll(sel));
|
|
12309
|
+
for (const sr of shadowRoots) if (within(sr.host, scope)) found.push(...Array.from(sr.querySelectorAll(sel)));
|
|
12310
|
+
return found;
|
|
12311
|
+
};
|
|
12015
12312
|
const leafCountable = !!leafTestId || roleOf2(el) === leafRole;
|
|
12016
12313
|
const leafMatches = (scope) => {
|
|
12017
12314
|
try {
|
|
12018
|
-
if (leafTestId) return scope
|
|
12315
|
+
if (leafTestId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
|
|
12019
12316
|
if (!leafCountable) return -1;
|
|
12020
|
-
const nodes = scope
|
|
12317
|
+
const nodes = queryAll(scope, roleSources);
|
|
12021
12318
|
if (nodes.length > 2e3) return -1;
|
|
12022
12319
|
let matched = 0;
|
|
12023
12320
|
for (let i = 0; i < nodes.length; i++) {
|
|
@@ -12033,20 +12330,20 @@ function showAnchorPicker(arg) {
|
|
|
12033
12330
|
};
|
|
12034
12331
|
let roleNodes = [];
|
|
12035
12332
|
try {
|
|
12036
|
-
const all = doc
|
|
12037
|
-
if (all.length <= 4e3) roleNodes =
|
|
12333
|
+
const all = queryAll(doc, roleSources);
|
|
12334
|
+
if (all.length <= 4e3) roleNodes = all;
|
|
12038
12335
|
} catch {
|
|
12039
12336
|
roleNodes = [];
|
|
12040
12337
|
}
|
|
12041
12338
|
const count = (sel) => {
|
|
12042
12339
|
try {
|
|
12043
|
-
return doc
|
|
12340
|
+
return queryAll(doc, sel).length;
|
|
12044
12341
|
} catch {
|
|
12045
12342
|
return void 0;
|
|
12046
12343
|
}
|
|
12047
12344
|
};
|
|
12048
12345
|
const rows = [];
|
|
12049
|
-
let node = el
|
|
12346
|
+
let node = parentOf(el);
|
|
12050
12347
|
let depth = 0;
|
|
12051
12348
|
while (node && depth < 12) {
|
|
12052
12349
|
depth++;
|
|
@@ -12083,7 +12380,7 @@ function showAnchorPicker(arg) {
|
|
|
12083
12380
|
hookLabel,
|
|
12084
12381
|
selectable: !!(testId || id || role && (ariaLabel || info.roleCount === 1))
|
|
12085
12382
|
});
|
|
12086
|
-
node = node
|
|
12383
|
+
node = parentOf(node);
|
|
12087
12384
|
}
|
|
12088
12385
|
if (rows.length === 0) {
|
|
12089
12386
|
g.__piwiAnchorState = "skipped";
|
|
@@ -12112,9 +12409,9 @@ function showAnchorPicker(arg) {
|
|
|
12112
12409
|
footer.style.cssText = "margin:10px 0;font-weight:600;";
|
|
12113
12410
|
const segMatches = (scope, info) => {
|
|
12114
12411
|
try {
|
|
12115
|
-
if (info.testId) return
|
|
12116
|
-
if (info.id) return
|
|
12117
|
-
const nodes =
|
|
12412
|
+
if (info.testId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(info.testId)}]`);
|
|
12413
|
+
if (info.id) return queryAll(scope, `#${doc.defaultView.CSS.escape(info.id)}`);
|
|
12414
|
+
const nodes = queryAll(scope, roleSources);
|
|
12118
12415
|
if (nodes.length > 2e3) return [];
|
|
12119
12416
|
return nodes.filter(
|
|
12120
12417
|
(n) => roleOf2(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
|
|
@@ -412,10 +412,11 @@ function installPickerOverlay(arg) {
|
|
|
412
412
|
}
|
|
413
413
|
const Z = 2147483600;
|
|
414
414
|
const testIdAttr = arg.testIdAttribute || "data-testid";
|
|
415
|
+
const enterReaches = arg.transport === "global";
|
|
415
416
|
const words = {
|
|
416
417
|
banner: "Piwi inspector \u2014 click any element to generate locators for it",
|
|
417
|
-
keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
418
|
-
keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
418
|
+
keys: enterReaches ? "Enter picks the focused element \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
419
|
+
keysHovering: enterReaches ? "click or Enter to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
419
420
|
analyzing: "Analyzing element\u2026"
|
|
420
421
|
};
|
|
421
422
|
for (const key of Object.keys(words)) {
|
|
@@ -510,6 +511,16 @@ function installPickerOverlay(arg) {
|
|
|
510
511
|
const cls = (el.getAttribute && el.getAttribute("class") || "").split(/\s+/).find((c) => c.length > 1);
|
|
511
512
|
return cls ? `locator(${quoteJs(`.${cls}`)})` : tag;
|
|
512
513
|
};
|
|
514
|
+
const parentOf = (node) => {
|
|
515
|
+
if (node.parentElement) return node.parentElement;
|
|
516
|
+
const root = node.getRootNode ? node.getRootNode() : null;
|
|
517
|
+
return root && root !== doc && root.host ? root.host : null;
|
|
518
|
+
};
|
|
519
|
+
const targetOf = (e) => {
|
|
520
|
+
const path13 = typeof e.composedPath === "function" ? e.composedPath() : null;
|
|
521
|
+
const first = path13 && path13.length ? path13[0] : null;
|
|
522
|
+
return first && first.nodeType === 1 ? first : e.target;
|
|
523
|
+
};
|
|
513
524
|
const buildChain = (raw) => {
|
|
514
525
|
const chain2 = [];
|
|
515
526
|
let node = raw;
|
|
@@ -517,7 +528,7 @@ function installPickerOverlay(arg) {
|
|
|
517
528
|
const tag = (node.tagName || "").toLowerCase();
|
|
518
529
|
if (tag === "body" || tag === "html") break;
|
|
519
530
|
chain2.push(node);
|
|
520
|
-
node = node
|
|
531
|
+
node = parentOf(node);
|
|
521
532
|
}
|
|
522
533
|
return chain2.length ? chain2 : [raw];
|
|
523
534
|
};
|
|
@@ -608,10 +619,14 @@ function installPickerOverlay(arg) {
|
|
|
608
619
|
}
|
|
609
620
|
};
|
|
610
621
|
const onMove = (e) => {
|
|
611
|
-
const raw = e
|
|
622
|
+
const raw = targetOf(e);
|
|
612
623
|
if (!raw || isOwn(raw)) {
|
|
624
|
+
chain = [];
|
|
625
|
+
idx = 0;
|
|
626
|
+
lastRaw = null;
|
|
613
627
|
highlight.style.display = "none";
|
|
614
628
|
label.style.display = "none";
|
|
629
|
+
foot.textContent = words.keys;
|
|
615
630
|
return;
|
|
616
631
|
}
|
|
617
632
|
if (raw !== lastRaw) {
|
|
@@ -690,17 +705,30 @@ function installPickerOverlay(arg) {
|
|
|
690
705
|
const onClick = (e) => {
|
|
691
706
|
stop(e);
|
|
692
707
|
const el = current();
|
|
693
|
-
if (!el || isOwn(e
|
|
708
|
+
if (!el || isOwn(targetOf(e))) return;
|
|
694
709
|
reportPicked(el);
|
|
695
710
|
};
|
|
711
|
+
const focused = () => {
|
|
712
|
+
let el = doc.activeElement;
|
|
713
|
+
while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
|
|
714
|
+
const tag = (el && el.tagName || "").toLowerCase();
|
|
715
|
+
return !el || tag === "body" || tag === "html" || isOwn(el) ? null : el;
|
|
716
|
+
};
|
|
696
717
|
const onKey = (e) => {
|
|
697
718
|
if (e.key === "Escape") {
|
|
698
719
|
stop(e);
|
|
699
720
|
reportSkipped();
|
|
700
721
|
return;
|
|
701
722
|
}
|
|
723
|
+
if (e.key === "Enter" && !e.isComposing) {
|
|
724
|
+
stop(e);
|
|
725
|
+
const el = current() || focused();
|
|
726
|
+
if (el) reportPicked(el);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
702
729
|
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
703
730
|
stop(e);
|
|
731
|
+
if (!chain.length) return;
|
|
704
732
|
if (e.key === "ArrowUp") idx = Math.min(idx + 1, chain.length - 1);
|
|
705
733
|
else idx = Math.max(idx - 1, 0);
|
|
706
734
|
refresh();
|
|
@@ -774,12 +802,43 @@ function showAnchorPicker(arg) {
|
|
|
774
802
|
const al = n.getAttribute && n.getAttribute("aria-level");
|
|
775
803
|
return al && /^\d+$/.test(al) ? Number(al) : null;
|
|
776
804
|
};
|
|
805
|
+
const parentOf = (n) => {
|
|
806
|
+
if (n.parentElement) return n.parentElement;
|
|
807
|
+
const root = n.getRootNode ? n.getRootNode() : null;
|
|
808
|
+
return root && root !== doc && root.host ? root.host : null;
|
|
809
|
+
};
|
|
810
|
+
const shadowRoots = [];
|
|
811
|
+
const collectRoots = (root) => {
|
|
812
|
+
const all = root.querySelectorAll("*");
|
|
813
|
+
for (let i = 0; i < all.length; i++) {
|
|
814
|
+
const sr = all[i].shadowRoot;
|
|
815
|
+
if (sr) {
|
|
816
|
+
shadowRoots.push(sr);
|
|
817
|
+
collectRoots(sr);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
try {
|
|
822
|
+
collectRoots(doc);
|
|
823
|
+
} catch {
|
|
824
|
+
shadowRoots.length = 0;
|
|
825
|
+
}
|
|
826
|
+
const within = (node2, scope) => {
|
|
827
|
+
if (scope === doc) return true;
|
|
828
|
+
for (let n = node2; n; n = parentOf(n)) if (n === scope) return true;
|
|
829
|
+
return false;
|
|
830
|
+
};
|
|
831
|
+
const queryAll = (scope, sel) => {
|
|
832
|
+
const found = Array.from(scope.querySelectorAll(sel));
|
|
833
|
+
for (const sr of shadowRoots) if (within(sr.host, scope)) found.push(...Array.from(sr.querySelectorAll(sel)));
|
|
834
|
+
return found;
|
|
835
|
+
};
|
|
777
836
|
const leafCountable = !!leafTestId || roleOf2(el) === leafRole;
|
|
778
837
|
const leafMatches = (scope) => {
|
|
779
838
|
try {
|
|
780
|
-
if (leafTestId) return scope
|
|
839
|
+
if (leafTestId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
|
|
781
840
|
if (!leafCountable) return -1;
|
|
782
|
-
const nodes = scope
|
|
841
|
+
const nodes = queryAll(scope, roleSources);
|
|
783
842
|
if (nodes.length > 2e3) return -1;
|
|
784
843
|
let matched = 0;
|
|
785
844
|
for (let i = 0; i < nodes.length; i++) {
|
|
@@ -795,20 +854,20 @@ function showAnchorPicker(arg) {
|
|
|
795
854
|
};
|
|
796
855
|
let roleNodes = [];
|
|
797
856
|
try {
|
|
798
|
-
const all = doc
|
|
799
|
-
if (all.length <= 4e3) roleNodes =
|
|
857
|
+
const all = queryAll(doc, roleSources);
|
|
858
|
+
if (all.length <= 4e3) roleNodes = all;
|
|
800
859
|
} catch {
|
|
801
860
|
roleNodes = [];
|
|
802
861
|
}
|
|
803
862
|
const count = (sel) => {
|
|
804
863
|
try {
|
|
805
|
-
return doc
|
|
864
|
+
return queryAll(doc, sel).length;
|
|
806
865
|
} catch {
|
|
807
866
|
return void 0;
|
|
808
867
|
}
|
|
809
868
|
};
|
|
810
869
|
const rows = [];
|
|
811
|
-
let node = el
|
|
870
|
+
let node = parentOf(el);
|
|
812
871
|
let depth = 0;
|
|
813
872
|
while (node && depth < 12) {
|
|
814
873
|
depth++;
|
|
@@ -845,7 +904,7 @@ function showAnchorPicker(arg) {
|
|
|
845
904
|
hookLabel,
|
|
846
905
|
selectable: !!(testId || id || role && (ariaLabel || info.roleCount === 1))
|
|
847
906
|
});
|
|
848
|
-
node = node
|
|
907
|
+
node = parentOf(node);
|
|
849
908
|
}
|
|
850
909
|
if (rows.length === 0) {
|
|
851
910
|
g.__piwiAnchorState = "skipped";
|
|
@@ -874,9 +933,9 @@ function showAnchorPicker(arg) {
|
|
|
874
933
|
footer.style.cssText = "margin:10px 0;font-weight:600;";
|
|
875
934
|
const segMatches = (scope, info) => {
|
|
876
935
|
try {
|
|
877
|
-
if (info.testId) return
|
|
878
|
-
if (info.id) return
|
|
879
|
-
const nodes =
|
|
936
|
+
if (info.testId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(info.testId)}]`);
|
|
937
|
+
if (info.id) return queryAll(scope, `#${doc.defaultView.CSS.escape(info.id)}`);
|
|
938
|
+
const nodes = queryAll(scope, roleSources);
|
|
880
939
|
if (nodes.length > 2e3) return [];
|
|
881
940
|
return nodes.filter(
|
|
882
941
|
(n) => roleOf2(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
|
|
@@ -2648,6 +2707,8 @@ var PIWI_ENV_KEYS = {
|
|
|
2648
2707
|
codeReachRoots: "PIWI_CODE_REACH_ROOTS",
|
|
2649
2708
|
coverageBrowser: "PIWI_COVERAGE_BROWSER",
|
|
2650
2709
|
coverageServer: "PIWI_COVERAGE_SERVER",
|
|
2710
|
+
externalIdPattern: "PIWI_EXTERNAL_ID_PATTERN",
|
|
2711
|
+
externalIdOnDuplicate: "PIWI_EXTERNAL_ID_ON_DUPLICATE",
|
|
2651
2712
|
captureServerTraces: "PIWI_CAPTURE_SERVER_TRACES",
|
|
2652
2713
|
captureResources: "PIWI_CAPTURE_RESOURCES",
|
|
2653
2714
|
leakCheck: "PIWI_LEAK_CHECK",
|
|
@@ -379,10 +379,11 @@ function installPickerOverlay(arg) {
|
|
|
379
379
|
}
|
|
380
380
|
const Z = 2147483600;
|
|
381
381
|
const testIdAttr = arg.testIdAttribute || "data-testid";
|
|
382
|
+
const enterReaches = arg.transport === "global";
|
|
382
383
|
const words = {
|
|
383
384
|
banner: "Piwi inspector \u2014 click any element to generate locators for it",
|
|
384
|
-
keys: "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
385
|
-
keysHovering: "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
385
|
+
keys: enterReaches ? "Enter picks the focused element \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "\u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
386
|
+
keysHovering: enterReaches ? "click or Enter to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip" : "click to pick \xB7 \u2191 parent \xB7 \u2193 child \xB7 Esc skip",
|
|
386
387
|
analyzing: "Analyzing element\u2026"
|
|
387
388
|
};
|
|
388
389
|
for (const key of Object.keys(words)) {
|
|
@@ -477,6 +478,16 @@ function installPickerOverlay(arg) {
|
|
|
477
478
|
const cls = (el.getAttribute && el.getAttribute("class") || "").split(/\s+/).find((c) => c.length > 1);
|
|
478
479
|
return cls ? `locator(${quoteJs(`.${cls}`)})` : tag;
|
|
479
480
|
};
|
|
481
|
+
const parentOf = (node) => {
|
|
482
|
+
if (node.parentElement) return node.parentElement;
|
|
483
|
+
const root = node.getRootNode ? node.getRootNode() : null;
|
|
484
|
+
return root && root !== doc && root.host ? root.host : null;
|
|
485
|
+
};
|
|
486
|
+
const targetOf = (e) => {
|
|
487
|
+
const path3 = typeof e.composedPath === "function" ? e.composedPath() : null;
|
|
488
|
+
const first = path3 && path3.length ? path3[0] : null;
|
|
489
|
+
return first && first.nodeType === 1 ? first : e.target;
|
|
490
|
+
};
|
|
480
491
|
const buildChain = (raw) => {
|
|
481
492
|
const chain2 = [];
|
|
482
493
|
let node = raw;
|
|
@@ -484,7 +495,7 @@ function installPickerOverlay(arg) {
|
|
|
484
495
|
const tag = (node.tagName || "").toLowerCase();
|
|
485
496
|
if (tag === "body" || tag === "html") break;
|
|
486
497
|
chain2.push(node);
|
|
487
|
-
node = node
|
|
498
|
+
node = parentOf(node);
|
|
488
499
|
}
|
|
489
500
|
return chain2.length ? chain2 : [raw];
|
|
490
501
|
};
|
|
@@ -575,10 +586,14 @@ function installPickerOverlay(arg) {
|
|
|
575
586
|
}
|
|
576
587
|
};
|
|
577
588
|
const onMove = (e) => {
|
|
578
|
-
const raw = e
|
|
589
|
+
const raw = targetOf(e);
|
|
579
590
|
if (!raw || isOwn(raw)) {
|
|
591
|
+
chain = [];
|
|
592
|
+
idx = 0;
|
|
593
|
+
lastRaw = null;
|
|
580
594
|
highlight.style.display = "none";
|
|
581
595
|
label.style.display = "none";
|
|
596
|
+
foot.textContent = words.keys;
|
|
582
597
|
return;
|
|
583
598
|
}
|
|
584
599
|
if (raw !== lastRaw) {
|
|
@@ -657,17 +672,30 @@ function installPickerOverlay(arg) {
|
|
|
657
672
|
const onClick = (e) => {
|
|
658
673
|
stop(e);
|
|
659
674
|
const el = current();
|
|
660
|
-
if (!el || isOwn(e
|
|
675
|
+
if (!el || isOwn(targetOf(e))) return;
|
|
661
676
|
reportPicked(el);
|
|
662
677
|
};
|
|
678
|
+
const focused = () => {
|
|
679
|
+
let el = doc.activeElement;
|
|
680
|
+
while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
|
|
681
|
+
const tag = (el && el.tagName || "").toLowerCase();
|
|
682
|
+
return !el || tag === "body" || tag === "html" || isOwn(el) ? null : el;
|
|
683
|
+
};
|
|
663
684
|
const onKey = (e) => {
|
|
664
685
|
if (e.key === "Escape") {
|
|
665
686
|
stop(e);
|
|
666
687
|
reportSkipped();
|
|
667
688
|
return;
|
|
668
689
|
}
|
|
690
|
+
if (e.key === "Enter" && !e.isComposing) {
|
|
691
|
+
stop(e);
|
|
692
|
+
const el = current() || focused();
|
|
693
|
+
if (el) reportPicked(el);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
669
696
|
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
|
670
697
|
stop(e);
|
|
698
|
+
if (!chain.length) return;
|
|
671
699
|
if (e.key === "ArrowUp") idx = Math.min(idx + 1, chain.length - 1);
|
|
672
700
|
else idx = Math.max(idx - 1, 0);
|
|
673
701
|
refresh();
|
|
@@ -741,12 +769,43 @@ function showAnchorPicker(arg) {
|
|
|
741
769
|
const al = n.getAttribute && n.getAttribute("aria-level");
|
|
742
770
|
return al && /^\d+$/.test(al) ? Number(al) : null;
|
|
743
771
|
};
|
|
772
|
+
const parentOf = (n) => {
|
|
773
|
+
if (n.parentElement) return n.parentElement;
|
|
774
|
+
const root = n.getRootNode ? n.getRootNode() : null;
|
|
775
|
+
return root && root !== doc && root.host ? root.host : null;
|
|
776
|
+
};
|
|
777
|
+
const shadowRoots = [];
|
|
778
|
+
const collectRoots = (root) => {
|
|
779
|
+
const all = root.querySelectorAll("*");
|
|
780
|
+
for (let i = 0; i < all.length; i++) {
|
|
781
|
+
const sr = all[i].shadowRoot;
|
|
782
|
+
if (sr) {
|
|
783
|
+
shadowRoots.push(sr);
|
|
784
|
+
collectRoots(sr);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
try {
|
|
789
|
+
collectRoots(doc);
|
|
790
|
+
} catch {
|
|
791
|
+
shadowRoots.length = 0;
|
|
792
|
+
}
|
|
793
|
+
const within = (node2, scope) => {
|
|
794
|
+
if (scope === doc) return true;
|
|
795
|
+
for (let n = node2; n; n = parentOf(n)) if (n === scope) return true;
|
|
796
|
+
return false;
|
|
797
|
+
};
|
|
798
|
+
const queryAll = (scope, sel) => {
|
|
799
|
+
const found = Array.from(scope.querySelectorAll(sel));
|
|
800
|
+
for (const sr of shadowRoots) if (within(sr.host, scope)) found.push(...Array.from(sr.querySelectorAll(sel)));
|
|
801
|
+
return found;
|
|
802
|
+
};
|
|
744
803
|
const leafCountable = !!leafTestId || roleOf(el) === leafRole;
|
|
745
804
|
const leafMatches = (scope) => {
|
|
746
805
|
try {
|
|
747
|
-
if (leafTestId) return scope
|
|
806
|
+
if (leafTestId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(leafTestId)}]`).length;
|
|
748
807
|
if (!leafCountable) return -1;
|
|
749
|
-
const nodes = scope
|
|
808
|
+
const nodes = queryAll(scope, roleSources);
|
|
750
809
|
if (nodes.length > 2e3) return -1;
|
|
751
810
|
let matched = 0;
|
|
752
811
|
for (let i = 0; i < nodes.length; i++) {
|
|
@@ -762,20 +821,20 @@ function showAnchorPicker(arg) {
|
|
|
762
821
|
};
|
|
763
822
|
let roleNodes = [];
|
|
764
823
|
try {
|
|
765
|
-
const all = doc
|
|
766
|
-
if (all.length <= 4e3) roleNodes =
|
|
824
|
+
const all = queryAll(doc, roleSources);
|
|
825
|
+
if (all.length <= 4e3) roleNodes = all;
|
|
767
826
|
} catch {
|
|
768
827
|
roleNodes = [];
|
|
769
828
|
}
|
|
770
829
|
const count = (sel) => {
|
|
771
830
|
try {
|
|
772
|
-
return doc
|
|
831
|
+
return queryAll(doc, sel).length;
|
|
773
832
|
} catch {
|
|
774
833
|
return void 0;
|
|
775
834
|
}
|
|
776
835
|
};
|
|
777
836
|
const rows = [];
|
|
778
|
-
let node = el
|
|
837
|
+
let node = parentOf(el);
|
|
779
838
|
let depth = 0;
|
|
780
839
|
while (node && depth < 12) {
|
|
781
840
|
depth++;
|
|
@@ -812,7 +871,7 @@ function showAnchorPicker(arg) {
|
|
|
812
871
|
hookLabel,
|
|
813
872
|
selectable: !!(testId || id || role && (ariaLabel || info.roleCount === 1))
|
|
814
873
|
});
|
|
815
|
-
node = node
|
|
874
|
+
node = parentOf(node);
|
|
816
875
|
}
|
|
817
876
|
if (rows.length === 0) {
|
|
818
877
|
g.__piwiAnchorState = "skipped";
|
|
@@ -841,9 +900,9 @@ function showAnchorPicker(arg) {
|
|
|
841
900
|
footer.style.cssText = "margin:10px 0;font-weight:600;";
|
|
842
901
|
const segMatches = (scope, info) => {
|
|
843
902
|
try {
|
|
844
|
-
if (info.testId) return
|
|
845
|
-
if (info.id) return
|
|
846
|
-
const nodes =
|
|
903
|
+
if (info.testId) return queryAll(scope, `[${testIdAttr}=${JSON.stringify(info.testId)}]`);
|
|
904
|
+
if (info.id) return queryAll(scope, `#${doc.defaultView.CSS.escape(info.id)}`);
|
|
905
|
+
const nodes = queryAll(scope, roleSources);
|
|
847
906
|
if (nodes.length > 2e3) return [];
|
|
848
907
|
return nodes.filter(
|
|
849
908
|
(n) => roleOf(n) === info.role && (!info.ariaLabel || n.getAttribute && n.getAttribute("aria-label") === info.ariaLabel)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@piwitests/reporter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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).
|