UNPKG

@wdio/devtools-service

Version:
816 lines (815 loc) 23.8 kB
import T from "node:fs/promises"; import h from "node:path"; import k from "@wdio/logger"; import { SevereServiceError as v } from "webdriverio"; import F from "node:url"; import { WebSocket as C } from "ws"; import { parse as $ } from "stack-trace"; import { resolve as K } from "import-meta-resolve"; import { P as V, F as x, a as Q, b as O, S as P, T as U, c as Y, d as M, e as Z, f as ee, g as te, h as ne, i as ie, j as re, k as se, l as oe, D as ce, m as ae, I as le, C as ue } from "./launcher-ZnS47QTk.js"; import fe from "@wdio/reporter"; import d from "fs"; import { createRequire as pe } from "node:module"; import { parse as W } from "@babel/parser"; import * as R from "@babel/traverse"; import { existsSync as de, readFileSync as me } from "node:fs"; import { TraceType as B } from "./types.js"; const m = k("@wdio/devtools-service:SessionCapturer"); class A { constructor(e = {}) { this.#e = !1, this.commandsLog = [], this.sources = /* @__PURE__ */ new Map(), this.mutations = [], this.traceLogs = [], this.consoleLogs = []; const { port: t, hostname: r } = e; r && t && (this.#n = new C(`ws://${r}:${t}/worker`), this.#n.on( "error", (i) => m.error( `Couldn't connect to devtools backend: ${i.message}` ) )); } #n; #e; get isReportingUpstream() { return !!this.#n && this.#n?.readyState === C.OPEN; } /** * after command hook * * Used to * - capture command logs * - capture trace data from the application under test * * @param {string} command command name * @param {Array} args command arguments * @param {object} result command result * @param {Error} error command error */ async afterCommand(e, t, r, i, s, c) { const a = $(new Error("")).filter((p) => !!p.getFileName()).map( (p) => [ p.getFileName(), p.getLineNumber(), p.getColumnNumber() ].join(":") ).filter( (p) => !p.includes("/node_modules/") && !p.includes("<anonymous>)") && !p.includes("node:internal") && !p.includes("/dist/") ).shift() || "", o = a.startsWith("file://") ? F.fileURLToPath(a) : a, l = o.split(":")[0], u = await T.access(l).then( () => !0, () => !1 ); if (a && !this.sources.has(a) && u) { const p = await T.readFile(l, "utf-8"); this.sources.set(l, p.toString()), this.sendUpstream("sources", { [l]: p.toString() }); } const f = { command: t, args: r, result: i, error: s, timestamp: Date.now(), callSource: c ?? o }; try { f.screenshot = await e.takeScreenshot(); } catch (p) { m.warn(`failed to capture screenshot: ${p.message}`); } this.commandsLog.push(f), this.sendUpstream("commands", [f]), V.includes(t) && await this.#t(e); } async injectScript(e) { if (this.#e) { m.info("Script already injected, skipping"); return; } if (!e.isBidi) throw new v( `Can not set up devtools for session with id "${e.sessionId}" because it doesn't support WebDriver Bidi` ); this.#e = !0, m.info("Injecting devtools script..."); const t = await K("@wdio/devtools-script", import.meta.url), i = `async () => { ${(await T.readFile(F.fileURLToPath(t))).toString()} }`; await e.scriptAddPreloadScript({ functionDeclaration: i }), m.info("✓ Script injected successfully"); } async #t(e) { if (!this.#e) { m.warn("Script not injected, skipping trace capture"); return; } try { if (!await e.execute( () => typeof window.wdioTraceCollector < "u" )) { m.warn( "wdioTraceCollector not loaded yet - page loaded before preload script took effect" ); return; } const { mutations: r, traceLogs: i, consoleLogs: s, metadata: c } = await e.execute(() => window.wdioTraceCollector.getTraceData()); this.metadata = c, Array.isArray(r) && (this.mutations.push(...r), this.sendUpstream("mutations", r)), Array.isArray(i) && (this.traceLogs.push(...i), this.sendUpstream("logs", i)), Array.isArray(s) && (this.consoleLogs.push(...s), this.sendUpstream("consoleLogs", s)), this.sendUpstream("metadata", c), m.info(`✓ Sent metadata upstream, WS state: ${this.#n?.readyState}`); } catch (t) { m.error(`Failed to capture trace: ${t.message}`); } } sendUpstream(e, t) { !this.#n || this.#n.readyState !== C.OPEN || this.#n.send(JSON.stringify({ scope: e, data: t })); } } const G = pe(import.meta.url), he = G("stack-trace"), y = /* @__PURE__ */ new Map(); let w; try { const n = G("@cucumber/cucumber-expressions"); w = { CucumberExpression: n.CucumberExpression, ParameterTypeRegistry: n.ParameterTypeRegistry }; } catch { } const q = R.default ?? R; let b; function N(n) { b = n; } function _(n) { const e = n; return e.parent ? _(e.parent) : n; } function j(n) { if (n) { if (n.type === "Identifier") return n.name; if (n.type === "MemberExpression") { const e = n.object; return e && e.type === "Identifier" ? e.name : void 0; } } } function H(n) { if (!d.existsSync(n)) return []; const e = d.readFileSync(n, "utf-8"), t = W(e, { sourceType: "module", plugins: O, errorRecovery: !0, allowReturnOutsideFunction: !0 }), r = [], i = [], s = (o) => !!o && P.includes(o) || o === "Feature", c = (o) => !!o && U.includes(o), a = (o) => { if (o) { if (o.type === "StringLiteral") return o.value; if (o.type === "TemplateLiteral" && o.expressions.length === 0) return o.quasis.map((l) => l.value.cooked).join(""); } }; return q(t, { enter(o) { if (!o.isCallExpression()) return; const l = o.node.callee, u = j(l); if (u) { if (s(u)) { const f = a(o.node.arguments?.[0]); f && (r.push({ type: "suite", name: f, titlePath: [...i, f], line: o.node.loc?.start.line, column: o.node.loc?.start.column }), i.push(f)); } else if (c(u)) { const f = a(o.node.arguments?.[0]); f && r.push({ type: "test", name: f, titlePath: [...i, f], line: o.node.loc?.start.line, column: o.node.loc?.start.column }); } } }, exit(o) { if (!o.isCallExpression()) return; const l = o.node.callee, u = j(l); if (!u || !s(u)) return; const f = (() => { const p = o.node.arguments?.[0]; if (p?.type === "StringLiteral") return p.value; if (p?.type === "TemplateLiteral" && p.expressions.length === 0) return p.quasis.map((g) => g.value.cooked).join(""); })(); f && i[i.length - 1] === f && i.pop(); } }), r; } function ge() { const n = he.parse(new Error()), e = (s) => { const c = n.find((a) => { const o = a.getFileName(); return !!o && !o.includes("node_modules") && s(a); }); return c ? { file: c.getFileName(), line: c.getLineNumber(), column: c.getColumnNumber() } : null; }, t = e((s) => { const c = s.getFileName(); return ee.test(c) || te.test(c); }); if (t) return t; const r = e((s) => ne.test(s.getFileName())); if (r) return r; const i = e((s) => x.test(s.getFileName())); return i || null; } function ye(n) { let e = n; for (let t = 0; t < Y; t++) { for (const i of M) { const s = h.join(e, i); if (d.existsSync(s) && d.statSync(s).isDirectory()) return s; } const r = h.dirname(e); if (r === e) break; e = r; } } let S; function Se() { if (S && d.existsSync(S)) return S; const e = [{ dir: process.cwd(), depth: 0 }], t = ie; for (; e.length; ) { const { dir: r, depth: i } = e.shift(); if (i > t) continue; const s = h.join(r, "features"); if (d.existsSync(s) && d.statSync(s).isDirectory()) for (const c of M) { const a = h.join(s, c); if (d.existsSync(a) && d.statSync(a).isDirectory()) return S = a, a; } for (const c of d.readdirSync(r)) { if (c.startsWith(".")) continue; const a = h.join(r, c); let o; try { o = d.statSync(a); } catch { continue; } o.isDirectory() && !a.includes("node_modules") && e.push({ dir: a, depth: i + 1 }); } } } function X(n) { const e = []; for (const t of d.readdirSync(n)) { const r = h.join(n, t); d.statSync(r).isDirectory() ? e.push(...X(r)) : re.test(t) && e.push(r); } return e; } function Ee(n) { const e = [], r = d.readFileSync(n, "utf-8").split(/\r?\n/); for (let i = 0; i < r.length; i++) { const s = r[i], c = s.match(se); if (c) { const o = c[2], l = o.lastIndexOf("/"), u = o.slice(1, l), f = o.slice(l + 1); try { e.push({ kind: "regex", regex: new RegExp(u, f), file: n, line: i + 1, column: c.index ?? 0 }); continue; } catch { } } const a = s.match(oe); if (a) { const o = a[1], l = a[3]; e.push({ kind: "string", keyword: o, text: l, file: n, line: i + 1, column: a.index ?? 0 }); } } return e; } const D = /* @__PURE__ */ new Map(); function Te(n) { const e = D.get(n); if (e) return e; const t = X(n), r = []; for (const i of t) { let s = 0; try { const c = d.readFileSync(i, "utf-8"), a = W(c, { sourceType: "module", plugins: O, errorRecovery: !0 }); q(a, { CallExpression(o) { const l = o.node.callee; let u; if (l?.type === "Identifier") u = l.name; else if (l?.type === "MemberExpression") { const g = l.property; g?.type === "Identifier" && (u = g.name); } if (!u || !Z.includes(u)) return; const f = o.node.arguments?.[0], p = { file: i, line: o.node.loc?.start.line ?? 1, column: o.node.loc?.start.column ?? 0 }; if (f?.type === "RegExpLiteral") r.push({ kind: "regex", regex: new RegExp(f.pattern, f.flags ?? ""), ...p }), s++; else if (f?.type === "StringLiteral") { if (w && f.value.includes("{")) { const g = new w.CucumberExpression( f.value, new w.ParameterTypeRegistry() ); r.push({ kind: "expression", expr: g, ...p }); } else r.push({ kind: "string", keyword: u, text: f.value, ...p }); s++; } } }); } catch { } if (s === 0) { const c = Ee(i); c.length && r.push(...c); } } return D.set(n, r), r; } function we(n, e) { const t = e ? h.extname(e) ? h.dirname(e) : e : void 0; let r = t ? ye(t) : void 0; if (r || (r = Se()), !r) return; const i = Te(r), s = String(n ?? "").trim(), c = s.replace(/^(Given|When|Then|And|But)\s+/i, "").trim(), a = i.find( (u) => u.kind === "string" && (c.localeCompare(u.text, "en", { sensitivity: "base" }) === 0 || s.localeCompare(`${u.keyword} ${u.text}`, "en", { sensitivity: "base" }) === 0) ); if (a) return { file: a.file, line: a.line, column: a.column }; const o = i.find( (u) => u.kind === "expression" && (() => { try { return !!u.expr.match(c) || !!u.expr.match(s); } catch { return !1; } })() ); if (o) return { file: o.file, line: o.line, column: o.column }; const l = i.find( (u) => u.kind === "regex" && (u.regex.test(c) || u.regex.test(s)) ); if (l) return { file: l.file, line: l.line, column: l.column }; } function xe(n) { return String(n || "").replace(/^\d+:\s*/, "").replace(/\s+/g, " ").trim(); } function J(n) { return n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function z(n, e) { let t = 1, r = 1; for (let i = 0; i < e && i < n.length; i++) n.charCodeAt(i) === 10 ? (t++, r = 1) : r++; return { line: t, column: r }; } function be(n, e) { try { const t = d.readFileSync(n, "utf-8"), r = `(['"\`])${J(e)}\\1`, i = String.raw`\b(?:${U.join("|")})\s*\(\s*${r}`, c = new RegExp(i).exec(t); if (c && typeof c.index == "number") { const { line: a, column: o } = z(t, c.index); return { file: n, line: a, column: o }; } } catch { } } function Ce(n, e) { try { const t = d.readFileSync(n, "utf-8"), r = `(['"\`])${J(e)}\\1`, i = String.raw`\b(?:${P.join("|")})\s*\(\s*${r}`, c = new RegExp(i).exec(t); if (c && typeof c.index == "number") { const { line: a, column: o } = z(t, c.index); return { file: n, line: a, column: o }; } } catch { } } function ve(n, e) { const t = String(n?.title ?? "").trim(), r = xe(n?.fullTitle), i = (Array.isArray(n.specs) ? n.specs[0] : void 0) || n.file || n.specFile || e || b; if (/^(Given|When|Then|And|But)\b/i.test(t)) { const a = we( t, x.test(String(i)) ? i : void 0 ); if (a) { Object.assign(n, a); return; } } const s = n.file || (Array.isArray(n.specs) ? n.specs[0] : void 0) || n.specFile || e || b; if (s && !x.test(s)) { if (!y.has(s)) try { y.set(s, H(s)); } catch { } const a = y.get(s); if (a?.length) { const l = a.find( (u) => u.type === "test" && u.name === t && r.includes(u.titlePath.join(" ")) ) || a.find((u) => u.type === "test" && u.name === t); if (l) { Object.assign(n, { file: s, line: l.line, column: l.column }); return; } } const o = be(s, t); if (o) { Object.assign(n, o); return; } } const c = ge(); c && Object.assign(n, c); } function _e(n, e, t = []) { const r = String(n?.title ?? "").trim(), i = n.file || e || b; if (!r || !i) return; if (x.test(i)) { try { const c = d.readFileSync(i, "utf-8").split(/\r?\n/), a = (l) => l.trim().replace(/\s+/g, " "), o = a(r); for (let l = 0; l < c.length; l++) { const u = c[l].match(Q); if (u && a(u[2]) === o) { Object.assign(n, { file: i, line: l + 1, column: 1 }); return; } } } catch { } return; } try { y.has(i) || y.set(i, H(i)); const c = y.get(i); if (c?.length) { const a = c.find( (o) => o.type === "suite" && Array.isArray(o.titlePath) && o.titlePath.length === t.length && o.titlePath.every((l, u) => l === t[u]) ) || c.find((o) => o.type === "suite" && o.titlePath.at(-1) === r); if (a?.line) { Object.assign(n, { file: i, line: a.line, column: a.column }); return; } } } catch { } const s = Ce(i, r); s && Object.assign(n, s); } const L = /* @__PURE__ */ new Map(); function I(n) { const e = n; if (e.type === "scenario" && /^\d+$/.test(e.uid)) { const a = [ n.title, e.file || "", e.parent || "", e.cid || "", // Use original UID (example index) to ensure stable identification `example-${e.uid}` ].join("::").split("").reduce((o, l) => (o << 5) - o + l.charCodeAt(0) | 0, 0); return `stable-${Math.abs(a).toString(36)}`; } const t = [e.file || "", String(e.fullTitle || n.title)], r = t.join("::"), i = L.get(r) || 0; L.set(r, i + 1), i > 0 && t.push(String(i)); const s = t.join("::").split("").reduce((c, a) => (c << 5) - c + a.charCodeAt(0) | 0, 0); return `stable-${Math.abs(s).toString(36)}`; } function Le() { L.clear(); } function Fe(n, e) { try { if (!de(n)) return null; const r = me(n, "utf-8").split(` `); let i = -1; for (let l = 0; l < r.length; l++) { const u = r[l].trim(); if ((u.startsWith("Scenario Outline:") || u.startsWith("Scenario:")) && u.includes(e)) { i = l; break; } } if (i === -1) return null; let s = -1; for (let l = i; l < r.length; l++) if (r[l].trim().startsWith("Examples:")) { s = l; break; } if (s === -1) return null; const c = /* @__PURE__ */ new Map(); let a = 0, o = !1; for (let l = s + 1; l < r.length; l++) { const u = r[l].trim(); if (u.startsWith("Scenario") || u.startsWith("Feature:") || !u && a > 0) break; u.startsWith("|") && (o ? (c.set(a, l + 1), a++) : o = !0); } return c.size > 0 ? c : null; } catch (t) { return console.error("[Reporter] Failed to parse feature file:", t), null; } } class Re extends fe { #n; #e; #t = []; constructor(e, t) { super(e), this.#n = t, Le(); } onSuiteStart(e) { super.onSuiteStart(e); const t = e; if (t.type === "scenario" && e.file?.endsWith(".feature")) { const i = parseInt(t.uid, 10); if (!isNaN(i)) { const s = Fe( e.file, e.title ); if (s?.has(i)) { const c = s.get(i); t.featureFile = e.file, t.featureLine = c; } } } const r = I(e); e.uid = r, this.#e = e.file, N(e.file), e.title && this.#t.push(e.title), _e(e, this.#e, this.#t), e.file && e.line !== null && (e.callSource = `${e.file}:${e.line}`), this.#i(); } onTestStart(e) { super.onTestStart(e); const t = e; t.argument?.uri && typeof t.argument?.line == "number" && (t.featureFile = t.argument.uri, t.featureLine = t.argument.line), ve(e, this.#e), e.file && e.line !== null && (e.callSource = `${e.file}:${e.line}`); const r = I(e); e.uid = r, this.#i(); } onTestEnd(e) { super.onTestEnd(e), this.#i(); } onSuiteEnd(e) { super.onSuiteEnd(e), e.title && this.#t[this.#t.length - 1] === e.title && this.#t.pop(), this.#t.length === 0 && (this.#e = void 0, N(void 0)), this.#i(); } #i() { if (!this.suites) return; const e = []; for (const t of Object.values(this.suites)) if (t) { const r = t.uid; e.push({ [r]: t }); } e.length > 0 && this.#n(e); } get report() { return this.suites; } } const Xe = ce, E = k("@wdio/devtools-service"); function Je(n) { let e = !1; const t = new Ae(); return t.captureType = B.Standalone, t.beforeSession(n, n), n.beforeCommand = Array.isArray(n.beforeCommand) ? n.beforeCommand : n.beforeCommand ? [n.beforeCommand] : [], n.beforeCommand.push(async function(i) { e || (e = !0, t.before( this.capabilities, [], this )), i === "deleteSession" && await t.after(); }, t.beforeCommand.bind(t)), n.afterCommand = Array.isArray(n.afterCommand) ? n.afterCommand : n.afterCommand ? [n.afterCommand] : [], n.afterCommand.push(t.afterCommand.bind(t)), n; } class Ae { constructor() { this.#n = [], this.#e = new A(), this.#i = [], this.#s = null, this.captureType = B.Testrunner, this.#r = !1; } #n; #e; #t; #i; #s; #r; async before(e, t, r) { this.#t = r; const i = e; this.#e = new A( i["wdio:devtoolsOptions"] ); try { await this.#o(r); } catch (s) { E.error( `Failed to inject script at session start: ${s.message}` ); } r.execute(() => window.visualViewport).then( (s) => this.#e.sendUpstream("metadata", { viewport: s || void 0, type: this.captureType, options: r.options, capabilities: r.capabilities }) ); } // The method signature is corrected to use W3CCapabilities beforeSession(e, t) { if (!("browserName" in t) && !("platformName" in t)) throw new v( "The DevTools hook does not support multiremote yet" ); if ("reporters" in e) { const i = this; e.reporters = [ ...e.reporters || [], /** * class wrapper to make sure we can access the reporter instance */ class extends Re { constructor(c) { super( c, (a) => i.#e.sendUpstream("suites", a) ), i.#n.push(this); } } ]; } } /** * Hook for Cucumber framework. * beforeScenario is triggered at the beginning of every worker session, therefore * we can use it to reset the command stack and last command signature */ beforeScenario() { this.resetStack(); } /** * Hook for Mocha/Jasmine frameworks. * It does the exact same thing as beforeScenario. */ beforeTest() { this.resetStack(); } resetStack() { this.#s = null, this.#i = []; } async beforeCommand(e, t) { if (!this.#t) return; e === "url" && this.#e.sendUpstream("metadata", { url: t[0] }), Error.stackTraceLimit = 20; const i = $(new Error("")).reverse().find((s) => { const c = s.getFileName(); return c && ae.test(c); }); if (i && this.#i.length === 0 && !le.includes(e)) { const s = i.getFileName() ?? void 0; let c = s; if (s?.startsWith("file://")) try { const f = new URL(s); c = decodeURIComponent(f.pathname); } catch { c = s; } c?.includes("?") && (c = c.split("?")[0]); const a = i.getLineNumber() ?? void 0, o = i.getColumnNumber() ?? void 0, l = c !== void 0 ? `${c}:${a ?? 0}:${o ?? 0}` : void 0, u = JSON.stringify({ command: e, args: t, src: l }); this.#s !== u && (this.#i.push({ command: e, callSource: l }), this.#s = u); } } afterCommand(e, t, r, i) { if (this.#r) return; const s = this.#i[this.#i.length - 1]; if (s?.command === e && (this.#i.pop(), this.#t)) return this.#e.afterCommand( this.#t, e, t, r, i, s.callSource ); ue.includes(e) && this.#c(`context-change:${e}`); } /** * after hook is triggered at the end of every worker session, therefore * we can use it to write all trace information to a file */ async after() { if (!this.#t) return; const e = this.#t.options.outputDir || process.cwd(), { ...t } = this.#t.options, r = { mutations: this.#e.mutations, logs: this.#e.traceLogs, consoleLogs: this.#e.consoleLogs, metadata: { type: this.captureType, ...this.#e.metadata, options: t, capabilities: this.#t.capabilities }, commands: this.#e.commandsLog, sources: Object.fromEntries(this.#e.sources), suites: this.#n.map((s) => s.report) }, i = h.join( e, `wdio-trace-${this.#t.sessionId}.json` ); await T.writeFile(i, JSON.stringify(r)), E.info(`DevTools trace saved to ${i}`); } /** * Synchronous injection that blocks until complete */ async #o(e) { if (!e.isBidi) throw new v( `Can not set up devtools for session with id "${e.sessionId}" because it doesn't support WebDriver Bidi` ); await this.#e.injectScript(_(e)), E.info("✓ Devtools preload script active"); } async #c(e) { if (!(!this.#t || this.#r)) try { if (this.#r = !0, await this.#t.execute(() => !!window.__WDIO_DEVTOOLS_MARK)) return; await this.#e.injectScript(_(this.#t)); } catch (t) { E.warn(`[inject] failed (reason=${e}): ${t.message}`); } finally { this.#r = !1; } } } export { B as TraceType, Ae as default, Xe as launcher, Je as setupForDevtools };