<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Amr Hares</title>
    <description>The latest articles on DEV Community by Amr Hares (@amrhares).</description>
    <link>https://dev.to/amrhares</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4126872%2F058c4a9a-8de5-4184-8586-343c9cb9dd91.png</url>
      <title>DEV Community: Amr Hares</title>
      <link>https://dev.to/amrhares</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amrhares"/>
    <language>en</language>
    <item>
      <title>Five things that break when you drive a real Chrome over CDP (and how to verify your writes actually landed)</title>
      <dc:creator>Amr Hares</dc:creator>
      <pubDate>Tue, 15 Sep 2026 20:10:27 +0000</pubDate>
      <link>https://dev.to/amrhares/five-things-that-break-when-you-drive-a-real-chrome-over-cdp-and-how-to-verify-your-writes-2jfk</link>
      <guid>https://dev.to/amrhares/five-things-that-break-when-you-drive-a-real-chrome-over-cdp-and-how-to-verify-your-writes-2jfk</guid>
      <description>&lt;h1&gt;
  
  
  Five things that break when you drive a real Chrome over CDP (and how to verify your writes actually landed)
&lt;/h1&gt;

&lt;p&gt;Most "browser automation" advice assumes a fresh, disposable browser. The interesting problems start when the browser is a &lt;em&gt;real&lt;/em&gt; one: your normal Chrome, with your normal profile, already logged into the sites you need. That is the setup I run — Chrome started with &lt;code&gt;--remote-debugging-port=9243&lt;/code&gt; on a persistent &lt;code&gt;--user-data-dir&lt;/code&gt;, driven from Python over raw Chrome DevTools Protocol.&lt;/p&gt;

&lt;p&gt;No Selenium, no Playwright, no driver binaries. Just &lt;code&gt;websocket-client&lt;/code&gt; and the protocol: hit &lt;code&gt;http://127.0.0.1:9243/json/list&lt;/code&gt; for the targets, connect to a page's &lt;code&gt;webSocketDebuggerUrl&lt;/code&gt;, then send &lt;code&gt;Runtime.evaluate&lt;/code&gt;, &lt;code&gt;Input.*&lt;/code&gt;, &lt;code&gt;DOM.*&lt;/code&gt;, &lt;code&gt;Network.*&lt;/code&gt; and friends.&lt;/p&gt;

&lt;p&gt;It works well. It also fails in ways that never show up as exceptions, which is the part worth writing down. Everything below is a failure I actually hit, in the order I hit it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The OAuth popup that never opens
&lt;/h2&gt;

&lt;p&gt;The first login I automated was a Google sign-in that opens a popup. The click fired, and nothing happened: Chrome blocked the popup because it wasn't tied to a user gesture it recognised.&lt;/p&gt;

&lt;p&gt;The fix is one flag at launch time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;google-chrome &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remote-debugging-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;9243 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--user-data-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/home/amr/.config/google-chrome-cdp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable-popup-blocking&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With popups allowed, the flow becomes: click the provider button, wait for a new target whose URL starts with &lt;code&gt;accounts.google.com&lt;/code&gt;, attach to &lt;em&gt;that&lt;/em&gt; target's own websocket, and drive it like any other page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_popup&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;                      &lt;span class="c1"&gt;# GET /json/list
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accounts.google.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="n"&gt;popup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_popup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;websocket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_connection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;popup&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;webSocketDebuggerUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# choose the account row, then the consent button, in a loop until the URL leaves Google
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that bit me later:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every OAuth is a popup.&lt;/strong&gt; When I signed up on dev.to, the "Continue with Google" button navigated the &lt;em&gt;current&lt;/em&gt; tab to &lt;code&gt;accounts.google.com&lt;/code&gt; and back to a &lt;code&gt;/users/auth/google_oauth2/callback&lt;/code&gt; URL. If your code only watches for a new target, it will sit there forever. Handle both: after clicking, poll for either a new popup target &lt;em&gt;or&lt;/em&gt; your own tab's URL changing to the provider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On a shared browser, do not grab "any" Google tab.&lt;/strong&gt; The browser I automate usually has several tabs doing different jobs. On one attempt I connected to the first &lt;code&gt;accounts.google.com&lt;/code&gt; target I found — it belonged to a different tab's Luma OAuth flow — and happily clicked through its consent screen. Take a snapshot of target IDs &lt;em&gt;before&lt;/em&gt; you click, then pick the target that wasn't there before, and sanity-check its &lt;code&gt;openerId&lt;/code&gt; against your own tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Input events into a background tab are silently dropped
&lt;/h2&gt;

&lt;p&gt;This one cost me fifteen identical clicks and a lot of confusion: I dispatched a mouse click at the exact centre of the right button, the coordinates were correct, &lt;code&gt;elementFromPoint&lt;/code&gt; at those coordinates returned the button, and the page did absolutely nothing.&lt;/p&gt;

&lt;p&gt;Headed Chrome only routes &lt;code&gt;Input.*&lt;/code&gt; events to the foreground tab. If your tab is in the background — because another part of your automation switched tabs, or the user did — clicks, keys and &lt;code&gt;insertText&lt;/code&gt; all go nowhere, with no error.&lt;/p&gt;

&lt;p&gt;The fix is to claim the foreground before every interaction, and to re-claim it between steps, because something else can steal it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Target.activateTarget&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;targetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;my_tab_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Page.bringToFront&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# now the click lands
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After adding activation, the same click sequence completed the whole OAuth flow in two steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. File inputs: &lt;code&gt;success&lt;/code&gt; with zero files
&lt;/h2&gt;

&lt;p&gt;Uploading the product zip to a Gumroad listing looked like the easy part. &lt;code&gt;DOM.setFileInputFiles&lt;/code&gt; reported success — and the page still showed nothing. The reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input[type=file]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;   &lt;span class="c1"&gt;// 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call is a no-op unless the backend node actually resolves to a real file input. Worse, the fallback path (clicking the input and driving the native dialog) doesn't exist on a bare X display with no window manager: no dialog opens, so there is nothing for &lt;code&gt;xdotool&lt;/code&gt; to type into.&lt;/p&gt;

&lt;p&gt;React drop-zones make this worse, because they don't read the file input at all. They listen for &lt;strong&gt;drag events&lt;/strong&gt; — &lt;code&gt;dragenter&lt;/code&gt;, &lt;code&gt;dragover&lt;/code&gt;, &lt;code&gt;drop&lt;/code&gt; — and they read &lt;code&gt;event.dataTransfer.files&lt;/code&gt;. A synthetic &lt;code&gt;change&lt;/code&gt; event on a hidden input is not a drop. What works is dispatching an actual drag sequence with the file attached to the drag payload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;items&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mimeType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/zip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ZIP_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dragOperationsMask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dragEnter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dragOver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Input.dispatchDragEvent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The coordinates must sit over the drop zone (same coordinates you would use to click it), &lt;code&gt;dragOperationsMask: 1&lt;/code&gt; is "copy", and binary payloads go in as base64. Then — and this is the whole point — &lt;strong&gt;re-read the persisted state&lt;/strong&gt; instead of trusting the drop. In this case the listing's server-rendered model finally showed the file with its real byte size. Before the drag-event route, it showed nothing for an hour while my own logs said the upload had "succeeded".&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rich-text editors: the DOM is not the model
&lt;/h2&gt;

&lt;p&gt;Gumroad's description editor is tiptap, a ProseMirror wrapper, and ProseMirror keeps its own document model. You can make its contenteditable DOM show your text and still save nothing, because the model never changed.&lt;/p&gt;

&lt;p&gt;What failed: dispatching a synthetic &lt;code&gt;paste&lt;/code&gt; (&lt;code&gt;ClipboardEvent&lt;/code&gt;) with the text in &lt;code&gt;clipboardData&lt;/code&gt;, and typing into the DOM's &lt;code&gt;innerText&lt;/code&gt; directly. Both left the editor's model empty; the editor itself still reported an empty document (&lt;code&gt;len=1&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;What worked was treating it like a human at a keyboard, with the tab foregrounded (see §2):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Page.bringToFront&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Input.dispatchMouseEvent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mousePressed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clickCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Input.dispatchMouseEvent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mouseReleased&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;left&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clickCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;js&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;document.activeElement === editor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Input.insertText&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;description_markdown&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Input.insertText&lt;/code&gt; goes in through the browser's real input pipeline, so ProseMirror sees it as a genuine text insertion. Paragraph breaks become real paragraphs, and the editor's empty-state class disappears — but that's still &lt;em&gt;editor&lt;/em&gt; evidence, not persistence evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verifying a write: three checks, in order
&lt;/h2&gt;

&lt;p&gt;The lesson from all four failures above is that the UI will happily show you a lie: a "success" file API call with zero files, an editor full of text that saves as empty, a click that lands on the right pixel and does nothing.&lt;/p&gt;

&lt;p&gt;So the rule I now follow is: &lt;strong&gt;a write is only real if you can read it back from somewhere that doesn't share state with the thing that wrote it.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture the network payload.&lt;/strong&gt; Enable &lt;code&gt;Network&lt;/code&gt; on the tab, click the real Save button with a trusted click, and inspect &lt;code&gt;Network.requestWillBeSent&lt;/code&gt; events for the request your form submits.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Network.enable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ... trusted click on Save ...
&lt;/span&gt;&lt;span class="n"&gt;evs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drain_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;evs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Network.requestWillBeSent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;params&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postData&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description in payload:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my first line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time I trusted the editor, that check is what proved the text never left the page.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hard-reload and re-read the persisted model.&lt;/strong&gt; For Gumroad the server renders the stored product into &lt;code&gt;#app[data-page]&lt;/code&gt; as JSON — description, files, price — the same object the public storefront reads. If the reloaded page's model doesn't have your text, nothing persisted, no matter what the editor said a second ago.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fetch the public URL and grep the HTML.&lt;/strong&gt; &lt;code&gt;curl -s https://the-public-page | grep -F "first line of my text"&lt;/code&gt;. This is the only check your users can reproduce, so it's the one that counts.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That loop — dispatch real input, capture the request, reload, curl the public page — has caught every one of the silent failures above. It is also how I confirmed that the article you are reading was actually published, rather than sitting in a draft tab that looked fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  If any of this is the kind of thing you fight
&lt;/h2&gt;

&lt;p&gt;I packaged the working pieces of this setup — the CDP client, a page watcher, a declarative form filler, a table scraper and a change diff — as a small toolkit: &lt;a href="https://basharwave8.gumroad.com/l/pwmfu" rel="noopener noreferrer"&gt;Browser Automation Toolkit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That's a paid bundle ($14), and I'd rather say what that means: the core source is MIT-licensed and public on &lt;a href="https://github.com/AH64-dll/browser-automation-toolkit" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The paid zip is for people who want the packaged version in one file — quickstart, changelog, examples and a working test suite, plus updates and email answers from me. If you'd rather read the source first, the repo is right there and it's the same code.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>python</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
