@capillarytech/cap-ui-utils 3.2.0-beta.1 → 3.2.0-beta.3

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,18 @@ 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`.
67
+ - **`playwright/apiFailureLogger`** — logs every same-origin API response outside
68
+ 2xx (response + request headers) and network-level request failures, with the
69
+ same wording as the WDIO CDP interceptor (`API <url> errored with status: ...`,
70
+ `Following request headers were sent for failed request`). Auto-attached by
71
+ `doLogin()`; also exported standalone as `attachApiFailureLogger(page, {baseUrl})`.
60
72
  - **`playwright/config`** — `createPlaywrightConfig()` wires the reporter, selects the
61
73
  suite via `SUITE` → `@<suite>` grep (mirrors `wdio --suite`), and sets pod-friendly
62
74
  defaults (headless chromium, trace/screenshot on failure, retries).
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ /**
3
+ * Failed-API logger — Playwright port of the WDIO base-conf network interceptor
4
+ * (test/wdio.conf.ts `Network.responseReceived` block): every SAME-ORIGIN API
5
+ * response outside 2xx is logged with its response + request headers, and
6
+ * network-level request failures (refused/aborted/DNS) are logged too.
7
+ *
8
+ * The lines go to stdout, which supervisor.py captures and ships to Apitester —
9
+ * and they use the SAME wording as the WDIO interceptor so existing debugging
10
+ * habits and log greps keep working:
11
+ *
12
+ * API <url> errored with status: <status> with following response headers:
13
+ * { ...response headers... }
14
+ * Following request headers were sent for failed request { ...request headers... }
15
+ *
16
+ * Auto-attached by doLogin() for every consumer (mirrors WDIO's always-on base
17
+ * conf). Idempotent per page. Logging must never fail the run.
18
+ */
19
+
20
+ const attached = new WeakSet();
21
+
22
+ function attachApiFailureLogger(page, opts = {}) {
23
+ if (!page || typeof page.on !== "function" || attached.has(page)) return;
24
+ attached.add(page);
25
+
26
+ // Same-origin filter — WDIO used cons['url'] (the intouch base URL).
27
+ const base = String(opts.baseUrl || process.env.INTOUCH_URL || "");
28
+
29
+ page.on("response", (response) => {
30
+ Promise.resolve()
31
+ .then(async () => {
32
+ const url = response.url();
33
+ if (base && !url.startsWith(base)) return;
34
+ const status = response.status();
35
+ if (status >= 200 && status <= 299) return;
36
+ const respHeaders = await response.allHeaders().catch(() => response.headers());
37
+ console.log(`API ${url} errored with status: ${status} with following response headers:`);
38
+ console.log(respHeaders);
39
+ console.log("Following request headers were sent for failed request", response.request().headers());
40
+ })
41
+ .catch(() => { /* logging must never fail the run */ });
42
+ });
43
+
44
+ page.on("requestfailed", (request) => {
45
+ try {
46
+ const url = request.url();
47
+ if (base && !url.startsWith(base)) return;
48
+ const failure = request.failure();
49
+ console.log(`API ${url} FAILED at network level: ${(failure && failure.errorText) || "unknown"} (method ${request.method()})`);
50
+ console.log("Following request headers were sent for failed request", request.headers());
51
+ } catch (e) { /* ignore */ }
52
+ });
53
+
54
+ console.log("----DONE SETTING UP NETWORK INTERCEPTOR TO LOG FAILED APIS----");
55
+ }
56
+
57
+ module.exports = { attachApiFailureLogger };
58
+ module.exports.default = { attachApiFailureLogger };
@@ -9,10 +9,14 @@ 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");
13
+ const apiFailureLogger_1 = require("./apiFailureLogger");
12
14
 
13
15
  exports.createPlaywrightConfig = config_1.createPlaywrightConfig;
14
16
  exports.doLogin = login_1.doLogin;
15
17
  exports.waitForApi = waits_1.waitForApi;
16
18
  exports.clickAndWaitForApi = waits_1.clickAndWaitForApi;
17
19
  exports.waitForApiIdle = waits_1.waitForApiIdle;
20
+ exports.collectLogs = logCollector_1.collectLogs;
21
+ exports.attachApiFailureLogger = apiFailureLogger_1.attachApiFailureLogger;
18
22
  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 };
@@ -10,6 +10,8 @@
10
10
  * import { doLogin } from '@capillarytech/cap-ui-utils/e2e/playwright/login';
11
11
  * await doLogin(page, { url, username, password });
12
12
  */
13
+ const { attachApiFailureLogger } = require("./apiFailureLogger");
14
+
13
15
  const MAX_LOGIN_ATTEMPTS = 3;
14
16
 
15
17
  function sel(page) {
@@ -28,6 +30,9 @@ function sel(page) {
28
30
 
29
31
  async function doLogin(page, opts, attempt = 0) {
30
32
  const { url, username, password } = opts;
33
+ // WDIO's base conf always logged failed same-origin API calls (CDP interceptor);
34
+ // wire the PW equivalent here so every consumer inherits it. Idempotent per page.
35
+ attachApiFailureLogger(page, { baseUrl: url });
31
36
  const s = sel(page);
32
37
  try {
33
38
  await page.goto(url, { waitUntil: "domcontentloaded" });
@@ -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.1",
3
+ "version": "3.2.0-beta.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },