Repository navigation
perf: settle only on visible finite animations; collect testIds in one call - #65
Conversation
fda8ac9 to
55b5cd2
Compare
Tickets P1-10 + P1-12. - Animation settle: wait only for running, finite animations whose target element is in the viewport (spinners, shimmer and other infinite or off-screen animations no longer hold the observation). Cap 1000 ms (was 3000 ms). Hidden pages poll with setTimeout (rAF does not fire). - collectTestIds: one in-page call instead of getAttribute + isVisible + textContent per element. Same output shape and order; visibility follows Playwright's isVisible (non-empty box, visibility visible, display: contents via children; opacity 0 counts as visible). Times out after 5 s and falls back to the old per-element locator loop on any failure. - Both in-page functions run in a CDP isolated world of the main frame (Page.createIsolatedWorld + Runtime.callFunctionOn), which shares the DOM but not page globals, so LavaMoat scuttling cannot break them. One session and world per page, recreated once after navigation. Extension bench (p50): list-testids toolMs 53 -> 1; home click observationMs 562 -> 515; first Send click observation 8003 ms (timed out, no testIds) -> 43 ms.
55b5cd2 to
2fa8fc3
Compare
| if (!target || typeof target.getBoundingClientRect !== 'function') { | ||
| continue; | ||
| } | ||
| const rect = target.getBoundingClientRect(); |
There was a problem hiding this comment.
Animations that start off-screen or at zero size are skipped: a drawer sliding in from the edge, a modal sliding up from the bottom, or an accordion opening from height 0 settle in about 8 ms while still moving (checked in Chromium). Since the cap is 1 s, could we wait for every running finite animation and drop the viewport and size check?
Suggestion: removing the "is it on screen and does it have a size" filter, so any animation that is running and will eventually end gets waited for.
Example: clicking the "Open account menu" and the menu slides in from the right over 400 ms.
Today: at the first check the menu is still just past the right edge, so it's ignored. The wait ends after 8 ms and the observation captures the menu half outside the screen, or not at all.
With the change: the slide is a finite running animation, so the code waits about 400 ms. The observation captures the menu fully open.
There was a problem hiding this comment.
Good catch, thanks. Confirmed in Chromium with the exact predicate: a drawer sliding in from the right, a bottom sheet and an accordion from height 0 all counted as settled on the first frame. Fixed in #71 (fix: wait for animations that start off screen or at zero size): animationsSettled now waits for every running, finite element animation, with no viewport or size check. Infinite ones are still ignored and the 1 s cap is unchanged. On the extension, a 400 ms drawer slide-in now adds ~400 ms to the observation (515 -> ~920 ms), and normal clicks on home/send/account list are unchanged.
| * Cap on the in-page testId collection call. After it, collection falls | ||
| * back to per-element locator calls. | ||
| */ | ||
| export const TESTID_COLLECT_TIMEOUT_MS = 5_000; |
There was a problem hiding this comment.
I'm not sure about the 5 s cap. The whole observation has an 8 s limit, and the call normally takes about 1 ms, so a hang would leave little time for the fallback and the snapshot. Maybe we keep it for now and see in real usage whether it ever times out, then fine tune the numbers if needed?
There was a problem hiding this comment.
Agreed, keeping the 5 s cap for now. A hang only costs the observation (the locator fallback or the 8 s observation limit), not a wrong action. We'll tune it if real usage shows timeouts.
…refs (#64) > **Summary:** Cancel timed-out requests cooperatively and fence ref-map commits against aborted or older requests. Capture mobile trees and test IDs together, and settle mobile observations after mutations. > > **Scope change (2026-10-27):** the ref-stability layer (renumbering refs so unchanged elements keep their numbers) was removed in `6944868`. Refs are positional again, as on `main`. See [Why no ref stability](#why-no-ref-stability). **Stack context:** mm CLI v2, Phase 1, **P1-08 + P1-09**, combined in one PR. Base: [#62](#62) (`cryptotavares/mmcli-p1-06-observe-level-no-state`); followed by [#65](#65). ## Problem A queue timeout rejects the caller, but the task can keep running and overwrite another request's refs. An observation timeout also leaves collection running. Mobile observations read test IDs and the tree from separate snapshots. A click can return the old screen or `Unlock, busy`. ## Solution Keep cancellation and commit fencing at separate boundaries: 1. **Cooperative cancellation:** `RequestQueue` passes an `AbortSignal` and aborts it before timeout rejection. Tools check supported checkpoints. Observations use a linked controller and their own deadline. 2. **Commit fencing:** the server owns a small `RefStore` (94 lines). Each started task gets a generation. Every ref write (`sessionManager.setRefMap` / `clearRefMap` from tools, and the observation) checks the generation and request signal, even when a tool ignores cancellation. ```mermaid flowchart TD A[Queued task starts] --> B[Create signal and ref generation] B --> C[Run tool through guarded context] C --> D[Optional observation checks linked abort signal] C -->|ref write| F{Request current and not aborted?} D -->|ref write| F F -->|yes| G[Commit map] F -->|no| X[Drop write. Keep current refs.] T[Queue timeout aborts request] -.-> C T -.-> D T -.-> F classDef new fill:#fff3cd,stroke:#d39e00 classDef stop fill:#f8d7da,stroke:#c82333 class B,C,D,F,G,T new class X stop ``` Yellow boxes are new. Red boxes stop the ref write. Tools write refs through `context.sessionManager`, which the daemon proxies: `setRefMap` and `clearRefMap` go through the request handle. Other members retain the real receiver, including `#private` fields. `ISessionManager` stays unchanged. Optional `captureScreen()` derives both mobile outputs from one snapshot; drivers without it retain separate reads. After eligible mutations, retry busy screens and allow one unchanged-screen retry, compared against the last tree the agent received (`lastScreen`: the delivered observation, else the `describe_screen` / full `accessibility_snapshot` tree). Launch, cleanup, and non-batch tools that set refs skip settling. ## What changes | Condition or boundary | Before | After | |---|---|---| | Queued task times out | Remaining work can continue and replace refs | Abort checkpoints stop cooperative work; the commit fence drops later ref writes | | Observation reaches its own timeout | Collection continues after the response | Abort collection; the final checkpoint prevents its late ref write | | Observation not delivered (`observe none`) | Still became the diff baseline | Not recorded; the next diff is against what the agent last saw | | `list_testids` | Reads the tree and replaces refs despite returning no refs | Collect only test IDs; leave refs unchanged | | Mobile discovery and observations | Separate snapshots can disagree or show an unsettled screen | Share a snapshot when supported; bound post-mutation settle retries | Queue timeout errors use HTTP 500 with `TOOL_EXECUTION_FAILED` and `Tool execution timed out after ${effectiveTimeout}ms`. `MM_REQUEST_ABORTED` is the internal checkpoint response. Cancellation does not undo an action already dispatched to a driver. Refresh with `describe-screen` before retrying. ## Decisions for reviewers | Decision | Reason | |---|---| | Keep abort checkpoints and the commit fence | A driver operation can finish after cancellation. Ref ownership must remain safe independently. Synchronous abort-handler settlement can win the queue's timeout race. | | No ref stability; refs stay positional | See [Why no ref stability](#why-no-ref-stability). | | The observation's refMap always wins, also after a tool set refs | The observation is the last tree in the response, so every ref in it resolves. | | `lastScreen` updates only after a delivered response | A timed-out request's tree is never the settle baseline. | ## Review guide Read the files in this order: | # | File | What to check | |---|---|---| | 1 | `src/server/request-queue.ts`, `src/types/http.ts` | Abort-before-reject ordering, bounded settle wait, timeout clamp, and optional context additions. | | 2 | `src/server/ref-store.ts` | Generation and abort checks; `toolSetRefs`. | | 3 | `src/server/create-server.ts` | Proxy receiver binding (`setRefMap`, `clearRefMap`), observation cancellation checkpoints, `screenAfterResponse` / `lastScreen`, batch behavior, separate diff baseline. | | 4 | `src/tools/utils.ts`, `src/tools/interaction.ts`, `src/tools/batch.ts`, `src/tools/discovery-tools.ts` | Checkpoints and `list_testids` tree-read removal. | | 5 | `src/platform/types.ts`, `src/platform/mobile-platform-driver.ts`, `src/server/screen-settle.ts` | Optional capture boundary, shared snapshot, busy detection, and bounded retries. | | 6 | Tests beside these modules; `README.md`, `SKILL.md` | Timeout isolation, matching exceptions, snapshot consistency, and caller recovery guidance. | <details> <summary>Testing</summary> At `6944868`: `yarn test` 52 files / 1528 tests pass; coverage 97.03 / 93.95 / 95.21 / 97.19; `yarn lint` passes. Every branch above it in the stack (#65, #66, #67, #44, #68) was restacked and passes `yarn test` + `yarn lint` at its own tip. </details> ## Breaking changes None. - `ToolContext.signal`, `IPlatformDriver.captureScreen` and `ScreenCapture` are optional additions. - `RequestQueue.enqueue` now passes the signal to its callback. Existing callbacks that take no argument keep working. ## Consumer impact | Consumer | Change needed | Done on branch | |---|---|---| | Extension `test/e2e/playwright/llm-workflow/` | None. It does not use `RequestQueue`, and its `setRefMap`/`getRefMap` implementation is unchanged. | n/a | | Mobile `tests/llm-workflow/` | None (same). | n/a | ## Why no ref stability The first version of this PR also renumbered refs so an element kept its number across observations (`stabilizeRefs`, a session high-water mark, baseline checks, merging tool and observation refs: about 500 lines of code and 900 of tests). It was removed because the problem it solves is rare: - Refs are deterministic per tree: the same screen gives the same refs, and an unchanged screen diffs as empty (device proof below). - Since P1-05/P1-06, read commands collect no observation, so they no longer renumber anything. - The only case it fixed is an element inserted above the one the agent holds. The response then carries the new refs (the diff lists the shifted nodes, or the full tree when every node shifted), and SKILL.md tells agents to use the refs from the latest response. - No baseline defect (B1–B15) was a wrong-element click caused by renumbering. If real runs show agents clicking stale refs, it can come back as its own ticket. ## Proof **After the shrink (`6944868`, run on the restacked stack tip):** | Check | Extension | iOS | Android | |---|---|---|---| | Timed-out click, then `describe-screen` (queueMs 0), then click by ref | pass | pass | pass | | `describe-screen` twice: identical refs | 17/17 | 67/67 | 242/242 | | Unchanged screen, 3 taps: diff | 4,243 B, +0 −0 | 4,860 B, +0 −0 | 5,306 B, +0 −0 | | Navigate away and back, click by the latest ref | pass | pass | pass | Android navigation observations can still show the previous screen (the new screen mounts about 2 s later). That is the existing Android settle limit noted in #44, not changed here. **Earlier revision (with ref stability), kept for history:** Tested with yalc builds in the Extension (e2e build, headless) and Mobile (dev client on iOS sim and Android emulator). The first full run used an earlier revision (e804b95). The final commit got a targeted mobile recheck plus a smoke run on all three platforms. Details are in the proof file in the mm-cli-v2 workspace. | Check | Before (#62) | After | |---|---|---| | `click --testid does-not-exist --timeout 500`, then `describe-screen`, then click by ref | – | works on ext, iOS and Android (queueMs 0, intended element clicked) | | Android unlock-click observation | login screen ("Unlock, busy") in 3/3 | no login or busy in 4/4 (3 wallet screen, 1 tab bar only) | | Unchanged-screen diff, iOS (`wait-for wallet-screen`) | 7495 B | 7518 B, diff +0 −0 | | Unchanged-screen diff, Android (`click tab-bar-item-Wallet`, p50 of 3) | 7672 B, observationMs 1924 | 40–110 KB, observationMs **965–1001** (one snapshot instead of two) | | Ext bench p50 (click / get-state / list-testids totalMs) | 580 / 5 / 565 | 582 / 6 / **60** (`list_testids` no longer reads the tree) | The Android unchanged-screen diff gap in that revision is closed by #67 and #44 (see the table above).
> **Summary:** Give duplicate browser accessibility refs unique DOM selectors when possible. Resolve ref targets page-wide before checking `--within`, so a parent cannot select another member of an ambiguous group. **Stack context:** mm CLI v2, Phase 1, **P1-11**. Base: [#65](#65) (`cryptotavares/mmcli-p1-10-ext-speedups`); followed by [#67](#67). Review #65 first. ## Problem Browser refs use role-and-name selectors. Two elements with the same role and name therefore share one selector. A click fails in Playwright strict mode. With `--within`, the parent can narrow that selector to a different element and click the wrong row. The Extension has repeated labels such as “Show QR code”, “Copy address”, and “Network options”. Ref identity must distinguish these elements without assuming AX-tree order matches Playwright locator order. ```text Two rows each expose a “Show QR code” button. Ref for row B + --within row A Before: the shared role selector can select row A's button. ``` ## Solution Build ref selectors at the browser discovery boundary. Keep the existing selector map consumed by Playwright tools and session managers. ```mermaid flowchart TD A[AX snapshot candidates] --> B[Group normalized role-and-name selectors] B -->|unique role| C[Keep exact role selector with snapshot scope] B -->|duplicate role or generic| D[Build unique DOM selector in cached isolated world] D --> E{Selector result?} E -->|unique| H[Store selector in ref map] E -->|unavailable| F[Keep strict role or text fallback] E -->|closed shadow root| G[Store unreachable marker] C --> H F --> H H --> I[Resolve Playwright locator for action] G --> X[Reject ref action before locator creation] classDef new fill:#fff3cd,stroke:#d39e00 classDef stop fill:#f8d7da,stroke:#c82333 class B,C,D,E,F,G new class X stop ``` Yellow boxes are new. Red boxes stop the action. The DOM builder prefers a unique `data-testid`, then a unique `id`, then a unique ancestor-anchored path. It uses `nth-of-type` or `nth-child` only within a path whose uniqueness it checks. Open shadow roots use chained host selectors: `css=<host> >> css=<inside>`. `callOnNodeInIsolatedWorld` resolves the backend node in the cached session and isolated world introduced by **P1-10**. This keeps selector construction outside the Extension's scuttled main-world globals. Lookups run concurrently with a fixed bound. Session or world failures retry once; missing nodes and page-function exceptions do not. Role and text selectors normalize whitespace and escape quoted values. The explicit `s` flag documents the role engine's existing exact, case-sensitive behavior. Scoped snapshots prefix role and text selectors with `css=<root> >> nth=0`. Unique DOM selectors remain page-wide; the scope root itself uses the root selector. For `--within` plus a ref, `waitForTarget` checks page-wide ambiguity before containment. It rechecks an outside-parent result after a short delay to allow DOM movement. Late targets wait for attachment within the shared timeout budget. ## What changes | Condition or boundary | Before | After | |---|---|---| | Duplicate role and name with resolvable DOM nodes | Refs share a role selector | Each ref gets its own verified unique DOM selector | | Duplicate without a unique DOM selector | Ambiguous role selector | Keeps the strict fallback; adds no AX-order `nth=` guess | | Selector builder finds a closed shadow root | Other selectors cannot reach that node | Stores an unreachable marker; ref resolution rejects it | | Scoped snapshot | Role selectors search page-wide | Role and text selectors retain the snapshot root; fallback relatives remain page-wide | | Ref target with `--within` | Parent can narrow an ambiguous ref to another element | Rejects page-wide ambiguity and a confirmed outside-parent target | | Consumer interface | `Map<string, string>` and `ISessionManager` | Same contracts; browser discovery and locator resolution own the change | Ref-specific errors include `Ref e2 is not inside the within element` and `Ref e2 is inside a closed shadow root: it cannot be targeted`. An ambiguous ref reports `Ref e2 is ambiguous (9 matches); take a new snapshot or target it by testId or selector`. ## Decisions for reviewers | Decision | Reason | |---|---| | Keep selector-backed refs instead of the backend-node ref model preferred by **P1-11** | Tools need Playwright locators. Consumers already implement `ISessionManager` with selector strings. | | Verify DOM paths instead of assigning an AX-order index | Dialogs, `inert`, shadow roots, slots, and `aria-owns` can produce different AX and Playwright orders. | | Reuse the **P1-10** isolated-world cache | Avoids per-node contexts and keeps shared session recovery in `cdp.ts`. | | Treat `--within` as containment for ref targets | A parent must not choose among the page-wide matches of an ambiguous ref. | ## Review guide Read the files in this order: | # | File | What to check | |---|---|---| | 1 | `src/tools/utils/discovery.ts` | Candidate grouping, scoped snapshots, fallback selectors, and page-wide `within` checks. | | 2 | `src/tools/utils/in-page.ts` | Self-contained selector builder, uniqueness rules, shadow roots, and escaping. | | 3 | `src/tools/utils/cdp.ts` | Cached-world reuse, node resolution, retry classification, and object-group release. | | 4 | `src/tools/utils/{discovery,in-page,cdp}.test.ts` | Duplicate targeting, parser round trips, bounded concurrency, shadow cases, and containment races. | | 5 | `src/server/create-server.test.ts` | Queue-wait test holds the first request longer; discovery tests tolerate the remaining timeout budget. | | 6 | `README.md`, `SKILL.md` | Browser ref guarantees, strict failures, and `--within` guidance. | <details> <summary>Testing</summary> - Fresh check at remote head `e93a529e34cc3b1c65615e87bbd282a23e16f504`: `CI=true yarn vitest run src/tools/utils/cdp.test.ts src/tools/utils/discovery.test.ts src/tools/utils/in-page.test.ts src/server/create-server.test.ts src/platform/playwright-driver.test.ts --coverage.enabled=false --maxWorkers=2` passes: 5 files, 352 tests, no type errors. - Existing tests cover duplicate selectors (**AC1**), exact names (**AC2**), scope roots, strict fallbacks, and delayed or ambiguous `within` targets. - In-page tests execute function source in a VM with a fake DOM. They cover open, nested, slotted, and closed shadow roots, escaping, and scuttled globals. - The `Proof` and `Unit tests` sections below retain the original historical evidence. The fresh check does not repeat that live validation. </details> ## Breaking changes None. Refs are created per snapshot, so no caller can depend on the old selector strings. Behaviour changes, both towards failing instead of hitting the wrong element: - `within` + a ref outside the parent now fails fast instead of acting inside the parent; - a ref that cannot be made unique fails in strict mode, as before. ## Consumer impact | Consumer | Change needed | Done on branch | |---|---|---| | Extension `test/e2e/playwright/llm-workflow/` | None | n/a | | Mobile `tests/llm-workflow/` | None (browser-only code paths) | n/a | ## Proof Tested with a yalc build of this branch in the Extension (e2e build, headless, fixture wallet). For the "before" I built #65 (50eb396) in a temp clone and ran the same script. | Check | Before (#65) | After (f7057e7) | |---|---|---| | Click the ref of a duplicated label (9 cases: groups of 2, 9 and 13, positions 0–8) | **9/9 fail** (strict mode: 2 / 9 / 13 matches) | **9/9 hit the right element** (dialog heading, clipboard address, Edit form name and chain id) | | All 13 "Network options" refs → Edit form | strict-mode failure | 13/13 open their own network | | `--within <first row>` + ref of the Solana row's QR button | opened **Ethereum's** QR dialog | fails fast: "not inside the within element" (750 ms wall, mostly CLI startup) | | "Privacy" vs "Privacy Policy" links | correct | correct (no change; `role=` was already exact) | | Smoke (unlock by ref, tabs, send/back, account list, `within`, scoped snapshot) | 7/11 | **11/11** | | `describe-screen` toolMs p50: home / networks / receive | 514 / 4033 / 4023 | 514 / 4031 / 4027 | Generic Chromium cases (built dist, real Chromium; none of these occur on the Extension today): modal `<dialog>`, `inert`, open, nested and slotted shadow DOM, `aria-owns`, and a page whose main world makes `CSS`, `Element`, `Node`, `window` and `Array.from` throw. 39/41 refs click the right element. The 2 closed-shadow refs fail on purpose, and **none hits a wrong element**. On 40 duplicate buttons, snapshot time went from 42 to 33 ms, because lookups now run concurrently. **Pre-existing issues found (not caused by this PR, unchanged on both builds):** - The home token-row ref never clicks: Chromium exposes a link, but Playwright sees no link because the element has no `href`. - `describe-screen` takes ~4 s on networks, receive, the account list and About; a fixed timeout is suspected. <details> <summary>Unit tests</summary> ``` Test Files 51 passed (51) Tests 1673 passed (1673) Type Errors no errors All files | 97.42 | 94.48 | 96.55 | 97.57 yarn lint → exit 0 ``` New tests: - AC1: duplicates get distinct unique selectors. - AC2: "Send" vs "Send max" are exact. - Escaping, checked with a round trip through Playwright's own selector parser; skipped when the internal path is missing. - Scoped prefix; `text=` stays strict; no `nth` fallback. - `in-page` builder in a `vm` with a fake DOM: testid, then id, then path; shadow roots and host chaining; slotted `:nth-child`; closed shadow marker; scuttled globals; `CSS.escape` parity. - `callOnNodeInIsolatedWorld`: retry, node gone, exceptions. - `within`: ambiguous ref, ref not inside the parent, and the race when the element moves into the parent between checks. `41a404f` deflakes the P1-01 queue-wait timing test: under host load it saw 45 ms against a 50 ms threshold, so it now holds the first request for 250 ms. </details>
…TimeoutMs cap (#71) > **Summary:** Fix the review comments on #62, #64 and #65 in one PR on top of the stack. The approved PRs stay unchanged. **Stack context:** mm CLI v2, Phase 1, **P1-43**. Base: [#69](#69), the top of the stack. ## Problem | Comment | Problem | |---|---| | [#65: off-screen animations](#65 (comment)) | The browser settle ignored animations whose target was outside the viewport or had zero size. A drawer sliding in, a bottom sheet or an accordion opening from height 0 start that way, so the observation caught them mid-move. | | [#64: late click](#64 (comment)) | `click` / `type` checked the request signal only before the target wait. When the request timed out during the wait, the agent got "timed out", but the click still ran once the target appeared. | | [#64: batchTimeoutMs](#64 (comment)) | `batchTimeoutMs` accepted up to 300 s, but the queue and the CLI stop `run_steps` after 30 s. | | [#62: refs after `none`](#62 (comment)) | `--observe none` does not refresh refs; some refs can then click a different element. Not documented. | | [#62: reads and animations](#62 (comment)) | Reads do not wait for animations, even with `--observe full`. Not documented. | ## Solution ```mermaid flowchart TD A[click / type] --> B[Wait for target] B --> C{Request timed out?} C -->|yes| D[MM_REQUEST_ABORTED, no action] C -->|no| E[Click / tap / type] E --> F[Mutating observation] F --> G[Wait for every running finite animation, max 1 s] G --> H[Read tree] classDef new fill:#fff3cd,stroke:#d39e00 class C,D,G new ``` Yellow boxes are new or changed. ## What changes | Commit | Change | |---|---| | `fix: wait for animations that start off screen or at zero size` | `animationsSettled` waits for every running, finite animation of an element. Infinite animations are still ignored; the 1 s cap is unchanged. | | `fix: do not click or type after the request timed out` | `IPlatformDriver.click` / `type` take an optional `signal`. Both drivers call `signal.throwIfAborted()` after the target wait, right before the action (mobile `type` also after the focus tap). The tools pass `context.signal` and return `MM_REQUEST_ABORTED`. | | `fix!: cap run_steps batchTimeoutMs at 30 s` | Schema range 1000–30000, like the `cdp` `timeoutMs`. | | `docs: …` | README, SKILL.md and CLI help: refs after `--observe none`, reads and animations, abort limits, `batchTimeoutMs`. | ## Decisions for reviewers | Decision | Reason | |---|---| | One new PR instead of changing #62/#64/#65 | Pushing to an approved PR dismisses its approval, and the restack would change every PR above it. | | An action that already started is not cancelled | `@metamask/device-mcp` takes no signal (`waitForElement`, `tapElement`, `typeText`), and Playwright's actionability checks are part of `locator.click`. The driver checks the signal between steps. Cancelling inside device-mcp needs a device-mcp change (follow-up). | | Cap `batchTimeoutMs` at 30 s instead of raising the request budget for batches | Matches the CLI and queue default; long flows can be split into several batches. | | Reads stay without the animation wait (docs only) | It is a less exact snapshot, not a wrong action. Can be revisited. | | The 5 s testId collection cap stays | Agreed with the reviewer: watch real usage first. | ## Breaking changes `run_steps` rejects `batchTimeoutMs` above 30000 (validation error), also when a server raised `requestTimeoutMs`. ## Review guide | # | File | What to check | |---|---|---| | 1 | `src/tools/utils/in-page.ts` | `animationsSettled` without the viewport/size filter. | | 2 | `src/platform/playwright-driver.ts`, `src/platform/mobile-platform-driver.ts`, `src/platform/types.ts` | `throwIfAborted()` placement. | | 3 | `src/tools/interaction.ts` | Signal passed; abort mapped to `MM_REQUEST_ABORTED`. | | 4 | `src/validation/schemas.ts` | `batchTimeoutMs` range and description. | | 5 | `README.md`, `SKILL.md`, `src/cli/mm.ts` | Docs. | <details> <summary>Testing</summary> `yarn test`: 51 files, 1809 tests pass. `yarn lint` passes. Each commit passes the tests on its own. **Extension** (e2e fixture wallet, headless), before = #69, after = this branch: | Check | Before | After | |---|---|---| | Drawer sliding in from off screen (400 ms): observationMs | 515–516 (did not wait) | 914–929 (waited) | | Accordion from height 0 (400 ms): observationMs | 515–516 | 916–917 | | Home click observationMs | 513–514 | 512–515 | | Send / account list click observationMs | 12–183 / 14–164 | 14–212 / 15–164 | | `run_steps` click whose target appears 1 s after the 30 s queue timeout | **clicked** | **not clicked** | **Android** (device-mcp 0.6.0, real funds, benign taps): all smoke and ref checks pass, same as #69. **iOS** (device-mcp 0.6.0, real funds, benign taps): all smoke and ref checks pass, same as #69. (A first attempt hit the stale idb companion after a simulator reboot, `dtuhidd connection ... invalidated`; fixed by restarting the companion. See MetaMask/device-mcp#43 and #70.) </details>
Stack context: mm CLI v2, Phase 1, P1-10 + P1-12, combined in one PR. Base:
cryptotavares/mmcli-p1-08-abort-refs; review #64 first. Followed by #66.Problem
Browser observations wait for every running animation, including spinners that never finish. The old main-world check also depends on globals that LavaMoat can restrict. Hidden tabs do not provide the animation frames that the check needs.
TestId collection adds three Playwright calls per element:
getAttribute,isVisible, andtextContent. Extension home therefore needs many round trips before the accessibility snapshot starts.These costs share the observation budget. The historical proof below records a first Send observation that times out without testIds. This PR changes the browser collection boundary used by observations and
PlaywrightPlatformDriver.getTestIds.Solution
Keep browser orchestration in the daemon. Move DOM inspection into self-contained functions that execute in the main frame's isolated world.
flowchart TD A[Browser observation] --> B{Read-only or discovery tool?} B -->|no| C[Wait for finite, in-viewport animations] B -->|yes| D[Collect testIds in cached isolated world] C --> D D -->|success| E[Map items to existing output shape] D -->|failure or timeout| F[Collect testIds through locator fallback] E --> G[Collect accessibility tree] F --> G G --> H[Return observation] classDef new fill:#fff3cd,stroke:#d39e00 class C,D,E,F newYellow boxes are new.
callInIsolatedWorldowns the CDP session and execution context. AWeakMapcaches them per page. Steady-state calls send oneRuntime.callFunctionOnrequest. Protocol or session failures clear the matching cache entry and retry once. In-page exceptions propagate without a retry.The isolated world shares the DOM but has separate globals. Page functions travel as source text, so they cannot import helpers or capture module state. Their helpers stay inside each function. The source wrapper supplies
__namefor keepNames-transformed functions; VM tests check the serialization boundary.discovery.tsowns the failure policy. Animation timeouts and unreadable pages end the wait. TestId call failures and timeouts use the locator fallback. The server still checks its abort signal between observation stages.What changes
tagstayselementCollection includes hidden testIds with
visible: false. Visibility follows Playwright's rules, includingdisplay: contents, non-empty boxes, and computedvisibility. Zero opacity and off-screen position do not hide an otherwise visible item.The collector visits document matches before open shadow-root matches. It skips empty testIds without consuming the limit. Text remains trimmed and capped at 100 characters; null text maps to
undefinedat the daemon boundary.Decisions for reviewers
Review guide
Read the files in this order:
src/server/create-server.tssnapshotBrowserkeeps category-based settling, collection order, and abort checkpoints.src/tools/utils/constants.tssrc/tools/utils/cdp.tssrc/tools/utils/in-page.tssrc/tools/utils/discovery.tssrc/tools/utils/{cdp,in-page,discovery}.test.ts,src/server/create-server.test.tsTesting
50eb3962b30ee8f5e2605dfcf307417e0282f560:CI=true yarn vitest run src/tools/utils/cdp.test.ts src/tools/utils/in-page.test.ts src/tools/utils/discovery.test.ts src/server/create-server.test.ts src/platform/playwright-driver.test.ts src/tools/discovery-tools.test.ts --coverage.enabled=false --maxWorkers=2 --minWorkers=1passes: 6 files, 317 tests.yarn lintpasses.yarn tsc --project tsconfig.build.json --noEmitpasses for production sources.yarn tsc --project tsconfig.json --noEmitfails at the remote head with 11 test diagnostics. One is the newin-page.test.ts:728type predicate (TS2677); ten come from inherited test code.EACCESlog from its read-only log-directory fixture and passes.ProofandUnit testssections below retain the original historical evidence. These fresh checks do not repeat the live browser benchmarks or full coverage run.Breaking changes
None. The constants are internal. Observations may now be taken while an infinite or off-screen animation is still running; that is intended.
Consumer impact
test/e2e/playwright/llm-workflow/tests/llm-workflow/Proof
Tested with a yalc build of this branch in the Extension (e2e build, headless). I ran a fresh before/after bench back to back: before = #64 (7d0c565), after = this branch. Every bench has 5 runs, and the 1-minute host load was 6–9. After a review round I re-checked the final commit (50eb396) and got the same numbers.
list-testidson home, toolMs p50/p95 (44 items)list-testidsJSON, home + unlocktextvalues, which also changes between two runs of the same buildThe remaining ~515 ms on home is the accessibility-tree snapshot:
get-state --observe fulltakes ~509 ms without any settle. The settle and testIds now add about 6 ms together. P1-14 targets that snapshot.Known gap: the locator fallback logs only through
debugWarn, so it is visible only withDEBUG=true. In the recheck it never ran: everylist_testidstook ~1–2 ms; the fallback takes ~50 ms.Unit tests
New tests:
in-page.test.ts:vmcontext, so a stray import or closure fails the test;toString()has no bundler helpers other than__name;display: contents,visibility, zero box), text, limit and shadow-root order.cdp.test.ts: session/world caching, retry after navigation, in-page exceptions, timeout.discovery.test.ts: the single call, plus fallback to locators on failure and on timeout.