I have Claude Code drive my everyday Chrome — the real, logged-in profile — through the Playwright MCP server. It checks admin screens, fills forms and verifies that posts actually went out, every day.
Automating a real browser is not like a headless test browser. Clicks wait 30 seconds and fail, non-ASCII text arrives with characters missing, and "I typed it" does not mean "it was saved". Here are 12 traps I actually hit, with the fix that worked. My setup is Windows 11 and Chrome.
1. You cannot attach a debugging port to your normal Chrome profile
Recent Chrome (136 and later) refuses --remote-debugging-port on the default profile.
chrome.exe --remote-debugging-port=9225 --user-data-dir="C:/Users/you/chrome-cdp-work" --no-first-run
- Create a dedicated
--user-data-dirfor automation and log in there once by hand. - Connect to
127.0.0.1, notlocalhost(name resolution can go to IPv6 and fail). - Pin one port and one profile per purpose, so you always know which account is being driven (for example 9225 for work A, 9226 for work B).
On the Playwright MCP side, use a CDP connection:
{ "command": "npx", "args": ["@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9225"] }
2. "visible, enabled and stable" times out after 30 s → the window is covered
This is the most common failure.
TimeoutError: locator.click: Timeout 30000ms exceeded.
- waiting for element to be visible, enabled and stable
It looks like an element problem. Almost always, the real cause is that the window is completely covered by another window, so the page is at document.visibilityState === 'hidden'. A hidden tab stops rendering, and Playwright cannot perform a real click.
// check this first
document.visibilityState // "hidden" means it is not the element's fault
The fix is to bring that Chrome window to the front. With several profiles on one monitor they overlap, so I use a small PowerShell script that calls Windows SetWindowPos (HWND_TOPMOST) to pin just that window on top, and unpins it afterwards. If you forget to unpin, you get in the human's way, so I treat "pin → work → unpin" as one unit.
3. A new tab is hidden right after it opens
Tabs opened with browser_tabs new or window.open can start in the background as hidden. Call page.bringToFront() before doing anything.
4. Display off or a locked screen makes everything hidden
After some idle time the display turns off and every window becomes hidden. You can prevent display-off with power settings or periodic input, but you cannot get past the Windows lock screen. A person has to unlock it, so for overnight work I lean on methods that do not need a click (APIs and evaluate). The most reliable way I found to detect the lock state was the output of quser.
5. Use insertText for non-ASCII input
Sending key events one character at a time drops or reorders Japanese text. CDP's Input.insertText (page.keyboard.insertText() in Playwright) inserts the whole string reliably.
await page.locator('.cm-content').click();
await page.keyboard.insertText(longMarkdown);
This works for CodeMirror editors too. Note that CodeMirror does not render off-screen lines, so innerText.length looks shorter than what you inserted. Confirm in the preview instead.
6. In React rich-text editors, "select all → delete" can leave the content behind
In a Draft.js-style editor, Ctrl+A → Delete cleared what I could see while the React state kept the old text, and submitting posted the old text. Select with document.execCommand('selectAll') before typing, and confirm with a real number on screen such as the character counter.
It also happens in the other direction. In one caption field, after pasting, the screen showed only the last line while the character counter showed the full length. If I had trusted the counter alone, I would have posted without the body. What you see and what the app holds can drift apart either way, so check both.
7. ProseMirror editors accept formatting through an HTML paste event
For ProseMirror-style editors (product description fields and the like), dispatching a paste event that carries text/html keeps headings, lists and bold intact.
const ed = document.querySelector('.ProseMirror');
ed.focus();
const r = document.createRange(); r.selectNodeContents(ed);
getSelection().removeAllRanges(); getSelection().addRange(r);
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', 'x');
ed.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
The first heading sometimes merges into the paragraph before it, so after pasting I count the structure (number of h2, number of li) to confirm.
8. To hand a local file to the page, go through a hidden input[type=file]
Inside browser_run_code, import('fs') was not always available. When I need to pass a long draft to the page, I create a hidden file input, call setInputFiles, and read it on the page side.
await page.evaluate(() => {
const i = document.createElement('input');
i.type = 'file'; i.id = 'tmp-file'; i.style.display = 'none';
document.body.appendChild(i);
});
await page.locator('#tmp-file').setInputFiles('C:\\path\\to\\article.md');
const text = await page.evaluate(() => document.getElementById('tmp-file').files[0].text());
The same goes for image uploads: on sites where a transparent overlay sits on top of the button and swallows the click, calling setInputFiles directly on the input[type=file] is the reliable route.
9. Confirm "it is published" while logged out
A browser logged in as the admin sees the newest, cache-bypassed page. An ordinary visitor may still see an old cache or an unpublished state. Before reporting "done", open the page in a clean profile.
chrome.exe --headless=new --user-data-dir="%TEMP%\anon-check" --virtual-time-budget=15000 --dump-dom "https://example.com/page" > page.html
For JavaScript-rendered sites curl returns an empty shell, so I take the rendered DOM with --dump-dom and then count the headings and links I expect.
10. A beforeunload dialog freezes everything
Navigating away from an editor with unsaved changes raises a beforeunload confirmation, and every later action hangs. If it appears when you thought you had published, that is the signal that you had not. Dismiss the dialog, check the save state, and redo it.
11. Small Windows traps: BOM and line endings
- A
.ps1containing non-ASCII text must be saved as UTF-8 with BOM, or Windows PowerShell 5.1 fails to parse it. - Python on Windows writes CRLF with
open(path, "w"). For files you upload to a server, passnewline="\n"explicitly. - Command output captured over ssh can have stderr warnings mixed in. Add
2>/dev/nullbefore you use the output as content.
12. Traps when you also run your own Chrome extension (MV3)
- Inline handlers such as
onclickare silently ignored under CSP. UseaddEventListener. - The service worker stops after about 30 seconds. Long waits built on
setTimeoutvanish midway. - For an unpacked extension, the old service worker keeps running after you replace the files; you have to reload the extension. Bumping
versioninmanifest.jsonevery time tells you which one is live.
Summary
When real-browser automation fails, check these three before you suspect the element or the selector:
- Is
document.visibilityStatevisible(is the window actually showing)? - Did the input reach the app's state (confirm with a counter, the save state or a preview, not with what you see)?
- Did you confirm the result while logged out?
How I make Claude Code follow this "verify, then report" rule is in my previous post: I don't write code. Here's how I made Claude Code the steward of my one-person business. The whole setup is also written up as a short Kindle book, Let Claude Code Run Your One-Person Business (in Kindle Unlimited).
Top comments (0)