Commit Graph

565 Commits

Author SHA1 Message Date
Tommy D. Rossi b7a2811caf release: playwriter@0.1.0 2026-04-16 12:01:54 +02:00
Tommy D. Rossi 9c2bac37d3 fix: handle shared dialog auto-close races in dialog abstraction
Move the JavaScript dialog race fix down into the Playwright fork instead of teaching the relay about a specific CDP error.

When multiple connectOverCDP clients share the same Playwriter tab, they all observe the same dialog and may all attempt the automatic best-effort close. Chrome accepts the first Page.handleJavaScriptDialog and rejects later attempts with "No dialog is showing". That follow-up failure should not crash the process because the dialog is already closed and the cleanup goal was achieved.

This commit:
- updates @xmorse/playwright-core Dialog.close() to ignore benign duplicate close races
- bumps playwright-core to 1.59.9 and documents the behavior in its changelog
- bumps playwriter to 0.0.112 and documents the user-visible fix
- adds a relay integration test that attaches two CDP clients to the same tab, opens an alert, and verifies both clients remain usable without an unhandled rejection

Why this abstraction is better:
- Dialog.close() is already the best-effort cleanup boundary
- the relay no longer needs protocol-specific knowledge of Page.handleJavaScriptDialog failures
- the behavior also protects any other shared-target setup that triggers the same close race

Validation:
- pnpm playwright:build
- pnpm typecheck (playwriter)
- pnpm test --run relay-core.test.ts -t "should ignore duplicate dialog dismissals from multiple CDP clients"
2026-04-14 10:57:03 +02:00
Tommy D. Rossi 19b0959585 fix: wrap all ghost cursor code in try/catch so it never crashes main logic
Every cursor code path that touches page.evaluate, DOM manipulation, or
runs inside a Playwright callback is now wrapped so errors stay cosmetic:

- onMouseAction callback: outer try/catch prevents cursor errors from
  propagating to Playwright's input dispatch (would break page.click)
- show()/hide(): swallow errors from closed/navigating pages
- enableGhostCursor/disableGhostCursor: same
- applyGhostCursorMouseAction: same
- Browser DOMContentLoaded auto-enable: inner try/catch
2026-04-13 09:26:26 +02:00
Tommy D. Rossi f7499bd1d9 fix: two-element cursor split so move and press transitions don't interfere
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).
2026-04-13 09:21:25 +02:00
Tommy D. Rossi 7455abbf5a feat: always-on ghost cursor injected by extension on tab attach
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.
2026-04-13 09:10:34 +02:00
Tommy D. Rossi 8ee26ffcb4 update @modelcontextprotocol/sdk to 1.29.0 and hono to 4.12.12 2026-04-13 09:00:31 +02:00
Tommy D. Rossi 9e33e21484 refactor(toolbar): drop session plumbing, copy raw eval code on pin-click
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.
2026-04-11 14:45:42 +02:00
Tommy D. Rossi 2cbb943b00 feat(toolbar): copy full playwriter -s X -e inspection command on pin-click
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.
2026-04-11 14:29:20 +02:00
Tommy D. Rossi af0f4aabe5 chore(deps): bump goke to ^6.6.0 and update --direct flag handling
goke 6.6.0 normalizes optional-value flags from 'string | boolean'
to 'string | undefined', with '' meaning 'flag present, no value'.
Update the --direct [endpoint] handler to match the new shape:
  - directEndpoint is just 'options.direct || null'
  - bare --direct check uses === '' instead of === true
2026-04-11 10:00:21 +02:00
Tommy D. Rossi 389e3cf23a chore(deps): bump goke to ^6.5.2 2026-04-11 09:32:15 +02:00
Tommy D. Rossi b6e241f487 fix: restore playwriter serve startup on clean installs
Closes #84\n\nThe CLI started importing browser-start helpers at module load time, which\npulled @xmorse/playwright-core into generic startup paths like\nplaywriter --help and playwriter serve. In clean installs that immediately\nreached the vendored extract-zip runtime, which still uses CommonJS\nrequire('get-stream'), and crashed with ERR_REQUIRE_ESM once the fork had\nget-stream v9 installed.\n\nThis change fixes the failure from both sides:\n- lazily load browser-start-only modules so normal CLI startup/help paths\n  do not pull playwright-core unless browser start is actually invoked\n- bump the playwright submodule to @xmorse/playwright-core 1.59.8, pinning\n  get-stream back to ^5.2.0 so the vendored unzip runtime stays compatible\n- add a black-box regression test that runs root help and serve help\n  through vite-node\n- update versions, changelogs, lockfile, and local memory notes\n\nVerified with pnpm typecheck, a direct require of playwright-core, and\npnpm vitest --run src/cli-help.test.ts.\n\nNote: lintcn still reports unrelated pre-existing warnings/errors in\ncli.ts, cdp-relay.ts, aria-snapshot.ts, and mcp.ts that were not part of\nthis fix.
2026-04-08 11:55:40 +02:00
Tommy D. Rossi 7e633d5ac0 fix: prevent cross-browser extension takeover loops
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.
2026-04-07 21:43:46 +02:00
Tommy D. Rossi 91c69b54cf remove extension-outdated version check — extension auto-updates 2026-04-07 11:55:17 +02:00
Tommy D. Rossi 129fff896c feat: auto-relocate popup windows into source tab's window
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
2026-04-05 14:22:21 +02:00
Tommy D. Rossi adc7125b47 fix: skip auto-printing Playwright handles in CLI execute output (#82)
When a single-expression code snippet auto-returns a Playwright object
(e.g. `await page.goto(url)` returning a Response), the CLI previously
called util.inspect() at depth 4 on the handle, producing 400K+ chars
that included every process.env variable (API keys, tokens, secrets).

Even with the playwright-core util.inspect fix (@xmorse/playwright-core@1.59.7),
these handles are useless output — they're programmatic references with no
display value. Agents and users get nothing meaningful from seeing:

  [return value] Response@response@abc123 { request: Channel<...>, ... }

Fix: add isPlaywrightChannelOwner() duck-type check in the executor return
value path. Handles are silently skipped (duck: _type + _guid + _connection).
The existing 'Code executed successfully (no output)' fallback applies when
there's no other output.

To see data, users must explicitly:
  return response.url()      → prints the URL string
  return response.status()   → prints 200
  console.log(response)      → prints safe summary via playwright-core

Also includes:
- Regression test (channel-owner-inspect.test.ts) for inspect handler
- isPlaywrightChannelOwner() unit tests (executor.unit.test.ts)
- Stale color-scheme snapshot fix (relay-core.test.ts)
- MEMORY.md with learnings (Node util.inspect bypass of Proxy traps)
2026-04-05 11:57:30 +02:00
Tommy D. Rossi 2a2db6be25 fix: correct test assertions for screenshot format and color scheme
- snapshot-tools.test.ts: fix wrong assertion expecting 'jpg' when screenshot returns 'png' (contradicted the mimeType assertion on the same screenshot)
- relay-core.test.ts: update inline snapshot for color scheme test to match actual system preference (light mode)
2026-04-01 12:16:31 +02:00
Tommy D. Rossi 7d54c4b6a0 fix: handle chrome-extension:// iframes from other extensions (#18)
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
2026-03-29 23:26:26 +02:00
Tommy D. Rossi a746f895da release: playwriter@0.0.102 2026-03-29 13:25:52 +02:00
Tommy D. Rossi 66e456e440 restore platform-specific Chrome launch instructions in skill.md
These were removed when browser start was introduced. Now that
browser start is deprecated, restore the original manual Chrome
launch commands and tab-capture flags documentation.
2026-03-29 13:23:21 +02:00
Tommy D. Rossi a55191c37a remove browser start references from skill.md
Command is deprecated and hidden from --help, so agent-facing
docs should no longer mention it.
2026-03-29 13:19:13 +02:00
Tommy D. Rossi 9253b57238 Update skill.md 2026-03-29 13:17:38 +02:00
Tommy D. Rossi f1187472d8 deprecate browser start command, hidden from --help
Update goke to 6.3.2 which adds .hidden() for commands.
browser start still works when called directly but no longer
appears in --help output.
2026-03-29 13:15:53 +02:00
Tommy D. Rossi 1588381c7d expose browser object in sandbox, document --direct profile access
- Add 'browser' to VM sandbox context so user code can call
  browser.contexts() to access pages from different Chrome profiles
- Update resetPlaywright to also refresh browser reference
- Document in skill.md: --direct gives access to all profiles via
  browser.contexts(), add browser to context variables list
2026-03-29 13:15:20 +02:00
Tommy D. Rossi b7397f877c simplify PLAYWRITER_DIRECT to only accept '1' for auto-discovery
Remove 'auto' and 'true' aliases — '1' is the standard boolean env var
pattern. Explicit ws:// endpoints still work as before.
2026-03-29 13:08:32 +02:00
Tommy D. Rossi e920c5c723 docs: update --direct references for Chrome 136+ and move to session new only
- skill.md: remove 'playwriter --direct' from MCP examples, use env var only
- MCP.md: add Direct CDP section with PLAYWRITER_DIRECT config example
- CHANGELOG.md: add 0.0.101 entry, fix stale 'playwriter --direct' in 0.0.98
- Bump version to 0.0.101
2026-03-29 13:01:01 +02:00
Tommy D. Rossi f2ee02079b fix(playwriter): support Chrome 136+ direct CDP discovery (404 on /json/version)
Chrome 136+ with chrome://inspect debugging returns 404 on all HTTP
endpoints (/json/version, /json, /json/list) but still accepts WebSocket
connections on /devtools/browser/*. The first WS connection triggers a
user-approval dialog in Chrome.

Previously, discoverChromeInstances() and resolveDirectInput() treated
HTTP 404 as 'blocked' and either returned nothing or threw an error,
making --direct unusable without manually constructing a ws:// URL.

Changes:
- discoverChromeInstances() now returns a synthetic instance for 'blocked'
  probes with a base ws:// URL, so browser list shows detected Chrome
- resolveDirectInput() returns a ws URL instead of throwing on 'blocked'
- appendSessionToWsUrl() appends session ID to bare /devtools/browser/
  paths for unique WS connections; leaves full GUID paths (from live
  probes) untouched
- Relay appends CLI session ID, MCP appends crypto.randomUUID()
- Removed --direct from root CLI command (only on session new now);
  MCP users configure via PLAYWRITER_DIRECT env var
- browser list shows connect hint when direct instances are detected

Discovery never triggers a WebSocket connection, so Chrome's approval
dialog only appears when the user explicitly runs a command.
2026-03-29 12:59:31 +02:00
Tommy D. Rossi 874fb5f060 fix(playwriter): default resizeImageForAgent format to PNG instead of JPEG
resizeImageForAgent() defaulted to JPEG when no format was specified,
causing image/png vs image/jpeg mismatch errors when MCP clients
(like Anthropic API) validated the media type against the actual image data.

All playwriter images are now consistently PNG unless explicitly
overridden with format: 'jpeg'.
2026-03-29 11:12:35 +02:00
Tommy D. Rossi b835e3b907 feat(playwriter): add Kitty Graphics Protocol support to CLI + JPEG→PNG migration
When AGENT_GRAPHICS=kitty is set, the CLI now emits screenshots and resized
images as Kitty Graphics Protocol APC escape sequences to stdout. Agents with
kitty-graphics-agent (or compatible parsers) automatically extract PNG images
and pass them to the LLM as media parts — no extra tool call needed.

Key changes:

- **Screenshots → PNG**: screenshotWithAccessibilityLabels() now captures PNG
  instead of JPEG. PNG is lossless and the only format extracted by the Kitty
  Graphics Protocol (f=100). resizeImage() gains a format option ('jpeg'|'png').

- **kitty-graphics.ts**: New utility with canEmitKittyGraphics() (checks
  AGENT_GRAPHICS env var) and emitKittyImage() (writes chunked APC escape
  sequences to stdout at 4096 bytes per chunk per the Kitty spec).

- **resizeImage → resizeImageForAgent**: Renamed to clarify intent. The wrapper
  in executor.ts now collects resized images into result.images so they are
  emitted via Kitty Graphics in the CLI and included as image parts in MCP.
  Old resizeImage name kept as backward-compatible alias in the sandbox.

- **CLI deduplication**: Uses a Set<base64> to avoid emitting the same image
  twice (screenshots appear in both result.screenshots and result.images).

- **10 new tests** including round-trip validation with kitty-graphics-agent
  parser to verify emitted sequences can be parsed back correctly.

Version 0.0.98 → 0.0.99
2026-03-28 16:37:24 +01:00
Tommy D. Rossi ceef6d7154 feat(cli,mcp): wire up resolveDirectInput + unify browser list
cli.ts — session new --direct:
  Previously, a --direct string value was passed verbatim as the CDP endpoint,
  which silently broke when the user passed 'localhost:9222' instead of a full
  ws:// URL. Now resolveDirectInput() is called first: it normalises host:port
  to a WebSocket URL via /json/version probe, passes ws:// URLs through as-is,
  and surfaces a clear error for blocked or dead endpoints before any session
  is created.

cli.ts — browser list command:
  The old implementation only listed raw CDP instances from discoverChromeInstances
  (DevToolsActivePort / port scan). Rewritten to show both:
    - Extension-connected browsers (fetched from relay / waitForConnectedExtensions)
    - Direct CDP instances on port 9222 (from discoverChromeInstances)
  The relay server is started if needed so extensions can connect before the
  2-second poll window. Output uses the shared printBrowserTable() formatter.
  TYPE column now shows '--direct' instead of 'direct' to hint at the flag name.
  Empty state message updated with actionable instructions for each connection type.

mcp.ts — getDirectCdpConfig:
  PLAYWRITER_DIRECT='auto'/'1'/'true' → auto-discover on port 9222 (unchanged behaviour).
  PLAYWRITER_DIRECT='ws://...' or 'host:port' → now routed through resolveDirectInput
  instead of an ad-hoc string check, which also covers the new host:port format.
2026-03-27 15:30:45 +01:00
Tommy D. Rossi 05071af0ba refactor(chrome-discovery): simplify to single port 9222 probe + add resolveDirectInput
The previous implementation used two layered strategies:
1. Glob for DevToolsActivePort files across `~/Library/Application Support`,
   `~/.config`, `%LOCALAPPDATA%` — walks the directory tree up to depth 3,
   reads the file to get port + WS path, then reads `Local State` for profiles.
2. Port scan 9222-9229 via GET /json/version — fills in any instances not found
   by file scan, or verifies/updates live wsUrl for those that were.

This caused real-world issues:
- Stale DevToolsActivePort files left by previous Chrome sessions returned dead
  instances that then failed at connection time.
- Reading `Local State` JSON and walking the directory tree is slow and fragile
  on systems where the browser data dir is not in a standard location.
- The deduplication + merge logic between the two strategies was complex and
  hard to reason about.
- Chrome 136+ on macOS does not expose /json/version on the default profile by
  default, so the HTTP probe already returned null for many users.

New approach:
- `discoverChromeInstances()` probes only port 9222 via GET /json/version.
  Returns one DiscoveredInstance if Chrome responds with webSocketDebuggerUrl,
  otherwise returns []. No file I/O, no port range scan, no profile reading.
- `resolveDirectInput(input)` is a new export that normalises a --direct value
  into a WebSocket URL:
    - ws:// or wss:// → returned as-is
    - host:port        → probed via /json/version; wsUrl returned if live,
                         'blocked' (HTTP 200 but no CDP) gives actionable error,
                         'dead' (ECONNREFUSED) gives clear "nothing listening" message
- Internal `probePortStatus()` replaces `probePort()` with a richer union type
  {type: 'live'|'blocked'|'dead'} so callers can distinguish between "Chrome is
  there but inaccessible" vs "nothing running at all".
- `probePort()` is kept as a thin public wrapper (live → wsUrl+browser, else null)
  for backward compatibility with external callers.
- Removed exports: getChromeProfiles, deriveBrowserName, getDataDirParents,
  findDevToolsActivePortFiles (and internal scanForActivePort).
- Tests updated: remove describe blocks for the deleted functions
  (deriveBrowserName, getDataDirParents, getChromeProfiles), keep
  parseDevToolsActivePort tests since that helper is still used.
2026-03-27 15:30:33 +01:00
Tommy D. Rossi 34ed2f4c50 refactor(cli): replace @xmorse/cac with goke
- swap cac import for goke, add zod import
- remove manual type annotations from action callbacks (goke infers from schema)
- typed options with z.number().default(10000) for --timeout and z.string().default('0.0.0.0') for serve --host; plain string descriptions everywhere else
- removed @xmorse/cac from dependencies
2026-03-27 13:16:12 +01:00
Tommy D. Rossi 4cb8a9030e docs(skill): clarify screenshotWithAccessibilityLabels is for element discovery only
Agents were using screenshotWithAccessibilityLabels when the goal was to
capture a screenshot to show the user or save an image. That tool overlays
Vimium-style labels on every interactive element — which is noisy and wasteful
when you just want a clean screenshot.

Added a one-line note immediately after the tool description pointing to
page.screenshot() + resizeImage() for those use-cases.
2026-03-26 19:01:06 +01:00
Tommy D. Rossi 648d096aa8 feat: pass browser name and profile metadata through direct CDP sessions
When creating a direct-CDP session the relay was always storing
  { browser: null, profile: null }
in session metadata, so tools like `list_sessions` showed no useful
label for those sessions.

This wires the information all the way through:

cli.ts
- BrowserOption grows a `profiles` field (raw discovery data).
- instanceToBrowserOption() populates it from DiscoveredInstance.
- createDirectSession() accepts optional `browser` and `profiles` args
  and includes them in the POST body.
- All three call-sites that create a direct session now pass these args.
- Remote-relay guard: when --host is set the relay is running elsewhere
  and cannot reach local Chrome debug ports, so directInstances discovery
  is skipped entirely (was always returning 0 results but wasted time and
  printed a confusing 'Discovering...' message).
- Improved --direct help text: explains that Chrome must be launched with
  --remote-debugging-port=9222 or via chrome://inspect/#remote-debugging.

cdp-relay.ts (/cli/session/new)
- Accepts `browser?: string` and `profiles?: Array<{name,email}>` in the
  POST body.
- Validates that cdpEndpoint starts with ws:// or wss:// and returns 400
  with a descriptive error if not (previously would throw at connect time
  with a cryptic message).
- Populates sessionMetadata.browser and sessionMetadata.profile from the
  supplied values so downstream tools can display them.
2026-03-26 19:01:00 +01:00
Tommy D. Rossi 05ceb6c7ab fix(chrome-discovery): probe all discovered ports and prune stale entries
Previously Strategy 2 only scanned the fixed range 9222-9229. This meant
browsers running on non-standard ports (detected via DevToolsActivePort) were
never liveness-checked: stale .DevToolsActivePort files from a previous Chrome
run would surface as valid instances even though nothing was listening.

Changes:
- Build a unified portsToProbe set: every port found in DevToolsActivePort
  files PLUS the standard 9222-9229 range (deduped via Set).
- If a probe returns a live wsUrl, update the existing instance's URL
  (DevToolsActivePort GUIDs go stale; the probed URL is always current).
- If a probe fails for a port that DevToolsActivePort reported, delete the
  entry — nothing is actually listening on that port.
- The 'Found only via port scan' branch is preserved for ports in 9222-9229
  that have no DevToolsActivePort file (e.g. Chrome launched with
  --remote-debugging-port=9222 but no profile dir).

The net effect: discoverChromeInstances() now only returns instances that
are genuinely alive, regardless of what port they are on.
2026-03-26 19:00:47 +01:00
Tommy D. Rossi d69be484d2 website: redesign header, TOC, and layout
- Add 5-column CSS grid layout (TOC | gap | content | gap | sidebar)
- Add sticky header with logo, tab bar (Intro/GitHub/Changelog), and icon links
- Replace text logo with SVG logo using CSS mask-image + --logo-color variable (#e8602c)
- TOC: support multiple expanded sections, remove View Transition API, add toggle
- TOC: remove tree prefixes from root-level items, keep for nested children only
- TOC: sticky positioning with proper alignment to content column
- Increase TOC line height from 1.32 to 1.6
- Remove search bar from header
- Add SidebarBanner and TabLink components
- Add --logo-color CSS variable, remove --logo-filter dark mode hack
- Reduce --bleed from 44px to 16px
2026-03-26 16:14:23 +01:00
Tommy D. Rossi bef44d343a fix(snapshot): address oracle review findings for contenteditable detection
1. Fix ref selector fallback: promoted contenteditable elements now
   store [contenteditable="true"] as their ref selector so
   getSelectorForRef() doesn't fall back to role=textbox (which
   Playwright can't match for bare contenteditable divs).

2. Broaden contenteditable detection: now handles contenteditable="",
   bare contenteditable attribute, and contenteditable="plaintext-only"
   per the HTML spec, not just contenteditable="true".

3. O(1) AX node lookups: build axByBackendId map upfront instead of
   repeated O(n) axNodes.find() calls during promotion and root finding.

4. Cross-platform tests: Meta+A → ControlOrMeta+A for Linux/Windows.
2026-03-26 13:37:48 +01:00
Tommy D. Rossi 7a43488f09 fix(snapshot): detect bare contenteditable elements as interactive textboxes
Rich text editors (ProseMirror, Tiptap, Slate, Lexical) use
`<div contenteditable="true">` without explicit `role="textbox"`.
Chrome's AX tree reports these as `generic`, so the snapshot code
treated them as skip-wrapper nodes and completely omitted them.

The fix scans DOM nodes for `contenteditable="true"` after building
the AX tree. If the corresponding AX node has a non-interactive role,
it gets promoted to `textbox`. Promoted elements use
`[contenteditable="true"]` as the Playwright locator since
`role=textbox` won't match bare contenteditable divs in Chrome.
Elements with explicit `role="textbox"` keep using role-based locators.

Before: editor invisible in snapshot, AI can't find or interact with it.
After:  `textbox [contenteditable="true"]` appears in snapshot with
        a working locator for click + keyboard.type().

Tests verify against prosemirror.net and local contenteditable pages.

Closes #72
2026-03-26 11:57:27 +01:00
Tommy D. Rossi 93e306d148 feat(playwriter): add direct CDP connection mode (--direct)
Connect to Chrome's built-in debugging WebSocket without the Playwriter
extension. Works with any Chromium-based browser (Chrome, Brave, Ghost
Browser, Arc, Edge, etc.) that has debugging enabled.

Discovery uses two browser-agnostic strategies:
- Glob for DevToolsActivePort files in platform data dirs (depth 1-3)
- Port scan 9222-9229 via GET /json/version

CLI:
- `playwriter session new --direct` auto-discovers and connects
- `playwriter session new --direct ws://...` explicit endpoint
- `playwriter browser list` shows discoverable instances
- Multi-browser table now includes direct CDP entries alongside extensions
- Single direct instance auto-selects (same as extension behavior)

MCP:
- PLAYWRITER_DIRECT=auto or PLAYWRITER_DIRECT=ws://... env var
- `playwriter --direct` flag for MCP server mode
- Skips relay server entirely in direct mode

Executor:
- connectToBrowser() branches on directCdpUrl in CdpConfig
- ensurePageForContext() handles direct mode (no extension check)
- reset() reuses shared connectToBrowser() helper (deduped from before)

Relay:
- /cli/session/new accepts { cdpEndpoint } for direct sessions
- ExecutorManager.getExecutor() accepts per-session cdpConfig override

Limitation: recording (chrome.tabCapture) unavailable in direct mode.

Bumps to 0.0.98.
2026-03-26 00:28:57 +01:00
Tommy D. Rossi 3dda03ea7e feat(playwriter): launch a managed browser with bundled extension
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.
2026-03-25 21:31:47 +01:00
Tommy D. Rossi 38147f2a4a test(playwriter): stabilize external accessibility snapshots
Wait for stable page-specific content before capturing accessibility snapshots from live external pages in relay-core coverage. This prevents flaky empty interactive snapshots from Hacker News and shadcn when the AX tree lags behind domcontentloaded.

Also refresh the download-event inline snapshot to match the current extension-mode behavior where both Browser.download* and Page.download* events are observed during the regression test.
2026-03-22 18:02:36 +01:00
Tommy D. Rossi 5961da626e fix(playwriter): preserve session cwd per CLI session
Show each session's cwd in session list and keep it on the executor so similar sessions are easier to distinguish. Resolve sandboxed relative fs paths from the cwd captured by 'playwriter session new' instead of the detached relay process cwd, preventing cross-session cwd leakage after relay restarts.
2026-03-22 17:40:40 +01:00
Tommy D. Rossi c812126ef0 Add long-running stability test script
Soak test that creates a session, opens one page, then loops for hours
executing 8 Playwright operations every 5 seconds (title, evaluate x3,
url, snapshot, locator count, goto). Tracks success/failure counts and
prints a summary on exit.

First 30-minute run: 2,272 operations, 0 failures, 100% success rate.

Usage: bun playwriter/scripts/long-running-stability-test.ts --duration 2
2026-03-21 12:30:54 +01:00
Tommy D. Rossi dc5f489d53 Return screenshot image inline in MCP tool result
screenshotWithAccessibilityLabels now returns the image directly in the
MCP content blocks so agents see it without re-reading the file path.

- executor: extracted ExecuteScreenshot type, return screenshots metadata
  separately from text so each consumer formats independently
- mcp: appends 'Screenshot saved to: path (image included below)' + snapshot
  text, with truncation cap to prevent output bloat
- cli: shows just the file path + label count + snapshot (no inline image)
- cdp-relay: normalized error response shape to include screenshots: []
- test: updated assertions to match new MCP text format
2026-03-20 13:46:06 +01:00
Tommy D. Rossi 3c19066532 release: playwriter@0.0.89 2026-03-12 22:18:02 +01:00
Tommy D. Rossi 24db529247 test/docs: add cookie workaround for issue #66 and skip failing repro
Add a focused regression test that reads page cookies via Network.getCookies through a page CDP session in extension mode. Keep the Storage.getCookies issue #66 reproduction test skipped with an explicit note that Storage.getCookies is unsupported in this relay path and document the required workaround in skill.md.
2026-03-12 16:30:44 +01:00
Tommy D. Rossi 4557fe16f9 fix(relay): restore extension-mode downloads via Browser/Page compatibility bridge (fixes #65)
Map Browser.setDownloadBehavior to per-page Page.setDownloadBehavior in the relay and cache behavior per extension so newly attached pages inherit the same download policy.

Forward compatibility download events in both namespaces by emitting synthetic root-session Browser.downloadWillBegin/Browser.downloadProgress while preserving original Page.download* events for backward compatibility clients.

Add an integration regression test that reproduces the extension-mode download path, verifies Playwright download event resolution, and snapshots observed CDP method coverage from cdp.jsonl.
2026-03-12 16:30:44 +01:00
Tommy D. Rossi 3a045c17d4 fix: reduce skill.md by 22% to prevent agents truncating docs (fixes #63)
`playwriter skill` output was ~58KB / 1302 lines. Agents (both Opus and
Sonnet) consistently truncated with `head` or skimmed, missing critical
sections like "working with pages" at line 606.

Reduced to 44KB / 1019 lines (−283 lines, −24%) by:

- Removing BAD examples from mistake sections, keeping only rules + correct patterns
- Removing duplicated sections: "checking page state" (duplicated interaction
  loop), "debugging web apps" (duplicated observation channels), "capabilities"
  list (obvious from rest of doc), "links that open new tabs" (duplicate of
  mistake #10)
- Condensing verbose sections: interaction feedback loop (removed ASCII diagram
  + Framer-specific 4-block example, replaced with compact generic example),
  getCleanHTML internals, recording prose, createDemoVideo (3 blocks → 1)
- Integrated debugging recipe (snapshot + logs + evaluate) into the feedback
  loop section as "deeper observation" examples
- Added explicit instruction in SKILL.md stub: "Do NOT pipe through head, tail,
  or any truncation command — read the entire output"
2026-03-07 09:25:31 +01:00
Tommy D. Rossi 4724383abc test: stabilize relay-core click error snapshots by ensuring connected tab
The three descriptive click-error tests can run after a prior test closes all
connected pages, causing MCP execute to fail early with 'No Playwright pages are
available' instead of exercising click visibility/interception errors.

Add ensureConnectedTabForExecute() and call it before the three snapshot tests
so they always have one connected tab context and assert the intended errors.
2026-03-05 00:38:47 +01:00
Tommy D. Rossi 905df6eec5 fix: route Target.detachFromTarget on root CDP session (#40)
When Playwright sends Target.detachFromTarget via the root browser session
(no top-level sessionId), the extension couldn't find the target tab because
it only checked msg.params.sessionId for routing. This caused 'No tab found'
errors that cascaded into disconnects and instability.

- Add getTabForCommand() helper with params.sessionId fallback so any command
  referencing a session in its params can be routed when the top-level
  sessionId is absent
- No-op Target.detachFromTarget for stale/unknown sessions instead of throwing
- Always re-apply tab group color on every sync to prevent Chrome resetting
  it to white
- Replace silent .catch() with error log in aria-snapshot OOPIF detach
- Add regression test using raw WebSocket to verify routing without sessionId

Extension bumped to 0.0.74.
2026-03-04 21:57:27 +01:00
Tommy D. Rossi 5ad2366d1e remove generic --timeout CLI option from skill docs
The --timeout flag isn't useful enough to document prominently.
Keeps the createDemoVideo-specific timeout note since that's
a real footgun (ffmpeg can take 60-120s).
2026-03-01 10:50:59 +01:00