feat(e2e): ajouter tests Playwright flux utilisateur core (login, workspace, palette, dashboard)
- e2e/flowdeck_e2e_final.spec.js: 4 tests E2E validés 1. Login UI local (#email, #password, .btn-primary) 2. Créer workspace local + entrer (modal .dialog-input) 3. Ouvrir palette Ctrl+K et valider 'Créer une collection' 4. Dashboard accessible - e2e/ : package.json, playwright.config.js, install chromium
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var channelSessions_exports = {};
|
||||
__export(channelSessions_exports, {
|
||||
isKnownChannel: () => isKnownChannel,
|
||||
listChannelSessions: () => listChannelSessions
|
||||
});
|
||||
module.exports = __toCommonJS(channelSessions_exports);
|
||||
var import_fs = __toESM(require("fs"));
|
||||
var import_net = __toESM(require("net"));
|
||||
var import_os = __toESM(require("os"));
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_extension = require("../utils/extension");
|
||||
function isKnownChannel(name) {
|
||||
return channelToUserDataDir.has(name);
|
||||
}
|
||||
async function listChannelSessions() {
|
||||
if (process.env.PWTEST_CLI_CHANNEL_SCAN_DISABLED_FOR_TEST)
|
||||
return [];
|
||||
const result = [];
|
||||
for (const [channel, dirs] of channelToUserDataDir) {
|
||||
const userDataDir = dirs[process.platform];
|
||||
if (!userDataDir)
|
||||
continue;
|
||||
if (!await pathExists(userDataDir))
|
||||
continue;
|
||||
const [endpoint, extensionInstalled] = await Promise.all([
|
||||
readEndpoint(userDataDir),
|
||||
(0, import_extension.isPlaywrightExtensionInstalled)(userDataDir)
|
||||
]);
|
||||
result.push({ channel, userDataDir, endpoint, extensionInstalled });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async function pathExists(p) {
|
||||
try {
|
||||
await import_fs.default.promises.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
async function readEndpoint(userDataDir) {
|
||||
let contents;
|
||||
try {
|
||||
contents = await import_fs.default.promises.readFile(import_path.default.join(userDataDir, "DevToolsActivePort"), "utf-8");
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
const port = parseInt(contents.trim().split("\n")[0], 10);
|
||||
if (!Number.isFinite(port))
|
||||
return void 0;
|
||||
if (!await isPortOpen(port))
|
||||
return void 0;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
async function isPortOpen(port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = import_net.default.createConnection(port, "127.0.0.1");
|
||||
const done = (value) => {
|
||||
socket.destroy();
|
||||
resolve(value);
|
||||
};
|
||||
socket.once("connect", () => done(true));
|
||||
socket.once("error", () => done(false));
|
||||
socket.setTimeout(250, () => done(false));
|
||||
});
|
||||
}
|
||||
const channelToUserDataDir = /* @__PURE__ */ new Map([
|
||||
["chrome", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome", "User Data")
|
||||
}],
|
||||
["chrome-beta", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome-beta"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome Beta", "User Data")
|
||||
}],
|
||||
["chrome-dev", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome-unstable"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome Dev", "User Data")
|
||||
}],
|
||||
["chrome-canary", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "google-chrome-canary"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Google", "Chrome SxS", "User Data")
|
||||
}],
|
||||
["msedge", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge", "User Data")
|
||||
}],
|
||||
["msedge-beta", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge-beta"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge Beta"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge Beta", "User Data")
|
||||
}],
|
||||
["msedge-dev", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge-dev"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge Dev"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge Dev", "User Data")
|
||||
}],
|
||||
["msedge-canary", {
|
||||
"linux": import_path.default.join(import_os.default.homedir(), ".config", "microsoft-edge-canary"),
|
||||
"darwin": import_path.default.join(import_os.default.homedir(), "Library", "Application Support", "Microsoft Edge Canary"),
|
||||
"win32": import_path.default.join(process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local"), "Microsoft", "Edge SxS", "User Data")
|
||||
}]
|
||||
]);
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
isKnownChannel,
|
||||
listChannelSessions
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
var import_program = require("./program");
|
||||
(0, import_program.program)().catch((e) => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+721
File diff suppressed because one or more lines are too long
+128
@@ -0,0 +1,128 @@
|
||||
"use strict";
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var minimist_exports = {};
|
||||
__export(minimist_exports, {
|
||||
minimist: () => minimist
|
||||
});
|
||||
module.exports = __toCommonJS(minimist_exports);
|
||||
function minimist(args, opts) {
|
||||
if (!opts)
|
||||
opts = {};
|
||||
const bools = {};
|
||||
const strings = {};
|
||||
for (const key of toArray(opts.boolean))
|
||||
bools[key] = true;
|
||||
for (const key of toArray(opts.string))
|
||||
strings[key] = true;
|
||||
const argv = { _: [] };
|
||||
function setArg(key, val) {
|
||||
if (argv[key] === void 0 || bools[key] || typeof argv[key] === "boolean")
|
||||
argv[key] = val;
|
||||
else if (Array.isArray(argv[key]))
|
||||
argv[key].push(val);
|
||||
else
|
||||
argv[key] = [argv[key], val];
|
||||
}
|
||||
let notFlags = [];
|
||||
const doubleDashIndex = args.indexOf("--");
|
||||
if (doubleDashIndex !== -1) {
|
||||
notFlags = args.slice(doubleDashIndex + 1);
|
||||
args = args.slice(0, doubleDashIndex);
|
||||
}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
let key;
|
||||
let next;
|
||||
if (/^--.+=/.test(arg)) {
|
||||
const m = arg.match(/^--([^=]+)=([\s\S]*)$/);
|
||||
key = m[1];
|
||||
if (bools[key])
|
||||
throw new Error(`boolean option '--${key}' should not be passed with '=value', use '--${key}' or '--no-${key}' instead`);
|
||||
setArg(key, m[2]);
|
||||
} else if (/^--no-.+/.test(arg)) {
|
||||
key = arg.match(/^--no-(.+)/)[1];
|
||||
setArg(key, false);
|
||||
} else if (/^--.+/.test(arg)) {
|
||||
key = arg.match(/^--(.+)/)[1];
|
||||
next = args[i + 1];
|
||||
if (next !== void 0 && !/^(-|--)[^-]/.test(next) && !bools[key]) {
|
||||
setArg(key, next);
|
||||
i += 1;
|
||||
} else if (/^(true|false)$/.test(next)) {
|
||||
setArg(key, next === "true");
|
||||
i += 1;
|
||||
} else {
|
||||
setArg(key, strings[key] ? "" : true);
|
||||
}
|
||||
} else if (/^-[A-Za-z]/.test(arg)) {
|
||||
const letters = arg.slice(1, -1).split("");
|
||||
let broken = false;
|
||||
for (let j = 0; j < letters.length; j++) {
|
||||
next = arg.slice(j + 2);
|
||||
if (next === "-") {
|
||||
setArg(letters[j], next);
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z]/.test(letters[j]) && next[0] === "=") {
|
||||
setArg(letters[j], next.slice(1));
|
||||
broken = true;
|
||||
break;
|
||||
}
|
||||
if (/[A-Za-z]/.test(letters[j]) && /-?\d+(\.\d*)?(e-?\d+)?$/.test(next)) {
|
||||
setArg(letters[j], next);
|
||||
broken = true;
|
||||
break;
|
||||
}
|
||||
if (letters[j + 1] && letters[j + 1].match(/\W/)) {
|
||||
setArg(letters[j], arg.slice(j + 2));
|
||||
broken = true;
|
||||
break;
|
||||
} else {
|
||||
setArg(letters[j], strings[letters[j]] ? "" : true);
|
||||
}
|
||||
}
|
||||
key = arg.slice(-1)[0];
|
||||
if (!broken && key !== "-") {
|
||||
if (args[i + 1] && !/^(-|--)[^-]/.test(args[i + 1]) && !bools[key]) {
|
||||
setArg(key, args[i + 1]);
|
||||
i += 1;
|
||||
} else if (args[i + 1] && /^(true|false)$/.test(args[i + 1])) {
|
||||
setArg(key, args[i + 1] === "true");
|
||||
i += 1;
|
||||
} else {
|
||||
setArg(key, strings[key] ? "" : true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
argv._.push(arg);
|
||||
}
|
||||
}
|
||||
for (const k of notFlags)
|
||||
argv._.push(k);
|
||||
return argv;
|
||||
}
|
||||
function toArray(value) {
|
||||
if (!value)
|
||||
return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
minimist
|
||||
});
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var output_exports = {};
|
||||
__export(output_exports, {
|
||||
JsonOutput: () => JsonOutput,
|
||||
TextOutput: () => TextOutput
|
||||
});
|
||||
module.exports = __toCommonJS(output_exports);
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_extension = require("../utils/extension");
|
||||
class TextOutput {
|
||||
constructor() {
|
||||
this.json = false;
|
||||
}
|
||||
version(v) {
|
||||
console.log(v);
|
||||
}
|
||||
help(text) {
|
||||
console.log(text);
|
||||
}
|
||||
errorUnknownCommand(name, globalHelp) {
|
||||
console.error(`Unknown command: ${name}
|
||||
`);
|
||||
console.log(globalHelp);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorUnknownOption(opts, commandHelp) {
|
||||
console.error(`Unknown option${opts.length > 1 ? "s" : ""}: ${opts.map((f) => `--${f}`).join(", ")}`);
|
||||
console.log("");
|
||||
console.log(commandHelp);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorTooManyArguments(expected, received, commandHelp) {
|
||||
console.error(`error: too many arguments: expected ${expected}, received ${received}`);
|
||||
console.log("");
|
||||
console.log(commandHelp);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorAttachConflict() {
|
||||
console.error(`Error: only one of [name], --cdp, --endpoint, or --extension can be specified`);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorDetachNotAttached(session) {
|
||||
console.error(`Error: session '${session}' was not attached; use \`playwright-cli${session !== "default" ? ` -s=${session}` : ""} close\` to stop it.`);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorBrowserNotOpenForTool(session) {
|
||||
console.log(`The browser '${session}' is not open, please run open first`);
|
||||
console.log("");
|
||||
console.log(` playwright-cli${session !== "default" ? ` -s=${session}` : ""} open [params]`);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorAttachNoTarget() {
|
||||
console.error(`Error: no target specified for attach command; use one of [name], --cdp, --endpoint, or --extension to specify the target to attach to.`);
|
||||
return process.exit(1);
|
||||
}
|
||||
errorInstallGlobalRequiresSkills() {
|
||||
console.error(`Error: --global requires --skills`);
|
||||
return process.exit(1);
|
||||
}
|
||||
list({ all, browsers, servers, channelSessions }) {
|
||||
const byWorkspace = /* @__PURE__ */ new Map();
|
||||
for (const browser of browsers) {
|
||||
let list = byWorkspace.get(browser.workspace);
|
||||
if (!list) {
|
||||
list = [];
|
||||
byWorkspace.set(browser.workspace, list);
|
||||
}
|
||||
list.push(browser);
|
||||
}
|
||||
let count = 0;
|
||||
for (const [workspaceKey, list] of byWorkspace) {
|
||||
if (count === 0)
|
||||
console.log("### Browsers");
|
||||
if (all)
|
||||
console.log(`${import_path.default.relative(process.cwd(), workspaceKey) || "/"}:`);
|
||||
for (const browser of list)
|
||||
console.log(renderBrowser(browser));
|
||||
count += list.length;
|
||||
}
|
||||
if (!all) {
|
||||
if (!count)
|
||||
console.log(" (no browsers)");
|
||||
return;
|
||||
}
|
||||
if (servers?.length) {
|
||||
if (count)
|
||||
console.log("");
|
||||
console.log("### Browser servers available for attach");
|
||||
const serversByWorkspace = /* @__PURE__ */ new Map();
|
||||
for (const server of servers) {
|
||||
let list = serversByWorkspace.get(server.workspaceDir ?? "");
|
||||
if (!list) {
|
||||
list = [];
|
||||
serversByWorkspace.set(server.workspaceDir ?? "", list);
|
||||
}
|
||||
list.push(server);
|
||||
}
|
||||
for (const [workspaceKey, list] of serversByWorkspace) {
|
||||
if (workspaceKey)
|
||||
console.log(`${import_path.default.relative(process.cwd(), workspaceKey) || "/"}:`);
|
||||
for (const server of list)
|
||||
console.log(renderServer(server));
|
||||
}
|
||||
count += servers.length;
|
||||
}
|
||||
if (!count)
|
||||
console.log(" (no browsers)");
|
||||
if (channelSessions?.length) {
|
||||
console.log("");
|
||||
console.log("### Browsers available to attach via CDP");
|
||||
for (const session of channelSessions)
|
||||
console.log(renderChannelSession(session));
|
||||
}
|
||||
}
|
||||
closeAll(_sessions) {
|
||||
}
|
||||
deleteData(session, result) {
|
||||
if (!result.existed) {
|
||||
console.log(`No user data found for browser '${session}'.`);
|
||||
return;
|
||||
}
|
||||
if (result.deletedUserDataDir)
|
||||
console.log(`Deleted user data for browser '${session}'.`);
|
||||
}
|
||||
killAll(pids) {
|
||||
for (const pid of pids)
|
||||
console.log(`Killed daemon process ${pid}`);
|
||||
if (pids.length === 0)
|
||||
console.log("No daemon processes found.");
|
||||
else
|
||||
console.log(`Killed ${pids.length} daemon process${pids.length === 1 ? "" : "es"}.`);
|
||||
}
|
||||
open(session, pid, toolResult) {
|
||||
console.log(`### Browser \`${session}\` opened with pid ${pid}.`);
|
||||
if (toolResult)
|
||||
console.log(toolResult);
|
||||
}
|
||||
attach(session, pid, endpoint, toolResult) {
|
||||
if (endpoint) {
|
||||
console.log(`### Session \`${session}\` created, attached to \`${endpoint}\`.`);
|
||||
console.log(`Run commands with: playwright-cli -s=${session} <command>`);
|
||||
console.log("");
|
||||
} else {
|
||||
console.log(`### Browser \`${session}\` opened with pid ${pid}.`);
|
||||
}
|
||||
if (toolResult)
|
||||
console.log(toolResult);
|
||||
}
|
||||
close(session, wasOpen) {
|
||||
if (!wasOpen) {
|
||||
console.log(`Browser '${session}' is not open.`);
|
||||
return;
|
||||
}
|
||||
console.log(`Browser '${session}' closed
|
||||
`);
|
||||
}
|
||||
detach(session, wasAttached) {
|
||||
if (!wasAttached) {
|
||||
console.log(`Browser '${session}' is not attached.`);
|
||||
return;
|
||||
}
|
||||
console.log(`Browser '${session}' detached
|
||||
`);
|
||||
}
|
||||
installed() {
|
||||
}
|
||||
show(_session, pid) {
|
||||
if (process.env.PWTEST_PRINT_DASHBOARD_PID_FOR_TEST)
|
||||
console.log(`### Dashboard opened with pid ${pid}.`);
|
||||
}
|
||||
toolResult(text) {
|
||||
console.log(text);
|
||||
}
|
||||
installStdio() {
|
||||
return "inherit";
|
||||
}
|
||||
}
|
||||
class JsonOutput {
|
||||
constructor() {
|
||||
this.json = true;
|
||||
}
|
||||
version(v) {
|
||||
this._emit({ version: v });
|
||||
}
|
||||
help(text) {
|
||||
this._emit({ help: text });
|
||||
}
|
||||
errorUnknownCommand(name, _globalHelp) {
|
||||
this._emit({ isError: true, error: `Unknown command: ${name}` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorUnknownOption(opts, _commandHelp) {
|
||||
this._emit({ isError: true, error: `Unknown option${opts.length > 1 ? "s" : ""}: ${opts.map((f) => `--${f}`).join(", ")}` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorTooManyArguments(expected, received, _commandHelp) {
|
||||
this._emit({ isError: true, error: `error: too many arguments: expected ${expected}, received ${received}` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorAttachConflict() {
|
||||
this._emit({ isError: true, error: `only one of [name], --cdp, --endpoint, or --extension can be specified` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorDetachNotAttached(session) {
|
||||
this._emit({ isError: true, error: `session '${session}' was not attached; use close to stop it.` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorBrowserNotOpenForTool(session) {
|
||||
this._emit({ isError: true, error: `The browser '${session}' is not open, please run open first` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorAttachNoTarget() {
|
||||
this._emit({ isError: true, error: `no target specified for attach command; use one of [name], --cdp, --endpoint, or --extension to specify the target to attach to.` });
|
||||
return process.exit(1);
|
||||
}
|
||||
errorInstallGlobalRequiresSkills() {
|
||||
this._emit({ isError: true, error: `--global requires --skills` });
|
||||
return process.exit(1);
|
||||
}
|
||||
list({ all, browsers, servers, channelSessions }) {
|
||||
const payload = { browsers };
|
||||
if (all) {
|
||||
payload.servers = servers ?? [];
|
||||
payload.channelSessions = channelSessions ?? [];
|
||||
}
|
||||
this._emit(payload);
|
||||
}
|
||||
closeAll(sessions) {
|
||||
this._emit({ closed: sessions });
|
||||
}
|
||||
deleteData(session, result) {
|
||||
this._emit({ session, deleted: result.existed });
|
||||
}
|
||||
killAll(pids) {
|
||||
this._emit({ killed: pids.length, pids });
|
||||
}
|
||||
open(session, pid, toolResult) {
|
||||
this._emit({ session, pid, result: parseJsonText(toolResult) });
|
||||
}
|
||||
attach(session, pid, endpoint, toolResult) {
|
||||
this._emit({
|
||||
session,
|
||||
pid,
|
||||
...endpoint ? { endpoint } : {},
|
||||
result: parseJsonText(toolResult)
|
||||
});
|
||||
}
|
||||
close(session, wasOpen) {
|
||||
this._emit({ session, status: wasOpen ? "closed" : "not-open" });
|
||||
}
|
||||
detach(session, wasAttached) {
|
||||
this._emit({ session, status: wasAttached ? "detached" : "not-attached" });
|
||||
}
|
||||
installed() {
|
||||
this._emit({ installed: true });
|
||||
}
|
||||
show(session, pid) {
|
||||
this._emit({ session, pid });
|
||||
}
|
||||
toolResult(text) {
|
||||
console.log(text);
|
||||
}
|
||||
installStdio() {
|
||||
return "ignore";
|
||||
}
|
||||
_emit(value) {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
}
|
||||
function parseJsonText(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
function renderBrowser(browser) {
|
||||
const lines = [`- ${browser.name}:`];
|
||||
lines.push(` - status: ${browser.status}`);
|
||||
if (browser.status === "open" && !browser.compatible)
|
||||
lines.push(` - version: v${browser.version} [incompatible please re-open]`);
|
||||
if (browser.browserType)
|
||||
lines.push(` - browser-type: ${browser.browserType}${browser.attached ? " (attached)" : ""}`);
|
||||
if (!browser.attached) {
|
||||
if (browser.userDataDir === null)
|
||||
lines.push(` - user-data-dir: <in-memory>`);
|
||||
else
|
||||
lines.push(` - user-data-dir: ${browser.userDataDir}`);
|
||||
if (browser.headed !== void 0)
|
||||
lines.push(` - headed: ${browser.headed}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
function renderServer(server) {
|
||||
const lines = [`- browser "${server.title}":`];
|
||||
lines.push(` - browser: ${server.browser.browserName}`);
|
||||
lines.push(` - version: v${server.playwrightVersion}`);
|
||||
if (server.browser.userDataDir)
|
||||
lines.push(` - data-dir: ${server.browser.userDataDir}`);
|
||||
else
|
||||
lines.push(` - data-dir: <in-memory>`);
|
||||
lines.push(` - run \`playwright-cli attach "${server.title}"\` to attach`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
function renderChannelSession(session) {
|
||||
const lines = [`- ${session.channel}:`];
|
||||
lines.push(` - data-dir: ${session.userDataDir}`);
|
||||
if (session.extensionInstalled)
|
||||
lines.push(` - attach (extension): \`playwright-cli attach --extension=${session.channel}\``);
|
||||
else
|
||||
lines.push(` - attach (extension): install at ${import_extension.playwrightExtensionInstallUrl}`);
|
||||
if (session.endpoint) {
|
||||
lines.push(` - attach (remote debugging): \`playwright-cli attach --cdp=${session.channel}\``);
|
||||
} else {
|
||||
const inspectScheme = session.channel.startsWith("msedge") ? "edge" : "chrome";
|
||||
lines.push(` - attach (remote debugging): enable at ${inspectScheme}://inspect/#remote-debugging`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
JsonOutput,
|
||||
TextOutput
|
||||
});
|
||||
+418
@@ -0,0 +1,418 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var program_exports = {};
|
||||
__export(program_exports, {
|
||||
calculateSha1: () => calculateSha1,
|
||||
program: () => program
|
||||
});
|
||||
module.exports = __toCommonJS(program_exports);
|
||||
var import_child_process = require("child_process");
|
||||
var import_crypto = __toESM(require("crypto"));
|
||||
var import_os = __toESM(require("os"));
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_channelSessions = require("./channelSessions");
|
||||
var import_output = require("./output");
|
||||
var import_registry = require("./registry");
|
||||
var import_session = require("./session");
|
||||
var import_package = require("../../package");
|
||||
var import_serverRegistry = require("../../serverRegistry");
|
||||
var import_minimist = require("./minimist");
|
||||
const globalOptions = [
|
||||
"json",
|
||||
"raw",
|
||||
"session"
|
||||
];
|
||||
const booleanOptions = [
|
||||
"all",
|
||||
"g",
|
||||
"help",
|
||||
"json",
|
||||
"raw",
|
||||
"version"
|
||||
];
|
||||
async function program(options) {
|
||||
const clientInfo = (0, import_registry.createClientInfo)();
|
||||
const help = require((0, import_package.libPath)("tools", "cli-client", "help.json"));
|
||||
const argv = process.argv.slice(2);
|
||||
const boolean = [...help.booleanOptions, ...booleanOptions];
|
||||
const args = (0, import_minimist.minimist)(argv, { boolean, string: ["_"] });
|
||||
if (args.s) {
|
||||
args.session = args.s;
|
||||
delete args.s;
|
||||
}
|
||||
if (args.g) {
|
||||
args.global = true;
|
||||
delete args.g;
|
||||
}
|
||||
const output = args.json ? new import_output.JsonOutput() : new import_output.TextOutput();
|
||||
const commandName = args._?.[0];
|
||||
if (args.version || args.v) {
|
||||
output.version(options?.embedderVersion ?? clientInfo.version);
|
||||
process.exit(0);
|
||||
}
|
||||
const command = commandName && help.commands[commandName];
|
||||
if (args.help || args.h || !commandName) {
|
||||
if (command) {
|
||||
output.help(command.help);
|
||||
} else {
|
||||
const lines = ["playwright-cli - run playwright mcp commands from terminal"];
|
||||
if (process.env.CLAUDECODE || process.env.COPILOT_CLI)
|
||||
lines.push(`Agent skill: ${import_path.default.relative(process.cwd(), (0, import_package.libPath)("tools", "skills", "playwright-cli", "SKILL.md"))}`);
|
||||
lines.push(help.global);
|
||||
output.help(lines.join("\n\n"));
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if (!command)
|
||||
output.errorUnknownCommand(commandName, help.global);
|
||||
validateFlags(args, command, output);
|
||||
validateArgs(args, command, output);
|
||||
const registry = await import_registry.Registry.load();
|
||||
const sessionName = (0, import_registry.resolveSessionName)(args.session);
|
||||
switch (commandName) {
|
||||
case "list": {
|
||||
const data = await collectList(registry, clientInfo, !!args.all);
|
||||
output.list(data);
|
||||
return;
|
||||
}
|
||||
case "close-all": {
|
||||
const entries = registry.entries(clientInfo);
|
||||
const closed = [];
|
||||
for (const entry of entries) {
|
||||
await new import_session.Session(entry).stop();
|
||||
closed.push(entry.config.name);
|
||||
}
|
||||
output.closeAll(closed);
|
||||
return;
|
||||
}
|
||||
case "delete-data": {
|
||||
const entry = registry.entry(clientInfo, sessionName);
|
||||
if (!entry) {
|
||||
output.deleteData(sessionName, { existed: false, deletedUserDataDir: false });
|
||||
return;
|
||||
}
|
||||
const result = await new import_session.Session(entry).deleteData();
|
||||
output.deleteData(sessionName, result);
|
||||
return;
|
||||
}
|
||||
case "kill-all": {
|
||||
const pids = await killAllDaemons();
|
||||
output.killAll(pids);
|
||||
return;
|
||||
}
|
||||
case "open": {
|
||||
const { pid } = await startSession(sessionName, registry, clientInfo, args, "open");
|
||||
const newEntry = await registry.loadEntry(clientInfo, sessionName);
|
||||
const params = args._.slice(1);
|
||||
const toolText = await runInSessionOrStop(newEntry, clientInfo, { _: ["goto", ...params.length ? params : ["about:blank"]] }, output);
|
||||
output.open(sessionName, pid, toolText);
|
||||
return;
|
||||
}
|
||||
case "attach": {
|
||||
const attachTarget = args._[1];
|
||||
const targetCount = (attachTarget ? 1 : 0) + (args.cdp ? 1 : 0) + (args.endpoint ? 1 : 0) + (args.extension ? 1 : 0);
|
||||
if (targetCount > 1)
|
||||
output.errorAttachConflict();
|
||||
if (attachTarget)
|
||||
args.endpoint = attachTarget;
|
||||
const extensionChannel = typeof args.extension === "string" ? args.extension : void 0;
|
||||
if (extensionChannel) {
|
||||
args.browser = extensionChannel;
|
||||
args.extension = true;
|
||||
}
|
||||
const cdpChannel = typeof args.cdp === "string" && (0, import_channelSessions.isKnownChannel)(args.cdp) ? args.cdp : void 0;
|
||||
const targetName = attachTarget ?? cdpChannel ?? extensionChannel ?? args.endpoint ?? args.cdp;
|
||||
if (!targetName)
|
||||
output.errorAttachNoTarget();
|
||||
const attachSessionName = (0, import_registry.explicitSessionName)(args.session) ?? attachTarget ?? cdpChannel ?? extensionChannel ?? sessionName;
|
||||
args.session = attachSessionName;
|
||||
const { pid } = await startSession(attachSessionName, registry, clientInfo, args, "attach");
|
||||
const newEntry = await registry.loadEntry(clientInfo, attachSessionName);
|
||||
const toolText = await runInSessionOrStop(newEntry, clientInfo, { _: ["snapshot"], filename: "<auto>" }, output);
|
||||
output.attach(attachSessionName, pid, targetName, toolText);
|
||||
return;
|
||||
}
|
||||
case "close": {
|
||||
const closeEntry = registry.entry(clientInfo, sessionName);
|
||||
const { wasOpen } = closeEntry ? await new import_session.Session(closeEntry).stop() : { wasOpen: false };
|
||||
output.close(sessionName, wasOpen);
|
||||
return;
|
||||
}
|
||||
case "detach": {
|
||||
const detachEntry = registry.entry(clientInfo, sessionName);
|
||||
if (detachEntry && !detachEntry.config.attached)
|
||||
output.errorDetachNotAttached(sessionName);
|
||||
const { wasOpen } = detachEntry ? await new import_session.Session(detachEntry).stop() : { wasOpen: false };
|
||||
output.detach(sessionName, wasOpen);
|
||||
return;
|
||||
}
|
||||
case "install":
|
||||
if (args.global && !args.skills)
|
||||
output.errorInstallGlobalRequiresSkills();
|
||||
await runInitWorkspace(args, output);
|
||||
output.installed();
|
||||
return;
|
||||
case "install-browser":
|
||||
await installBrowser();
|
||||
output.installed();
|
||||
return;
|
||||
case "show": {
|
||||
const daemonScript = (0, import_package.libPath)("entry", "dashboardApp.js");
|
||||
const daemonArgs = [
|
||||
daemonScript,
|
||||
`--workspaceDir=${clientInfo.workspaceDir ?? ""}`
|
||||
];
|
||||
const explicit = (0, import_registry.explicitSessionName)(args.session);
|
||||
if (explicit)
|
||||
daemonArgs.push(`--sessionName=${explicit}`);
|
||||
if (args.port !== void 0)
|
||||
daemonArgs.push(`--port=${args.port}`);
|
||||
if (args.host !== void 0)
|
||||
daemonArgs.push(`--host=${args.host}`);
|
||||
if (args.kill) {
|
||||
daemonArgs.push(`--kill`);
|
||||
const child2 = (0, import_child_process.spawn)(process.execPath, daemonArgs, { stdio: "ignore" });
|
||||
await new Promise((resolve) => child2.on("exit", () => resolve()));
|
||||
return;
|
||||
}
|
||||
if (args.annotate) {
|
||||
const entry = registry.entry(clientInfo, sessionName);
|
||||
if (!entry)
|
||||
output.errorBrowserNotOpenForTool(sessionName);
|
||||
args.raw = true;
|
||||
const text = await runInSession(entry, clientInfo, args, output);
|
||||
output.toolResult(text);
|
||||
return;
|
||||
}
|
||||
const foreground = args.port !== void 0;
|
||||
const child = (0, import_child_process.spawn)(process.execPath, daemonArgs, {
|
||||
detached: !foreground,
|
||||
stdio: foreground ? "inherit" : ["pipe", "pipe", "ignore"],
|
||||
windowsHide: true
|
||||
});
|
||||
if (foreground) {
|
||||
await new Promise((resolve) => child.on("exit", () => resolve()));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => child.stdin.destroy(), 6e4);
|
||||
child.unref();
|
||||
let daemonPid;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
let outLog = "";
|
||||
child.stdout.on("data", (data) => {
|
||||
outLog += data.toString();
|
||||
const match = outLog.match(/Dashboard is running pid=(\d+)/);
|
||||
if (match) {
|
||||
daemonPid = Number(match[1]);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
child.once("exit", (code, signal) => reject(new Error(`Dashboard daemon exited (code=${code}, signal=${signal}) before signaling READY${outLog ? "\n" + outLog : ""}`)));
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
child.removeAllListeners("exit");
|
||||
child.stdin.destroy();
|
||||
child.stdout.destroy();
|
||||
}
|
||||
output.show(sessionName, daemonPid);
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
const entry = registry.entry(clientInfo, sessionName);
|
||||
if (!entry)
|
||||
output.errorBrowserNotOpenForTool(sessionName);
|
||||
if (command.raw)
|
||||
args.raw = true;
|
||||
const text = await runInSession(entry, clientInfo, args, output);
|
||||
output.toolResult(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function startSession(sessionName, registry, clientInfo, args, mode) {
|
||||
const entry = registry.entry(clientInfo, sessionName);
|
||||
if (entry)
|
||||
await new import_session.Session(entry).stop();
|
||||
return await import_session.Session.startDaemon(clientInfo, args, mode);
|
||||
}
|
||||
async function runInSession(entry, clientInfo, args, output) {
|
||||
const raw = !!args.raw;
|
||||
for (const globalOption of globalOptions)
|
||||
delete args[globalOption];
|
||||
const session = new import_session.Session(entry);
|
||||
const result = await session.run(clientInfo, args, { raw, json: output.json });
|
||||
if (result.isError)
|
||||
process.exitCode = 1;
|
||||
return result.text;
|
||||
}
|
||||
async function runInSessionOrStop(entry, clientInfo, args, output) {
|
||||
try {
|
||||
return await runInSession(entry, clientInfo, args, output);
|
||||
} catch (e) {
|
||||
await new import_session.Session(entry).stop().catch(() => {
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
async function runInitWorkspace(args, output) {
|
||||
const cliPath = (0, import_package.libPath)("entry", "cliDaemon.js");
|
||||
const daemonArgs = [
|
||||
cliPath,
|
||||
"--init-workspace",
|
||||
...args.skills ? [args.global ? "--init-skills-global" : "--init-skills", String(args.skills)] : []
|
||||
];
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = (0, import_child_process.spawn)(process.execPath, daemonArgs, {
|
||||
stdio: output.installStdio(),
|
||||
cwd: process.cwd()
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0)
|
||||
resolve();
|
||||
else
|
||||
reject(new Error(`Workspace initialization failed with exit code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
async function installBrowser() {
|
||||
const argv = process.argv.map((arg) => arg === "install-browser" ? "install" : arg);
|
||||
const { libCli } = require("../../coreBundle.js");
|
||||
const { program: program2 } = require("../../utilsBundle.js");
|
||||
if (!program2.version())
|
||||
libCli.decorateProgram(program2);
|
||||
program2.parse(argv);
|
||||
}
|
||||
const daemonProcessPatterns = ["run-mcp-server", "run-cli-server", "cli-daemon", "cliDaemon.js", "dashboardApp.js"];
|
||||
async function killAllDaemons() {
|
||||
const platform = import_os.default.platform();
|
||||
const pidFilterEnv = process.env.PWTEST_KILL_ALL_PID_FILTER_FOR_TEST;
|
||||
const pidFilter = pidFilterEnv ? new Set(pidFilterEnv.split(",").map((p) => parseInt(p, 10)).filter((n) => !isNaN(n))) : void 0;
|
||||
const killed = [];
|
||||
try {
|
||||
if (platform === "win32") {
|
||||
const clauses = [`(${daemonProcessPatterns.map((p) => `$_.CommandLine -like '*${p}*'`).join(" -or ")})`];
|
||||
if (pidFilter)
|
||||
clauses.push(`(${[...pidFilter].map((p) => `$_.ProcessId -eq ${p}`).join(" -or ")})`);
|
||||
const whereClause = clauses.join(" -and ");
|
||||
const result = (0, import_child_process.execSync)(
|
||||
`powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { ${whereClause} } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $_.ProcessId }"`,
|
||||
{ encoding: "utf-8" }
|
||||
);
|
||||
const pids = result.split("\n").map((line) => line.trim()).filter((line) => /^\d+$/.test(line));
|
||||
for (const pid of pids)
|
||||
killed.push(parseInt(pid, 10));
|
||||
} else {
|
||||
const result = (0, import_child_process.execSync)("ps auxww", { encoding: "utf-8" });
|
||||
const lines = result.split("\n");
|
||||
for (const line of lines) {
|
||||
if (daemonProcessPatterns.some((p) => line.includes(p))) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[1];
|
||||
if (pid && /^\d+$/.test(pid)) {
|
||||
const numericPid = parseInt(pid, 10);
|
||||
if (pidFilter && !pidFilter.has(numericPid))
|
||||
continue;
|
||||
try {
|
||||
process.kill(numericPid, "SIGKILL");
|
||||
killed.push(numericPid);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
async function collectList(registry, clientInfo, all) {
|
||||
const browsers = [];
|
||||
const entries = registry.entryMap();
|
||||
const serverEntries = await import_serverRegistry.serverRegistry.list();
|
||||
const key = (0, import_registry.clientKey)(clientInfo);
|
||||
for (const [workspaceKey, list] of entries) {
|
||||
if (!all && workspaceKey !== key)
|
||||
continue;
|
||||
for (const entry of list) {
|
||||
const session = new import_session.Session(entry);
|
||||
const canConnect = await session.canConnect();
|
||||
if (!canConnect) {
|
||||
await session.deleteSessionConfig();
|
||||
continue;
|
||||
}
|
||||
const config = session.config;
|
||||
const channel = config.browser?.launchOptions.channel ?? config.browser?.browserName;
|
||||
browsers.push({
|
||||
name: session.name,
|
||||
workspace: workspaceKey,
|
||||
status: canConnect ? "open" : "closed",
|
||||
browserType: channel,
|
||||
userDataDir: config.browser?.userDataDir ?? null,
|
||||
headed: config.browser ? !config.browser.launchOptions.headless : void 0,
|
||||
persistent: !!config.cli.persistent,
|
||||
attached: !!config.attached,
|
||||
compatible: session.isCompatible(clientInfo),
|
||||
version: config.version
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!all)
|
||||
return { all, browsers };
|
||||
const servers = [...serverEntries.values()].flat();
|
||||
return { all, browsers, servers, channelSessions: await (0, import_channelSessions.listChannelSessions)() };
|
||||
}
|
||||
function validateFlags(args, command, output) {
|
||||
const unknownFlags = [];
|
||||
for (const key of Object.keys(args)) {
|
||||
if (key === "_")
|
||||
continue;
|
||||
if (globalOptions.includes(key))
|
||||
continue;
|
||||
if (!(key in command.flags))
|
||||
unknownFlags.push(key);
|
||||
}
|
||||
if (unknownFlags.length)
|
||||
output.errorUnknownOption(unknownFlags, command.help);
|
||||
}
|
||||
function validateArgs(args, command, output) {
|
||||
const positional = args._.slice(1);
|
||||
if (positional.length > command.args.length && !command.variadicArg)
|
||||
output.errorTooManyArguments(command.args.length, positional.length, command.help);
|
||||
}
|
||||
function calculateSha1(buffer) {
|
||||
const hash = import_crypto.default.createHash("sha1");
|
||||
hash.update(buffer);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
calculateSha1,
|
||||
program
|
||||
});
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var registry_exports = {};
|
||||
__export(registry_exports, {
|
||||
Registry: () => Registry,
|
||||
baseDaemonDir: () => baseDaemonDir,
|
||||
clientKey: () => clientKey,
|
||||
createClientInfo: () => createClientInfo,
|
||||
explicitSessionName: () => explicitSessionName,
|
||||
resolveSessionName: () => resolveSessionName
|
||||
});
|
||||
module.exports = __toCommonJS(registry_exports);
|
||||
var import_crypto = __toESM(require("crypto"));
|
||||
var import_fs = __toESM(require("fs"));
|
||||
var import_os = __toESM(require("os"));
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_package = require("../../package");
|
||||
function clientKey(clientInfo) {
|
||||
return clientInfo.workspaceDir || clientInfo.workspaceDirHash;
|
||||
}
|
||||
class Registry {
|
||||
constructor(files) {
|
||||
this._files = files;
|
||||
}
|
||||
entry(clientInfo, sessionName) {
|
||||
const key = clientKey(clientInfo);
|
||||
const entries = this._files.get(key) || [];
|
||||
return entries.find((entry) => entry.config.name === sessionName);
|
||||
}
|
||||
entries(clientInfo) {
|
||||
return this._files.get(clientKey(clientInfo)) || [];
|
||||
}
|
||||
entryMap() {
|
||||
return this._files;
|
||||
}
|
||||
async loadEntry(clientInfo, sessionName) {
|
||||
const entry = await Registry._loadSessionEntry(clientInfo.daemonProfilesDir, sessionName + ".session");
|
||||
if (!entry)
|
||||
throw new Error(`Could not start the session "${sessionName}"`);
|
||||
const key = clientKey(clientInfo);
|
||||
let list = this._files.get(key);
|
||||
if (!list) {
|
||||
list = [];
|
||||
this._files.set(key, list);
|
||||
}
|
||||
const oldIndex = list.findIndex((e) => e.config.name === sessionName);
|
||||
if (oldIndex !== -1)
|
||||
list.splice(oldIndex, 1);
|
||||
list.push(entry);
|
||||
return entry;
|
||||
}
|
||||
static async _loadSessionEntry(daemonDir, file) {
|
||||
try {
|
||||
const fileName = import_path.default.join(daemonDir, file);
|
||||
const data = await import_fs.default.promises.readFile(fileName, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
if (!config.name)
|
||||
config.name = import_path.default.basename(file, ".session");
|
||||
if (!config.timestamp)
|
||||
config.timestamp = 0;
|
||||
return { file: fileName, config, daemonDir };
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
}
|
||||
static async load() {
|
||||
const sessions = /* @__PURE__ */ new Map();
|
||||
const hashDirs = await import_fs.default.promises.readdir(baseDaemonDir()).catch(() => []);
|
||||
for (const workspaceDirHash of hashDirs) {
|
||||
const daemonDir = import_path.default.join(baseDaemonDir(), workspaceDirHash);
|
||||
const stat = await import_fs.default.promises.stat(daemonDir);
|
||||
if (!stat.isDirectory())
|
||||
continue;
|
||||
const files = await import_fs.default.promises.readdir(daemonDir).catch(() => []);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".session"))
|
||||
continue;
|
||||
const entry = await Registry._loadSessionEntry(daemonDir, file);
|
||||
if (!entry)
|
||||
continue;
|
||||
const key = entry.config.workspaceDir || workspaceDirHash;
|
||||
let list = sessions.get(key);
|
||||
if (!list) {
|
||||
list = [];
|
||||
sessions.set(key, list);
|
||||
}
|
||||
list.push(entry);
|
||||
}
|
||||
}
|
||||
return new Registry(sessions);
|
||||
}
|
||||
}
|
||||
function computeBaseDaemonDir() {
|
||||
if (process.env.PWTEST_DAEMON_SESSION_DIR)
|
||||
return process.env.PWTEST_DAEMON_SESSION_DIR;
|
||||
let localCacheDir;
|
||||
if (process.platform === "linux")
|
||||
localCacheDir = process.env.XDG_CACHE_HOME || import_path.default.join(import_os.default.homedir(), ".cache");
|
||||
if (process.platform === "darwin")
|
||||
localCacheDir = import_path.default.join(import_os.default.homedir(), "Library", "Caches");
|
||||
if (process.platform === "win32")
|
||||
localCacheDir = process.env.LOCALAPPDATA || import_path.default.join(import_os.default.homedir(), "AppData", "Local");
|
||||
if (!localCacheDir)
|
||||
throw new Error("Unsupported platform: " + process.platform);
|
||||
return import_path.default.join(localCacheDir, "ms-playwright", "daemon");
|
||||
}
|
||||
let _baseDaemonDir;
|
||||
function baseDaemonDir() {
|
||||
return _baseDaemonDir ??= computeBaseDaemonDir();
|
||||
}
|
||||
function createClientInfo() {
|
||||
const workspaceDir = findWorkspaceDir(process.cwd());
|
||||
const version = process.env.PLAYWRIGHT_CLI_VERSION_FOR_TEST || import_package.packageJSON.version;
|
||||
const hash = import_crypto.default.createHash("sha1");
|
||||
hash.update(workspaceDir || import_package.packageRoot);
|
||||
const workspaceDirHash = hash.digest("hex").substring(0, 16);
|
||||
return {
|
||||
version,
|
||||
workspaceDir,
|
||||
workspaceDirHash,
|
||||
daemonProfilesDir: daemonProfilesDir(workspaceDirHash),
|
||||
homeDir: import_os.default.homedir()
|
||||
};
|
||||
}
|
||||
function findWorkspaceDir(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (import_fs.default.existsSync(import_path.default.join(dir, ".playwright")))
|
||||
return dir;
|
||||
const parentDir = import_path.default.dirname(dir);
|
||||
if (parentDir === dir)
|
||||
break;
|
||||
dir = parentDir;
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
const daemonProfilesDir = (workspaceDirHash) => {
|
||||
return import_path.default.join(baseDaemonDir(), workspaceDirHash);
|
||||
};
|
||||
function explicitSessionName(sessionName) {
|
||||
return sessionName || process.env.PLAYWRIGHT_CLI_SESSION;
|
||||
}
|
||||
function resolveSessionName(sessionName) {
|
||||
return explicitSessionName(sessionName) || "default";
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
Registry,
|
||||
baseDaemonDir,
|
||||
clientKey,
|
||||
createClientInfo,
|
||||
explicitSessionName,
|
||||
resolveSessionName
|
||||
});
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var session_exports = {};
|
||||
__export(session_exports, {
|
||||
Session: () => Session
|
||||
});
|
||||
module.exports = __toCommonJS(session_exports);
|
||||
var import_child_process = require("child_process");
|
||||
var import_fs = __toESM(require("fs"));
|
||||
var import_net = __toESM(require("net"));
|
||||
var import_os = __toESM(require("os"));
|
||||
var import_path = __toESM(require("path"));
|
||||
var import_package = require("../../package");
|
||||
var import_socketConnection = require("../utils/socketConnection");
|
||||
var import_registry = require("./registry");
|
||||
class Session {
|
||||
constructor(sessionFile) {
|
||||
this.config = sessionFile.config;
|
||||
this.name = this.config.name;
|
||||
this._sessionFile = sessionFile;
|
||||
}
|
||||
isCompatible(clientInfo) {
|
||||
return (0, import_socketConnection.compareSemver)(clientInfo.version, this.config.version) >= 0;
|
||||
}
|
||||
async run(clientInfo, args, options) {
|
||||
if (!this.isCompatible(clientInfo))
|
||||
throw new Error(`Client is v${clientInfo.version}, session '${this.name}' is v${this.config.version}. Run
|
||||
|
||||
playwright-cli${this.name !== "default" ? ` -s=${this.name}` : ""} open
|
||||
|
||||
to restart the browser session.`);
|
||||
const { socket } = await this._connect();
|
||||
if (!socket)
|
||||
throw new Error(`Browser '${this.name}' is not open. Run
|
||||
|
||||
playwright-cli${this.name !== "default" ? ` -s=${this.name}` : ""} open
|
||||
|
||||
to start the browser session.`);
|
||||
return await SocketConnectionClient.sendAndClose(socket, "run", { args, cwd: process.cwd(), raw: options?.raw, json: options?.json });
|
||||
}
|
||||
async stop() {
|
||||
if (!await this.canConnect())
|
||||
return { wasOpen: false };
|
||||
await this._stopDaemon();
|
||||
return { wasOpen: true };
|
||||
}
|
||||
async deleteData() {
|
||||
await this.stop();
|
||||
const dataDirs = await import_fs.default.promises.readdir(this._sessionFile.daemonDir).catch(() => []);
|
||||
const matchingEntries = dataDirs.filter((file) => file === `${this.name}.session` || file.startsWith(`ud-${this.name}-`));
|
||||
if (matchingEntries.length === 0)
|
||||
return { existed: false, deletedUserDataDir: false };
|
||||
let deletedUserDataDir = false;
|
||||
for (const entry of matchingEntries) {
|
||||
const userDataDir = import_path.default.resolve(this._sessionFile.daemonDir, entry);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
await import_fs.default.promises.rm(userDataDir, { recursive: true });
|
||||
if (entry.startsWith("ud-"))
|
||||
deletedUserDataDir = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT")
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
||||
if (i === 4)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { existed: true, deletedUserDataDir };
|
||||
}
|
||||
async _connect() {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = import_net.default.createConnection(this.config.socketPath, () => {
|
||||
resolve({ socket });
|
||||
});
|
||||
socket.on("error", (error) => {
|
||||
if (import_os.default.platform() !== "win32")
|
||||
void import_fs.default.promises.unlink(this.config.socketPath).catch(() => {
|
||||
}).then(() => resolve({ error }));
|
||||
else
|
||||
resolve({ error });
|
||||
});
|
||||
});
|
||||
}
|
||||
async canConnect() {
|
||||
const { socket } = await this._connect();
|
||||
if (socket) {
|
||||
socket.destroy();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static async startDaemon(clientInfo, cliArgs, mode) {
|
||||
await import_fs.default.promises.mkdir(clientInfo.daemonProfilesDir, { recursive: true });
|
||||
const cliPath = (0, import_package.libPath)("entry", "cliDaemon.js");
|
||||
const sessionName = (0, import_registry.resolveSessionName)(cliArgs.session);
|
||||
const errLog = import_path.default.join(clientInfo.daemonProfilesDir, sessionName + ".err");
|
||||
const err = import_fs.default.openSync(errLog, "w");
|
||||
const args = [
|
||||
cliPath,
|
||||
sessionName
|
||||
];
|
||||
if (cliArgs.headed)
|
||||
args.push("--headed");
|
||||
if (cliArgs.mobile)
|
||||
args.push("--mobile");
|
||||
if (cliArgs.device)
|
||||
args.push(`--device=${cliArgs.device}`);
|
||||
if (cliArgs.browser)
|
||||
args.push(`--browser=${cliArgs.browser}`);
|
||||
if (cliArgs.persistent)
|
||||
args.push("--persistent");
|
||||
if (cliArgs.profile)
|
||||
args.push(`--profile=${cliArgs.profile}`);
|
||||
if (cliArgs.config)
|
||||
args.push(`--config=${cliArgs.config}`);
|
||||
if (cliArgs.extension)
|
||||
args.push("--extension");
|
||||
else if (cliArgs.cdp)
|
||||
args.push(`--cdp=${cliArgs.cdp}`);
|
||||
else if (cliArgs.endpoint)
|
||||
args.push(`--endpoint=${cliArgs.endpoint}`);
|
||||
const child = (0, import_child_process.spawn)(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", err],
|
||||
cwd: process.cwd(),
|
||||
// Will be used as root.
|
||||
windowsHide: true
|
||||
});
|
||||
let signalled = false;
|
||||
const sigintHandler = () => {
|
||||
signalled = true;
|
||||
child.kill("SIGINT");
|
||||
};
|
||||
const sigtermHandler = () => {
|
||||
signalled = true;
|
||||
child.kill("SIGTERM");
|
||||
};
|
||||
process.on("SIGINT", sigintHandler);
|
||||
process.on("SIGTERM", sigtermHandler);
|
||||
let outLog = "";
|
||||
const rejectWithPid = (reject, message) => reject(Object.assign(new Error(`Daemon pid=${child.pid}: ${message}`), { daemonPid: child.pid }));
|
||||
await new Promise((resolve, reject) => {
|
||||
child.stdout.on("data", (data) => {
|
||||
outLog += data.toString();
|
||||
if (outLog.includes("Daemon listening on"))
|
||||
resolve();
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (!signalled) {
|
||||
const errLogContent = import_fs.default.readFileSync(errLog, "utf-8");
|
||||
rejectWithPid(reject, `Daemon process exited with code ${code}` + (outLog ? "\n" + outLog : "") + (errLogContent ? "\n" + errLogContent : ""));
|
||||
}
|
||||
});
|
||||
});
|
||||
process.off("SIGINT", sigintHandler);
|
||||
process.off("SIGTERM", sigtermHandler);
|
||||
child.stdout.destroy();
|
||||
child.unref();
|
||||
return { pid: child.pid, sessionName, endpoint: cliArgs.endpoint };
|
||||
}
|
||||
async _stopDaemon() {
|
||||
const { socket } = await this._connect();
|
||||
if (!socket)
|
||||
return;
|
||||
let error;
|
||||
await SocketConnectionClient.sendAndClose(socket, "stop", {}).catch((e) => error = e);
|
||||
if (error && !error?.message?.includes("Session closed"))
|
||||
throw error;
|
||||
}
|
||||
async deleteSessionConfig() {
|
||||
await import_fs.default.promises.rm(this._sessionFile.file).catch(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
class SocketConnectionClient {
|
||||
constructor(socket) {
|
||||
this._nextMessageId = 1;
|
||||
this._callbacks = /* @__PURE__ */ new Map();
|
||||
this._connection = new import_socketConnection.SocketConnection(socket);
|
||||
this._connection.onmessage = (message) => this._onMessage(message);
|
||||
this._connection.onclose = () => this._rejectCallbacks();
|
||||
}
|
||||
async send(method, params = {}) {
|
||||
const messageId = this._nextMessageId++;
|
||||
const message = {
|
||||
id: messageId,
|
||||
method,
|
||||
params
|
||||
};
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
this._callbacks.set(messageId, { resolve, reject, method, params });
|
||||
});
|
||||
const [result] = await Promise.all([responsePromise, this._connection.send(message)]);
|
||||
return result;
|
||||
}
|
||||
static async sendAndClose(socket, method, params = {}) {
|
||||
const connection = new SocketConnectionClient(socket);
|
||||
try {
|
||||
return await connection.send(method, params);
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
close() {
|
||||
this._connection.close();
|
||||
}
|
||||
_onMessage(object) {
|
||||
if (object.id && this._callbacks.has(object.id)) {
|
||||
const callback = this._callbacks.get(object.id);
|
||||
this._callbacks.delete(object.id);
|
||||
if (object.error)
|
||||
callback.reject(new Error(object.error));
|
||||
else
|
||||
callback.resolve(object.result);
|
||||
} else if (object.id) {
|
||||
throw new Error(`Unexpected message id: ${object.id}`);
|
||||
} else {
|
||||
throw new Error(`Unexpected message without id: ${JSON.stringify(object)}`);
|
||||
}
|
||||
}
|
||||
_rejectCallbacks() {
|
||||
for (const callback of this._callbacks.values())
|
||||
callback.reject(new Error("Session closed"));
|
||||
this._callbacks.clear();
|
||||
}
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
Session
|
||||
});
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
+425
@@ -0,0 +1,425 @@
|
||||
---
|
||||
name: playwright-cli
|
||||
description: Automate browser interactions, test web pages and work with Playwright tests.
|
||||
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
|
||||
---
|
||||
|
||||
# Browser Automation with playwright-cli
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# open new browser
|
||||
playwright-cli open
|
||||
# navigate to a page
|
||||
playwright-cli goto https://playwright.dev
|
||||
# interact with the page using refs from the snapshot
|
||||
playwright-cli click e15
|
||||
playwright-cli type "page.click"
|
||||
playwright-cli press Enter
|
||||
# take a screenshot (rarely used, as snapshot is more common)
|
||||
playwright-cli screenshot
|
||||
# close the browser
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Core
|
||||
|
||||
```bash
|
||||
playwright-cli open
|
||||
# open and navigate right away
|
||||
playwright-cli open https://example.com/
|
||||
playwright-cli goto https://playwright.dev
|
||||
playwright-cli type "search query"
|
||||
playwright-cli click e3
|
||||
playwright-cli dblclick e7
|
||||
# --submit presses Enter after filling the element
|
||||
playwright-cli fill e5 "[email protected]" --submit
|
||||
playwright-cli drag e2 e8
|
||||
# drop files or data onto an element (from outside the page)
|
||||
playwright-cli drop e4 --path=./image.png
|
||||
playwright-cli drop e4 --data="text/plain=hello world"
|
||||
playwright-cli hover e4
|
||||
playwright-cli select e9 "option-value"
|
||||
playwright-cli upload ./document.pdf
|
||||
playwright-cli check e12
|
||||
playwright-cli uncheck e12
|
||||
playwright-cli snapshot
|
||||
# search the snapshot for text or a regexp, returns matching nodes with surrounding context
|
||||
playwright-cli find "Sign in"
|
||||
playwright-cli find --regex "Sign (in|up)"
|
||||
# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive
|
||||
playwright-cli find --regex "/sign (in|up)/i"
|
||||
playwright-cli eval "document.title"
|
||||
playwright-cli eval "el => el.textContent" e5
|
||||
# get element id, class, or any attribute not visible in the snapshot
|
||||
playwright-cli eval "el => el.id" e5
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e5
|
||||
playwright-cli dialog-accept
|
||||
playwright-cli dialog-accept "confirmation text"
|
||||
playwright-cli dialog-dismiss
|
||||
playwright-cli resize 1920 1080
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
playwright-cli go-back
|
||||
playwright-cli go-forward
|
||||
playwright-cli reload
|
||||
```
|
||||
|
||||
### Keyboard
|
||||
|
||||
```bash
|
||||
playwright-cli press Enter
|
||||
playwright-cli press ArrowDown
|
||||
playwright-cli keydown Shift
|
||||
playwright-cli keyup Shift
|
||||
```
|
||||
|
||||
### Mouse
|
||||
|
||||
```bash
|
||||
playwright-cli mousemove 150 300
|
||||
playwright-cli mousedown
|
||||
playwright-cli mousedown right
|
||||
playwright-cli mouseup
|
||||
playwright-cli mouseup right
|
||||
playwright-cli mousewheel 0 100
|
||||
```
|
||||
|
||||
### Save as
|
||||
|
||||
```bash
|
||||
playwright-cli screenshot
|
||||
playwright-cli screenshot e5
|
||||
playwright-cli screenshot --filename=page.png
|
||||
playwright-cli screenshot --hires
|
||||
playwright-cli pdf --filename=page.pdf
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```bash
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-new
|
||||
playwright-cli tab-new https://example.com/page
|
||||
playwright-cli tab-close
|
||||
playwright-cli tab-close 2
|
||||
playwright-cli tab-select 0
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
```bash
|
||||
playwright-cli state-save
|
||||
playwright-cli state-save auth.json
|
||||
playwright-cli state-load auth.json
|
||||
|
||||
# Cookies
|
||||
playwright-cli cookie-list
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
playwright-cli cookie-get session_id
|
||||
playwright-cli cookie-set session_id abc123
|
||||
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
|
||||
playwright-cli cookie-delete session_id
|
||||
playwright-cli cookie-clear
|
||||
|
||||
# LocalStorage
|
||||
playwright-cli localstorage-list
|
||||
playwright-cli localstorage-get theme
|
||||
playwright-cli localstorage-set theme dark
|
||||
playwright-cli localstorage-delete theme
|
||||
playwright-cli localstorage-clear
|
||||
|
||||
# SessionStorage
|
||||
playwright-cli sessionstorage-list
|
||||
playwright-cli sessionstorage-get step
|
||||
playwright-cli sessionstorage-set step 3
|
||||
playwright-cli sessionstorage-delete step
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
### Network
|
||||
|
||||
```bash
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
|
||||
playwright-cli route-list
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
### DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli console
|
||||
playwright-cli console warning
|
||||
playwright-cli requests
|
||||
playwright-cli request 5
|
||||
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
|
||||
playwright-cli run-code --filename=script.js
|
||||
playwright-cli tracing-start
|
||||
playwright-cli tracing-stop
|
||||
|
||||
# record user actions in the browser, print them as Playwright code on stop
|
||||
playwright-cli recording-start
|
||||
playwright-cli recording-stop
|
||||
|
||||
playwright-cli video-start video.webm
|
||||
playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
|
||||
playwright-cli video-stop
|
||||
|
||||
# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target
|
||||
playwright-cli video-show-actions --duration=600 --position=top-right
|
||||
playwright-cli video-hide-actions
|
||||
|
||||
# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes
|
||||
playwright-cli show --annotate
|
||||
|
||||
# generate a Playwright locator for an element from its ref or selector
|
||||
playwright-cli generate-locator e5 --raw
|
||||
|
||||
# show a persistent highlight overlay for an element, optionally with a custom style
|
||||
playwright-cli highlight e5
|
||||
playwright-cli highlight e5 --style="outline: 3px dashed red"
|
||||
# hide a single element highlight, or all page highlights when no target is given
|
||||
playwright-cli highlight e5 --hide
|
||||
playwright-cli highlight --hide
|
||||
```
|
||||
|
||||
## Raw output
|
||||
|
||||
The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing.
|
||||
|
||||
```bash
|
||||
playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart'
|
||||
playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json
|
||||
playwright-cli --raw snapshot > before.yml
|
||||
playwright-cli click e5
|
||||
playwright-cli --raw snapshot > after.yml
|
||||
diff before.yml after.yml
|
||||
TOKEN=$(playwright-cli --raw cookie-get session_id)
|
||||
playwright-cli --raw localstorage-get theme
|
||||
```
|
||||
|
||||
For structured output wrapping every reply as JSON, pass --json
|
||||
```bash
|
||||
playwright-cli list --json
|
||||
```
|
||||
|
||||
## Open parameters
|
||||
```bash
|
||||
# Use specific browser when creating session
|
||||
playwright-cli open --browser=chrome
|
||||
playwright-cli open --browser=firefox
|
||||
playwright-cli open --browser=webkit
|
||||
playwright-cli open --browser=msedge
|
||||
|
||||
# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit).
|
||||
# Prefer this when a mobile layout is acceptable: mobile pages are usually
|
||||
# lighter, so snapshots are smaller and cheaper.
|
||||
playwright-cli open --mobile
|
||||
playwright-cli open --device="iPhone 15"
|
||||
|
||||
# Use persistent profile (by default profile is in-memory)
|
||||
playwright-cli open --persistent
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open --profile=/path/to/profile
|
||||
|
||||
# Connect to browser via Playwright Extension
|
||||
playwright-cli attach --extension=chrome
|
||||
|
||||
# Connect to a running Chrome or Edge by channel name
|
||||
playwright-cli attach --cdp=chrome
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Connect to a running browser via CDP endpoint
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
|
||||
# Start with config file
|
||||
playwright-cli open --config=my-config.json
|
||||
|
||||
# Close the browser
|
||||
playwright-cli close
|
||||
# Detach from an attached browser (leaves the external browser running)
|
||||
playwright-cli -s=msedge detach
|
||||
# Delete user data for the default session
|
||||
playwright-cli delete-data
|
||||
```
|
||||
|
||||
## URLs with `&` on Windows
|
||||
|
||||
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
|
||||
|
||||
```batch
|
||||
playwright-cli goto "https://example.com/?a=1^&b=2"
|
||||
```
|
||||
|
||||
```powershell
|
||||
playwright-cli --% goto "https://example.com/?a=1&b=2"
|
||||
```
|
||||
|
||||
## Snapshots
|
||||
|
||||
After each command, playwright-cli provides a snapshot of the current browser state.
|
||||
|
||||
```bash
|
||||
> playwright-cli goto https://example.com
|
||||
### Page
|
||||
- Page URL: https://example.com/
|
||||
- Page Title: Example Domain
|
||||
### Snapshot
|
||||
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
|
||||
```
|
||||
|
||||
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
|
||||
|
||||
```bash
|
||||
# default - save to a file with timestamp-based name
|
||||
playwright-cli snapshot
|
||||
|
||||
# save to file, use when snapshot is a part of the workflow result
|
||||
playwright-cli snapshot --filename=after-click.yaml
|
||||
|
||||
# snapshot an element instead of the whole page
|
||||
playwright-cli snapshot "#main"
|
||||
|
||||
# limit snapshot depth for efficiency, take a partial snapshot afterwards
|
||||
playwright-cli snapshot --depth=4
|
||||
playwright-cli snapshot e34
|
||||
|
||||
# include each element's bounding box as [box=x,y,width,height]
|
||||
playwright-cli snapshot --boxes
|
||||
|
||||
# search a large snapshot instead of capturing it all — returns matching nodes
|
||||
# with 3 lines of context around each match (like grep -C)
|
||||
playwright-cli find "Add to cart"
|
||||
playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}"
|
||||
```
|
||||
|
||||
## Targeting elements
|
||||
|
||||
By default, use refs from the snapshot to interact with page elements.
|
||||
|
||||
```bash
|
||||
# get snapshot with refs
|
||||
playwright-cli snapshot
|
||||
|
||||
# interact using a ref
|
||||
playwright-cli click e15
|
||||
```
|
||||
|
||||
You can also use css selectors or Playwright locators.
|
||||
|
||||
```bash
|
||||
# css selector
|
||||
playwright-cli click "#main > button.submit"
|
||||
|
||||
# role locator
|
||||
playwright-cli click "getByRole('button', { name: 'Submit' })"
|
||||
|
||||
# test id
|
||||
playwright-cli click "getByTestId('submit-button')"
|
||||
```
|
||||
|
||||
## Browser Sessions
|
||||
|
||||
```bash
|
||||
# create new browser session named "mysession" with persistent profile
|
||||
playwright-cli -s=mysession open example.com --persistent
|
||||
# same with manually specified profile directory (use when requested explicitly)
|
||||
playwright-cli -s=mysession open example.com --profile=/path/to/profile
|
||||
playwright-cli -s=mysession click e6
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
playwright-cli -s=mysession delete-data # delete user data for persistent session
|
||||
|
||||
playwright-cli list
|
||||
# Close all browsers
|
||||
playwright-cli close-all
|
||||
# Forcefully kill all browser processes
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
If global `playwright-cli` command is not available, try a local version via `npx playwright cli`:
|
||||
|
||||
```bash
|
||||
npx --no-install playwright --version
|
||||
```
|
||||
|
||||
When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command:
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
```
|
||||
|
||||
## Example: Form submission
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com/form
|
||||
playwright-cli snapshot
|
||||
|
||||
playwright-cli fill e1 "[email protected]"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Multi-tab workflow
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tab-new https://example.com/other
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-select 0
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Debugging with DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli console
|
||||
playwright-cli requests
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tracing-start
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli tracing-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Interactive session
|
||||
|
||||
Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean":
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli show --annotate
|
||||
```
|
||||
|
||||
## Specific tasks
|
||||
|
||||
* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
|
||||
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
|
||||
* **Running Playwright code** [references/running-code.md](references/running-code.md)
|
||||
* **Browser session management** [references/session-management.md](references/session-management.md)
|
||||
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
|
||||
* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md)
|
||||
* **Tracing** [references/tracing.md](references/tracing.md)
|
||||
* **Video recording** [references/video-recording.md](references/video-recording.md)
|
||||
* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
|
||||
Generated
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
# Inspecting Element Attributes
|
||||
|
||||
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot
|
||||
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
|
||||
|
||||
# get the element's id
|
||||
playwright-cli eval "el => el.id" e7
|
||||
|
||||
# get all CSS classes
|
||||
playwright-cli eval "el => el.className" e7
|
||||
|
||||
# get a specific attribute
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e7
|
||||
playwright-cli eval "el => el.getAttribute('aria-label')" e7
|
||||
|
||||
# get a computed style property
|
||||
playwright-cli eval "el => getComputedStyle(el).display" e7
|
||||
```
|
||||
Generated
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
# Running Playwright Tests
|
||||
|
||||
To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
|
||||
# Run all tests through a custom npm script
|
||||
PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
|
||||
```
|
||||
|
||||
# Debugging Playwright Tests
|
||||
|
||||
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
|
||||
|
||||
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
|
||||
|
||||
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
|
||||
|
||||
```bash
|
||||
# Run the test
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
|
||||
# ...
|
||||
# ... debugging instructions for "tw-abcdef" session ...
|
||||
# ...
|
||||
|
||||
# Attach to the test
|
||||
playwright-cli attach tw-abcdef
|
||||
```
|
||||
|
||||
Keep the test running in the background while you explore and look for a fix.
|
||||
The test is paused at the start, so you should step over or pause at a particular location
|
||||
where the problem is most likely to be.
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
|
||||
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
|
||||
|
||||
After fixing the test, stop the background test run. Rerun to check that test passes.
|
||||
Generated
Vendored
+87
@@ -0,0 +1,87 @@
|
||||
# Request Mocking
|
||||
|
||||
Intercept, mock, modify, and block network requests.
|
||||
|
||||
## CLI Route Commands
|
||||
|
||||
```bash
|
||||
# Mock with custom status
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
|
||||
# Mock with JSON body
|
||||
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
|
||||
|
||||
# Mock with custom headers
|
||||
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
|
||||
|
||||
# Remove headers from requests
|
||||
playwright-cli route "**/*" --remove-header=cookie,authorization
|
||||
|
||||
# List active routes
|
||||
playwright-cli route-list
|
||||
|
||||
# Remove a route or all routes
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
## URL Patterns
|
||||
|
||||
```
|
||||
**/api/users - Exact path match
|
||||
**/api/*/details - Wildcard in path
|
||||
**/*.{png,jpg,jpeg} - Match file extensions
|
||||
**/search?q=* - Match query parameters
|
||||
```
|
||||
|
||||
## Advanced Mocking with run-code
|
||||
|
||||
For conditional responses, request body inspection, response modification, or delays:
|
||||
|
||||
### Conditional Response Based on Request
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/login', route => {
|
||||
const body = route.request().postDataJSON();
|
||||
if (body.username === 'admin') {
|
||||
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
|
||||
} else {
|
||||
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
|
||||
}
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Modify Real Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/user', async route => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.isPremium = true;
|
||||
await route.fulfill({ response, json });
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Simulate Network Failures
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
|
||||
}"
|
||||
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
|
||||
```
|
||||
|
||||
### Delayed Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/slow', async route => {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
|
||||
});
|
||||
}"
|
||||
```
|
||||
Generated
Vendored
+241
@@ -0,0 +1,241 @@
|
||||
# Running Custom Playwright Code
|
||||
|
||||
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
|
||||
|
||||
## Syntax
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
// Your Playwright code here
|
||||
// Access page.context() for browser context operations
|
||||
}"
|
||||
```
|
||||
|
||||
You can also load the function from a file:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code --filename=./my-script.js
|
||||
```
|
||||
|
||||
|
||||
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
|
||||
import/export/require syntax is not supported.
|
||||
|
||||
## Geolocation
|
||||
|
||||
```bash
|
||||
# Grant geolocation permission and set location
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||
}"
|
||||
|
||||
# Set location to London
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
||||
}"
|
||||
|
||||
# Clear geolocation override
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().clearPermissions();
|
||||
}"
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
```bash
|
||||
# Grant multiple permissions
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions([
|
||||
'geolocation',
|
||||
'notifications',
|
||||
'camera',
|
||||
'microphone'
|
||||
]);
|
||||
}"
|
||||
|
||||
# Grant permissions for specific origin
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read'], {
|
||||
origin: 'https://example.com'
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Media Emulation
|
||||
|
||||
```bash
|
||||
# Emulate dark color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
}"
|
||||
|
||||
# Emulate light color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
}"
|
||||
|
||||
# Emulate reduced motion
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
}"
|
||||
|
||||
# Emulate print media
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
}"
|
||||
```
|
||||
|
||||
## Wait Strategies
|
||||
|
||||
```bash
|
||||
# Wait for network idle
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
}"
|
||||
|
||||
# Wait for specific element
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.loading').waitFor({ state: 'hidden' });
|
||||
}"
|
||||
|
||||
# Wait for function to return true
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForFunction(() => window.appReady === true);
|
||||
}"
|
||||
|
||||
# Wait with timeout
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.result').waitFor({ timeout: 10000 });
|
||||
}"
|
||||
```
|
||||
|
||||
## Frames and Iframes
|
||||
|
||||
```bash
|
||||
# Work with iframe
|
||||
playwright-cli run-code "async page => {
|
||||
const frame = page.locator('iframe#my-iframe').contentFrame();
|
||||
await frame.locator('button').click();
|
||||
}"
|
||||
|
||||
# Get all frames
|
||||
playwright-cli run-code "async page => {
|
||||
const frames = page.frames();
|
||||
return frames.map(f => f.url());
|
||||
}"
|
||||
```
|
||||
|
||||
## File Downloads
|
||||
|
||||
```bash
|
||||
# Handle file download
|
||||
playwright-cli run-code "async page => {
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'Download' }).click();
|
||||
const download = await downloadPromise;
|
||||
await download.saveAs('./downloaded-file.pdf');
|
||||
return download.suggestedFilename();
|
||||
}"
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
```bash
|
||||
# Read clipboard (requires permission)
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read']);
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
}"
|
||||
|
||||
# Write to clipboard
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
|
||||
}"
|
||||
```
|
||||
|
||||
## Page Information
|
||||
|
||||
```bash
|
||||
# Get page title
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.title();
|
||||
}"
|
||||
|
||||
# Get current URL
|
||||
playwright-cli run-code "async page => {
|
||||
return page.url();
|
||||
}"
|
||||
|
||||
# Get page content
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.content();
|
||||
}"
|
||||
|
||||
# Get viewport size
|
||||
playwright-cli run-code "async page => {
|
||||
return page.viewportSize();
|
||||
}"
|
||||
```
|
||||
|
||||
## JavaScript Execution
|
||||
|
||||
```bash
|
||||
# Execute JavaScript and return result
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(() => {
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
cookiesEnabled: navigator.cookieEnabled
|
||||
};
|
||||
});
|
||||
}"
|
||||
|
||||
# Pass arguments to evaluate
|
||||
playwright-cli run-code "async page => {
|
||||
const multiplier = 5;
|
||||
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
|
||||
}"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```bash
|
||||
# Try-catch in run-code
|
||||
playwright-cli run-code "async page => {
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
|
||||
return 'clicked';
|
||||
} catch (e) {
|
||||
return 'element not found';
|
||||
}
|
||||
}"
|
||||
```
|
||||
|
||||
## Complex Workflows
|
||||
|
||||
```bash
|
||||
# Login and save state
|
||||
playwright-cli run-code "async page => {
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await page.waitForURL('**/dashboard');
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
return 'Login successful';
|
||||
}"
|
||||
|
||||
# Scrape data from multiple pages
|
||||
playwright-cli run-code "async page => {
|
||||
const results = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.goto(\`https://example.com/page/\${i}\`);
|
||||
const items = await page.locator('.item').allTextContents();
|
||||
results.push(...items);
|
||||
}
|
||||
return results;
|
||||
}"
|
||||
```
|
||||
Generated
Vendored
+225
@@ -0,0 +1,225 @@
|
||||
# Browser Session Management
|
||||
|
||||
Run multiple isolated browser sessions concurrently with state persistence.
|
||||
|
||||
## Named Browser Sessions
|
||||
|
||||
Use `-s` flag to isolate browser contexts:
|
||||
|
||||
```bash
|
||||
# Browser 1: Authentication flow
|
||||
playwright-cli -s=auth open https://app.example.com/login
|
||||
|
||||
# Browser 2: Public browsing (separate cookies, storage)
|
||||
playwright-cli -s=public open https://example.com
|
||||
|
||||
# Commands are isolated by browser session
|
||||
playwright-cli -s=auth fill e1 "[email protected]"
|
||||
playwright-cli -s=public snapshot
|
||||
```
|
||||
|
||||
## Browser Session Isolation Properties
|
||||
|
||||
Each browser session has independent:
|
||||
- Cookies
|
||||
- LocalStorage / SessionStorage
|
||||
- IndexedDB
|
||||
- Cache
|
||||
- Browsing history
|
||||
- Open tabs
|
||||
|
||||
## Browser Session Commands
|
||||
|
||||
```bash
|
||||
# List all browser sessions
|
||||
playwright-cli list
|
||||
|
||||
# Stop a browser session (close the browser)
|
||||
playwright-cli close # stop the default browser
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
|
||||
# Stop all browser sessions
|
||||
playwright-cli close-all
|
||||
|
||||
# Forcefully kill all daemon processes (for stale/zombie processes)
|
||||
playwright-cli kill-all
|
||||
|
||||
# Delete browser session user data (profile directory)
|
||||
playwright-cli delete-data # delete default browser data
|
||||
playwright-cli -s=mysession delete-data # delete named browser data
|
||||
```
|
||||
|
||||
## Environment Variable
|
||||
|
||||
Set a default browser session name via environment variable:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_CLI_SESSION="mysession"
|
||||
playwright-cli open example.com # Uses "mysession" automatically
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Concurrent Scraping
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Scrape multiple sites concurrently
|
||||
|
||||
# Start all browsers
|
||||
playwright-cli -s=site1 open https://site1.com &
|
||||
playwright-cli -s=site2 open https://site2.com &
|
||||
playwright-cli -s=site3 open https://site3.com &
|
||||
wait
|
||||
|
||||
# Take snapshots from each
|
||||
playwright-cli -s=site1 snapshot
|
||||
playwright-cli -s=site2 snapshot
|
||||
playwright-cli -s=site3 snapshot
|
||||
|
||||
# Cleanup
|
||||
playwright-cli close-all
|
||||
```
|
||||
|
||||
### A/B Testing Sessions
|
||||
|
||||
```bash
|
||||
# Test different user experiences
|
||||
playwright-cli -s=variant-a open "https://app.com?variant=a"
|
||||
playwright-cli -s=variant-b open "https://app.com?variant=b"
|
||||
|
||||
# Compare
|
||||
playwright-cli -s=variant-a screenshot
|
||||
playwright-cli -s=variant-b screenshot
|
||||
```
|
||||
|
||||
### Persistent Profile
|
||||
|
||||
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
|
||||
|
||||
```bash
|
||||
# Use persistent profile (auto-generated location)
|
||||
playwright-cli open https://example.com --persistent
|
||||
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open https://example.com --profile=/path/to/profile
|
||||
```
|
||||
|
||||
## Attaching to a Running Browser
|
||||
|
||||
Use `attach` to connect to a browser that is already running, instead of launching a new one.
|
||||
|
||||
### Attach by channel name
|
||||
|
||||
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
|
||||
|
||||
```bash
|
||||
# Attach to Chrome
|
||||
playwright-cli attach --cdp=chrome
|
||||
|
||||
# Attach to Chrome Canary
|
||||
playwright-cli attach --cdp=chrome-canary
|
||||
|
||||
# Attach to Microsoft Edge
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Attach to Edge Dev
|
||||
playwright-cli attach --cdp=msedge-dev
|
||||
```
|
||||
|
||||
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
|
||||
|
||||
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
|
||||
|
||||
### Attach via CDP endpoint
|
||||
|
||||
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
```
|
||||
|
||||
### Attach via browser extension
|
||||
|
||||
Connect to a browser with the Playwright extension installed:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --extension
|
||||
```
|
||||
|
||||
### Detach
|
||||
|
||||
Tear down an attached session without affecting the external browser:
|
||||
|
||||
```bash
|
||||
# Detach the default attached session
|
||||
playwright-cli detach
|
||||
|
||||
# Detach a specific attached session
|
||||
playwright-cli -s=msedge detach
|
||||
```
|
||||
|
||||
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
|
||||
|
||||
## Default Browser Session
|
||||
|
||||
When `-s` is omitted, commands use the default browser session:
|
||||
|
||||
```bash
|
||||
# These use the same default browser session
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli close # Stops default browser
|
||||
```
|
||||
|
||||
## Browser Session Configuration
|
||||
|
||||
Configure a browser session with specific settings when opening:
|
||||
|
||||
```bash
|
||||
# Open with config file
|
||||
playwright-cli open https://example.com --config=.playwright/my-cli.json
|
||||
|
||||
# Open with specific browser
|
||||
playwright-cli open https://example.com --browser=firefox
|
||||
|
||||
# Open in headed mode
|
||||
playwright-cli open https://example.com --headed
|
||||
|
||||
# Open with persistent profile
|
||||
playwright-cli open https://example.com --persistent
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Name Browser Sessions Semantically
|
||||
|
||||
```bash
|
||||
# GOOD: Clear purpose
|
||||
playwright-cli -s=github-auth open https://github.com
|
||||
playwright-cli -s=docs-scrape open https://docs.example.com
|
||||
|
||||
# AVOID: Generic names
|
||||
playwright-cli -s=s1 open https://github.com
|
||||
```
|
||||
|
||||
### 2. Always Clean Up
|
||||
|
||||
```bash
|
||||
# Stop browsers when done
|
||||
playwright-cli -s=auth close
|
||||
playwright-cli -s=scrape close
|
||||
|
||||
# Or stop all at once
|
||||
playwright-cli close-all
|
||||
|
||||
# If browsers become unresponsive or zombie processes remain
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
### 3. Delete Stale Browser Data
|
||||
|
||||
```bash
|
||||
# Remove old browser data to free disk space
|
||||
playwright-cli -s=oldsession delete-data
|
||||
```
|
||||
Generated
Vendored
+275
@@ -0,0 +1,275 @@
|
||||
# Storage Management
|
||||
|
||||
Manage cookies, localStorage, sessionStorage, and browser storage state.
|
||||
|
||||
## Storage State
|
||||
|
||||
Save and restore complete browser state including cookies and storage.
|
||||
|
||||
### Save Storage State
|
||||
|
||||
```bash
|
||||
# Save to auto-generated filename (storage-state-{timestamp}.json)
|
||||
playwright-cli state-save
|
||||
|
||||
# Save to specific filename
|
||||
playwright-cli state-save my-auth-state.json
|
||||
```
|
||||
|
||||
### Restore Storage State
|
||||
|
||||
```bash
|
||||
# Load storage state from file
|
||||
playwright-cli state-load my-auth-state.json
|
||||
|
||||
# Reload page to apply cookies
|
||||
playwright-cli open https://example.com
|
||||
```
|
||||
|
||||
### Storage State File Format
|
||||
|
||||
The saved file contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "session_id",
|
||||
"value": "abc123",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
"expires": 1893456000,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://example.com",
|
||||
"localStorage": [
|
||||
{ "name": "theme", "value": "dark" },
|
||||
{ "name": "user_id", "value": "12345" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cookies
|
||||
|
||||
### List All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list
|
||||
```
|
||||
|
||||
### Filter Cookies by Domain
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
```
|
||||
|
||||
### Filter Cookies by Path
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --path=/api
|
||||
```
|
||||
|
||||
### Get Specific Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-get session_id
|
||||
```
|
||||
|
||||
### Set a Cookie
|
||||
|
||||
```bash
|
||||
# Basic cookie
|
||||
playwright-cli cookie-set session abc123
|
||||
|
||||
# Cookie with options
|
||||
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
|
||||
|
||||
# Cookie with expiration (Unix timestamp)
|
||||
playwright-cli cookie-set remember_me token123 --expires=1893456000
|
||||
```
|
||||
|
||||
### Delete a Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-delete session_id
|
||||
```
|
||||
|
||||
### Clear All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Cookies or Custom Options
|
||||
|
||||
For complex scenarios like adding multiple cookies at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().addCookies([
|
||||
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
|
||||
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
|
||||
]);
|
||||
}"
|
||||
```
|
||||
|
||||
## Local Storage
|
||||
|
||||
### List All localStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-get token
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set theme dark
|
||||
```
|
||||
|
||||
### Set JSON Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-delete token
|
||||
```
|
||||
|
||||
### Clear All localStorage
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Operations
|
||||
|
||||
For complex scenarios like setting multiple values at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('token', 'jwt_abc123');
|
||||
localStorage.setItem('user_id', '12345');
|
||||
localStorage.setItem('expires_at', Date.now() + 3600000);
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Session Storage
|
||||
|
||||
### List All sessionStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-get form_data
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-set step 3
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-delete step
|
||||
```
|
||||
|
||||
### Clear sessionStorage
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
## IndexedDB
|
||||
|
||||
### List Databases
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(async () => {
|
||||
const databases = await indexedDB.databases();
|
||||
return databases;
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Delete Database
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
indexedDB.deleteDatabase('myDatabase');
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication State Reuse
|
||||
|
||||
```bash
|
||||
# Step 1: Login and save state
|
||||
playwright-cli open https://app.example.com/login
|
||||
playwright-cli snapshot
|
||||
playwright-cli fill e1 "[email protected]"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
|
||||
# Save the authenticated state
|
||||
playwright-cli state-save auth.json
|
||||
|
||||
# Step 2: Later, restore state and skip login
|
||||
playwright-cli state-load auth.json
|
||||
playwright-cli open https://app.example.com/dashboard
|
||||
# Already logged in!
|
||||
```
|
||||
|
||||
### Save and Restore Roundtrip
|
||||
|
||||
```bash
|
||||
# Set up authentication state
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
|
||||
|
||||
# Save state to file
|
||||
playwright-cli state-save my-session.json
|
||||
|
||||
# ... later, in a new session ...
|
||||
|
||||
# Restore state
|
||||
playwright-cli state-load my-session.json
|
||||
playwright-cli open https://example.com
|
||||
# Cookies and localStorage are restored!
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit storage state files containing auth tokens
|
||||
- Add `*.auth-state.json` to `.gitignore`
|
||||
- Delete state files after automation completes
|
||||
- Use environment variables for sensitive data
|
||||
- By default, sessions run in-memory mode which is safer for sensitive operations
|
||||
Generated
Vendored
+433
@@ -0,0 +1,433 @@
|
||||
# Test generation (plan → generate → heal)
|
||||
|
||||
End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently:
|
||||
|
||||
- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions.
|
||||
- **Plan** — explore the app, produce a spec file describing what to test.
|
||||
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
|
||||
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
|
||||
|
||||
Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics.
|
||||
|
||||
---
|
||||
|
||||
## 0. How generation works
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files.
|
||||
|
||||
```bash
|
||||
# Start a session
|
||||
playwright-cli open https://example.com/login
|
||||
|
||||
# Take a snapshot to see elements
|
||||
playwright-cli snapshot
|
||||
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
|
||||
|
||||
# Fill form fields - generates code automatically
|
||||
playwright-cli fill e1 "[email protected]"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
|
||||
|
||||
playwright-cli fill e2 "password123"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
|
||||
playwright-cli click e3
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
```
|
||||
|
||||
### Building a test file
|
||||
|
||||
Collect the generated code into a Playwright test:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('login flow', async ({ page }) => {
|
||||
// Generated code from playwright-cli session:
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Add assertions
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
```
|
||||
|
||||
### Use semantic locators
|
||||
|
||||
The generated code uses role-based locators when possible, which are more resilient:
|
||||
|
||||
```typescript
|
||||
// Generated (good - semantic)
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Avoid (fragile - CSS selectors)
|
||||
await page.locator('#submit-btn').click();
|
||||
```
|
||||
|
||||
### Explore before recording
|
||||
|
||||
Take snapshots to understand the page structure before recording actions:
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
# Review the element structure
|
||||
playwright-cli click e5
|
||||
```
|
||||
|
||||
### Add assertions manually
|
||||
|
||||
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
|
||||
|
||||
- `toBeVisible()` — element is rendered and visible
|
||||
- `toHaveText(text)` — element text content matches
|
||||
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
|
||||
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
|
||||
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
|
||||
|
||||
Use `playwright-cli generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
|
||||
|
||||
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
|
||||
|
||||
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
|
||||
|
||||
```bash
|
||||
# Get a stable locator for an element ref to use in the assertion
|
||||
playwright-cli --raw generate-locator e5
|
||||
# getByRole('button', { name: 'Submit' })
|
||||
|
||||
# Capture expected text content for toHaveText
|
||||
playwright-cli --raw eval "el => el.textContent" e5
|
||||
|
||||
# Capture expected input value for toHaveValue/toBeEmpty
|
||||
playwright-cli --raw eval "el => el.value" e5
|
||||
|
||||
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
|
||||
# (whole page, or use a ref to scope to a region)
|
||||
playwright-cli --raw snapshot
|
||||
playwright-cli --raw snapshot e5
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Generated action
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Manual assertions using the outputs above:
|
||||
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
|
||||
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
|
||||
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('[email protected]');
|
||||
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
|
||||
|
||||
// toMatchAriaSnapshot on the whole page, finds a matching region
|
||||
await expect(page).toMatchAriaSnapshot(`
|
||||
- heading "Welcome, user"
|
||||
- link /\\d+ new messages?/
|
||||
- button "Sign out"
|
||||
`);
|
||||
|
||||
// toMatchAriaSnapshot scoped to a region
|
||||
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
|
||||
- link "Home"
|
||||
- link /\\d+ new messages?/
|
||||
- link "Profile"
|
||||
`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Planning
|
||||
|
||||
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
|
||||
|
||||
### 1.1 Prerequisite: workspace
|
||||
|
||||
Check the workspace has Playwright installed before anything else:
|
||||
|
||||
```bash
|
||||
# Either of these confirms a workspace:
|
||||
test -f playwright.config.ts || test -f playwright.config.js
|
||||
npx --no-install playwright --version
|
||||
```
|
||||
|
||||
If there is no Playwright install, bootstrap one and let the user pick the defaults:
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
### 1.2 Prerequisite: seed test
|
||||
|
||||
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
|
||||
|
||||
Minimum viable seed:
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
await page.goto('https://example.com/');
|
||||
});
|
||||
```
|
||||
|
||||
Preferred — push navigation into a fixture so scenario tests reuse it:
|
||||
|
||||
```ts
|
||||
// tests/fixtures.ts
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
export const test = baseTest.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.goto('https://example.com/');
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from './fixtures';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
// Fixture already navigates. This empty body tells agents where to start.
|
||||
});
|
||||
```
|
||||
|
||||
If no seed exists, create one that at least navigates to the app.
|
||||
|
||||
### 1.3 Explore the app
|
||||
|
||||
Launch the app via the seed in the background and attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli
|
||||
# wait for "Debugging Instructions" and the session name tw-XXXX
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
Resume so the seed runs, then probe the app:
|
||||
|
||||
```bash
|
||||
playwright-cli resume # resume so that seed test runs fully
|
||||
playwright-cli snapshot # inventory of interactive elements
|
||||
playwright-cli click e5 # follow a flow
|
||||
playwright-cli eval "location.href" # read URL / state
|
||||
playwright-cli show --annotate # ask the user to point at something
|
||||
```
|
||||
|
||||
Map out:
|
||||
|
||||
- Interactive surfaces (forms, buttons, lists, filters, modals).
|
||||
- Primary user journeys end-to-end.
|
||||
- Edge cases: empty states, validation errors, very long input, boundary values.
|
||||
- Persistence: reload, local/session storage, URL fragments.
|
||||
- Navigation: which controls change the URL, back/forward behaviour.
|
||||
|
||||
**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
**Important**: Stop the background test when done exploring.
|
||||
|
||||
### 1.4 Write the spec file
|
||||
|
||||
Save under `specs/<feature>.plan.md`. Use this structure:
|
||||
|
||||
```markdown
|
||||
# <Feature> Test Plan
|
||||
|
||||
## Application Overview
|
||||
|
||||
<One paragraph describing what the feature does and why it matters.>
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. <Group Name>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
|
||||
#### 1.1. <kebab-case-scenario-name>
|
||||
|
||||
**File:** `tests/<group>/<kebab-case-scenario-name>.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. <Concrete user step>
|
||||
- expect: <observable outcome>
|
||||
- expect: <another observable outcome>
|
||||
2. <Next step>
|
||||
- expect: <outcome>
|
||||
|
||||
#### 1.2. <next-scenario>
|
||||
...
|
||||
|
||||
### 2. <Next Group>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
...
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
|
||||
- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`).
|
||||
- Cover happy path, edge cases, validation, negative flows, persistence.
|
||||
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
|
||||
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generate
|
||||
|
||||
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
|
||||
|
||||
### 2.1 Inputs
|
||||
|
||||
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
|
||||
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
|
||||
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
|
||||
|
||||
### 2.2 Generate one scenario
|
||||
|
||||
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test <seed-file> --debug=cli # background
|
||||
playwright-cli attach tw-XXXX
|
||||
# resume
|
||||
```
|
||||
|
||||
**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
|
||||
Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
|
||||
|
||||
Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)):
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # find refs
|
||||
playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
|
||||
playwright-cli press Enter
|
||||
playwright-cli click e7
|
||||
```
|
||||
|
||||
For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details.
|
||||
|
||||
Collect the generated code and write the test file at the path given in the spec:
|
||||
|
||||
```ts
|
||||
// spec: specs/basic-operations.plan.md
|
||||
// seed: tests/seed.spec.ts
|
||||
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
|
||||
|
||||
test.describe('Signing in and out', () => {
|
||||
test('should sign in', async ({ page }) => {
|
||||
// 1. Navigate to the application
|
||||
// (handled by the seed fixture)
|
||||
|
||||
// 2. Type 'John Doe' into the username field
|
||||
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
|
||||
|
||||
// 3. Type password
|
||||
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
|
||||
|
||||
// 4. Press Enter to submit
|
||||
await page.getByRole('textbox', { name: 'password' }).press('Enter');
|
||||
|
||||
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
|
||||
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
|
||||
- Use the describe group name verbatim from the spec (no `1.` ordinal).
|
||||
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
|
||||
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
|
||||
|
||||
### 2.3 Generate multiple scenarios
|
||||
|
||||
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
|
||||
|
||||
### 2.4 Run generated tests
|
||||
|
||||
After generation, run the new tests once:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts
|
||||
```
|
||||
|
||||
Any failure goes to Section 3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Heal
|
||||
|
||||
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
|
||||
|
||||
### 3.1 Find failing tests
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
```
|
||||
|
||||
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
|
||||
|
||||
### 3.2 Debug one failure
|
||||
|
||||
Run the single failing test in debug mode in the background, then attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts:<line> --debug=cli
|
||||
# wait for "Debugging Instructions" and the tw-XXXX session name
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # did the element change / move / rename?
|
||||
playwright-cli console # app-side errors?
|
||||
playwright-cli requests # failed request? wrong payload?
|
||||
playwright-cli show --annotate # ask the user to point somewhere
|
||||
```
|
||||
|
||||
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
|
||||
|
||||
Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test.
|
||||
|
||||
### 3.3 Apply the fix
|
||||
|
||||
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
|
||||
|
||||
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
|
||||
|
||||
### 3.4 Reconcile with the spec
|
||||
|
||||
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
|
||||
|
||||
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
|
||||
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
|
||||
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
|
||||
- the scenario id (e.g. `2.3`),
|
||||
- the spec lines that no longer match,
|
||||
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
|
||||
|
||||
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
|
||||
|
||||
### 3.5 Iteration and giving up
|
||||
|
||||
- Fix failures one at a time; rerun after each.
|
||||
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
| For... | See |
|
||||
|---|---|
|
||||
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
|
||||
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
|
||||
| Managing the CLI browser session | [session-management.md](session-management.md) |
|
||||
Generated
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
# Tracing
|
||||
|
||||
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Start trace recording
|
||||
playwright-cli tracing-start
|
||||
|
||||
# Perform actions
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e1
|
||||
playwright-cli fill e2 "test"
|
||||
|
||||
# Stop trace recording
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
## Trace Output Files
|
||||
|
||||
When you start tracing, Playwright creates a `.playwright-cli/traces/` directory with several files:
|
||||
|
||||
### `trace-{timestamp}.trace`
|
||||
|
||||
**Action log** - The main trace file containing:
|
||||
- Every action performed (clicks, fills, navigations)
|
||||
- DOM snapshots before and after each action
|
||||
- Screenshots at each step
|
||||
- Timing information
|
||||
- Console messages
|
||||
- Source locations
|
||||
|
||||
### `trace-{timestamp}.network`
|
||||
|
||||
**Network log** - Complete network activity:
|
||||
- All HTTP requests and responses
|
||||
- Request headers and bodies
|
||||
- Response headers and bodies
|
||||
- Timing (DNS, connect, TLS, TTFB, download)
|
||||
- Resource sizes
|
||||
- Failed requests and errors
|
||||
|
||||
### `resources/`
|
||||
|
||||
**Resources directory** - Cached resources:
|
||||
- Images, fonts, stylesheets, scripts
|
||||
- Response bodies for replay
|
||||
- Assets needed to reconstruct page state
|
||||
|
||||
## What Traces Capture
|
||||
|
||||
| Category | Details |
|
||||
|----------|---------|
|
||||
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
|
||||
| **DOM** | Full DOM snapshot before/after each action |
|
||||
| **Screenshots** | Visual state at each step |
|
||||
| **Network** | All requests, responses, headers, bodies, timing |
|
||||
| **Console** | All console.log, warn, error messages |
|
||||
| **Timing** | Precise timing for each operation |
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Debugging Failed Actions
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://app.example.com
|
||||
|
||||
# This click fails - why?
|
||||
playwright-cli click e5
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Open trace to see DOM state when click was attempted
|
||||
```
|
||||
|
||||
### Analyzing Performance
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://slow-site.com
|
||||
playwright-cli tracing-stop
|
||||
|
||||
# View network waterfall to identify slow resources
|
||||
```
|
||||
|
||||
### Capturing Evidence
|
||||
|
||||
```bash
|
||||
# Record a complete user flow for documentation
|
||||
playwright-cli tracing-start
|
||||
|
||||
playwright-cli open https://app.example.com/checkout
|
||||
playwright-cli fill e1 "4111111111111111"
|
||||
playwright-cli fill e2 "12/25"
|
||||
playwright-cli fill e3 "123"
|
||||
playwright-cli click e4
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Trace shows exact sequence of events
|
||||
```
|
||||
|
||||
## Trace vs Video vs Screenshot
|
||||
|
||||
| Feature | Trace | Video | Screenshot |
|
||||
|---------|-------|-------|------------|
|
||||
| **Format** | .trace file | .webm video | .png/.jpeg image |
|
||||
| **DOM inspection** | Yes | No | No |
|
||||
| **Network details** | Yes | No | No |
|
||||
| **Step-by-step replay** | Yes | Continuous | Single frame |
|
||||
| **File size** | Medium | Large | Small |
|
||||
| **Best for** | Debugging | Demos | Quick capture |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Start Tracing Before the Problem
|
||||
|
||||
```bash
|
||||
# Trace the entire flow, not just the failing step
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://example.com
|
||||
# ... all steps leading to the issue ...
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
### 2. Clean Up Old Traces
|
||||
|
||||
Traces can consume significant disk space:
|
||||
|
||||
```bash
|
||||
# Remove traces older than 7 days
|
||||
find .playwright-cli/traces -mtime +7 -delete
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Traces add overhead to automation
|
||||
- Large traces can consume significant disk space
|
||||
- Some dynamic content may not replay perfectly
|
||||
Generated
Vendored
+143
@@ -0,0 +1,143 @@
|
||||
# Video Recording
|
||||
|
||||
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
|
||||
|
||||
## Basic Recording
|
||||
|
||||
```bash
|
||||
# Open browser first
|
||||
playwright-cli open
|
||||
|
||||
# Start recording
|
||||
playwright-cli video-start demo.webm
|
||||
|
||||
# Add a chapter marker for section transitions
|
||||
playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
|
||||
|
||||
# Navigate and perform actions
|
||||
playwright-cli goto https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli click e1
|
||||
|
||||
# Add another chapter
|
||||
playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000
|
||||
playwright-cli fill e2 "test input"
|
||||
|
||||
# Stop and save
|
||||
playwright-cli video-stop
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Descriptive Filenames
|
||||
|
||||
```bash
|
||||
# Include context in filename
|
||||
playwright-cli video-start recordings/login-flow-2024-01-15.webm
|
||||
playwright-cli video-start recordings/checkout-test-run-42.webm
|
||||
```
|
||||
|
||||
### 2. Record entire hero scripts.
|
||||
|
||||
When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
|
||||
It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
|
||||
|
||||
1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
|
||||
2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
|
||||
3) Use playwright-cli run-code --filename your-script.js
|
||||
|
||||
**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
|
||||
|
||||
```js
|
||||
async page => {
|
||||
await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
|
||||
await page.goto('https://demo.playwright.dev/todomvc');
|
||||
|
||||
// Show a chapter card — blurs the page and shows a dialog.
|
||||
// Blocks until duration expires, then auto-removes.
|
||||
// Use this for simple use cases, but always feel free to hand-craft your own beautiful
|
||||
// overlay via await page.screencast.showOverlay().
|
||||
await page.screencast.showChapter('Adding Todo Items', {
|
||||
description: 'We will add several items to the todo list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Perform action
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Show next chapter
|
||||
await page.screencast.showChapter('Verifying Results', {
|
||||
description: 'Checking the item appeared in the list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Add a sticky annotation that stays while you perform actions.
|
||||
// Overlays are pointer-events: none, so they won't block clicks.
|
||||
const annotation = await page.screencast.showOverlay(`
|
||||
<div style="position: absolute; top: 8px; right: 8px;
|
||||
padding: 6px 12px; background: rgba(0,0,0,0.7);
|
||||
border-radius: 8px; font-size: 13px; color: white;">
|
||||
✓ Item added successfully
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Perform more actions while the annotation is visible
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Remove the annotation when done
|
||||
await annotation.dispose();
|
||||
|
||||
// You can also highlight relevant locators and provide contextual annotations.
|
||||
const bounds = await page.getByText('Walk the dog').boundingBox();
|
||||
await page.screencast.showOverlay(`
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y}px;
|
||||
left: ${bounds.x}px;
|
||||
width: ${bounds.width}px;
|
||||
height: ${bounds.height}px;
|
||||
border: 1px solid red;">
|
||||
</div>
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y + bounds.height + 5}px;
|
||||
left: ${bounds.x + bounds.width / 2}px;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px;
|
||||
background: #808080;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: white;">Check it out, it is right above this text
|
||||
</div>
|
||||
`, { duration: 2000 });
|
||||
|
||||
await page.screencast.stop();
|
||||
}
|
||||
```
|
||||
|
||||
Embrace creativity, overlays are powerful.
|
||||
|
||||
### Overlay API Summary
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
|
||||
| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
|
||||
| `disposable.dispose()` | Remove a sticky overlay added without duration |
|
||||
| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
|
||||
|
||||
## Tracing vs Video
|
||||
|
||||
| Feature | Video | Tracing |
|
||||
|---------|-------|---------|
|
||||
| Output | WebM file | Trace file (viewable in Trace Viewer) |
|
||||
| Shows | Visual recording | DOM snapshots, network, console, actions |
|
||||
| Use case | Demos, documentation | Debugging, analysis |
|
||||
| Size | Larger | Smaller |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Recording adds slight overhead to automation
|
||||
- Large recordings can consume significant disk space
|
||||
Generated
Vendored
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: playwright-component-testing
|
||||
description: Set up component testing with Playwright using a story gallery — scaffold stories and a gallery dev page driven by the built-in mount fixture, no dedicated component-testing runtime. Use when asked to test React or Vue components in isolation with Playwright, or to migrate off @playwright/experimental-ct-react / -vue.
|
||||
---
|
||||
|
||||
# Component Testing with Playwright
|
||||
|
||||
Test components with regular Playwright e2e tests against a small **story gallery** page hosted by the app's own dev server. No extra test runner, bundler integration or npm packages are required.
|
||||
|
||||
## Concept
|
||||
|
||||
- A **story** is a tiny wrapper component that embeds the component under test in one specific scenario: hard-coded props, mock data, providers, recorded callbacks. Stories live next to the component in `*.story.tsx` (or `.ts`/`.jsx`/`.js`/`.vue`) files; each named export is one story.
|
||||
- The **gallery** is a single page you implement to `references/gallery-spec.md`: it exposes `window.mount(params)` / `window.unmount()` that render a story — resolved from your story files (e.g. with `import.meta.glob`) — into `#root`. It is framework-specific and yours to own — there is no template to copy for it.
|
||||
- Tests are plain Playwright tests. The built-in **`mount(storyId, props?)` fixture** (from `@playwright/test`) drives the gallery's `window.mount` and returns a `Locator` for the gallery root (`#root`). Scope the queries from there — `component.getByRole('button').click()`, not `component.click()`. Nothing to scaffold for it.
|
||||
|
||||
Everything the component needs must be set up *inside the story* (it runs in the browser); everything the test asserts must be observable *through the page* (DOM, URL, network). Where the component takes callbacks, the story creates the state, provides the callbacks and records the state into a hidden form for the test to assert on. `mount(id, props)` passes plain serializable `props` to the story.
|
||||
|
||||
## Setup workflow
|
||||
|
||||
1. **Detect the framework and bundler.** React vs Vue decides the framework notes and example story to follow. Then:
|
||||
- **App runs on Vite** (has `vite.config.*`): the gallery is served by the existing dev server at `/playwright/gallery/index.html` — Vite serves any `.html` file under the project root, the app's plugins/aliases/CSS apply automatically, and `vite build` ignores it. No extra server needed.
|
||||
- **Anything else** (Next.js, webpack, no dev server): run a small standalone dev server (e.g. Vite) that serves the gallery page, and point `baseURL` at it. Requires `vite` and the framework plugin as devDependencies.
|
||||
2. **Implement the gallery** to `references/gallery-spec.md`: a page at `<project>/playwright/gallery/` that renders the requested story into `#root`. Start from the worked example in the spec and the framework notes in `references/react.md` / `references/vue.md`. Keep story discovery (`import.meta.glob`) and the framework mount here — this is the only framework-specific glue, so keep it small. Import the app's global CSS the same way the app's own entry does.
|
||||
3. **Configure Playwright** — add to `playwright.config.ts`:
|
||||
|
||||
```ts
|
||||
projects: [
|
||||
{
|
||||
name: 'components',
|
||||
testDir: './tests/components',
|
||||
use: { ...devices['Desktop Chrome'], baseURL: 'http://localhost:5173/playwright/gallery/index.html', serviceWorkers: 'block', reuseContext: true },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev', // or: npx vite --config playwright/vite.config.ts
|
||||
url: 'http://localhost:5173/playwright/gallery/index.html', // standalone server: http://localhost:3100/playwright/gallery/index.html
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
```
|
||||
|
||||
Match the port to the dev server. `mount` navigates to `baseURL`, so set `baseURL` to the gallery's URL. `serviceWorkers: 'block'` keeps the app's own service worker from serving cached responses that would shadow your `page.route()` mocks. `reuseContext: true` reuses the browser context across tests in a worker (as the old component-testing runtime did) — a large speedup for component suites. If the config already has projects/webServer, merge instead of replacing.
|
||||
4. **Write a first story** next to an existing component, modeled on `templates/<react|vue>/Button.story.*`.
|
||||
5. **Write a first spec**, modeled on `templates/react/button.spec.ts`, importing `test`/`expect` from `@playwright/test`.
|
||||
6. **Run**: `npx playwright test --project=components`. Open `http://localhost:5173/playwright/gallery/index.html` in a browser to eyeball all stories.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Story id: path under `src/` without the `.story.*` extension, plus the export name — `src/components/Button.story.tsx` export `Primary` → `components/Button/Primary`. Any unique suffix works too: `mount('Button/Primary')`. A `.story.vue` single-file component is one story, addressable by its path alone (its `default` export). With gallery types (`references/typing.md`) ids are prefixed with the package name: `acme-ui/components/Button/Primary`.
|
||||
- One export per scenario. Prefer a new story export over parameterizing an existing one — stories are greppable, reviewable documentation of component states.
|
||||
|
||||
## Testing patterns
|
||||
|
||||
Examples are React; the Vue equivalents differ only in story syntax.
|
||||
|
||||
### Callbacks and events
|
||||
|
||||
**The story owns the state and provides the callbacks.** Where the component takes callbacks, create the state inside the story, wire the callbacks to it, and record the state into a hidden form next to the component. Tests perform operations and assert on the recorded values:
|
||||
|
||||
```tsx
|
||||
export const Stateful = () => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
return <>
|
||||
<Expandable expanded={expanded} setExpanded={setExpanded} title="Title">Details</Expandable>
|
||||
<form hidden><input data-testid="expanded" readOnly value={String(expanded)} /></form>
|
||||
</>;
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
test('click should expand', async ({ mount }) => {
|
||||
const component = await mount('components/Expandable/Stateful');
|
||||
await component.locator('.codicon-chevron-right').click();
|
||||
await expect(component.getByTestId('expanded')).toHaveValue('true');
|
||||
});
|
||||
```
|
||||
|
||||
This keeps the whole scenario in the browser: no callback marshalling, the story doubles as documentation, and the recorded state is visible when eyeballing the gallery. Record each observed value in its own `data-testid` input (`String(...)` or `JSON.stringify(...)` for payloads) and assert with `toHaveValue()` — a web-first assertion that retries until the state lands. The negative direction works the same way: perform the operation, then assert the value did **not** change.
|
||||
|
||||
### Per-test props
|
||||
|
||||
When a scenario is genuinely parametric (e.g. a boundary-value sweep), pass props as the second argument to `mount`; the gallery hands them to the story as its props. Keep props to plain serializable data — callbacks belong inside the story.
|
||||
|
||||
```tsx
|
||||
export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
|
||||
<Button title={title} />;
|
||||
```
|
||||
|
||||
```ts
|
||||
const component = await mount('components/Button/WithTitle', { title: 'Hello' });
|
||||
```
|
||||
|
||||
Props are type-checked in two optional ways, see `references/typing.md`: pass the story type as a template argument (`mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' })`, no setup), or generate gallery types with a small Vite plugin so the id itself is typed (`mount('acme-ui/components/Button/WithTitle', { title: 'Hello' })`, with autocomplete and rename safety). Vue stories must additionally declare the props at runtime — see the `Typed props` sections in `references/react.md` / `references/vue.md`.
|
||||
|
||||
### Prop transitions with `update()`
|
||||
|
||||
To test how a component reacts to a prop change **without remounting** (state preserved), call `component.update(newProps)` — it re-renders the same story with new props on the existing root:
|
||||
|
||||
```ts
|
||||
const component = await mount('components/Counter/Default', { value: 1 });
|
||||
await expect(component.getByTestId('value')).toHaveText('1');
|
||||
await component.update({ value: 2 });
|
||||
await expect(component.getByTestId('value')).toHaveText('2');
|
||||
```
|
||||
|
||||
This requires the gallery to reuse its root/instance (`references/gallery-spec.md`); state survives as long as the story stays the same.
|
||||
|
||||
### Multiple states in one test
|
||||
|
||||
Each `mount()` navigates fresh, so tests are fully isolated and mounting several stories in one test is cheap:
|
||||
|
||||
```ts
|
||||
await expect(await mount('Button/Primary')).toHaveScreenshot('primary.png');
|
||||
await expect(await mount('Button/Disabled')).toHaveScreenshot('disabled.png');
|
||||
```
|
||||
|
||||
For visual comparison, screenshot the returned root locator (as above), not the page, to avoid asserting on browser chrome.
|
||||
|
||||
### Network mocking
|
||||
|
||||
Use `page.route()` as usual — register routes before `mount()`, since mounting navigates. `serviceWorkers: 'block'` (set in the config above) keeps the app's own service worker from serving cached responses that shadow the routes. Teams with MSW handler libraries can start the worker inside a story or decorator instead.
|
||||
|
||||
### Debugging stories
|
||||
|
||||
Open your gallery URL (`baseURL`) in a browser and call `await window.mount({ story: 'components/Button/Primary' })` from the devtools console — that is exactly what the `mount` fixture does. An unknown story rejects `window.mount`, which surfaces as the test's `mount()` throwing with a real stack. To browse without the console, give your gallery an optional index page.
|
||||
|
||||
## Decision points
|
||||
|
||||
- **Monorepos / non-`src` layouts**: change the glob and the id derivation in your gallery (`references/gallery-spec.md`) to match, and prefix ids with the package name (`references/typing.md`).
|
||||
- **Global providers** (theme, i18n, store, router): create a shared `decorator` helper next to the gallery and wrap components in stories; see `references/react.md` / `references/vue.md`.
|
||||
## References
|
||||
|
||||
- `references/gallery-spec.md` — the gallery endpoint contract to implement (**start here**).
|
||||
- `references/typing.md` — optional typing for `mount`: explicit story types vs generated gallery types, with the Vite plugin.
|
||||
- `references/react.md` — React walkthrough: providers, StrictMode, CSS.
|
||||
- `references/vue.md` — Vue walkthrough: `.story.ts` and `.story.vue` stories, plugins.
|
||||
- `references/migration.md` — migrating off `@playwright/experimental-ct-react` / `-vue`.
|
||||
Generated
Vendored
+144
@@ -0,0 +1,144 @@
|
||||
# Gallery contract
|
||||
|
||||
The **gallery** is a single page, served by your dev server at the URL you set as `baseURL` in your
|
||||
Playwright config, that exposes two methods on `window` for Playwright to drive:
|
||||
|
||||
- `window.mount(params)` — render a story.
|
||||
- `window.unmount()` — unmount the current story.
|
||||
|
||||
The built-in `mount` fixture navigates to the gallery, then calls `window.mount` via
|
||||
`page.evaluate()`. Keep props to plain serializable data — where the component takes callbacks,
|
||||
the story creates the state, provides the callbacks and records the state into a hidden form for
|
||||
the test to assert on.
|
||||
|
||||
## `window.mount(params)`
|
||||
|
||||
`params` is `{ story, props }`, straight from the test's `mount(story, props)` call:
|
||||
|
||||
- `story` — the story id (string). Resolve it (see id grammar) to a component.
|
||||
- `props` — the plain serializable props object passed to the component.
|
||||
|
||||
Render the resolved component with `props` into `#root`. Return a `Promise` that resolves once the
|
||||
component is mounted and **rejects on failure** (unknown story, render throw). The rejection
|
||||
surfaces as the test's `await mount(...)` throwing, with a real stack — there is no HTTP-status or
|
||||
DOM-attribute signalling.
|
||||
|
||||
**Reuse the root across calls.** The test's `component.update(props)` calls `window.mount` again
|
||||
with the same story and new props, **without navigating**. If you render into the same root /
|
||||
instance rather than recreating it, the framework reconciles and component-internal state is
|
||||
preserved — that is CT's `update()`. Recreating the root each call (or navigating) resets state, so
|
||||
reuse it: create it on first mount and render into it on every call. The framework reconciles,
|
||||
remounting on its own only when the story (component type) changes.
|
||||
|
||||
**`window.mount` is your setup/teardown hook.** It is the browser-side equivalent of CT's
|
||||
`beforeMount` / `afterMount`: install providers or plugins, seed a store, start an in-browser mock
|
||||
server *before* you render, and run post-render work *after* — all inside this one function,
|
||||
branched on the `story` / `props` the test passed. There is no separate hook registry; the function
|
||||
you own is the hook.
|
||||
|
||||
## `window.unmount()`
|
||||
|
||||
Unmount the current story from `#root` and return a `Promise`. The test calls it via
|
||||
`component.unmount()`. Needed only to assert teardown/cleanup effects — each `mount` navigates
|
||||
fresh, so tests are already isolated.
|
||||
|
||||
## `#root`
|
||||
|
||||
Render the component into an element with `id="root"`. `mount` returns a `Locator` for `#root`
|
||||
itself, so tests scope their queries from there — `component.getByRole('button').click()`, not
|
||||
`component.click()`. Stories are free to render fragments, e.g. the component plus a hidden form
|
||||
recording its state.
|
||||
|
||||
## Story id grammar (recommended)
|
||||
|
||||
The gallery owns resolution; `mount` passes the id through untouched. Recommended scheme:
|
||||
|
||||
- `<path under src, without the .story.* extension>/<ExportName>` — e.g.
|
||||
`src/components/Button.story.tsx` export `Primary` → `components/Button/Primary`.
|
||||
- Any unique trailing suffix resolves too: `Button/Primary`.
|
||||
- A single-file-component story (`Button.story.vue`) is one story, addressed by its path alone
|
||||
(its default export): `components/Button`.
|
||||
|
||||
## Worked example (React + Vite SPA)
|
||||
|
||||
An illustration of the contract, **not** a file to copy — implement the equivalent for your stack.
|
||||
`import.meta.glob` stays inline here: Vite analyzes it statically, relative to this file, so it
|
||||
cannot be moved into shared/shipped code. That is exactly why the gallery is yours to own.
|
||||
|
||||
```tsx
|
||||
// playwright/gallery/main.tsx
|
||||
import { flushSync } from 'react-dom';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const stories = import.meta.glob('../../src/**/*.story.{tsx,jsx}');
|
||||
const id = (f: string) => f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
|
||||
|
||||
async function resolve(storyId: string) {
|
||||
const sep = storyId.lastIndexOf('/');
|
||||
const [path, name] = [storyId.slice(0, sep), storyId.slice(sep + 1)];
|
||||
const file = Object.keys(stories).find(f => id(f) === path || id(f).endsWith('/' + path));
|
||||
const mod = (file && await stories[file]()) as Record<string, any> | undefined;
|
||||
return mod?.[name] ?? mod?.default;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root')!;
|
||||
let root: Root | undefined;
|
||||
|
||||
(window as any).mount = async ({ story, props }: { story: string, props?: Record<string, any> }) => {
|
||||
const Story = await resolve(story);
|
||||
if (!Story)
|
||||
throw new Error(`Unknown story: ${story}`);
|
||||
root ??= createRoot(rootEl); // reuse the root so update() reconciles and preserves state
|
||||
// flushSync so a render error rejects the promise instead of being swallowed.
|
||||
flushSync(() => root!.render(<Story {...props} />));
|
||||
};
|
||||
|
||||
(window as any).unmount = async () => {
|
||||
root?.unmount();
|
||||
root = undefined;
|
||||
};
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- playwright/gallery/index.html -->
|
||||
<!DOCTYPE html>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
```
|
||||
|
||||
## Vue variant (state-preserving)
|
||||
|
||||
Vue's `createApp(...).mount()` builds a fresh instance each call, so mount a small **reactive host**
|
||||
once and update its refs — updating them re-renders in place, which is what preserves state across
|
||||
`update()`:
|
||||
|
||||
```ts
|
||||
// playwright/gallery/main.ts
|
||||
import { createApp, h, shallowRef, type App, type Component } from 'vue';
|
||||
|
||||
// resolve() and the import.meta.glob are the same as the React example.
|
||||
const story = shallowRef<Component | null>(null);
|
||||
const props = shallowRef<Record<string, any>>({});
|
||||
const host = { render: () => (story.value ? h(story.value, props.value) : null) };
|
||||
let app: App | undefined;
|
||||
|
||||
(window as any).mount = async ({ story: id, props: next }: { story: string, props?: Record<string, any> }) => {
|
||||
const resolved = await resolve(id);
|
||||
if (!resolved)
|
||||
throw new Error(`Unknown story: ${id}`);
|
||||
story.value = resolved;
|
||||
props.value = next ?? {};
|
||||
if (!app) { // mount once; the ref updates above re-render in place
|
||||
app = createApp(host);
|
||||
app.mount('#root');
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).unmount = async () => {
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
};
|
||||
```
|
||||
|
||||
Keep the story-resolution glob and the framework mount in this file; everything
|
||||
else lives in your stories and tests.
|
||||
Generated
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
# Migrating from @playwright/experimental-ct-react / -vue
|
||||
|
||||
The CT packages compiled JSX in the test file and marshalled it into the browser. The gallery
|
||||
pattern moves the scenario into a story export that runs natively in the browser: structure (which
|
||||
component, its children, providers) plus behavior (state and callbacks, recorded into a hidden
|
||||
form for the test to assert on). Plain data props travel through `mount(storyId, props)`;
|
||||
`update()` and `unmount()` work as before.
|
||||
|
||||
## Concept mapping
|
||||
|
||||
| `@playwright/experimental-ct-*` | Gallery pattern |
|
||||
|---|---|
|
||||
| `mount(<Button title="…" onClick={spy} />)` | Stateful story: the story provides `onClick`, records the effect into a hidden form input; the test asserts `toHaveValue()` |
|
||||
| Plain data props from the test | Unchanged in spirit: `mount(id, props)` |
|
||||
| JSX children / slots from the test | Cannot cross — bake each composition into its own story export (Vue: `.story.vue` for slot-heavy scenarios) |
|
||||
| `component.update(<Button count={2} />)` | `component.update({ count: 2 })` — state-preserving, needs the gallery to reuse its root (`gallery-spec.md`) |
|
||||
| `component.unmount()` | `component.unmount()` — backed by the gallery's `window.unmount()` |
|
||||
| `beforeMount`/`afterMount` in `playwright/index.ts` | The body of the gallery's `window.mount` (global), or story decorators (per-story) |
|
||||
| `hooksConfig` per-test variation | Props: `mount('App/Routing', { route: '/dashboard' })` — the story/decorator interprets them |
|
||||
| `router` fixture / MSW handlers in Node | `page.route()` in the test, or MSW `setupWorker` inside a story/decorator |
|
||||
| `playwright/index.html` (styles, fonts, theme) | The gallery's `index.html` / entry module imports |
|
||||
| `ctViteConfig`, `ctPort`, `ctTemplateDir`, `ctCacheDir` | Gone — the gallery runs through the app's own dev server; port lives in `webServer` + `baseURL`; location is `playwright/gallery/` |
|
||||
| `defineConfig` from `@playwright/experimental-ct-react` | Plain `defineConfig` from `@playwright/test`, with `baseURL` = gallery URL, `serviceWorkers: 'block'`, `reuseContext: true` (see `SKILL.md`) |
|
||||
|
||||
The CT packages were removed in Playwright 1.63 and are no longer published, so migrate while
|
||||
pinned to Playwright 1.62 and upgrade once the last spec is ported.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Set up the gallery and config per `SKILL.md`. Keep the old CT project running until the last
|
||||
spec is migrated.
|
||||
2. For each CT spec, split every `mount(<…/>)` call: JSX structure becomes a story export next to
|
||||
the component; plain data props stay in the test as `mount`'s second argument. Callback spies
|
||||
become story state recorded into a hidden form. A call site that only varies data props usually
|
||||
needs just one generic story that spreads them:
|
||||
`export const Default = (props: ButtonProps) => <Button title="Submit" {...props} />`.
|
||||
3. Rewrite the spec: import `test`/`expect` from `@playwright/test`; `mount(<X a={1}/>)`
|
||||
→ `mount('X/Default', { a: 1 })`; `update(<X a={2}/>)` → `update({ a: 2 })`;
|
||||
`unmount()` unchanged. `mount` returns a locator for the gallery root — scope the queries:
|
||||
`component.getByRole('button').click()`. Spy assertions become `toHaveValue()` on the story's
|
||||
recorded state.
|
||||
4. Port `beforeMount` hooks: app-wide setup into the gallery's `window.mount`; per-test
|
||||
`hooksConfig` branches into props interpreted by a story or decorator.
|
||||
5. When all specs are green, delete the CT project from the config, drop the
|
||||
`@playwright/experimental-ct-*` dependency, remove `playwright/index.html`,
|
||||
`playwright/index.ts*` and `playwright/.cache`, and lift the Playwright version pin.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Story ids are strings.** Renaming or moving a story breaks specs at runtime, not compile time
|
||||
— and the suffix-matching resolution can silently match a different story after a rename.
|
||||
Gallery types (`typing.md`) turn registered ids into a compile-time check.
|
||||
- **Per-test JSX is gone.** Any test that built a different JSX tree per test (children matrices,
|
||||
inline wrappers) becomes one story export per composition.
|
||||
|
||||
## Before / after
|
||||
|
||||
```tsx
|
||||
// Before (CT)
|
||||
import { test, expect } from '@playwright/experimental-ct-react';
|
||||
import Button from '../src/components/Button';
|
||||
|
||||
test('click', async ({ mount }) => {
|
||||
const messages: string[] = [];
|
||||
const component = await mount(<Button title="Submit" onClick={data => messages.push(data)} />);
|
||||
await component.click();
|
||||
expect(messages).toEqual(['hello']);
|
||||
});
|
||||
```
|
||||
|
||||
```tsx
|
||||
// After: src/components/Button.story.tsx
|
||||
import Button from './Button';
|
||||
|
||||
export const Default = (props: { onClick?: (data: string) => void }) =>
|
||||
<Button title="Submit" {...props} />;
|
||||
```
|
||||
|
||||
```ts
|
||||
// After: src/components/Button.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('click', async ({ mount }) => {
|
||||
const messages: string[] = [];
|
||||
const component = await mount('components/Button/Default', { onClick: (data: string) => messages.push(data) });
|
||||
await component.click();
|
||||
expect(messages).toEqual(['hello']);
|
||||
});
|
||||
```
|
||||
Generated
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
# React setup
|
||||
|
||||
Follow the setup workflow in `SKILL.md`. Implement the gallery per `references/gallery-spec.md` (which has a React worked example); this page covers React-specific details.
|
||||
|
||||
## Files
|
||||
|
||||
- `playwright/gallery/` — the gallery you implement to `references/gallery-spec.md` (an `index.html` + a `main.tsx` module). Requires `react` and `react-dom` 18+ (`createRoot`).
|
||||
- Stories: `src/**/*.story.tsx` (the glob also picks up `.story.jsx`); example in `templates/react/Button.story.tsx`.
|
||||
|
||||
## StrictMode
|
||||
|
||||
Wrap the rendered story in `<React.StrictMode>` in your gallery to match how most apps render. In development builds StrictMode intentionally double-invokes render functions and effects. This matters for stories that record events with counters set in effects — recording via state updates from event handlers (like the `CountsClicks` example story) is unaffected. If a story misbehaves under StrictMode, that is usually a real finding about the component; drop the wrapper only if the app itself does not use StrictMode.
|
||||
|
||||
## Global providers
|
||||
|
||||
If components require context (theme, store, i18n, router), create one shared decorator and use it in stories, so each story states its scenario and nothing more:
|
||||
|
||||
```tsx
|
||||
// src/stories/decorators.tsx
|
||||
import { ThemeProvider } from '../theme';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
export function AppScaffold({ children, route = '/' }: { children: React.ReactNode, route?: string }) {
|
||||
return (
|
||||
<ThemeProvider theme="light">
|
||||
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// src/components/ProfilePage.story.tsx
|
||||
export const LoggedIn = () => (
|
||||
<AppScaffold route="/profile/42">
|
||||
<ProfilePage user={{ id: 42, name: 'Test User' }} />
|
||||
</AppScaffold>
|
||||
);
|
||||
```
|
||||
|
||||
Do not build the decorator into the gallery — keeping it in story files makes the wrapping visible and lets stories opt out.
|
||||
|
||||
## Typed props
|
||||
|
||||
`mount` is generic over the story: pass the story type as a template argument to type-check per-test props (and `update()`). Props are inferred from the component signature; function and class components both work.
|
||||
|
||||
```tsx
|
||||
// src/components/Button.story.tsx
|
||||
export const WithTitle = ({ title = 'Default' }: { title?: string }) =>
|
||||
<Button title={title} />;
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/components/button.spec.ts
|
||||
import type { WithTitle } from './Button.story';
|
||||
|
||||
const component = await mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' });
|
||||
```
|
||||
|
||||
Alternatively, generate gallery types so the id itself is typed and no type import is needed: `mount('acme-ui/components/Button/WithTitle', { title: 'Hello' })` — see `references/typing.md`.
|
||||
|
||||
## CSS
|
||||
|
||||
- Global stylesheets: import them in your gallery entry (`playwright/gallery/main.tsx`, e.g. `import '../../src/index.css'`), mirroring the app's own entry point.
|
||||
- Tailwind: if content scanning is path-based, make sure `*.story.tsx` files are covered.
|
||||
|
||||
## Data fetching
|
||||
|
||||
For libraries with client objects (React Query, Apollo), create the client inside the story or decorator so each navigation starts fresh.
|
||||
Generated
Vendored
+173
@@ -0,0 +1,173 @@
|
||||
# Typing `mount()`
|
||||
|
||||
`mount(storyId, props?)` accepts any string id and any serializable props. Two optional layers
|
||||
add type checking on top; both are supported and can be mixed in one project.
|
||||
|
||||
| | Explicit story type | Gallery types |
|
||||
|---|---|---|
|
||||
| Call | `mount<typeof WithTitle>('components/Button/WithTitle', { title })` | `mount('acme-ui/components/Button/WithTitle', { title })` |
|
||||
| Checks | props and `update()` | props, `update()`, and the id itself (autocomplete, rename safety) |
|
||||
| Needs | `import type { WithTitle } from './Button.story'` in the spec | a generated `stories.d.ts` kept in sync by a gallery plugin |
|
||||
| Id grammar | any suffix the gallery resolves | full id, prefixed with the package name |
|
||||
|
||||
## Explicit story type
|
||||
|
||||
Pass the story type as a template argument. Nothing to set up; works in every project.
|
||||
|
||||
```ts
|
||||
import type { WithTitle } from './Button.story';
|
||||
|
||||
const component = await mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' });
|
||||
await component.update({ title: 'Again' });
|
||||
```
|
||||
|
||||
Use `import type` so the story (and with it React/Vue and CSS imports) is never loaded into the
|
||||
Node test process. Function components, class components and `defineComponent` stories all infer
|
||||
their props; see the `Typed props` sections in `react.md` / `vue.md` for framework details.
|
||||
|
||||
## Gallery types
|
||||
|
||||
`@playwright/test` exports an empty `interface Stories {}`. When a story id is a key of `Stories`,
|
||||
`mount` types the props from that entry; any other string keeps untyped props, so adoption can be
|
||||
partial. A tiny Vite plugin next to the gallery generates `stories.d.ts`, which fills `Stories` in
|
||||
via module augmentation:
|
||||
|
||||
```ts
|
||||
// playwright/gallery/stories.d.ts (generated)
|
||||
type StoriesOf<Prefix extends string, Mod> = { [K in keyof Mod & string as `${Prefix}/${K}`]: Mod[K] };
|
||||
|
||||
declare module '@playwright/test' {
|
||||
interface Stories extends
|
||||
StoriesOf<'acme-ui/components/Button', typeof import('../../src/components/Button.story')>,
|
||||
StoriesOf<'acme-ui/components/Expandable', typeof import('../../src/components/Expandable.story')> {}
|
||||
}
|
||||
|
||||
export {};
|
||||
```
|
||||
|
||||
One line per story file; export names and prop types come from the story module itself, so the
|
||||
file only changes when a story file is added, removed or moved. Specs then need no type imports:
|
||||
|
||||
```ts
|
||||
const component = await mount('acme-ui/components/Button/WithTitle', { title: 'Hello' });
|
||||
// ^ autocompletes registered ids ^ checked against WithTitle's props
|
||||
```
|
||||
|
||||
### Namespace ids with the package name
|
||||
|
||||
`Stories` is merged per TypeScript program. In a monorepo whose packages share one `tsconfig`,
|
||||
two packages both registering `components/Button/Default` with different props fail with
|
||||
`TS2320: Interface 'Stories' cannot simultaneously extend types ...` at the `Stories` declaration,
|
||||
and even distinct ids leak into each other's autocomplete. Prefix every id with the package name,
|
||||
`<package>/<path under src>/<Export>`, from the start, so the convention holds when a project
|
||||
becomes a package. Read the name from `package.json` in both places that derive ids, so they
|
||||
cannot drift:
|
||||
|
||||
```ts
|
||||
// playwright/gallery/main.tsx
|
||||
import packageJSON from '../../package.json';
|
||||
|
||||
const id = (f: string) => packageJSON.name + '/' + f.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
|
||||
```
|
||||
|
||||
Suffix resolution keeps working at runtime, so `mount('Button/Primary')` still renders; only the
|
||||
full prefixed id is typed.
|
||||
|
||||
### The plugin
|
||||
|
||||
Framework-agnostic: it only lists story files. Adjust `storyFile` to match the gallery's glob.
|
||||
|
||||
```ts
|
||||
// playwright/gallery/storyTypes.ts
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
const storyFile = /\.story\.(tsx|jsx)$/;
|
||||
|
||||
export function storyTypes(options: { prefix: string, src: string, outFile: string }): Plugin {
|
||||
const generate = () => {
|
||||
const content = render(listStoryFiles(options.src), options);
|
||||
if (fs.existsSync(options.outFile) && fs.readFileSync(options.outFile, 'utf8') === content)
|
||||
return;
|
||||
fs.writeFileSync(options.outFile, content);
|
||||
};
|
||||
return {
|
||||
name: 'story-types',
|
||||
buildStart() {
|
||||
this.addWatchFile(options.src);
|
||||
generate();
|
||||
},
|
||||
configureServer(server) {
|
||||
const onFile = (file: string) => {
|
||||
if (storyFile.test(file))
|
||||
generate();
|
||||
};
|
||||
server.watcher.on('add', onFile);
|
||||
server.watcher.on('unlink', onFile);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function listStoryFiles(dir: string): string[] {
|
||||
const result: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const file = path.join(dir, entry.name);
|
||||
if (entry.isDirectory())
|
||||
result.push(...listStoryFiles(file));
|
||||
else if (storyFile.test(entry.name))
|
||||
result.push(file);
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
function render(files: string[], { prefix, src, outFile }: { prefix: string, src: string, outFile: string }): string {
|
||||
const outDir = path.dirname(outFile);
|
||||
const posix = (p: string) => p.split(path.sep).join('/');
|
||||
const entries = files.map(file => {
|
||||
const id = prefix + '/' + posix(path.relative(src, file)).replace(storyFile, '');
|
||||
const specifier = posix(path.relative(outDir, file)).replace(/\.\w+$/, '');
|
||||
return ` StoriesOf<'${id}', typeof import('${specifier.startsWith('.') ? specifier : './' + specifier}')>`;
|
||||
});
|
||||
const stories = entries.length ? `interface Stories extends\n${entries.join(',\n')} {}` : 'interface Stories {}';
|
||||
return `// Generated by storyTypes.ts, do not edit.
|
||||
|
||||
type StoriesOf<Prefix extends string, Mod> = { [K in keyof Mod & string as \`\${Prefix}/\${K}\`]: Mod[K] };
|
||||
|
||||
declare module '@playwright/test' {
|
||||
${stories}
|
||||
}
|
||||
|
||||
export {};
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
Register it on whichever Vite server serves the gallery (the app's `vite.config.ts`, or the
|
||||
standalone `playwright/vite.config.ts`):
|
||||
|
||||
```ts
|
||||
import packageJSON from './package.json';
|
||||
import { storyTypes } from './playwright/gallery/storyTypes';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
storyTypes({
|
||||
prefix: packageJSON.name,
|
||||
src: path.resolve(__dirname, 'src'),
|
||||
outFile: path.resolve(__dirname, 'playwright/gallery/stories.d.ts'),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
The file is regenerated on every dev-server start and build, and while the server runs, whenever
|
||||
a story file appears or disappears. Its content is deterministic, so **commit it**: type-checking
|
||||
in CI or an editor must not depend on a running dev server. Nothing imports the file, so list it
|
||||
explicitly in the project's `tsconfig` (`"files": ["playwright/gallery/stories.d.ts"]`) so that
|
||||
`tsc` and editors load it.
|
||||
|
||||
For `.story.vue` single-file components, map the `default` export to the bare path in
|
||||
`StoriesOf` (`K extends 'default' ? Prefix : \`${Prefix}/${K}\``) and rely on `vue-tsc` for the
|
||||
SFC's types; a story without inferable props types as `unknown` props, which accepts anything.
|
||||
Generated
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
# Vue setup
|
||||
|
||||
Follow the setup workflow in `SKILL.md`. Implement the gallery per `references/gallery-spec.md`, exposing `window.mount`/`window.unmount` — in Vue, mount with `app = createApp(h(story, props)); app.mount('#root')` and unmount with `app.unmount()`; this page covers Vue-specific details.
|
||||
|
||||
## Files
|
||||
|
||||
- `playwright/gallery/` — the gallery you implement to `references/gallery-spec.md` (an `index.html` + a `main.ts` module). Requires Vue 3.
|
||||
- Stories: `src/**/*.story.{ts,js,vue}`; example in `templates/vue/Button.story.ts`.
|
||||
|
||||
## Two ways to write stories
|
||||
|
||||
**Render-function stories** (`Button.story.ts`) — several scenarios per file, one named export each; see `templates/vue/Button.story.ts`. Uses `defineComponent` + `h()`, no SFC compilation involved.
|
||||
|
||||
**Single-file-component stories** (`Button.primary.story.vue`) — one story per file, full template syntax:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import Button from './Button.vue';
|
||||
const clicks = ref(0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button title="Submit" @click="clicks++" />
|
||||
<form hidden><input data-testid="click-count" readonly :value="String(clicks)" /></form>
|
||||
</template>
|
||||
```
|
||||
|
||||
An SFC story is addressed by its path without the extension: `mount('components/Button.primary')`. Prefer SFC stories when the scenario needs slots or non-trivial templates.
|
||||
|
||||
## Global plugins
|
||||
|
||||
Apps that rely on plugins (Pinia, vue-router, i18n) should wrap components with a decorator story helper that creates a fresh instance per story:
|
||||
|
||||
```ts
|
||||
// src/stories/decorators.ts
|
||||
import { defineComponent, h, type Component } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
export function withStore(story: Component) {
|
||||
return defineComponent(() => {
|
||||
const pinia = createPinia();
|
||||
return () => h(story, { pinia });
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
For plugins that must be installed on the app instance (`app.use(...)`), add them in your gallery right after `createApp(...)` — that is the equivalent of the app's own bootstrap.
|
||||
|
||||
## Typed props
|
||||
|
||||
A story that takes per-test props declares them twice: in the setup signature (for the type) and in the `props` option (so Vue delivers them as props rather than `attrs`):
|
||||
|
||||
```ts
|
||||
// src/components/Button.story.ts
|
||||
export const WithTitle = defineComponent(
|
||||
(props: { title?: string }) => () => h(Button, { title: props.title ?? 'Default' }),
|
||||
{ props: ['title'] },
|
||||
);
|
||||
```
|
||||
|
||||
`mount` is generic over the story: pass the story type as a template argument to type-check the props (and `update()`):
|
||||
|
||||
```ts
|
||||
// src/components/button.spec.ts
|
||||
import type { WithTitle } from './Button.story';
|
||||
|
||||
const component = await mount<typeof WithTitle>('components/Button/WithTitle', { title: 'Hello' });
|
||||
```
|
||||
|
||||
Alternatively, generate gallery types so the id itself is typed and no type import is needed: `mount('acme-ui/components/Button/WithTitle', { title: 'Hello' })` — see `references/typing.md`.
|
||||
|
||||
Options-API stories (`defineComponent({ props: { ... } })`) infer props the same way. For `.story.vue` SFC stories, prop types are only inferable when the setup generates SFC types (Volar/vue-tsc); otherwise pass the props type directly: `mount<{ title?: string }>('components/Button.primary', { title: 'Hello' })`.
|
||||
|
||||
## CSS
|
||||
|
||||
Import global stylesheets in your gallery entry (`playwright/gallery/main.ts`, e.g. `import '../../src/assets/main.css'`), mirroring the app's entry point.
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
---
|
||||
name: playwright-trace
|
||||
description: Inspect Playwright trace files from the command line — list actions, view requests, console, errors, snapshots and screenshots.
|
||||
allowed-tools: Bash(npx:*)
|
||||
---
|
||||
|
||||
# Playwright Trace CLI
|
||||
|
||||
Inspect `.zip` trace files produced by Playwright tests without opening a browser.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Start with `trace open <trace.zip>` to extract the trace and see its metadata.
|
||||
2. Use `trace actions` to see all actions with their action IDs.
|
||||
3. Use `trace action <action-id>` to drill into a specific action — see parameters, logs, source location, and available snapshots.
|
||||
4. Use `trace requests`, `trace console`, or `trace errors` for cross-cutting views.
|
||||
5. Use `trace snapshot <action-id>` to get the DOM snapshot, or run a browser command against it.
|
||||
6. Use `trace close` to remove the extracted trace data when done.
|
||||
|
||||
All commands after `open` operate on the currently opened trace — no need to pass the trace file again. Opening a new trace replaces the previous one.
|
||||
|
||||
## Commands
|
||||
|
||||
### Open a trace
|
||||
|
||||
```bash
|
||||
# Extract trace and show metadata: browser, viewport, duration, action/error counts
|
||||
npx playwright trace open <trace.zip>
|
||||
```
|
||||
|
||||
### Close a trace
|
||||
|
||||
```bash
|
||||
# Remove extracted trace data
|
||||
npx playwright trace close
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
```bash
|
||||
# List all actions as a tree with action IDs and timing
|
||||
npx playwright trace actions
|
||||
|
||||
# Filter by action title (regex, case-insensitive)
|
||||
npx playwright trace actions --grep "click"
|
||||
|
||||
# Only failed actions
|
||||
npx playwright trace actions --errors-only
|
||||
```
|
||||
|
||||
### Action details
|
||||
|
||||
```bash
|
||||
# Show full details for one action: params, result, logs, source, snapshots
|
||||
npx playwright trace action <action-id>
|
||||
```
|
||||
|
||||
The `action` command displays available snapshot phases (before, action, after) and the exact command to extract them.
|
||||
|
||||
### Requests
|
||||
|
||||
```bash
|
||||
# All network requests: start time (on the `trace actions` clock), method, status, URL, duration, size
|
||||
npx playwright trace requests
|
||||
|
||||
# Filter by URL pattern
|
||||
npx playwright trace requests --grep "api"
|
||||
|
||||
# Filter by HTTP method
|
||||
npx playwright trace requests --method POST
|
||||
|
||||
# Only failed requests (status >= 400)
|
||||
npx playwright trace requests --failed
|
||||
```
|
||||
|
||||
### Request details
|
||||
|
||||
```bash
|
||||
# Show full details for one request: headers, body, security
|
||||
npx playwright trace request <request-id>
|
||||
```
|
||||
|
||||
### Console
|
||||
|
||||
```bash
|
||||
# All console messages and stdout/stderr
|
||||
npx playwright trace console
|
||||
|
||||
# Only errors
|
||||
npx playwright trace console --errors-only
|
||||
|
||||
# Only browser console (no stdout/stderr)
|
||||
npx playwright trace console --browser
|
||||
|
||||
# Only stdout/stderr (no browser console)
|
||||
npx playwright trace console --stdio
|
||||
|
||||
# Filter by message text pattern
|
||||
npx playwright trace console --grep "failed to fetch"
|
||||
```
|
||||
|
||||
### Errors
|
||||
|
||||
```bash
|
||||
# All errors with stack traces and associated actions
|
||||
npx playwright trace errors
|
||||
```
|
||||
|
||||
### Snapshots
|
||||
|
||||
The `snapshot` command loads the DOM snapshot for an action into a headless browser and runs a single browser command against it. Without a browser command, it returns the accessibility snapshot.
|
||||
|
||||
```bash
|
||||
# Get the accessibility snapshot (default)
|
||||
npx playwright trace snapshot <action-id>
|
||||
|
||||
# Use a specific phase
|
||||
npx playwright trace snapshot <action-id> --phase before
|
||||
|
||||
# Run eval to query the DOM
|
||||
npx playwright trace snapshot <action-id> -- eval "document.title"
|
||||
npx playwright trace snapshot <action-id> -- eval "document.querySelector('#error').textContent"
|
||||
|
||||
# Eval on a specific element ref (from the snapshot)
|
||||
npx playwright trace snapshot <action-id> -- eval "el => el.getAttribute('data-testid')" e5
|
||||
|
||||
# Take a screenshot of the snapshot
|
||||
npx playwright trace snapshot <action-id> -- screenshot
|
||||
|
||||
# Redirect output to a file
|
||||
npx playwright trace snapshot <action-id> -- eval "document.body.outerHTML" --filename=page.html
|
||||
npx playwright trace snapshot <action-id> -- screenshot --filename=screenshot.png
|
||||
```
|
||||
|
||||
Only three browser commands are useful on a frozen snapshot: `snapshot`, `eval`, and `screenshot`.
|
||||
|
||||
### Attachments
|
||||
|
||||
```bash
|
||||
# List all trace attachments
|
||||
npx playwright trace attachments
|
||||
|
||||
# Extract an attachment by its number
|
||||
npx playwright trace attachment 1
|
||||
npx playwright trace attachment 1 -o out.png
|
||||
```
|
||||
|
||||
## Typical investigation
|
||||
|
||||
```bash
|
||||
# 1. Open the trace and see what's inside
|
||||
npx playwright trace open test-results/my-test/trace.zip
|
||||
|
||||
# 2. What actions ran?
|
||||
npx playwright trace actions
|
||||
|
||||
# 3. Which action failed?
|
||||
npx playwright trace actions --errors-only
|
||||
|
||||
# 4. What went wrong?
|
||||
npx playwright trace action 12
|
||||
|
||||
# 5. What did the page look like at that moment?
|
||||
npx playwright trace snapshot 12
|
||||
|
||||
# 6. Query the DOM for more detail
|
||||
npx playwright trace snapshot 12 -- eval "document.querySelector('.error-message').textContent"
|
||||
|
||||
# 7. Any relevant network failures?
|
||||
npx playwright trace requests --failed
|
||||
|
||||
# 8. Any console errors?
|
||||
npx playwright trace console --errors-only
|
||||
```
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
"use strict";
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var extension_exports = {};
|
||||
__export(extension_exports, {
|
||||
findPlaywrightExtensionProfile: () => findPlaywrightExtensionProfile,
|
||||
isExtensionInstalledInProfile: () => isExtensionInstalledInProfile,
|
||||
isPlaywrightExtensionInstalled: () => isPlaywrightExtensionInstalled,
|
||||
playwrightExtensionId: () => playwrightExtensionId,
|
||||
playwrightExtensionInstallUrl: () => playwrightExtensionInstallUrl
|
||||
});
|
||||
module.exports = __toCommonJS(extension_exports);
|
||||
var import_fs = __toESM(require("fs"));
|
||||
var import_path = __toESM(require("path"));
|
||||
const playwrightExtensionId = "mmlmfjhmonkocbjadbfplnigmagldckm";
|
||||
const playwrightExtensionInstallUrl = `https://chromewebstore.google.com/detail/playwright-extension/${playwrightExtensionId}`;
|
||||
async function findPlaywrightExtensionProfile(userDataDir) {
|
||||
const profiles = await listProfileDirectories(userDataDir);
|
||||
const lastUsed = await readLastUsedProfile(userDataDir);
|
||||
const ordered = lastUsed && profiles.includes(lastUsed) ? [lastUsed, ...profiles.filter((profile) => profile !== lastUsed)] : profiles;
|
||||
for (const profile of ordered) {
|
||||
if (await isExtensionInstalledInProfile(import_path.default.join(userDataDir, profile)))
|
||||
return profile;
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
async function listProfileDirectories(userDataDir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await import_fs.default.promises.readdir(userDataDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const profiles = entries.filter((entry) => entry === "Default" || /^Profile \d+$/.test(entry));
|
||||
profiles.sort((a, b) => profileRank(a) - profileRank(b));
|
||||
return profiles;
|
||||
}
|
||||
function profileRank(profile) {
|
||||
return profile === "Default" ? -1 : parseInt(profile.slice("Profile ".length), 10);
|
||||
}
|
||||
async function readLastUsedProfile(userDataDir) {
|
||||
try {
|
||||
const localState = JSON.parse(await import_fs.default.promises.readFile(import_path.default.join(userDataDir, "Local State"), "utf-8"));
|
||||
const lastUsed = localState?.profile?.last_used;
|
||||
return typeof lastUsed === "string" ? lastUsed : void 0;
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
}
|
||||
async function isPlaywrightExtensionInstalled(userDataDir) {
|
||||
return await findPlaywrightExtensionProfile(userDataDir) !== void 0;
|
||||
}
|
||||
async function isExtensionInstalledInProfile(profileDir) {
|
||||
if (await pathExists(import_path.default.join(profileDir, "Extensions", playwrightExtensionId)))
|
||||
return true;
|
||||
for (const fileName of ["Preferences", "Secure Preferences"]) {
|
||||
if (await hasExtensionSettingsRecord(import_path.default.join(profileDir, fileName)))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
async function hasExtensionSettingsRecord(prefsPath) {
|
||||
let prefs;
|
||||
try {
|
||||
prefs = JSON.parse(await import_fs.default.promises.readFile(prefsPath, "utf-8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const record = prefs?.extensions?.settings?.[playwrightExtensionId];
|
||||
return !!record && typeof record === "object" && Object.keys(record).length > 0;
|
||||
}
|
||||
async function pathExists(p) {
|
||||
try {
|
||||
await import_fs.default.promises.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
findPlaywrightExtensionProfile,
|
||||
isExtensionInstalledInProfile,
|
||||
isPlaywrightExtensionInstalled,
|
||||
playwrightExtensionId,
|
||||
playwrightExtensionInstallUrl
|
||||
});
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"use strict";
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
var socketConnection_exports = {};
|
||||
__export(socketConnection_exports, {
|
||||
SocketConnection: () => SocketConnection,
|
||||
compareSemver: () => compareSemver
|
||||
});
|
||||
module.exports = __toCommonJS(socketConnection_exports);
|
||||
class SocketConnection {
|
||||
constructor(socket) {
|
||||
this._pendingBuffers = [];
|
||||
this._socket = socket;
|
||||
socket.on("data", (buffer) => this._onData(buffer));
|
||||
socket.on("close", () => {
|
||||
this.onclose?.();
|
||||
});
|
||||
socket.on("error", (e) => console.error(`error: ${e.message}`));
|
||||
}
|
||||
async send(message) {
|
||||
await new Promise((resolve, reject) => {
|
||||
this._socket.write(`${JSON.stringify(message)}
|
||||
`, (error) => {
|
||||
if (error)
|
||||
reject(error);
|
||||
else
|
||||
resolve(void 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
close() {
|
||||
this._socket.destroy();
|
||||
}
|
||||
_onData(buffer) {
|
||||
let end = buffer.indexOf("\n");
|
||||
if (end === -1) {
|
||||
this._pendingBuffers.push(buffer);
|
||||
return;
|
||||
}
|
||||
this._pendingBuffers.push(buffer.slice(0, end));
|
||||
const message = Buffer.concat(this._pendingBuffers).toString();
|
||||
this._dispatchMessage(message);
|
||||
let start = end + 1;
|
||||
end = buffer.indexOf("\n", start);
|
||||
while (end !== -1) {
|
||||
const message2 = buffer.toString(void 0, start, end);
|
||||
this._dispatchMessage(message2);
|
||||
start = end + 1;
|
||||
end = buffer.indexOf("\n", start);
|
||||
}
|
||||
this._pendingBuffers = [buffer.slice(start)];
|
||||
}
|
||||
_dispatchMessage(message) {
|
||||
try {
|
||||
this.onmessage?.(JSON.parse(message));
|
||||
} catch (e) {
|
||||
console.error("failed to dispatch message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
function compareSemver(a, b) {
|
||||
const aBase = a.replace(/-.*$/, "");
|
||||
const bBase = b.replace(/-.*$/, "");
|
||||
const aParts = aBase.split(".").map(Number);
|
||||
const bParts = bBase.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (aParts[i] > bParts[i])
|
||||
return 1;
|
||||
if (aParts[i] < bParts[i])
|
||||
return -1;
|
||||
}
|
||||
const aTimestamp = parseSuffixTimestamp(a);
|
||||
const bTimestamp = parseSuffixTimestamp(b);
|
||||
if (aTimestamp > bTimestamp)
|
||||
return 1;
|
||||
if (aTimestamp < bTimestamp)
|
||||
return -1;
|
||||
return 0;
|
||||
}
|
||||
function parseSuffixTimestamp(version) {
|
||||
const match = version.match(/^\d+\.\d+\.\d+-(?:alpha|beta)-(.+)$/);
|
||||
if (!match)
|
||||
return Infinity;
|
||||
const suffix = match[1];
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(suffix))
|
||||
return new Date(suffix).getTime();
|
||||
return Number(suffix);
|
||||
}
|
||||
// Annotate the CommonJS export names for ESM import in node:
|
||||
0 && (module.exports = {
|
||||
SocketConnection,
|
||||
compareSemver
|
||||
});
|
||||
Reference in New Issue
Block a user