@capillarytech/cap-ui-utils 3.1.3 → 3.2.0-beta.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/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,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.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -30,10 +30,20 @@
30
30
  "nanoid": ">=3"
31
31
  },
32
32
  "peerDependenciesMeta": {
33
- "webdriverio": { "optional": true },
34
- "@rpii/wdio-commands": { "optional": true },
35
- "axios": { "optional": true },
36
- "supertest": { "optional": true },
37
- "nanoid": { "optional": true }
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
+ }
38
48
  }
39
49
  }