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

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,6 +36,32 @@ 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` |
40
+
41
+ ## Playwright toolkit (phase 2 — coexistence)
42
+
43
+ WDIO and Playwright coexist: the runner picks the engine per module from
44
+ `module-map.json`. A migrated module's product repo carries **only** Playwright code
45
+ and builds its config from here, so the results contract stays intact.
46
+
47
+ ```ts
48
+ // playwright.config.ts (product repo)
49
+ import { createPlaywrightConfig } from '@capillarytech/cap-ui-utils/e2e/playwright/config';
50
+ export default createPlaywrightConfig({ testDir: './tests', title: 'Garuda E2E' });
51
+
52
+ // a spec
53
+ import { doLogin } from '@capillarytech/cap-ui-utils/e2e/playwright/login';
54
+ import { waitForApi } from '@capillarytech/cap-ui-utils/e2e/playwright/waits';
55
+ ```
56
+
57
+ - **`playwright/reporter`** — emits `reports/html-reports/master-report.html` (same
58
+ path/shape the WDIO reporter produced) so `supervisor.py`'s `generateReport()` →
59
+ Apitester pipeline is unchanged. Referenced by path via `require.resolve`.
60
+ - **`playwright/config`** — `createPlaywrightConfig()` wires the reporter, selects the
61
+ suite via `SUITE` → `@<suite>` grep (mirrors `wdio --suite`), and sets pod-friendly
62
+ defaults (headless chromium, trace/screenshot on failure, retries).
63
+ - **`playwright/login`** — Playwright port of `pages/common/login.page` (intouch V2/V1).
64
+ - **`playwright/waits`** — network-driven wait helpers (no fixed sleeps).
39
65
 
40
66
  Two small decouplings were applied on the way in:
41
67
  - `logCollectorUtil` had a **dead** `import { config } from '../../test/wdio.conf'` (never used) — removed.
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ /**
3
+ * Shared Playwright config factory — phase-2 coexistence.
4
+ *
5
+ * Product repos build their playwright.config from this so every migrated module
6
+ * inherits the same reporter (results contract), suite selection, and pod-friendly
7
+ * defaults. Returns a plain config object (no hard require of @playwright/test), so
8
+ * it resolves even where defineConfig isn't importable.
9
+ *
10
+ * // playwright.config.ts (in a product repo)
11
+ * import { createPlaywrightConfig } from '@capillarytech/cap-ui-utils/e2e/playwright/config';
12
+ * export default createPlaywrightConfig({ testDir: './tests', title: 'Garuda E2E' });
13
+ *
14
+ * Suite selection: the runner exports SUITE (smoke|sanity|regression). Tests are
15
+ * tagged (e.g. test('@smoke ...')) and this config greps `@<suite>` so
16
+ * `npm run test:e2e` runs only that suite — same contract as `wdio --suite`.
17
+ */
18
+ const path = require("path");
19
+
20
+ function num(v, dflt) {
21
+ const n = parseInt(v, 10);
22
+ return Number.isFinite(n) ? n : dflt;
23
+ }
24
+
25
+ function createPlaywrightConfig(overrides = {}) {
26
+ const suite = process.env.SUITE || process.env.suite || "";
27
+ const reporterPath = require.resolve("./reporter");
28
+
29
+ const base = {
30
+ testDir: overrides.testDir || "./tests",
31
+ // A migrated module is Playwright-only; keep the tree explicit.
32
+ fullyParallel: overrides.fullyParallel !== undefined ? overrides.fullyParallel : true,
33
+ forbidOnly: !!process.env.CI,
34
+ retries: num(process.env.PW_RETRIES, overrides.retries !== undefined ? overrides.retries : 1),
35
+ workers: num(process.env.PW_WORKERS, overrides.workers !== undefined ? overrides.workers : 1),
36
+ timeout: num(process.env.PW_TIMEOUT, overrides.timeout || 90000),
37
+ expect: { timeout: num(process.env.PW_EXPECT_TIMEOUT, 15000) },
38
+ // Only the migrated suite runs — same behaviour as `wdio --suite <suite>`.
39
+ grep: suite ? new RegExp("@" + suite + "\\b") : undefined,
40
+ reporter: [
41
+ [reporterPath, { title: overrides.title || "E2E Report (Playwright)" }],
42
+ ["line"],
43
+ ],
44
+ use: Object.assign(
45
+ {
46
+ baseURL: overrides.baseURL || process.env.INTOUCH_URL || undefined,
47
+ headless: true,
48
+ trace: "retain-on-failure",
49
+ screenshot: "only-on-failure",
50
+ video: "off",
51
+ actionTimeout: num(process.env.PW_ACTION_TIMEOUT, 15000),
52
+ navigationTimeout: num(process.env.PW_NAV_TIMEOUT, 60000),
53
+ ignoreHTTPSErrors: true,
54
+ viewport: { width: 1920, height: 1080 },
55
+ },
56
+ overrides.use || {}
57
+ ),
58
+ projects: overrides.projects || [{ name: "chromium", use: { browserName: "chromium" } }],
59
+ };
60
+ return Object.assign(base, overrides.extra || {});
61
+ }
62
+
63
+ module.exports = { createPlaywrightConfig };
64
+ module.exports.default = createPlaywrightConfig;
@@ -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 };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ /**
3
+ * Playwright subpath barrel — phase-2 coexistence toolkit.
4
+ * import { createPlaywrightConfig, doLogin, waitForApi } from '@capillarytech/cap-ui-utils/e2e/playwright';
5
+ * The reporter is referenced by PATH in a config (not imported), e.g.
6
+ * require.resolve('@capillarytech/cap-ui-utils/e2e/playwright/reporter')
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ const config_1 = require("./config");
10
+ const login_1 = require("./login");
11
+ const waits_1 = require("./waits");
12
+
13
+ exports.createPlaywrightConfig = config_1.createPlaywrightConfig;
14
+ exports.doLogin = login_1.doLogin;
15
+ exports.waitForApi = waits_1.waitForApi;
16
+ exports.clickAndWaitForApi = waits_1.clickAndWaitForApi;
17
+ exports.waitForApiIdle = waits_1.waitForApiIdle;
18
+ exports.reporterPath = require.resolve("./reporter");
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ /**
3
+ * Shared Playwright login — phase-2 coexistence.
4
+ *
5
+ * Playwright port of e2e/pages/common/login.page (WDIO). Same intouch flow:
6
+ * username -> (V2: Continue -> password) -> Sign in -> wait for /home/ui ->
7
+ * sidebar shell mounted. Uses role/label + id locators and web-first waits
8
+ * instead of WDIO waitForDisplayed. V2 flow is selected by env V2_LOGIN=true.
9
+ *
10
+ * import { doLogin } from '@capillarytech/cap-ui-utils/e2e/playwright/login';
11
+ * await doLogin(page, { url, username, password });
12
+ */
13
+ const MAX_LOGIN_ATTEMPTS = 3;
14
+
15
+ function sel(page) {
16
+ const v2 = process.env.V2_LOGIN === "true";
17
+ return {
18
+ v2,
19
+ username: v2 ? page.locator("#login-username") : page.locator("#login_user"),
20
+ password: v2 ? page.locator("#login-password") : page.locator("#login_cred"),
21
+ signIn: v2 ? page.getByRole("button", { name: "Sign in" }) : page.locator("#c-login-btn"),
22
+ continueBtn: page.getByRole("button", { name: "Continue" }),
23
+ // Post-login readiness: MFE sidebar shell testid, with the legacy
24
+ // "Explore features" text as a pre-MFE fallback (mirrors the WDIO page).
25
+ homeReady: page.locator('[data-testid="cap-navigation-spa-sidebar"], :text-is("Explore features")').first(),
26
+ };
27
+ }
28
+
29
+ async function doLogin(page, opts, attempt = 0) {
30
+ const { url, username, password } = opts;
31
+ const s = sel(page);
32
+ try {
33
+ await page.goto(url, { waitUntil: "domcontentloaded" });
34
+
35
+ // Already home (retry path)?
36
+ if (attempt > 0 && page.url().includes("/home/ui")) {
37
+ try {
38
+ await s.homeReady.waitFor({ state: "visible", timeout: 60000 });
39
+ console.log("-----Logged In (already home on retry)-----");
40
+ return;
41
+ } catch (e) { /* fall through to full login */ }
42
+ }
43
+
44
+ await s.username.waitFor({ state: "visible", timeout: 60000 });
45
+ await s.username.fill(username);
46
+
47
+ if (s.v2) {
48
+ await s.continueBtn.click();
49
+ await s.password.waitFor({ state: "visible", timeout: 30000 });
50
+ }
51
+ await s.password.fill(password);
52
+ await s.signIn.click();
53
+
54
+ await page.waitForURL(/\/home\/ui/, { timeout: 60000 });
55
+ console.log("-----Browser url contains /home/ui after login-----");
56
+ await s.homeReady.waitFor({ state: "visible", timeout: 60000 });
57
+ console.log("-----Logged In-----");
58
+ } catch (error) {
59
+ console.log(`Login attempt #${attempt} failed:`, error && error.message);
60
+ if (attempt >= MAX_LOGIN_ATTEMPTS - 1) {
61
+ throw new Error("Failed to get the user logged in: " + (error && error.message));
62
+ }
63
+ await doLogin(page, opts, attempt + 1);
64
+ }
65
+ }
66
+
67
+ module.exports = { doLogin, MAX_LOGIN_ATTEMPTS };
68
+ module.exports.default = { doLogin };
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ /**
3
+ * Shared Playwright reporter — phase-2 coexistence.
4
+ *
5
+ * The runner's supervisor.py reads `reports/html-reports/master-report.html`
6
+ * (historically produced by wdio-html-nice-reporter), inlines any linked CSS, and
7
+ * POSTs the result to log_collector -> Apitester. To keep that RESULTS CONTRACT
8
+ * intact for Playwright modules, this reporter emits the SAME file at the SAME
9
+ * path — self-contained (inline <style>, no external <link>) so it renders
10
+ * identically wherever the wdio report did.
11
+ *
12
+ * Wire it in a product repo's playwright.config via createPlaywrightConfig(), or
13
+ * directly: reporter: [[require.resolve('@capillarytech/cap-ui-utils/e2e/playwright/reporter')]]
14
+ */
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+
18
+ function esc(s) {
19
+ return String(s == null ? "" : s)
20
+ .replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22
+ }
23
+
24
+ function fmtDuration(ms) {
25
+ if (ms == null) return "";
26
+ const s = ms / 1000;
27
+ if (s < 60) return s.toFixed(2) + "s";
28
+ const m = Math.floor(s / 60);
29
+ return m + "m " + (s - m * 60).toFixed(0) + "s";
30
+ }
31
+
32
+ class CapPlaywrightReporter {
33
+ constructor(options = {}) {
34
+ // Default path matches supervisor.py's generateReport() (cwd = /usr/wdio).
35
+ this.outputFile = options.outputFile ||
36
+ path.resolve(process.cwd(), "reports", "html-reports", "master-report.html");
37
+ this.title = options.title || "E2E Report (Playwright)";
38
+ this.results = [];
39
+ this.startedAt = Date.now();
40
+ }
41
+
42
+ onBegin(config, suite) {
43
+ this._total = typeof suite.allTests === "function" ? suite.allTests().length : 0;
44
+ this._suite = process.env.SUITE || process.env.suite || "";
45
+ this._module = process.env.module || process.env.E2E_MODULE || "";
46
+ }
47
+
48
+ 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
53
+ duration: result.duration,
54
+ retry: result.retry,
55
+ error: result.error && (result.error.message || String(result.error)),
56
+ });
57
+ }
58
+
59
+ onEnd(result) {
60
+ const counts = { passed: 0, failed: 0, skipped: 0, flaky: 0 };
61
+ for (const r of this.results) {
62
+ if (r.status === "passed") counts.passed++;
63
+ else if (r.status === "skipped") counts.skipped++;
64
+ else counts.failed++; // failed | timedOut | interrupted
65
+ }
66
+ const total = this.results.length;
67
+ const wallMs = Date.now() - this.startedAt;
68
+ const overall = (result && result.status) || (counts.failed ? "failed" : "passed");
69
+
70
+ const html = this._render(counts, total, wallMs, overall);
71
+ try {
72
+ fs.mkdirSync(path.dirname(this.outputFile), { recursive: true });
73
+ fs.writeFileSync(this.outputFile, html, "utf-8");
74
+ console.log(`[cap-pw-reporter] wrote ${this.outputFile}`);
75
+ } catch (e) {
76
+ console.log("[cap-pw-reporter] FAILED to write report:", e && e.message);
77
+ }
78
+ // Also echo a plain summary to stdout so it lands in the Apitester log.
79
+ console.log(`[cap-pw-reporter] SUITE=${this._suite} module=${this._module} ` +
80
+ `total=${total} passed=${counts.passed} failed=${counts.failed} skipped=${counts.skipped} overall=${overall}`);
81
+ }
82
+
83
+ _statusPill(status) {
84
+ const map = {
85
+ passed: ["#1B7A1B", "#E7F3E8", "PASS"],
86
+ skipped: ["#8a6d00", "#FBEFD6", "SKIP"],
87
+ };
88
+ const [fg, bg, label] = map[status] || ["#B00020", "#FBE0DD", status.toUpperCase()];
89
+ return `<span class="pill" style="color:${fg};background:${bg}">${esc(label)}</span>`;
90
+ }
91
+
92
+ _render(counts, total, wallMs, overall) {
93
+ const rows = this.results.map((r, i) => `
94
+ <tr class="${r.status}">
95
+ <td class="num">${i + 1}</td>
96
+ <td>${this._statusPill(r.status)}</td>
97
+ <td class="title">${esc(r.title)}${r.retry ? ` <span class="retry">(retry ${r.retry})</span>` : ""}</td>
98
+ <td class="dur">${fmtDuration(r.duration)}</td>
99
+ <td class="err">${r.error ? `<pre>${esc(r.error)}</pre>` : ""}</td>
100
+ </tr>`).join("");
101
+
102
+ const overallColor = overall === "passed" ? "#1B7A1B" : "#B00020";
103
+ return `<!DOCTYPE html>
104
+ <html lang="en">
105
+ <head>
106
+ <meta charset="utf-8"/>
107
+ <title>${esc(this.title)}</title>
108
+ <style>
109
+ body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0;background:#f6f8fa;color:#1f2933}
110
+ .wrap{max-width:1100px;margin:0 auto;padding:24px}
111
+ h1{font-size:20px;margin:0 0 4px}
112
+ .meta{color:#556;font-size:13px;margin-bottom:16px}
113
+ .meta b{color:#1f2933}
114
+ .cards{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px}
115
+ .card{background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:12px 18px;min-width:90px}
116
+ .card .n{font-size:24px;font-weight:700;font-variant-numeric:tabular-nums}
117
+ .card .l{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#667}
118
+ .overall{font-weight:700;color:${overallColor}}
119
+ table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden}
120
+ th,td{padding:9px 12px;text-align:left;font-size:13px;border-bottom:1px solid #eef2f6;vertical-align:top}
121
+ th{background:#1F3A5F;color:#fff;font-size:11px;text-transform:uppercase;letter-spacing:.04em}
122
+ td.num{color:#889;width:34px}
123
+ td.dur{white-space:nowrap;font-variant-numeric:tabular-nums;color:#556}
124
+ .pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:700}
125
+ .retry{color:#8a6d00;font-size:11px}
126
+ tr.failed td.title{font-weight:600}
127
+ pre{margin:0;white-space:pre-wrap;font-size:12px;color:#B00020;max-height:200px;overflow:auto}
128
+ .foot{color:#889;font-size:12px;margin-top:14px}
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <div class="wrap">
133
+ <h1>${esc(this.title)}</h1>
134
+ <div class="meta">
135
+ module <b>${esc(this._module || "NA")}</b> &nbsp;·&nbsp; suite <b>${esc(this._suite || "NA")}</b>
136
+ &nbsp;·&nbsp; engine <b>playwright</b> &nbsp;·&nbsp; result <span class="overall">${esc(overall.toUpperCase())}</span>
137
+ </div>
138
+ <div class="cards">
139
+ <div class="card"><div class="n">${total}</div><div class="l">Total</div></div>
140
+ <div class="card"><div class="n" style="color:#1B7A1B">${counts.passed}</div><div class="l">Passed</div></div>
141
+ <div class="card"><div class="n" style="color:#B00020">${counts.failed}</div><div class="l">Failed</div></div>
142
+ <div class="card"><div class="n" style="color:#8a6d00">${counts.skipped}</div><div class="l">Skipped</div></div>
143
+ <div class="card"><div class="n">${fmtDuration(wallMs)}</div><div class="l">Duration</div></div>
144
+ </div>
145
+ <table>
146
+ <thead><tr><th>#</th><th>Status</th><th>Test</th><th>Time</th><th>Error</th></tr></thead>
147
+ <tbody>${rows || '<tr><td colspan="5">No tests ran.</td></tr>'}</tbody>
148
+ </table>
149
+ <div class="foot">Generated by @capillarytech/cap-ui-utils Playwright reporter — results contract compatible with the WDIO master-report.</div>
150
+ </div>
151
+ </body>
152
+ </html>`;
153
+ }
154
+ }
155
+
156
+ module.exports = CapPlaywrightReporter;
157
+ module.exports.default = CapPlaywrightReporter;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ /**
3
+ * Network-driven wait helpers — phase-2 Playwright convention.
4
+ *
5
+ * Rule (see phase-2 docs): NEVER use fixed sleeps. Prefer web-first assertions
6
+ * (their timeout is a max budget, not a delay) and arm waitForResponse BEFORE the
7
+ * action. These helpers standardise that so every migrated suite waits the same way.
8
+ */
9
+
10
+ /**
11
+ * Arm a response wait BEFORE the triggering action, then await it after.
12
+ * const wait = waitForApi(page, '/api/badges');
13
+ * await createBtn.click();
14
+ * const resp = await wait;
15
+ */
16
+ function waitForApi(page, urlPart, status = 200, timeout = 30000) {
17
+ return page.waitForResponse(
18
+ (r) => r.url().includes(urlPart) && (status == null || r.status() === status),
19
+ { timeout }
20
+ );
21
+ }
22
+
23
+ /** Run an action and wait for the matching response in one call. */
24
+ async function clickAndWaitForApi(locator, page, urlPart, status = 200, timeout = 30000) {
25
+ const wait = waitForApi(page, urlPart, status, timeout);
26
+ await locator.click();
27
+ return wait;
28
+ }
29
+
30
+ /** Wait until any in-flight XHR/fetch to `urlPart` settles (response seen). */
31
+ async function waitForApiIdle(page, urlPart, status = 200, timeout = 30000) {
32
+ await waitForApi(page, urlPart, status, timeout);
33
+ }
34
+
35
+ module.exports = { waitForApi, clickAndWaitForApi, waitForApiIdle };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capillarytech/cap-ui-utils",
3
- "version": "3.1.3",
3
+ "version": "3.2.0-beta.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },