feat(locator): tap, doubleTap, and secondaryTap hold modifiers

Merged
OK
22d ago
+357-41

Actual Budget's "shift-click range selection skips hidden reconciled transactions" needs click({ modifiers: ['Shift'] }). e2e had no modifiers (migration table: missing), so the port faked a MouseEvent with shiftKey through web.evaluate.

await screen.getByRole('row', { name: 'Transaction 1' }).click();
await screen.getByRole('row', { name: 'Transaction 4' }).click({ modifiers: ['Shift'] });
await row.doubleTap({ modifiers: ['Alt'] });
await row.secondaryTap({ modifiers: ['ControlOrMeta'] });
  • tap/click, doubleTap, secondaryTap take modifiers (the KeyModifier names of the key grammar, now also exported from e2e). The step label names them: getByRole("row", ...) with Shift.
  • Unknown, repeated, or non-array modifiers, and modifiers beside position, are INVALID_ARGUMENT before any lookup.
  • Engine contract: the tap kinds of LocatorAction carry modifiers?. An engine opts in with a new Engine member, tapModifiers: true; defineEngine requires perform for it.
  • An engine without tapModifiers (mobile, any third-party engine written before this) gets UNSUPPORTED_CAPABILITY before the node resolves (packages/e2e/src/locator/engine.ts checkAction, with session.perform as the backstop), so it never drops them silently.
  • @e2e-dev/web declares it and hands the list to Playwright's click/dblclick.
  • Changesets: e2e minor, @e2e-dev/web minor. No mobile changeset: its code is untouched, and the refusal is runner behavior covered by the e2e entry (mobile docs updated).
  • Docs: reference/engine.mdx, writing-an-engine.mdx, reference/screen.mdx, the Playwright migration table, mobile pages, skills/e2e.
flowchart LR
  A["click({ modifiers })"] --> B{valid list?}
  B -- no --> X[INVALID_ARGUMENT]
  B -- yes --> C{engine declares tapModifiers?}
  C -- no --> Y[UNSUPPORTED_CAPABILITY, before resolve]
  C -- yes --> D["resolve, perform({ kind: 'tap', modifiers })"]

Tests: locator-action-options (options, label, pre-resolve refusal), engine.test.ts (manifest), engine-contract.test.ts (fake engine with and without the flag through the real runner), web-platform.test.ts (click/dblclick/contextmenu observe shift, alt, ctrl in Chromium; fails if the web engine drops them).

Verified

Ran it locally: yes

  • Scratch project, a page of five transaction buttons with a Shift-click range selection (node server.mjs on http://127.0.0.1:0), one test: click row 1, Shift-click row 4, expect 4 selected. Same test against a main build and this branch, e2e run --reporter list.
mainthis branch
INVALID_ARGUMENT: click options has no key "modifiers"; it takes timeout, positionpasses, 4 selected

main:

INVALID_ARGUMENT: click options has no key "modifiers"; it takes timeout, position
 ❯ tests/range.e2e.ts:6:63
 Tests  1 failed | 0 passed (1)

this branch:

 ✓ |web| tests/range.e2e.ts (1 test) 227ms
   ✓ shift-click selects a range 227ms
 Tests  1 passed (1)

Steps in .e2e/report.json:

app.open | / | passed
locator.click | getByRole("button", name: "Transaction 1") | passed
locator.click | getByRole("button", name: "Transaction 4") with Shift | passed
expect.toHaveText | getByRole("status") | passed
  • The engine-contract test fails when the session gate is removed; the pre-resolve unit test fails (waits, LOCATOR_NOT_FOUND) without the checkAction gate.
  • Not run on a device: the mobile refusal is proven by the contract test's non-declaring fake engine.
  • pnpm check stops at check:peer-ranges (canary peers left by the release, fixed by #619); every other step passes.

Devin Review