@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 +26 -0
- package/e2e/playwright/config.js +64 -0
- package/e2e/playwright/debugRecorder.js +167 -0
- package/e2e/playwright/index.js +18 -0
- package/e2e/playwright/login.js +68 -0
- package/e2e/playwright/reporter.js +157 -0
- package/e2e/playwright/waits.js +35 -0
- package/package.json +1 -1
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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
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> · suite <b>${esc(this._suite || "NA")}</b>
|
|
136
|
+
· engine <b>playwright</b> · 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 };
|