playwright-interactive — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited playwright-interactive (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Use a persistent Playwright js_repl session to debug local web or Electron apps, keep the same handles between iterations, and run functional and visual QA without restarting the entire toolchain unless process ownership has actually changed.
js_repl must be enabled for this skill.js_repl is missing, enable it in ~/.codex/config.toml:[features]
js_repl = true--enable js_repl (equivalent to -c features.js_repl=true).js_repl, start a new Codex session so the tool list refreshes.--sandbox danger-full-access (or the equivalent configuration for sandbox_mode=danger-full-access). This is a temporary requirement while js_repl + Playwright support inside the sandbox is still being finalized.js_repl_reset as a recovery tool, not routine cleanup. Resetting the kernel destroys your Playwright handles.test -f package.json || npm init -y
npm install playwright
# Web-only, for headed Chromium or mobile emulation:
# npx playwright install chromium
# Electron-only, and only if the target workspace is the app itself:
# npm install --save-dev electron
node -e "import('playwright').then(() => console.log('playwright import ok')).catch((error) => { console.error(error); process.exit(1); })"If you later switch to a different workspace, repeat setup there.
var chromium;
var electronLauncher;
var browser;
var context;
var page;
var mobileContext;
var mobilePage;
var electronApp;
var appWindow;
try {
({ chromium, _electron: electronLauncher } = await import("playwright"));
console.log("Playwright loaded");
} catch (error) {
throw new Error(
`Could not load playwright from the current js_repl cwd. Run the setup commands from this workspace first. Original error: ${error}`
);
}Binding rules:
var for shared top-level Playwright handles, because subsequent js_repl cells reuse them.undefined and re-run the cell rather than scattering recovery logic everywhere.page, mobilePage, appWindow) rather than re-discovering pages from a context.Shared web helpers:
var resetWebHandles = function () {
context = undefined;
page = undefined;
mobileContext = undefined;
mobilePage = undefined;
};
var ensureWebBrowser = async function () {
if (browser && !browser.isConnected()) {
browser = undefined;
resetWebHandles();
}
browser ??= await chromium.launch({ headless: false });
return browser;
};
var reloadWebContexts = async function () {
for (const currentContext of [context, mobileContext]) {
if (!currentContext) continue;
for (const p of currentContext.pages()) {
await p.reload({ waitUntil: "domcontentloaded" });
}
}
console.log("Reloaded existing web tabs");
};For web apps, default to an explicit viewport, and treat native window mode as a separate validation pass.
deviceScaleFactor, rather than switching to native window mode.viewport: null) for a separate headed pass when you need to verify launch window size, OS-level DPI behavior, browser chrome interactions, or bugs that may depend on host display configuration.noDefaultViewport, so treat it as a real desktop window and verify size and layout at launch before changing anything.context for a native-window pass or vice versa; close the old page and context, then create new ones for the new mode.Desktop and mobile web sessions share the same browser, helpers, and QA flow. The main difference is which context and page pair you create.
Set TARGET_URL for the application you are debugging. For local servers, prefer 127.0.0.1 over localhost.
var TARGET_URL = "http://127.0.0.1:3000";
if (page?.isClosed()) page = undefined;
await ensureWebBrowser();
context ??= await browser.newContext({
viewport: { width: 1600, height: 900 },
});
page ??= await context.newPage();
await page.goto(TARGET_URL, { waitUntil: "domcontentloaded" });
console.log("Loaded:", await page.title());If context or page are stale, set context = page = undefined and re-run the cell.
Reuse TARGET_URL if it already exists; otherwise specify the mobile target directly.
var MOBILE_TARGET_URL = typeof TARGET_URL === "string"
? TARGET_URL
: "http://127.0.0.1:3000";
if (mobilePage?.isClosed()) mobilePage = undefined;
await ensureWebBrowser();
mobileContext ??= await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
mobilePage ??= await mobileContext.newPage();
await mobilePage.goto(MOBILE_TARGET_URL, { waitUntil: "domcontentloaded" });
console.log("Loaded mobile:", await mobilePage.title());If mobileContext or mobilePage are stale, set mobileContext = mobilePage = undefined and re-run the cell.
var TARGET_URL = "http://127.0.0.1:3000";
await ensureWebBrowser();
await page?.close().catch(() => {});
await context?.close().catch(() => {});
page = undefined;
context = undefined;
browser ??= await chromium.launch({ headless: false });
context = await browser.newContext({ viewport: null });
page = await context.newPage();
await page.goto(TARGET_URL, { waitUntil: "domcontentloaded" });
console.log("Loaded native window:", await page.title());Set ELECTRON_ENTRY to . when the current workspace is the Electron app itself and package.json points main at the correct entry file. If you need to target a specific main-process file directly, use a path such as ./main.js.
var ELECTRON_ENTRY = ".";
if (appWindow?.isClosed()) appWindow = undefined;
if (!appWindow && electronApp) {
await electronApp.close().catch(() => {});
electronApp = undefined;
}
electronApp ??= await electronLauncher.launch({
args: [ELECTRON_ENTRY],
});
appWindow ??= await electronApp.firstWindow();
console.log("Loaded Electron window:", await appWindow.title());If js_repl is not running from the Electron app workspace, pass cwd explicitly at launch.
If the application process looks stale, set electronApp = appWindow = undefined and re-run the cell.
If you already have an Electron session but need a fresh process after changing main-process, preload, or startup code, use the restart cell in the next section instead of re-running this one.
Keep the same session alive whenever possible.
Reload the web renderer:
await reloadWebContexts();Reload only the Electron renderer:
await appWindow.reload({ waitUntil: "domcontentloaded" });
console.log("Reloaded Electron window");Restart Electron after main-process, preload, or startup changes:
await electronApp.close().catch(() => {});
electronApp = undefined;
appWindow = undefined;
electronApp = await electronLauncher.launch({
args: [ELECTRON_ENTRY],
});
appWindow = await electronApp.firstWindow();
console.log("Relaunched Electron window:", await appWindow.title());If launch requires an explicit cwd, include the same cwd here.
Default behavior:
js_repl cell short and focused on a single burst of interaction.browser, context, page, electronApp, appWindow) rather than redeclaring them.electronApp.evaluate(...) only for main-process inspection or specialized diagnostics.js_repl bootstrap once, then keep the same Playwright handles alive between iterations.page.evaluate(...) and electronApp.evaluate(...) may inspect or set up state, but do not count as input for signoff.If you plan to deliver a screenshot through codex.emitImage(...), default to the CSS-normalized paths in the next section. These are the canonical examples for screenshots that will be interpreted by the model or used for subsequent coordinate-based actions. Keep raw captures as an exception only for precision-sensitive debugging; raw screenshot exception examples appear after the normalization guidance.
If you will deliver a screenshot through codex.emitImage(...) for model interpretation, normalize it to CSS pixels for the exact region you captured before delivery. This keeps any returned coordinates aligned with Playwright's CSS pixels if the response is later used for clicks, and reduces image payload size and model token cost.
Do not deliver raw native-window screenshots by default. Skip normalization only when you explicitly need device-pixel accuracy, e.g., for debugging Retina or DPI artifacts, inspecting pixel-accurate rendering, or another precision-sensitive case where raw pixels matter more than payload size. For local inspection that will not be delivered to the model, a raw capture is fine.
Do not assume page.screenshot({ scale: "css" }) is sufficient in native window mode (viewport: null). On Chromium with macOS Retina displays, headed native-window screenshots can still come back at device-pixel size even when scale: "css" is requested. The same caveat applies to Electron windows launched through Playwright, because Electron operates with noDefaultViewport, and appWindow.screenshot({ scale: "css" }) may still return device-pixel output.
Use separate normalization paths for web pages and Electron windows:
page.screenshot({ scale: "css" }) directly. If a native-window Chromium still returns device-pixel output, resize inside the current page through canvas; a scratch page is not required.appWindow.context().newPage() or electronApp.context().newPage() as a scratch page. Electron contexts do not reliably support that path. Capture in the main process via BrowserWindow.capturePage(...), resize via nativeImage.resize(...), and deliver those bytes directly.Common helpers and conventions:
var emitJpeg = async function (bytes) {
await codex.emitImage({
bytes,
mimeType: "image/jpeg",
detail: "original",
});
};
var emitWebJpeg = async function (surface, options = {}) {
await emitJpeg(await surface.screenshot({
type: "jpeg",
quality: 85,
scale: "css",
...options,
}));
};
var clickCssPoint = async function ({ surface, x, y, clip }) {
await surface.mouse.click(
clip ? clip.x + x : x,
clip ? clip.y + y : y
);
};
var tapCssPoint = async function ({ page, x, y, clip }) {
await page.touchscreen.tap(
clip ? clip.x + x : x,
clip ? clip.y + y : y
);
};page or mobilePage for web, or appWindow for Electron, as the surface.clip as CSS pixels from getBoundingClientRect() in the renderer.quality: 85 unless lossless precision is specifically required.{ x, y } directly.The preferred web path for explicit-viewport contexts, and often for web in general:
await emitWebJpeg(page);Mobile web uses the same path; substitute mobilePage for page:
await emitWebJpeg(mobilePage);If the model returns { x, y }, click directly:
await clickCssPoint({ surface: page, x, y });Click path for mobile web:
await tapCssPoint({ page: mobilePage, x, y });For web screenshots with clip, or element screenshots on this normal path, scale: "css" usually works directly. Add the region origin back when clicking.
await emitWebJpeg(page, { clip })await emitWebJpeg(mobilePage, { clip })await clickCssPoint({ surface: page, clip, x, y })await tapCssPoint({ page: mobilePage, clip, x, y })await clickCssPoint({ surface: page, clip: box, x, y }) after const box = await locator.boundingBox()Fallback for native web window when scale: "css" still comes back at device-pixel size:
var emitWebScreenshotCssScaled = async function ({ page, clip, quality = 0.85 } = {}) {
var NodeBuffer = (await import("node:buffer")).Buffer;
const target = clip
? { width: clip.width, height: clip.height }
: await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
const screenshotBuffer = await page.screenshot({
type: "png",
...(clip ? { clip } : {}),
});
const bytes = await page.evaluate(
async ({ imageBase64, targetWidth, targetHeight, quality }) => {
const image = new Image();
image.src = `data:image/png;base64,${imageBase64}`;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = true;
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, "image/jpeg", quality)
);
return new Uint8Array(await blob.arrayBuffer());
},
{
imageBase64: NodeBuffer.from(screenshotBuffer).toString("base64"),
targetWidth: target.width,
targetHeight: target.height,
quality,
}
);
await emitJpeg(bytes);
};For a fallback full-viewport capture, treat the returned { x, y } as direct CSS coordinates:
await emitWebScreenshotCssScaled({ page });
await clickCssPoint({ surface: page, x, y });For a fallback cropped capture, add the clip origin back:
await emitWebScreenshotCssScaled({ page, clip });
await clickCssPoint({ surface: page, clip, x, y });For Electron, normalize in the main process instead of opening a Playwright scratch page. The helper below returns CSS-scaled bytes for the entire content area or for a clipped region in CSS pixels. Treat clip as CSS pixels of the content area, e.g., values taken from getBoundingClientRect() in the renderer.
var emitElectronScreenshotCssScaled = async function ({ electronApp, clip, quality = 85 } = {}) {
const bytes = await electronApp.evaluate(async ({ BrowserWindow }, { clip, quality }) => {
const win = BrowserWindow.getAllWindows()[0];
const image = clip ? await win.capturePage(clip) : await win.capturePage();
const target = clip
? { width: clip.width, height: clip.height }
: (() => {
const [width, height] = win.getContentSize();
return { width, height };
})();
const resized = image.resize({
width: target.width,
height: target.height,
quality: "best",
});
return resized.toJPEG(quality);
}, { clip, quality });
await emitJpeg(bytes);
};Full Electron window:
await emitElectronScreenshotCssScaled({ electronApp });
await clickCssPoint({ surface: appWindow, x, y });Cropped Electron region using CSS pixels from the renderer:
var clip = await appWindow.evaluate(() => {
const rect = document.getElementById("board").getBoundingClientRect();
return {
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
});
await emitElectronScreenshotCssScaled({ electronApp, clip });
await clickCssPoint({ surface: appWindow, clip, x, y });Use these only when raw pixels matter more than CSS-coordinate alignment, e.g., for debugging Retina or DPI artifacts, inspecting pixel-accurate rendering, or other precision-sensitive review.
Raw delivery from desktop web:
await codex.emitImage({
bytes: await page.screenshot({ type: "jpeg", quality: 85 }),
mimeType: "image/jpeg",
detail: "original",
});Raw delivery from Electron:
await codex.emitImage({
bytes: await appWindow.screenshot({ type: "jpeg", quality: 85 }),
mimeType: "image/jpeg",
detail: "original",
});Raw mobile delivery once a mobile web context is already running:
await codex.emitImage({
bytes: await mobilePage.screenshot({ type: "jpeg", quality: 85 }),
mimeType: "image/jpeg",
detail: "original",
});Do not assume a screenshot is acceptable just because the main widget is visible. Before signoff, explicitly verify that the intended initial view satisfies the product's requirement, using both screenshot review and numeric checks.
Check for web or renderer:
console.log(await page.evaluate(() => ({
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
clientWidth: document.documentElement.clientWidth,
clientHeight: document.documentElement.clientHeight,
scrollWidth: document.documentElement.scrollWidth,
scrollHeight: document.documentElement.scrollHeight,
canScrollX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
canScrollY: document.documentElement.scrollHeight > document.documentElement.clientHeight,
})));Check for Electron:
console.log(await appWindow.evaluate(() => ({
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
clientWidth: document.documentElement.clientWidth,
clientHeight: document.documentElement.clientHeight,
scrollWidth: document.documentElement.scrollWidth,
scrollHeight: document.documentElement.scrollHeight,
canScrollX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
canScrollY: document.documentElement.scrollHeight > document.documentElement.clientHeight,
})));Supplement the numeric check with getBoundingClientRect() checks for required visible regions in your specific UI when clipping is a realistic failure mode; document-level metrics alone are not enough for fixed shells.
For local web debugging, keep the app running in a persistent TTY session. Do not rely on one-off background commands from a short-lived shell.
Use the project's normal start command, e.g.:
npm startBefore page.goto(...), verify the chosen port is listening and the app responds.
For Electron debugging, launch the app from js_repl via _electron.launch(...) so the same session owns the process. If the Electron renderer depends on a separate dev server (e.g., Vite or Next), keep that server running in a persistent TTY session and then restart or reload the Electron app from js_repl.
Run cleanup only when the task is genuinely complete:
js_repl session does not implicitly trigger electronApp.close(), context.close(), or browser.close().if (electronApp) {
await electronApp.close().catch(() => {});
}
if (mobileContext) {
await mobileContext.close().catch(() => {});
}
if (context) {
await context.close().catch(() => {});
}
if (browser) {
await browser.close().catch(() => {});
}
browser = undefined;
context = undefined;
page = undefined;
mobileContext = undefined;
mobilePage = undefined;
electronApp = undefined;
appWindow = undefined;
console.log("Playwright session closed");If you plan to exit Codex right after debugging, run the cleanup cell first and wait for the "Playwright session closed" log before exiting.
Cannot find module 'playwright': run the one-time setup in the current workspace and verify the import before using js_repl.npx playwright install chromium.page.goto: net::ERR_CONNECTION_REFUSED: confirm the dev server is still running in a persistent TTY session, double-check the port, and prefer http://127.0.0.1:<port>.electron.launch hangs, times out, or exits immediately: check the local electron dependency, confirm the args target, and ensure any renderer dev server is already running before launch.Identifier has already been declared: reuse existing top-level bindings, choose a new name, or wrap the code in { ... }. Use js_repl_reset only when the kernel is genuinely stuck.browserContext.newPage: Protocol error (Target.createTarget): Not supported when working with Electron: do not use appWindow.context().newPage() or electronApp.context().newPage() as a scratch page; use the Electron-specific screenshot normalization flow in the screenshots-for-the-model section.js_repl timed out or reset: re-run the bootstrap cell and rebuild the session with shorter, more focused cells.--sandbox danger-full-access, and restart with that flag if needed.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.