@capillarytech/cap-ui-utils 3.2.0-beta.0 → 3.2.0-beta.2
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/e2e/README.md +8 -1
- package/e2e/playwright/debugRecorder.js +167 -0
- package/e2e/playwright/index.js +2 -0
- package/e2e/playwright/logCollector.js +115 -0
- package/e2e/playwright/reporter.js +72 -8
- package/package.json +6 -16
package/e2e/README.md
CHANGED
|
@@ -36,7 +36,7 @@ Only helpers that are **self-contained and free of per-repo test data**:
|
|
|
36
36
|
| Misc infra | `utils/debugModeUtil`, `utils/featureFlagUtil`, `utils/automationBypassUtil`, `utils/deletionRegistry`, `utils/htmlEditorUtil`, `utils/virtualListUtil`, `utils/unmatchedBracesUtil`, `utils/uploaders/*` |
|
|
37
37
|
| Services | `services/lockService`, `services/locks/*` |
|
|
38
38
|
| Config | `constants/common` (waits, log markers, `maxLoginAttempts`) |
|
|
39
|
-
| Playwright (phase 2) | `playwright/reporter`, `playwright/config`, `playwright/login`, `playwright/waits` |
|
|
39
|
+
| Playwright (phase 2) | `playwright/reporter`, `playwright/config`, `playwright/login`, `playwright/waits`, `playwright/logCollector` |
|
|
40
40
|
|
|
41
41
|
## Playwright toolkit (phase 2 — coexistence)
|
|
42
42
|
|
|
@@ -57,6 +57,13 @@ import { waitForApi } from '@capillarytech/cap-ui-utils/e2e/playwright/waits';
|
|
|
57
57
|
- **`playwright/reporter`** — emits `reports/html-reports/master-report.html` (same
|
|
58
58
|
path/shape the WDIO reporter produced) so `supervisor.py`'s `generateReport()` →
|
|
59
59
|
Apitester pipeline is unchanged. Referenced by path via `require.resolve`.
|
|
60
|
+
Retries are de-duplicated (last attempt wins → WDIO test-count parity), failure
|
|
61
|
+
screenshots are inlined into the report (parity with WDIO's `logScreenshot` on
|
|
62
|
+
error), and each test's FINAL result is POSTed to the in-pod log_collector via
|
|
63
|
+
`playwright/logCollector` — the same per-test feed WDIO's `afterTest` sent.
|
|
64
|
+
- **`playwright/logCollector`** — per-test POST to `http://127.0.0.1:4000/logger`
|
|
65
|
+
with the exact WDIO payload (`status` / `time` / `validataionMessage` / `total`,
|
|
66
|
+
keyed runId → suite → test). Non-fatal on any failure; honours `skipLogCollector`.
|
|
60
67
|
- **`playwright/config`** — `createPlaywrightConfig()` wires the reporter, selects the
|
|
61
68
|
suite via `SUITE` → `@<suite>` grep (mirrors `wdio --suite`), and sets pod-friendly
|
|
62
69
|
defaults (headless chromium, trace/screenshot on failure, retries).
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Playwright FULL_DEBUG_MODE recorders — phase-2 parity with the WDIO debug bundle.
|
|
4
|
+
*
|
|
5
|
+
* Reproduces, engine-natively, the two artifact sets the WDIO
|
|
6
|
+
* screenshotRecorderUtil + requestRecorderService produce, at the SAME paths so
|
|
7
|
+
* the zip-compare tooling is unchanged:
|
|
8
|
+
*
|
|
9
|
+
* reports/debug/screenshots/<module>/<test>/NNN_click.png (+ steps.json)
|
|
10
|
+
* reports/debug/request-payloads/<module>/<test>.json
|
|
11
|
+
*
|
|
12
|
+
* Gated by the shared debugMode util (FULL_DEBUG_MODE=true [+ optional
|
|
13
|
+
* DEBUG_MODULES]); a no-op otherwise, so zero overhead when off.
|
|
14
|
+
*
|
|
15
|
+
* import { installDebugRecorders } from '@capillarytech/cap-ui-utils/e2e/playwright/debugRecorder';
|
|
16
|
+
* const rec = await installDebugRecorders(page, { module, testTitle, baseURL });
|
|
17
|
+
* ... run test ...
|
|
18
|
+
* await rec.flush(); // in fixture teardown
|
|
19
|
+
*
|
|
20
|
+
* Notes on WDIO parity:
|
|
21
|
+
* - screenshots: WDIO captured after every `click` command; we hook real DOM
|
|
22
|
+
* clicks (capture phase) so programmatic PW clicks count too, then settle +
|
|
23
|
+
* full-page screenshot, dropping byte-identical duplicates (same as WDIO).
|
|
24
|
+
* - request payloads: WDIO recorded POST/PATCH/PUT/DELETE to the app host via CDP;
|
|
25
|
+
* we use page.on('request') (no CDP needed). Initiator JS stack isn't available
|
|
26
|
+
* via the PW request API, so `initiator` is null (only field that differs).
|
|
27
|
+
*/
|
|
28
|
+
const fs = require("fs");
|
|
29
|
+
const path = require("path");
|
|
30
|
+
const debugMode = require("../utils/debugModeUtil").default;
|
|
31
|
+
|
|
32
|
+
const RECORDED_METHODS = ["POST", "PATCH", "PUT", "DELETE"];
|
|
33
|
+
const SETTLE_MS = Number(process.env.FULL_DEBUG_SCREENSHOT_DELAY_MS) || 500;
|
|
34
|
+
const CAPTURE_TIMEOUT_MS = Number(process.env.FULL_DEBUG_SCREENSHOT_TIMEOUT_MS) || 8000;
|
|
35
|
+
|
|
36
|
+
function safeName(s) {
|
|
37
|
+
// Drop the leading suite tag (e.g. "@smoke ") so the folder matches the WDIO
|
|
38
|
+
// it()-title naming (smoke_garudaUI_analytics_KPI_visibility).
|
|
39
|
+
return String(s || "unknown_test").replace(/^@\w+\s+/, "").replace(/[^\w.-]+/g, "_");
|
|
40
|
+
}
|
|
41
|
+
function hostOf(u) {
|
|
42
|
+
try { return new URL(u).host; } catch (_) { return ""; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class PwDebugRecorder {
|
|
46
|
+
constructor(page, opts = {}) {
|
|
47
|
+
this.page = page;
|
|
48
|
+
this.enabled = debugMode.isCaptureEnabled();
|
|
49
|
+
this.module = opts.module || process.env.module || "unknown";
|
|
50
|
+
this.testTitle = safeName(opts.testTitle);
|
|
51
|
+
this.baseHost = hostOf(opts.baseURL || process.env.INTOUCH_URL || "");
|
|
52
|
+
this.requests = [];
|
|
53
|
+
this.steps = [];
|
|
54
|
+
this.counter = 0;
|
|
55
|
+
this.lastImage = null;
|
|
56
|
+
this.chain = Promise.resolve();
|
|
57
|
+
const root = path.join(process.cwd(), "reports", "debug");
|
|
58
|
+
this.ssDir = path.join(root, "screenshots", this.module, this.testTitle);
|
|
59
|
+
this.rpDir = path.join(root, "request-payloads", this.module);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async install() {
|
|
63
|
+
if (!this.enabled) return this;
|
|
64
|
+
|
|
65
|
+
// --- request payloads (POST/PATCH/PUT/DELETE to the app host) ---
|
|
66
|
+
this.page.on("request", (req) => {
|
|
67
|
+
try {
|
|
68
|
+
const method = req.method();
|
|
69
|
+
if (!RECORDED_METHODS.includes(method)) return;
|
|
70
|
+
const url = req.url();
|
|
71
|
+
if (this.baseHost && !url.includes(this.baseHost)) return;
|
|
72
|
+
this.requests.push({
|
|
73
|
+
method,
|
|
74
|
+
url,
|
|
75
|
+
timestamp: new Date().toISOString(),
|
|
76
|
+
postData: req.postData() || null,
|
|
77
|
+
headers: req.headers(),
|
|
78
|
+
resourceType: req.resourceType(),
|
|
79
|
+
initiator: null, // JS initiator stack not exposed by the PW request API
|
|
80
|
+
});
|
|
81
|
+
} catch (_) { /* never let recording break the run */ }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// --- screenshot after every click ---
|
|
85
|
+
await this.page.exposeFunction("__capOnClick", (info) => {
|
|
86
|
+
// Serialize captures so rapid clicks don't overlap screenshots.
|
|
87
|
+
this.chain = this.chain.then(() => this._capture(info)).catch(() => {});
|
|
88
|
+
});
|
|
89
|
+
const inject = () => {
|
|
90
|
+
// eslint-disable-next-line no-undef
|
|
91
|
+
if (window.__capClickHooked) return;
|
|
92
|
+
// eslint-disable-next-line no-undef
|
|
93
|
+
window.__capClickHooked = true;
|
|
94
|
+
// eslint-disable-next-line no-undef
|
|
95
|
+
document.addEventListener(
|
|
96
|
+
"click",
|
|
97
|
+
(e) => {
|
|
98
|
+
try {
|
|
99
|
+
const t = e.target;
|
|
100
|
+
// eslint-disable-next-line no-undef
|
|
101
|
+
window.__capOnClick &&
|
|
102
|
+
window.__capOnClick({
|
|
103
|
+
tag: t && t.tagName,
|
|
104
|
+
text: ((t && t.innerText) || "").trim().slice(0, 40),
|
|
105
|
+
});
|
|
106
|
+
} catch (_) { /* ignore */ }
|
|
107
|
+
},
|
|
108
|
+
true // capture phase — fires even if a handler stops propagation
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
await this.page.addInitScript(inject); // future navigations / SPA reloads
|
|
112
|
+
await this.page.evaluate(inject).catch(() => {}); // the already-loaded document
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async _capture(info) {
|
|
117
|
+
const clickTime = new Date().toISOString();
|
|
118
|
+
await this.page.waitForTimeout(SETTLE_MS).catch(() => {}); // let the click settle (WDIO parity)
|
|
119
|
+
let buf;
|
|
120
|
+
try {
|
|
121
|
+
buf = await this.page.screenshot({ fullPage: true, timeout: CAPTURE_TIMEOUT_MS });
|
|
122
|
+
} catch (_) {
|
|
123
|
+
return; // a mid-navigation screenshot can fail; never block the run
|
|
124
|
+
}
|
|
125
|
+
if (this.lastImage && buf.equals(this.lastImage)) return; // drop byte-identical duplicate
|
|
126
|
+
this.lastImage = buf;
|
|
127
|
+
this.counter += 1;
|
|
128
|
+
const file = String(this.counter).padStart(3, "0") + "_click.png";
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(this.ssDir, { recursive: true });
|
|
131
|
+
fs.writeFileSync(path.join(this.ssDir, file), buf);
|
|
132
|
+
this.steps.push({ step: this.counter, file, command: "click", clickTime, target: (info && info.tag) || null });
|
|
133
|
+
} catch (_) { /* ignore */ }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Drain pending screenshots, then write steps.json + the request-payloads file. */
|
|
137
|
+
async flush() {
|
|
138
|
+
if (!this.enabled) return;
|
|
139
|
+
await this.chain.catch(() => {});
|
|
140
|
+
try {
|
|
141
|
+
if (this.steps.length) {
|
|
142
|
+
fs.mkdirSync(this.ssDir, { recursive: true });
|
|
143
|
+
fs.writeFileSync(path.join(this.ssDir, "steps.json"), JSON.stringify(this.steps, null, 2));
|
|
144
|
+
}
|
|
145
|
+
} catch (_) { /* ignore */ }
|
|
146
|
+
try {
|
|
147
|
+
fs.mkdirSync(this.rpDir, { recursive: true });
|
|
148
|
+
const payload = {
|
|
149
|
+
test: this.testTitle,
|
|
150
|
+
module: this.module,
|
|
151
|
+
recordedAt: new Date().toISOString(),
|
|
152
|
+
requestCount: this.requests.length,
|
|
153
|
+
requests: this.requests,
|
|
154
|
+
};
|
|
155
|
+
fs.writeFileSync(path.join(this.rpDir, this.testTitle + ".json"), JSON.stringify(payload, null, 2));
|
|
156
|
+
} catch (_) { /* ignore */ }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function installDebugRecorders(page, opts = {}) {
|
|
161
|
+
const rec = new PwDebugRecorder(page, opts);
|
|
162
|
+
await rec.install();
|
|
163
|
+
return rec;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { installDebugRecorders, PwDebugRecorder };
|
|
167
|
+
module.exports.default = { installDebugRecorders };
|
package/e2e/playwright/index.js
CHANGED
|
@@ -9,10 +9,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
9
9
|
const config_1 = require("./config");
|
|
10
10
|
const login_1 = require("./login");
|
|
11
11
|
const waits_1 = require("./waits");
|
|
12
|
+
const logCollector_1 = require("./logCollector");
|
|
12
13
|
|
|
13
14
|
exports.createPlaywrightConfig = config_1.createPlaywrightConfig;
|
|
14
15
|
exports.doLogin = login_1.doLogin;
|
|
15
16
|
exports.waitForApi = waits_1.waitForApi;
|
|
16
17
|
exports.clickAndWaitForApi = waits_1.clickAndWaitForApi;
|
|
17
18
|
exports.waitForApiIdle = waits_1.waitForApiIdle;
|
|
19
|
+
exports.collectLogs = logCollector_1.collectLogs;
|
|
18
20
|
exports.reporterPath = require.resolve("./reporter");
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Per-test log_collector feed — Playwright port of e2e/utils/logCollectorUtil.
|
|
4
|
+
*
|
|
5
|
+
* CONTRACT (owned by wdio-ui-automation + the in-pod log_collector daemon):
|
|
6
|
+
* one POST per executed test to http://127.0.0.1:4000/logger with EXACTLY the
|
|
7
|
+
* payload the WDIO util sends — Apitester reads these rows keyed by runId:
|
|
8
|
+
*
|
|
9
|
+
* { "result": { [runId]: { [suiteName]: { [testTitle]: {
|
|
10
|
+
* "status": "passed" | "failed" | "skipped",
|
|
11
|
+
* "time": "<seconds, stringified>",
|
|
12
|
+
* "validataionMessage": "<error text, ANSI-stripped>", // typo IS the contract
|
|
13
|
+
* "total": <running count of posted tests> } } } } }
|
|
14
|
+
*
|
|
15
|
+
* Behaviour parity with the WDIO util:
|
|
16
|
+
* - passed/skipped -> empty validataionMessage
|
|
17
|
+
* - failed with no error object -> "cannot capture error"
|
|
18
|
+
* - error text stripped of ANSI escape sequences (same regex)
|
|
19
|
+
* - 10s deadline; a POST failure NEVER fails the run (collector absent locally)
|
|
20
|
+
* - skipLogCollector env short-circuits (pod semantics, avoids local noise)
|
|
21
|
+
*
|
|
22
|
+
* Zero-dependency (node http) so it adds nothing to any consumer's install graph.
|
|
23
|
+
*/
|
|
24
|
+
const http = require("http");
|
|
25
|
+
|
|
26
|
+
const LOG_COLLECTOR_HOST = "127.0.0.1";
|
|
27
|
+
const LOG_COLLECTOR_PORT = 4000;
|
|
28
|
+
const LOG_COLLECTOR_PATH = "/logger";
|
|
29
|
+
const DEADLINE_MS = 10000;
|
|
30
|
+
|
|
31
|
+
// Same strip pattern as e2e/utils/logCollectorUtil.js
|
|
32
|
+
const ANSI_RE = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
|
33
|
+
|
|
34
|
+
let callCount = 0;
|
|
35
|
+
|
|
36
|
+
function skipCollector() {
|
|
37
|
+
return ["1", "true", "yes"].includes(String(process.env.skipLogCollector || "").toLowerCase());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function post(payload) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const body = JSON.stringify(payload);
|
|
43
|
+
const req = http.request(
|
|
44
|
+
{
|
|
45
|
+
host: LOG_COLLECTOR_HOST,
|
|
46
|
+
port: LOG_COLLECTOR_PORT,
|
|
47
|
+
path: LOG_COLLECTOR_PATH,
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"Content-Length": Buffer.byteLength(body),
|
|
52
|
+
},
|
|
53
|
+
timeout: DEADLINE_MS,
|
|
54
|
+
},
|
|
55
|
+
(res) => {
|
|
56
|
+
let data = "";
|
|
57
|
+
res.on("data", (c) => { data += c; });
|
|
58
|
+
res.on("end", () => {
|
|
59
|
+
console.log("API Response", data);
|
|
60
|
+
resolve(undefined);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
req.on("timeout", () => req.destroy(new Error(`deadline of ${DEADLINE_MS}ms exceeded`)));
|
|
65
|
+
req.on("error", reject);
|
|
66
|
+
req.write(body);
|
|
67
|
+
req.end();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* POST one test result. Mirrors logCollectorUtil.collectLogs(runId, test, error, ...).
|
|
73
|
+
* @param {string} runId process.env.runId
|
|
74
|
+
* @param {string} suiteName WDIO used test.parent (the describe title)
|
|
75
|
+
* @param {string} testTitle the test title
|
|
76
|
+
* @param {"passed"|"failed"|"skipped"} status final-attempt outcome
|
|
77
|
+
* @param {number} durationMs
|
|
78
|
+
* @param {string|undefined} errorMessage
|
|
79
|
+
*/
|
|
80
|
+
async function collectLogs(runId, suiteName, testTitle, status, durationMs, errorMessage) {
|
|
81
|
+
if (skipCollector()) return;
|
|
82
|
+
callCount += 1;
|
|
83
|
+
|
|
84
|
+
let validataionMessage = "";
|
|
85
|
+
if (status === "failed") {
|
|
86
|
+
validataionMessage = errorMessage == null
|
|
87
|
+
? "cannot capture error"
|
|
88
|
+
: String(errorMessage).replace(ANSI_RE, "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const exeTime = (durationMs || 0) / 1000;
|
|
92
|
+
const message = {
|
|
93
|
+
[runId]: {
|
|
94
|
+
[suiteName]: {
|
|
95
|
+
[testTitle]: {
|
|
96
|
+
status,
|
|
97
|
+
time: exeTime.toString(),
|
|
98
|
+
validataionMessage,
|
|
99
|
+
total: callCount,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await post({ result: message });
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// The in-pod collector can be slow or absent; never fail the test run.
|
|
109
|
+
console.log("logCollector POST failed (non-fatal):", err && err.message);
|
|
110
|
+
}
|
|
111
|
+
console.log("Message", JSON.stringify(message));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { collectLogs };
|
|
115
|
+
module.exports.default = { collectLogs };
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
*/
|
|
15
15
|
const fs = require("fs");
|
|
16
16
|
const path = require("path");
|
|
17
|
+
const { collectLogs } = require("./logCollector");
|
|
18
|
+
|
|
19
|
+
// Cap on inlined failure screenshots per test (base64 inflates ~4/3; the report
|
|
20
|
+
// must stay POST-able to log_collector).
|
|
21
|
+
const MAX_SHOT_BYTES_PER_TEST = 3 * 1024 * 1024;
|
|
17
22
|
|
|
18
23
|
function esc(s) {
|
|
19
24
|
return String(s == null ? "" : s)
|
|
@@ -35,7 +40,13 @@ class CapPlaywrightReporter {
|
|
|
35
40
|
this.outputFile = options.outputFile ||
|
|
36
41
|
path.resolve(process.cwd(), "reports", "html-reports", "master-report.html");
|
|
37
42
|
this.title = options.title || "E2E Report (Playwright)";
|
|
38
|
-
|
|
43
|
+
// Keyed by stable test identity so RETRIES don't inflate the count — the
|
|
44
|
+
// last attempt wins (final outcome). A flaky test that passes on retry
|
|
45
|
+
// counts as ONE passed. Critical for "same number of tests" parity with
|
|
46
|
+
// the WDIO suites (verified live: without this, 5 tests reported as 6).
|
|
47
|
+
this.byTest = new Map();
|
|
48
|
+
// In-flight log_collector POSTs — awaited in onEnd so none are lost.
|
|
49
|
+
this._pending = [];
|
|
39
50
|
this.startedAt = Date.now();
|
|
40
51
|
}
|
|
41
52
|
|
|
@@ -45,18 +56,65 @@ class CapPlaywrightReporter {
|
|
|
45
56
|
this._module = process.env.module || process.env.E2E_MODULE || "";
|
|
46
57
|
}
|
|
47
58
|
|
|
59
|
+
/** WDIO's collectLogs used test.parent (the describe title); nearest non-empty ancestor here. */
|
|
60
|
+
_suiteNameOf(test) {
|
|
61
|
+
for (let s = test.parent; s; s = s.parent) {
|
|
62
|
+
if (s.title) return s.title;
|
|
63
|
+
}
|
|
64
|
+
return this._module || "suite";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Inline failure screenshots (PW attachments) as data URIs, size-capped. */
|
|
68
|
+
_screenshotsOf(result) {
|
|
69
|
+
const shots = [];
|
|
70
|
+
let budget = MAX_SHOT_BYTES_PER_TEST;
|
|
71
|
+
for (const a of result.attachments || []) {
|
|
72
|
+
if (!a || a.contentType !== "image/png") continue;
|
|
73
|
+
if (!/screenshot/i.test(a.name || "")) continue;
|
|
74
|
+
try {
|
|
75
|
+
const buf = a.body || (a.path && fs.readFileSync(a.path));
|
|
76
|
+
if (!buf || buf.length > budget) continue;
|
|
77
|
+
budget -= buf.length;
|
|
78
|
+
shots.push(`data:image/png;base64,${Buffer.from(buf).toString("base64")}`);
|
|
79
|
+
} catch (e) { /* attachment unreadable — skip, never fail reporting */ }
|
|
80
|
+
}
|
|
81
|
+
return shots;
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
onTestEnd(test, result) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
// titlePath is stable across retry attempts (test.id proved not to be).
|
|
86
|
+
const titleParts = (typeof test.titlePath === "function" ? test.titlePath() : [test.title])
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
const key = titleParts.join(" › ");
|
|
89
|
+
const status = result.status === "passed" ? "passed"
|
|
90
|
+
: result.status === "skipped" ? "skipped"
|
|
91
|
+
: "failed"; // failed | timedOut | interrupted
|
|
92
|
+
const errorMessage = result.error && (result.error.message || String(result.error));
|
|
93
|
+
|
|
94
|
+
// Last attempt wins (final outcome) — overwrites earlier retries.
|
|
95
|
+
this.byTest.set(key, {
|
|
96
|
+
title: key,
|
|
97
|
+
status: result.status,
|
|
53
98
|
duration: result.duration,
|
|
54
99
|
retry: result.retry,
|
|
55
|
-
error:
|
|
100
|
+
error: errorMessage,
|
|
101
|
+
screenshots: status === "failed" ? this._screenshotsOf(result) : [],
|
|
56
102
|
});
|
|
103
|
+
|
|
104
|
+
// log_collector CONTRACT (same feed WDIO's afterTest sent): one POST per
|
|
105
|
+
// test, on its FINAL attempt only (a will-be-retried failure is not final).
|
|
106
|
+
const isFinal = status !== "failed" || result.retry >= ((test.retries != null ? test.retries : 0));
|
|
107
|
+
if (isFinal) {
|
|
108
|
+
const runId = process.env.runId || "NA";
|
|
109
|
+
this._pending.push(
|
|
110
|
+
collectLogs(runId, this._suiteNameOf(test), test.title, status, result.duration, errorMessage)
|
|
111
|
+
.catch(() => { /* never fail the run on logging */ })
|
|
112
|
+
);
|
|
113
|
+
}
|
|
57
114
|
}
|
|
58
115
|
|
|
59
|
-
onEnd(result) {
|
|
116
|
+
async onEnd(result) {
|
|
117
|
+
this.results = Array.from(this.byTest.values());
|
|
60
118
|
const counts = { passed: 0, failed: 0, skipped: 0, flaky: 0 };
|
|
61
119
|
for (const r of this.results) {
|
|
62
120
|
if (r.status === "passed") counts.passed++;
|
|
@@ -78,6 +136,9 @@ class CapPlaywrightReporter {
|
|
|
78
136
|
// Also echo a plain summary to stdout so it lands in the Apitester log.
|
|
79
137
|
console.log(`[cap-pw-reporter] SUITE=${this._suite} module=${this._module} ` +
|
|
80
138
|
`total=${total} passed=${counts.passed} failed=${counts.failed} skipped=${counts.skipped} overall=${overall}`);
|
|
139
|
+
|
|
140
|
+
// Flush per-test log_collector POSTs before the runner tears down.
|
|
141
|
+
await Promise.allSettled(this._pending);
|
|
81
142
|
}
|
|
82
143
|
|
|
83
144
|
_statusPill(status) {
|
|
@@ -96,7 +157,9 @@ class CapPlaywrightReporter {
|
|
|
96
157
|
<td>${this._statusPill(r.status)}</td>
|
|
97
158
|
<td class="title">${esc(r.title)}${r.retry ? ` <span class="retry">(retry ${r.retry})</span>` : ""}</td>
|
|
98
159
|
<td class="dur">${fmtDuration(r.duration)}</td>
|
|
99
|
-
<td class="err">${r.error ? `<pre>${esc(r.error)}</pre>` : ""}
|
|
160
|
+
<td class="err">${r.error ? `<pre>${esc(r.error)}</pre>` : ""}${
|
|
161
|
+
(r.screenshots || []).map((s) => `<img class="shot" src="${s}" alt="failure screenshot"/>`).join("")
|
|
162
|
+
}</td>
|
|
100
163
|
</tr>`).join("");
|
|
101
164
|
|
|
102
165
|
const overallColor = overall === "passed" ? "#1B7A1B" : "#B00020";
|
|
@@ -125,6 +188,7 @@ class CapPlaywrightReporter {
|
|
|
125
188
|
.retry{color:#8a6d00;font-size:11px}
|
|
126
189
|
tr.failed td.title{font-weight:600}
|
|
127
190
|
pre{margin:0;white-space:pre-wrap;font-size:12px;color:#B00020;max-height:200px;overflow:auto}
|
|
191
|
+
img.shot{display:block;max-width:520px;margin-top:8px;border:1px solid #dde3ea;border-radius:4px}
|
|
128
192
|
.foot{color:#889;font-size:12px;margin-top:14px}
|
|
129
193
|
</style>
|
|
130
194
|
</head>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capillarytech/cap-ui-utils",
|
|
3
|
-
"version": "3.2.0-beta.
|
|
3
|
+
"version": "3.2.0-beta.2",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -30,20 +30,10 @@
|
|
|
30
30
|
"nanoid": ">=3"
|
|
31
31
|
},
|
|
32
32
|
"peerDependenciesMeta": {
|
|
33
|
-
"webdriverio": {
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
},
|
|
39
|
-
"axios": {
|
|
40
|
-
"optional": true
|
|
41
|
-
},
|
|
42
|
-
"supertest": {
|
|
43
|
-
"optional": true
|
|
44
|
-
},
|
|
45
|
-
"nanoid": {
|
|
46
|
-
"optional": true
|
|
47
|
-
}
|
|
33
|
+
"webdriverio": { "optional": true },
|
|
34
|
+
"@rpii/wdio-commands": { "optional": true },
|
|
35
|
+
"axios": { "optional": true },
|
|
36
|
+
"supertest": { "optional": true },
|
|
37
|
+
"nanoid": { "optional": true }
|
|
48
38
|
}
|
|
49
39
|
}
|