@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 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 };
@@ -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
- this.results = [];
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
- this.results.push({
50
- title: (typeof test.titlePath === "function" ? test.titlePath() : [test.title])
51
- .filter(Boolean).join(" › "),
52
- status: result.status, // passed | failed | timedOut | skipped | interrupted
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: result.error && (result.error.message || String(result.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>` : ""}</td>
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.0",
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
- "optional": true
35
- },
36
- "@rpii/wdio-commands": {
37
- "optional": true
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
  }