The toolbar Record button already starts recording from the extension
service worker (chrome-extension:// origin). Allowing any website origin
on POST /recorder/start and /recorder/stop was leftover from when the
page fetched the relay directly.
A page can no longer pass the CORS preflight, so it cannot start or
stop a recording. The worker fetch still skips the Sec-Fetch-Site block
because chrome-extension:// to 127.0.0.1 is cross-site.
Also switch the idle-icon tutorial to npx playwriter examples, bump
the extension to 0.0.118, and tighten the changeset/extension rules
in AGENTS.md.
Session: ses_fd2023624ffeK812PCDMCsUNVJ
The toolbar used to pop in at full opacity. It now fades in with a 200ms
opacity transition via @starting-style, and skips the transition when
prefers-reduced-motion is set.
Pin-mode hover was the only hot path that mattered. Mouse moves called
elementsFromPoint and toggled display on every event, which forces layout
on heavy pages. Overlay updates now coalesce to one requestAnimationFrame,
hit-test with elementFromPoint, and hide with visibility instead of
display. contain:layout style paint isolates the toolbar and overlay from
page layout.
Did not rewrite drag to transform. Drag is rare and the risk is not worth
it.
Bumps the extension to 0.0.117.
Session: ses_fd213a8deffeVULbxEatPrFCEL
The first tutorial commit shipped with no changeset. Add one so the
next playwriter release notes mention the new idle-icon page.
Also tighten the copy: title is now The Playwriter daemon is not
running, and the last line says to click the icon on a normal website
tab so agents can control an existing tab.
Bump the extension to 0.0.114.
Session: ses_fd6aecbb4ffe6Qzpvnjmc14rqQ
`npx skills add remorses/playwriter` showed the GitHub owner in every
install command. Akarso already avoids that by hosting the skills.sh
discovery document on its own domain. Do the same on playwriter.dev.
The website now answers:
GET /.well-known/agent-skills/index.json
GET /.well-known/agent-skills/playwriter/SKILL.md
The SKILL.md bytes are imported from skills/playwriter/SKILL.md so the
served file cannot drift from git. The index includes a sha256 digest
because skills.sh checks it.
All public install commands now use:
npx -y skills add https://playwriter.dev
This covers the README, website docs, welcome page, and tutorial page.
The extension version is 0.0.113 because those HTML pages changed.
The tutorial page is also shorter than the previous daemon walkthrough.
That rewrite was already in the working tree with the skill URL change.
Session: ses_fd657bfa3ffe8lJ3G5L134SgMB
First-time users click the Playwriter icon and see a gray or orange badge.
Nothing tells them the extension cannot attach a tab until an agent runs a
playwriter command and starts the local daemon.
When the relay is idle, an icon click now opens src/tutorial.html. The page
explains why it opened, that the agent must run playwriter first, and how
to install the CLI and skill. It polls /version so the banner turns green
when the daemon starts. A second click focuses the existing tutorial tab.
The click still tries to attach the original tab. An orange ... badge means
the tab is waiting and can turn green by itself after the daemon starts.
Skip the tutorial in tests and in bundled automation builds that already
disable welcome.html. Bump the extension to 0.0.110.
Session: ses_fd6aecbb4ffe6Qzpvnjmc14rqQ
While a skill records, the toolbar button used to show only
Recording skill. That text looks like status, not an action.
The button now shows a red stop square and Stop recording
with a unicode ellipsis. Click still stops the recording.
Also bump the extension to 0.0.109 and update the security
docs to match the new label.
Session: ses_fd70431a9ffejIwMLovDAQ4TLr
The in-page Record button posted /recorder/start with no sessionId. The
relay treated many sessions as an error, so Record failed whenever more
than one playwriter session existed.
Session pick on start is not useful for this flow. Extension sessions
share the same Chrome tabs. The agent identifies the recording later,
at stop time.
Start without a sessionId now:
- uses an explicit sessionId when the CLI sent one
- otherwise reuses a free extension session
- otherwise creates an extension session
- never picks a headless or cloud session (those have no user tabs)
Stop without a recording id still stops the only active recording. If
more than one recording is active, stop throws and lists each id,
session, and current or last page URL:
Multiple active recordings. Pass a recording id.
3 session 1 https://app.example.com/settings
5 session 2 https://github.com/remorses/playwriter
Then: playwriter recorder stop 3
The toolbar Stop button now sends the recording id it started, so it is
never ambiguous. Failed start/stop calls toast the error.
Also document the recorder goal, mechanics, commands, multiple-recording
case, and troubleshooting in skill-recorder, CLI, and troubleshooting
docs. recorder status prints page URLs.
Extension 0.0.108.
Session: ses_fd76982e8ffejVfvd9Qqui5xcE
After a recording stops, agents were told to write SKILL.md plus a utils.js
script and to ask a long list of questions first. The toolbar clipboard
prompt also skipped how Playwriter works, so agents often drove raw
Playwright.
recorder stop and the toolbar copy now start with playwriter skill /
https://playwriter.dev/SKILL.md. The next step is recorder events, then a
SKILL.md of markdown steps with example playwriter -e commands built from
the events.
Docs and skill.md match that flow. Frames are described as
recordings/<id>/frames/<ms>.jpg: read the jpeg closest to an event ms.
CLI help no longer mentions snapshot-diff events that the recorder does
not emit.
Tests assert the structured fields already stored on actions (fill.text,
click.button, setInputFiles.files) and that thin page-error events keep
.message. Extension version is 0.0.106.
Session: ses_fe411266affeR59e58UaOBxQm4
Playwriter:
- Remove redundant `as` casts that already match the inferred type (CDP session.send results, JSON bodies, recording messages).
- Replace `as Protocol.X` on already-typed CDP params with `params!` or optional access.
- Annotate Hono `c.req.json()` results instead of asserting them.
- Mark fire-and-forget work with `void` (proxy page apply, MCP relay logs).
- Drop redundant callback parameter types in wait-for-page-load.
Extension:
- Switch tsconfig `moduleResolution` from `node` (node10, rejected by tsgolint) to `bundler` so lintcn can typecheck the package.
- Annotate offscreen `sendMessage` results instead of asserting them.
- Mark fire-and-forget chrome APIs and startup loops with `void`.
- Use `isTruthy` instead of ad hoc `filter` type guards for tab IDs.
- Bump manifest to 0.0.105.
Session: ses_fdb498c34ffe4ONDh2gAJKc45I
`injectRecorderCallbacks` used to install MAIN-world callbacks that
fetched the relay directly from the page. That fetch runs with the page
origin, so every cross-origin site failed the CORS preflight and the
Record Skill button silently did nothing.
The relay now allows CORS on the two toggle routes, but the extension no
longer depends on it: the request is made from the service worker, which
can reach localhost freely from the extension context.
MAIN world (toolbar)
│ window.postMessage({ __playwriter: 'recorder_start' })
▼
ISOLATED world bridge
│ chrome.runtime.sendMessage({ action: 'actionRecorderStart' })
▼
service worker
│ fetch http://127.0.0.1:19988/recorder/start
▼
relay
`window.postMessage` is used rather than `CustomEvent` because
CustomEvent does not reliably cross the MAIN/ISOLATED world boundary;
postMessage is the pattern Chrome documents for this. The bridge guards
on `__playwriterRecorderBridge` so repeated injections do not stack
listeners, and ignores messages whose `event.source` is not this window.
Feedback now comes back to the page through `executeScript`:
- start failure logs and shows the relay error in a toast, so a failed
start is visible instead of silent
- stop copies the analysis prompt, falls back to a hidden textarea plus
`document.execCommand('copy')` when the async clipboard API is denied,
flips the button back to idle, shows a toast, and plays the success
sound
Session: ses_fe43304dbffeT4MJIAE09Oo2s4
Session: ses_fe45ea8e3ffepbcZz6OQQ9sFSH
Toolbar polish, split across three parts.
**Tooltips.** Native `title` attributes take ~1s to appear, cannot be
styled, and looked out of place next to the dark toolbar. Buttons now use
a `data-tooltip` attribute rendered through `[data-tooltip]::after`, with
a 0.3s delay applied only on the fade-in (`transition: opacity 0.15s ease
0.3s`) so moving between buttons stays instant while a hover still needs
intent. `aria-label` is set alongside every `data-tooltip`, since the
pseudo-element text is invisible to screen readers.
Copy is action-first:
| control | tooltip |
| ------- | ------------------------------------ |
| pin | Select and copy element as prompt |
| record | Capture actions as a reusable skill |
| record (active) | Stop and copy analysis prompt |
| close | Hide toolbar |
| drag | Drag to move |
**Record Skill label.** `Record` alone did not say what gets recorded.
The idle state now reads `Record Skill`. The active state drops the stop
square icon and the red tint, leaving `Recording skill…` in the normal
zinc palette, so a recording in progress does not scream at the user from
every page.
**Sounds.** Buttons play short Web Audio tones synthesized at click time,
so no audio files ship with the extension. Recipes are adapted from
cuelume (https://github.com/Danilaa1/cuelume): `click` for any toolbar
button, `loading` for recording start, `success` when an element is
pinned or a prompt lands on the clipboard. The `AudioContext` is created
lazily and resumed on demand, because pages that never had a user gesture
start it suspended.
Two globals, `__playwriterToolbarShowToast` and
`__playwriterToolbarPlaySound`, let background.ts drive toast and sound
from the service worker. Both are removed in the destroy hook.
Clicking Record when the relay never injected the start callback now
shows `Relay not connected` instead of doing nothing.
Extension version 0.0.104.
Session: ses_fe43304dbffeT4MJIAE09Oo2s4
Session: ses_fe417770affe1ncN9WImr6JxOp
Complete visual redesign of the extension toolbar, inspired by egaki:
**Dark theme**
- Background: #1c1c1c with rgba(255,255,255,0.1) border
- Pill shape (border-radius: 9999px)
- Deep shadow (multiple layers for depth)
- Zinc-toned icon colors with hover states
- System font stack
**Drag handle**
- 6-dot grip icon (2x3 grid) on the right edge
- Drag to reposition anywhere on the viewport
- Position persisted in localStorage as viewport percentages
- Survives page reloads and window resizes
- Clamped to viewport bounds during drag
**Recording controls**
- Record/Stop button always visible in toolbar
- Shows red circle icon + "Record" label (idle state)
- Shows red square icon + "Recording skill..." label (active state)
- Start recording: calls /recorder/start on the relay via fetch
- Stop recording: calls /recorder/stop, copies analysis prompt to
clipboard, resets UI state
- Toolbar state synced from relay via setRecorderState message
- Globals exposed: __playwriterToolbarSetRecording,
__playwriterToolbarStartRecording, __playwriterToolbarStopRecording
**Pin button**
- New SVG: evenodd fill-rule punches a cursor-shaped hole through the
icon (transparent cutout instead of white fill)
- Renamed from "clipboard" to "pin" for clarity
**Background.ts integration**
- Handle "setRecorderState" one-way messages from relay
- Inject start/stop recording callbacks after toolbar injection
- setRecorderStateInAllTabs() updates toolbar recording state in all
connected tabs
- Re-inject callbacks after DOMContentLoaded navigation
**Version bump: 0.0.99 -> 0.0.100**
Session: ses_fe518993fffeLkk8nc00YYO4l2
Add a capture-phase mousedown handler that calls preventDefault()
while pin mode is active. The browser starts text selection on
mousedown, not click, so the existing click handler preventDefault
was too late to suppress shift+click selection behavior.
Session: ses_03126316bffeg7zoAmgWgz8P6W
Shift+clicking elements in pin mode now accumulates them instead of
exiting. Each shift-click adds the element to the selection, keeps
the green outline persistent, and updates the clipboard with all
accumulated locator strings joined by comma + newline.
Normal click (no shift) finalizes: includes any shift-accumulated
elements in the clipboard, then exits pin mode. Single click without
any prior shift-clicks behaves exactly as before.
Edge cases handled:
- Shift-clicking the same element twice is a no-op (prevents saving
the already-green outline as the "original" style)
- On final normal click, persistent outlines are cleared before the
temporary flash so cleanup never restores green
- Escape and toolbar close clear all persistent outlines
- Re-entering pin mode resets accumulated state
Manifest version bumped to 0.0.99.
Session: ses_03126316bffeg7zoAmgWgz8P6W
Filter high-frequency CDP events that cause relay saturation on
realtime/large pages (issue #96). Two layers:
**Dropped entirely** (not forwarded, not logged):
- Network.dataReceived
- Network.resourceChangedPriority
**Filtered from logs only** (still forwarded to Playwright):
- Network.*ExtraInfo (needed by ResponseExtraInfoTracker for allHeaders())
- Network.webSocketFrame* (needed by page.on websocket)
- Network.requestServedFromCache
- Network.requestWillBeSent, responseReceived, loadingFinished
Extension-side filtering prevents events from hitting the WebSocket.
Relay-side filtering provides backwards compat with old extensions.
Both relay-server.log and cdp.jsonl now batch writes with a 500ms
flush interval instead of one appendFile per event. Added flush()
to Logger type for crash/exit paths.
Fixes#96
Session: ses_0ed5496d9ffeeoNKF0jxBnUQkH
- Added Network.enable and Browser.getWindowForTarget to timeout map (both
are part of Chromium page init and hang on frozen tabs)
- Removed Page.addScriptToEvaluateOnNewDocument from timeout map because
user-provided scripts with runImmediately:true can legitimately take >10s
- Added clearTimeout in finally block to prevent dangling timers when
commands resolve before the timeout fires
Session: ses_1111c0a0cffe6Wt66mAFRmke9e
Ghost Browser (and potentially other Chromium forks) can have frozen/hibernated
tabs where chrome.debugger.sendCommand hangs forever. This blocked the entire
Playwright connectOverCDP setup because initialization commands like Page.enable,
Runtime.enable, Log.enable etc. would each wait 30s before timing out.
Added a FAST_CDP_COMMAND_TIMEOUT_MS map with 11 CDP commands that should return
near-instantly on a healthy tab. These now use Promise.race with a 10s timeout
via sendCommandWithTimeout(). Commands not in the map (Runtime.evaluate,
Network.enable, Input.*, DOM.*, screenshots, navigation) pass through without
any timeout since they can legitimately take longer.
Applied to three code paths:
- Default command handler (generic CDP forwarding)
- Runtime.enable/disable special case (OOPIF iframe handling)
- Target.setAutoAttach broadcast loop (all connected tabs)
Frozen tabs now fail in 10s instead of 30s, giving connectOverCDP enough time
to finish initializing the responsive tabs.
Session: ses_1111c0a0cffe6Wt66mAFRmke9e
Route extension relay connections by their per-profile install id before falling back to Google account identity. This keeps two Chrome profiles signed into the same account from replacing each other in the relay.
Keep Playwright-created tabs in the intended Chrome window by remembering the window where the extension was activated and using that window for Target.createTarget and initial tabs.
Handle Chrome debugger cancellation as a global detach event. When Chrome reports canceled_by_user, clear all tracked debugger tabs and emit detach events so Playwright stops sending commands to tabs Chrome already detached from. Also avoid resurrecting stale error tab state if the detach happens while attachTab is still unwinding.
Validation:
- pnpm test --run src/relay-state.test.ts
- pnpm typecheck
- pnpm build in extension/
- Manual two-profile Hacker News navigation and comment extraction
- Mythos review found no blocking issues
Session: ses_120923e0fffe5l4GT77WwyXEGP
Use full user-agent client hints when the browser exposes them so the extension can report more specific Chromium-family names before falling back to low-entropy brands and the user agent string.
This keeps detection inside the browser instead of guessing from local filesystem installs. If a browser only exposes the generic Google Chrome brand, Playwriter keeps showing Chrome.
Bump the extension manifest to 0.0.95 and add a playwriter patch changeset for the browser list improvement.
Session: ses_143f600f9ffe0ftfzwvYK56gQV
The old toast just said "Copied pin #N" which gave no hint about what to do next. Now it says "Copied playwriter element reference, use it in your agent prompt" so users know to paste it into their AI agent.
Bump extension to 0.0.94.
Session: ses_1d03be05affeS58tMdQDs6GMbj
Update vite in root and extension packages from ^8.0.3 to ^8.0.12.
Lockfile reflects all transitive dependency updates from this session:
goke 6.12.1, @holocron.so/vite 0.8.0, dompurify 3.4.3, katex 0.16.46,
and various @csstools patches.
bippy.normalizeFileName strips "/app-pages-browser/" but not the
parenthesized form "/(app-pages-browser)/" that Next.js webpack
actually uses in source maps. Added cleanFileName/cleanName that
runs after normalizeFileName and strips any /(layer-name)/ prefix
using regex, plus trailing "./".
Covers all Next.js webpack layer names: (app-pages-browser), (ssr),
(rsc), (action-browser), (pages-dir-browser), (pages-dir-edge),
(pages-dir-node).
Applied in both:
- Extension context menu handler (background.ts)
- MCP react-source.ts (getReactSource + getReactComponentInfo)
Also applied oracle review fixes:
- userGesture: true on clipboard Runtime.evaluate calls to prevent
NotAllowedError when page loses focus after context menu
- Defensive fileName null check before building clipboard text
Session: ses_208943d84ffenqdbjxK0cO0iak
New context menu item that copies the React component source file path
to clipboard when right-clicking an element on a React page.
Uses bippy (React fiber introspection library) to walk the fiber tree
from the right-clicked DOM element up to the nearest composite component
with source location info. Tries getSource() first (direct __source prop
from JSX transform), then falls back to getOwnerStack() for builds with
source maps.
Copies "path/to/file.tsx:42" format to clipboard on success (green flash),
shows red flash if the element is not part of a React app or no source
maps are available.
Also refactored all inline JS strings in the context menu handlers to use
string-dedent with descriptive variable names for syntax highlighting.
extension@0.0.93
Session: ses_208943d84ffenqdbjxK0cO0iak
Remove the natural-language wrapper around pinned element clipboard text so toolbar and context-menu pins copy a shell-ready playwriter command. This keeps the copied text compact while still delegating DOM and React inspection to inspectPinnedElement.
Session: ses_221fdd7c5ffexkyCmOtfmgwjoI
Expose getReactComponentInfo so agents can inspect the React component name, parent hierarchy, sanitized props, and source metadata for a locator or element handle. Non-React elements return null so the helper is safe to call from generic DOM inspection flows.
Update toolbar and context-menu pinned element copy to use a short inspectPinnedElement(url, expression) call. The wrapper resolves the pinned element, logs outerHTML plus React info internally, and keeps copied prompts small.
Session: ses_221fdd7c5ffexkyCmOtfmgwjoI
Removed the verbose describeElement() summary (tag, id, classes, role, aria,
href, type, text, rect, visible) — all redundant since the agent runs the code
and sees outerHTML directly. Also fixed the evaluate call to use a static
property access (globalThis.playwriterPinnedElem1) instead of passing the
pin number as a runtime argument.
Bumps extension to 0.0.90.
Session: ses_25fb67764ffeSaDgE3xne8F5sB
The cursor DOM is now outer (translate, move timing) + inner (scale+opacity,
press timing). Previously setPressed() overrode transitionDuration to 140ms
which retargeted in-flight 1500ms move transitions, causing clicks to
'sprint' the cursor to the target.
Also:
- Fix toast animation jump when positioned above anchor (toolbar.ts):
keyframes now use --toast-transform CSS variable so translateY(-100%)
base transform is included during animation, not just after.
- Remove stale navigation-rehydration entry from CHANGELOG (was cut in
the simplification pass but the changelog entry remained).
The Chrome extension now injects the ghost-cursor-client.js bundle into
every attached tab via CDP Page.addScriptToEvaluateOnNewDocument +
Runtime.evaluate. The cursor auto-enables on load (top frame only, skips
iframes) and stays on the last spot Playwright clicked or moved to.
Key changes:
- Extension injects cursor bundle via ?raw vite import, inlined into
background.js at build time. Torn down on detachTab.
- RecordingGhostCursorController renamed to GhostCursorController with
attachToPage/detachFromPage instead of enableForRecording/disableForRecording.
Single instance on PlaywrightExecutor, wired in setupPageListeners.
- Recording no longer toggles the cursor on/off.
- 5s idle debounce: cursor fades out (600ms) after no activity, wakes on
next action by teleporting to the new target and fading back in (140ms).
- Animation polish (Emil Kowalski guidelines): easeInOutCubic for moves,
dedicated 140ms press duration with strong ease-out, subtler 0.95 press
scale, transform-origin anchored to cursor hotspot.
- Slower cinematic defaults: 220ms min, 1500ms max, 1.2 px/ms.
- Test: verify cursor element exists on attached tab without explicit enable.
Clipboard now copies a prompt-style message instead of a bare JS snippet:
see the element I pinned in the playwriter tab `playwriter -e '<code>'`
The user pastes this into a chat with their agent. The agent reads it as
"user pinned an element, here is the code to run" and executes it with
their own session (via `-s <id>` or `PLAYWRITER_SESSION`). Makes the
intent obvious when the clipboard lands in a chat window — the agent
sees the goal plus the exact code without needing instructions.
Also fixes a latent bash-safety bug in `buildInspectionCode`. The code
is wrapped in bash `'...'` so single quotes are the only character we
cannot have inside it. `JSON.stringify` does NOT escape literal `'`, so
element text containing an apostrophe (e.g. "Don't save", aria labels
like "I'm loading", URLs with `?q=what's+new`) leaked a literal `'` into
the code and broke the outer bash wrapper.
Fix: replace every `'` in the JSON-stringified URL and summary with
`\u0027`. This is a valid JSON escape that the JS engine parses back to
`'`, so the string value is unchanged at runtime, but the generated
code is now guaranteed to contain zero literal single quotes regardless
of page content.
Single vs double bash quotes comparison:
'...' literal everything except ' ← 1 problem char
"..." $foo expands, `cmd` runs, ← 4 problem chars
\ escapes, " terminates
Our code is full of " (JSON strings) and can contain $ from element
text, so double quotes are a non-starter. Single quotes with the
\u0027 substitution is the simplest robust solution.
Verified with hostile input:
- url with "what's+new" in query → 0 literal ' in code
- summary with "Don't save 'now'" → 0 literal ' in code
- JS eval of the escaped code reproduces the original strings
Bumps extension to 0.0.85. Bundle 72.79 kB → 72.90 kB (+110 bytes for
the new prose prefix and the two .replace calls).
The previous implementation fetched the CLI session list from the relay via
a new GET /extension/sessions endpoint, plumbed it into the MAIN-world
toolbar via chrome.scripting.executeScript args, cached it in a closure,
and baked `playwriter -s <id> -e '<code>'` into the clipboard. The
picked session id was fragile: sessions[0] is the oldest by insertion
order, which is almost never the session the agent is actually using.
The agent also has its own active session already — guessing one is
the wrong job for a browser extension.
Drop all of it. The toolbar now copies only the JS eval code. The agent
wraps it in their own `playwriter -s <session> -e '<paste>'` using
whichever session they already have (or they create a new one). The
code is JSON.stringify'd so it contains no single quotes and slots
into any shell quoting strategy the agent prefers.
Removed:
- `GET /extension/sessions` on the relay (playwriter/src/cdp-relay.ts)
- `fetchSessionSummary` helper in background.ts
- `ToolbarConfig` interface + import + args plumbing
- Async wrapping around `chrome.scripting.executeScript` in both
injection sites (attachTab + webNavigation.onDOMContentLoaded)
- `currentSessions` / `nextSuggested` closure vars in toolbar.ts
- `window.__playwriterUpdateSessions` setter
- `pickSessionId()` helper
- `shellSingleQuote()` helper (agent does their own shell quoting)
- Re-init update routing through `window.__playwriterUpdateSessions`
`onClick` now just:
copyText(buildInspectionCode(n, url, summary))
showToast(`Copied inspection code (pin #${n})`)
The eval code shape is unchanged:
state.page=context.pages().find(x=>x.url()===URL)||context.pages()[0];
console.log(SUMMARY + "\n\nouterHTML:\n" +
await state.page.evaluate(n => globalThis["playwriterPinnedElem"+n]?.outerHTML, N))
Bundle drops from 74.35 kB to 72.79 kB (-1.56 kB). Bumps extension to
0.0.84 and playwriter to 0.0.108 with CHANGELOG entries in both packages.
The in-page toolbar's pin button used to copy a plain
`see playwriter reference: globalThis.playwriterPinnedElem1` string that
still required the agent to wrap it in their own `-e` call. It now copies
a complete, ready-to-run shell command:
playwriter -s 1 -e 'state.page=context.pages().find(x=>x.url()===URL)||context.pages()[0]; console.log(SUMMARY+"\n\nouterHTML:\n"+await state.page.evaluate(n=>globalThis["playwriterPinnedElem"+n]?.outerHTML,N))'
Two `;`-separated statements: pick the right page by URL with first-page
fallback, assign state.page for follow-up calls, log a pre-baked element
summary plus the current outerHTML of `globalThis.playwriterPinnedElemN`
fetched via one `page.evaluate`. No `getCleanHTML`, no
`getLocatorStringForElement`, no ElementHandle dance, no locator round-
trips — the generated JS is runnable against any playwriter version.
Everything the eval code doesn't strictly need a Playwright sandbox for is
pre-computed at pin time in MAIN world (synchronous DOM access) and baked
into the command via `JSON.stringify`:
- page URL from `location.href`
- element summary: tag, id, classes, role, aria-label, name, href, type,
truncated text, bounding rect, visibility
- pin index
Session-id plumbing:
- New public `GET /extension/sessions` endpoint on the relay returns
`{ sessions: [{ id, stateKeys }], nextSuggested }`. It sits outside the
`/cli/*` privileged middleware, which rejects the cross-site
`Sec-Fetch-Site` that chrome-extension fetches to 127.0.0.1 send. It
exposes the same data as `playwriter session list`, so no new attack
surface.
- `background.ts` `fetchSessionSummary()` calls the endpoint with a
1500ms timeout and falls back to `{ sessions: [], nextSuggested: '1' }`
on any failure (relay down, old relay without the endpoint, timeout,
malformed JSON). The extension stays backward-compatible with older
relays.
- Both toolbar injection sites (`attachTab` + `webNavigation.onDOMContentLoaded`
re-inject) now fetch the summary and pass it as
`chrome.scripting.executeScript({ args: [summary] })`.
- The guarded re-init path in `initPlaywriterToolbar` now calls
`window.__playwriterUpdateSessions?.(config)` so re-injects refresh the
closure cache instead of becoming no-ops.
- `pickSessionId()` prefers the first existing session id, falling back
to `nextSuggested` (typically "1") so the agent stays in their current
state when one exists.
Toolbar runtime behavior does not depend on the relay: sessions are
cached in closure at inject time, clipboard writes stay offline, and
all failure modes surface as visible `undefined` in the command's
output rather than exceptions or timeouts.
Bumps extension to 0.0.83 and playwriter to 0.0.107 with CHANGELOG
entries in both packages.
- Deactivate pin mode after each click (toggle off after pin)
- Green flash outline: 3px → 1px to match overlay edge thickness
- Clipboard text prefixed: 'see playwriter reference: globalThis.playwriterPinnedElemN'
so both user and AI agent understand the string's purpose at a glance
- Replace clipboard icon with Playwriter logo-square SVG (inlined)
- Replace (window as Record<string, Element>)[name] with a typed template literal
index signature on the Window interface: [key: playwriterPinnedElem${number}]: Element
- Return type of allocatePinName narrowed to playwriterPinnedElem${number} so
the assignment window[name] = target type-checks without any casts
- Fix stray pw reference missed during declare global refactor
Declare PlaywriterWindow inside the function body (interfaces are TypeScript-only
and stripped at compile time, so safe inside the serialized func injection).
Cast window once to pw = window as PlaywriterWindow and use typed property access
throughout. Dynamic element assignment uses Record<string, Element>.
Injects a floating toolbar into the top-right corner of every Playwriter-connected
tab via chrome.scripting.executeScript({ world: 'MAIN', func: initPlaywriterToolbar }).
- Toolbar uses a closed Shadow DOM for full style isolation from page CSS
- Matches mesurer.ibelick.com visual language: white pill background, mesurer's
exact shadow, 32x32 buttons with rounded-[8px], #0d99ff active state
- Selection overlay uses four 1px edge divs + color-mix fill (same technique as
mesurer's measurement-box) instead of a CSS outline — thinner and cleaner
Pin element mode (clipboard icon button):
- Toggle activates crosshair cursor + hover overlay tracking the element under cursor
- Click assigns the element to globalThis.playwriterPinnedElemN and copies
'globalThis.playwriterPinnedElemN' to clipboard — identical format to right-click menu
- Esc exits pin mode; × button hides toolbar for the session
- Uses window.__playwriterPinCount as a shared MAIN-world counter so toolbar and
right-click context menu never produce conflicting element indices
background.ts changes:
- Inject toolbar in attachTab() after debugger attaches
- Destroy toolbar in detachTab() (fire-and-forget scripting call)
- Re-inject via chrome.webNavigation.onDOMContentLoaded after hard navigations
- Right-click handler now reads window.__playwriterPinCount via Runtime.evaluate
instead of the Zustand store's pinnedCount field
Removed pinnedCount from TabInfo — counter now lives entirely in MAIN world.
No new manifest permissions needed (scripting + webNavigation already present).
Persist a per-install fallback identity for the extension so Chromium-family browsers without chrome.identity profile data no longer collapse to browser-only relay keys and replace each other.
Also stop replaced workers from reclaiming the relay slot while the replacement connection is merely idle, add a regression test that launches a second Chromium context against the same relay, and bump the shipped package versions/changelogs for the extension and playwriter packages.
Chrome popup windows (window.open with features, OAuth login popups)
could not be controlled by Playwriter because chrome.debugger does not
attach to separate popup windows. Agents had to work around this with
cmd+click (`{ modifiers: ['Meta'] }`) to coerce new tabs instead.
The extension now listens for chrome.windows.onCreated with
type === 'popup' and relocates the tab(s) into the source tab's window,
then auto-attaches Playwriter so the new tab appears in context.pages().
Gating: the popup is only relocated when the source tab is already
Playwriter-connected. Otherwise the popup is left alone so unrelated
sites keep their normal Chrome popup behavior. Source tab is discovered
via chrome.webNavigation.onCreatedNavigationTarget because
chrome.tabs.Tab.openerTabId is unreliable for window.open popups on
Chromium 145 (leaves it null).
Focus is not stolen — the relocated tab is appended at the end of the
source tab's strip (index: -1) but the user's active tab stays active.
Executor changes
- Replaced setupPopupDetection with setupNewPageLogging. Warning
message changed from "Popup window detected ... cannot be controlled"
to "New page opened from current page (index N, initial url: ...)"
pointing the agent at the controllable tab.
- Added MIN_EXTENSION_PLAYWRITER_VERSION check so agents running with
old extensions see a targeted warning to update via chrome://extensions.
- enqueueWarning is now called from warnIfExtensionOutdated so the LLM
sees version-skew messages in its warning stream, not just stdout.
Extension changes
- New `webNavigation` permission for the source-tab correlation map.
- connectTab now verifies `chrome.tabs.get(tabId)` before writing an
`error` state, preventing dead tabs from leaking into store.tabs /
badge counts / group sync when a tab closes mid-attach.
- popupSourceTabMap entries are cleaned up both on a 10s timeout and
when chrome.tabs.onRemoved fires, capping memory for plain-new-tab
cases that never trigger windows.onCreated.
Tests
- popup-relocation.test.ts verifies both paths: when a Playwriter tab
is connected the popup is relocated into the main window and appears
in context.pages(); when no tab is connected the popup-type window
remains (extension does not interfere).
Skill docs
- Removed section #10's cmd+click workaround; added a note about
auto-relocation under "working with pages".
Versions
- extension 0.0.79 -> 0.0.80
- playwriter 0.0.103 -> 0.0.104
Chrome's chrome.debugger.attach API refuses to attach to tabs containing
chrome-extension:// iframes from other extensions (LastPass, SurfingKeys,
password managers, etc). This causes the extension to immediately disconnect
after clicking the icon.
Two-layer fix:
1. Pre-attachment (removeRestrictedIframes): when chrome.debugger.attach fails
with the chrome-extension:// error, use chrome.scripting.executeScript to
remove restricted iframes from the DOM, then retry attachment. Requires
new 'scripting' permission.
2. Runtime filtering (onDebuggerEvent): when Target.attachedToTarget arrives
for a restricted child target (injected after debugger attached), filter it
out immediately — don't track, don't forward to relay, detach the child
target. Defense-in-depth against runtime re-injection by other extensions'
content scripts.
Test: loads a fixture extension alongside playwriter, creates a page with a
chrome-extension:// iframe, enables the debugger, and verifies it stays
connected and can execute JS.
Closes#18
Bundle the extension into the CLI so fresh AVPS and local source checkouts can start a Playwriter-ready Chrome for Testing or Chromium without separate installation steps. Also disable the packaged welcome tab, reuse Playwright\s managed Chromium fallback, surface searched browser paths on discovery failures, and document that managed browser launches already enable the tab-capture flags needed for recording on normal web pages.
Respect PLAYWRITER_EXTENSION_DIST in the Prism download step so release builds include welcome page Prism scripts in dist-release/src instead of polluting dist/src.