` (with `role="textbox"`), NOT a textarea. Setting `.value` via the React-aware textarea setter does nothing. | Click into the contenteditable div to focus, then use `page.keyboard.type(text, { delay: 5 })`. Optionally `Meta+A` → `Delete` first to clear prefilled content. |
| YouTube Studio | Every visible `ytcp-button` wraps a hidden native `
` with the same text. Querying `button, ytcp-button` and filtering by text returns the count doubled (`Publish` matches twice; `Done` matches four times when a sub-dialog is open). | Scope queries to the smallest container that uniquely contains the action (e.g., `document.querySelector('ytcp-banner-editor').querySelector('button')`), or take only the first non-disabled match. The duplicates don't cause click failures — clicking either works — but probe output is confusing if you don't expect it. |
| YouTube Studio | File inputs for profile picture and banner are hidden (`width=0`, `height=0`), parented under `ytcp-profile-image-upload` / `ytcp-banner-upload` custom elements. Setting `.files` directly on these inputs DOES trigger the upload flow — no need to click the visible "Change" button first. | Find by parent class containing `profile-im` or `banner-upl`. Set `files`, dispatch `change`, then wait ~3.5–5s for the editor dialog to mount. |
| YouTube Studio | After upload, a crop/customize dialog mounts (`ytcp-profile-image-editor` or `ytcp-banner-editor`). Both dialogs may persist in the DOM after their first use and stay `display:visible` even when the other is the "active" one — querying any `ytcp-dialog` returns multiple matches, and the wrong "Done" button click leaves your upload pending (Publish stays disabled). | Target the dialog by editor class explicitly: `document.querySelector('ytcp-banner-editor').querySelector('button')` for the banner Done, `ytcp-profile-image-editor` for the avatar Done. If you see Publish stay disabled after clicking Done, you clicked the wrong dialog's Done — re-probe and target the correct editor. |
| YouTube Studio | Save action is "Publish" (top-right). It's greyed until there are unsaved changes, and reverts to greyed after a successful publish. Success signal is a black toast at the bottom of the viewport reading "Changes published" with a "Go to channel" button. | Wait for the toast, not just the click. Re-check Publish-disabled state to confirm save completed. |
| YouTube Studio video details dropdowns (2026-05) | Hidden stale `tp-yt-paper-dialog` dropdowns remain mounted after prior opens. Querying `tp-yt-paper-item` globally can select an invisible stale `English`/`Music` item at rect `0,0`, so the click appears to run but the field does not change. Language dropdowns also render long lists where the target option exists below the visible menu viewport. | Scope option lookup to the currently visible `tp-yt-paper-dialog` (`getBoundingClientRect().width/height > 0`), then scroll that dialog's `.content` container to `item.offsetTop - 250` before clicking by coordinates. Close the menu with `Escape` before clicking `Save`, and verify by re-reading the dropdown text after reload. |
| YouTube Studio | The customization page URL is `studio.youtube.com/channel//editing` (Profile tab is at `/editing/profile`). The public channel page (`youtube.com/channel/`) shows a "Customize channel" link that opens Studio — but for automation, navigate the existing tab to the Studio URL directly via `page.goto`. | Use `page.goto("https://studio.youtube.com/channel//editing/profile", { waitUntil: 'domcontentloaded' })` then `waitForTimeout(5000)` because Studio's Polymer/Lit startup is slow. |
| YouTube Studio | Link slots ("Links" section) and the Contact-info Email field have NO `aria-label`. Matching `aria-label` for `link title` / `url` / `email` returns zero — the only stable identifier is `placeholder`. | Match by `placeholder`: `"Enter a title"` for link titles, `"Enter a URL"` for link URLs, `"Email address"` for the contact email. |
| YouTube Studio | The "Add link" button is disabled while any existing link row is still empty. Calling it twice in a row on a fresh page (one empty slot pre-mounted) only fills one row and silently no-ops on the second click. | Fill the visible empty row's title+URL pair first (fires the input/change events), then re-query and click "Add link" — it re-enables once the previous row has both fields filled. |
| YouTube Studio | Public verification for channel links + contact email: server-side render on `/about` page exposes all three. | `curl -sL https://www.youtube.com/@/about \| grep -oE 'instagram\.com/<...>|soundcloud\.com/<...>|'` — fastest non-DOM verification. |
| Instagram web (caption editor — CRITICAL) | The caption `[contenteditable="true"]` is a **Lexical** editor (Meta's framework, identified by `` wrappers). `document.execCommand('insertText', false, text)` renders the text VISUALLY in the DOM but does NOT reliably commit to Lexical's internal `EditorState`. Share fires the post with the React-state caption (empty) and the post lands with NO caption — but every screenshot you took along the way (composer with caption visible, character counter showing N/2,200) looks fine. Failure mode: 5 posts shipped, all captions blank server-side, undetectable until you reopen the Edit dialog from a fresh navigation. | Use real keyboard events: `page.type('div[role="dialog"] [contenteditable="true"]', text, { delay: 10 })` after `evaluate(() => ce.focus())`. This emits `keydown/beforeinput/input/keyup` which Lexical's listeners require. `page.type` also handles unicode/emoji correctly. After typing, `waitForTimeout(1500-2000)` for Lexical's async reconciliation pass. |
| Instagram web (Done/Submit clicks — CRITICAL) | Clicking a `` via `element.click()` inside `page.evaluate(...)` fires the DOM click event but does NOT reliably trigger React's synthetic event handler on Lexical-backed forms. The Edit dialog's "Done" button closes the dialog visually (so it looks like it worked) but does NOT dispatch the save mutation — the caption stays whatever was on the server before. This is the same root cause as the Share-without-caption failure in the post composer. | Use real mouse events via the host-side API: mark the target with a unique attribute (`done.setAttribute('data-jg-done','1')`), then `page.click('[data-jg-done="1"]')`. `page.click` synthesizes the full `mousedown/mouseup/click` pair which React's event system listens for. Same applies to Share, Next, and any other div-role-button. **The in-evaluate `.click()` works fine on real `
` elements; the bug is specific to `` + React synthetic events.** |
| Instagram web (caption-edit recovery) | If posts shipped without captions (Lexical/click bug above), the Edit dialog can backfill them in-place. No need to delete-and-repost. Caption is editable indefinitely on regular feed posts. | For each post: `goto(.../p/
/)` → click `svg[aria-label="More options"]` ancestor → click `Edit` button in menu → focus contenteditable → `page.type(...)` the caption → `page.click('[data-jg-done="1"]')` on Done. Verify by navigating away and reopening Edit; `ce.innerText` should match. |
| Instagram web (caption verification oracle) | Standalone post page (`//p//`) renders the caption column EMPTY in the desktop logged-in viewport when comment count is zero — this is a layout artifact, NOT a caption bug. The `embed/captioned` view ALSO renders without caption text for cold posts. The FB-crawler-UA fetch of the post page returns `"caption":null` regardless. None of these are reliable. | The authoritative caption oracle is **opening the post's Edit dialog and reading the contenteditable's `innerText`** — that's what's actually stored server-side. Don't trust standalone-page screenshots or embed views; don't trust og:description content; don't trust the FB-UA HTML fetch. |
| Instagram web (per-script timeout budget) | The dev-browser QuickJS sandbox kills any single `run` after ~30s. Multi-post verification or fix loops that exceed 30s get terminated mid-script. Posts that complete before the timeout DO persist (Share/Done already fired), but any post-action verification gets skipped. | Split work into one-post-per-`run` invocations. Use bash to chain them with `sleep` between calls. Move the verification re-open path into a separate `run` that does 2-3 posts per script (each iteration ~10s). Don't bundle all 5 posts into one script. |
| Midjourney v7 web (2026-05-25) | Omni Reference is **NOT** a tab in the "Add Images" drawer. The drawer has exactly three tabs: `Start Frame`, `Image Prompts`, `Style References`. Per MJ docs, Omni Reference (`--oref`) materializes as a **drag-and-drop bin labeled "Omni-reference"** on the prompt bar only **during a drag operation** from the OS / desktop. Static DOM scans find zero `omni`/`oref` markers because the drop bin isn't mounted at rest. Also: **Omni supports exactly ONE image** (not multiple); strength is controlled by `--ow` (range 0–1000, default 100); a lock icon pins the ref to the imagine bar across multiple prompts. | To attach an Omni Reference programmatically: (a) stage the source file locally in `~/.dev-browser/tmp/`, (b) target the prompt bar / textarea container, (c) dispatch synthetic `dragenter`/`dragover` with a `DataTransfer` containing the File to surface the "Omni-reference" drop bin, (d) dispatch `drop` on that bin. This is **untested** on the stacy.offline account — first run should be exploratory with screenshots between each event. Reusing a gallery thumbnail as the Omni source is also untested; documented happy path is local-file drag. |
| Midjourney v7 web (CDN URLs, 2026-05-25) | Gallery thumbnails are served at `cdn.midjourney.com/u//_384_N.jpg`. Guessed full-res variants (`_N.jpg`, `_1024_N.jpg`, `_2048_N.jpg`, `_full_N.jpg`) return **403** even when the HEAD request is made from inside the authenticated page context. The 384px thumb is the only public CDN size reachable by URL guessing. | To get a higher-resolution copy of a generated/uploaded image: open its job-detail page (`midjourney.com/jobs/?index=`) and read the lightbox ` ` from there — the lightbox surface serves a larger size. Path-guessing from the thumb URL is not viable. |
| Spotify for Artists (2026-06-26) | A Spotify **listener session** (nav shows "Log out" + avatar initial) is INDEPENDENT of **artist-dashboard access**. With a session but no claimed artist, `artists.spotify.com/home` renders the *marketing* page (only out-link is "Get access" → `/c/claim`) and the real dashboard route `artists.spotify.com/c/home` 404s ("We couldn't find that page", title → "Error - Spotify for Artists"). Mechanical populate (avatar/header/social links) is impossible until a human completes the `/c/claim` identity flow. | Don't infer dashboard access from "logged into Spotify." Probe the dashboard route `/c/home`: a 404 / "Get access" affordance = not claimed → STOP, report, hand the claim to the human. The SPA also paints a gray skeleton left-rail for ~7s before resolving — wait before trusting empty `innerText`. |
| Spotify for Artists (2026-07-27, post-claim) | Once the roster is granted, routes are artist-scoped under `/c/artist//…`, not bare `/c/home`. Confirmed for stacy offline (`14uypNwqTyJY37xXss14Px`): **Home** `…/home`, **Overview** `…/profile/overview`, **Image editor** `…/profile/edit-image` (Header image + Avatar image, each with **Update** + shared Cancel/Save), **About** `…/profile/about` (`edit bio` / `edit more info` aria-labels; social inputs `instagram-link-editor-input-label`, `twitter-link-editor-input-label`, etc.), **Settings** `…/settings` (ad prefs only). Images: click the matching **Update**, set file on the live `input[type=file]` (DataTransfer), **Save** → toast "Your image was saved. It may take up to 72 hours…". Socials: edit more info → fill → Save → "We saved your links…". extension mode under 50+ tabs often thrash (`extension_unstable` / ambiguous multi-tab targets). | Mode B: never re-claim. Prefer one S4A tab. If extension mode flaky, **Chrome AppleScript `execute javascript`** on the S4A tab works for inject+fill (chunked base64 into `window.__*B64`, never use `//` in injected JS chunk math). Bio remains human-only. |
| Apple Music for Artists (2026-06-26) | The marketing page (`artists.apple.com`) ALWAYS shows "Sign In" / "Claim your artist page" regardless of session, so it's useless as an auth oracle. The app surface is `/ui`; hitting it unauthenticated 302s to `idmsa.apple.com/IDMSWebAuth/signin?...&authResult=FAILED` (title "Sign In - Apple"). | Use the **`authResult=FAILED`** query param on the `idmsa.apple.com` redirect as the clean "not authenticated" signal — match on it rather than scraping the marketing page. Sign-in is an Apple ID identity wall (human-only); never click through it. |
| Audiomack (2026-06-26) | Auth state reads straight off the top-nav: `Sign Up` / `Sign In` present = logged out (authenticated users get an avatar/account menu instead). Profile/creator editing lives on a SEPARATE origin — `creators.audiomack.com` (e.g. `/upload`), NOT `audiomack.com`. False-positive trap: trending-tile text puts artist/song names into `/artist/`-ish hrefs (e.g. `/reggie-guyguy/song/me`) that can look like a logged-in account link. | Read auth from the nav `Sign In` presence, not from any `/artist/` href on the page. For mechanical populate, target `creators.audiomack.com` once the human has signed in. |
| Suno web (`suno.com/create` audio upload, 2026-07-02) | Upload entry is a `+ Audio` button (aria-label "Add audio - Browse, upload, or record audio") that opens a 3-item popover: **Browse / Upload / Record**. The `input[type=file]` elements are **detached at `document.body` root** (rect 0×0, no React fiber), so DOM-ancestry/dialog-scoped matching can't find the live one. No "I own the rights" checkbox exists — the only content gate is a 3-step wizard: (a) "Identify audio content" type-selector ``s (toggle aria/magenta state, no inner checkbox), (b) optional "Describe Your Audio" free-text, (c) Continue → "Saving…" → clip lands in the workspace list with an "Upload" badge. | Monkey-patch `HTMLInputElement.prototype.click` to capture `this` when `type==="file"`, then fire the `+ Audio` button's React `onClick` followed by the "Upload" menu item's React `onClick` — the flow calls `.click()` on the real input, which you stash (e.g. `window.__jgUploadInput`). Inject a synthetic File via DataTransfer + `input`/`change` dispatch on the captured input. Verify via the workspace clip list (`a[href*=""]`), then `goto` the `/song/` page. |
| Suno web (editing surfaces, 2026-07-02) | Two distinct "Edit" surfaces: the **song page** `Edit` button is a metadata/publish dialog (caption, lyrics, styles, toggles, Delete) — NOT the audio editor. The real audio-editing menu is the workspace row's `More options (…) → Edit` submenu: Extend, Crop, Remove Section, Reverse, Adjust Speed, plus Pro-gated Fade In/Out, Add Instrumental/Vocal, Replace Section, Get Stems/MIDI, Remaster, Open in Studio/Editor. **`Open in Studio` does not respond to programmatic clicks** (React `onClick` and native `.click()` both no-op; Base UI submenu portal timing) — URL stays `/create`. | Use the row context menu for audio operations, not the song-page Edit dialog. For Studio, a human hover on Edit → click Open in Studio is the reliable path; don't burn calls on programmatic attempts. Backgrounded tab (`visibilityState=hidden`) does NOT block uploads or injection. |
| dev-browser relay `evaluate` (general, 2026-07-02) | Async IIFEs ARE awaited and the resolved value returned — but only for **single-expression** code returning a **primitive**. Multi-statement bodies (`window.x=""; "RESET"`) come back `{type:"undefined"}`. Separately, **loopback fetch from an https page silently fails**: `fetch("http://127.0.0.1:/...")` makes the whole eval return undefined (no error surfaces) even though same-origin fetch works — don't build upload flows on a local CORS server. | Rewrite multi-statement evals as comma expressions (`(window.x="", "RESET")`). To move file bytes into the page: base64 the file host-side, split into ~200KB chunks, append each via comma-expression evals into `window.__b64`, then `atob` + `Uint8Array` decode in-page to build the `File`. Verify byte-exactness (decoded length + head/tail compare). |
| Home Assistant web (2026-07-07) | `location.reload()` fired via the relay's `evaluate` can wedge an HA tab: the HA WebSocket dies (`No PONG received` in HA core logs), `hui-view` never mounts (blank dashboard — toolbar/background render fine), NO console errors, and the wedge survives service-worker unregisters, hard reloads, and dashboard-config reverts — every SPA navigation in that tab stays broken, which mimics a server-side failure and sends you chasing ghosts. | Never verify HA changes with reload loops on one tab. Protocol: `open --url --page-name ` → host-side `sleep ~10` → `screenshot --target-id ` (take the id from `open`'s output; `--target-url` is ambiguous once several HA tabs exist). Healthy first loads render in ~5s; a tab that was evaluate-hammered + reloaded is disposable — abandon it and open fresh. |
| (add as encountered) | | |