@wdio/devtools-service
Version:
Hook up WebdriverIO with DevTools
816 lines (815 loc) • 23.8 kB
JavaScript
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
};