Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ On real apps, prefer `name` — it reads the accessibility tree, skips hidden/ut
| `id` | an element id | |
| `name` | an ARIA accessible name | prefer on real apps; pair with `--role` / `--nth` / `--match` |
| `ref` | a `snap`-issued `e<id>` ref | act on the exact node `snap` reported, no re-resolve |
| `cell` | a `[row\|]column` grid header | resolves the editable input in that grid cell |
| `cell` | a `[row\|]column` grid header | resolves the editable input in that grid cell; a candidate in the header's own grid beats one elsewhere on the page, and an unoccluded one beats a covered one |
| `label` | a form control's visible label | for controls whose label isn't wired (no `aria-label` / `<label for>`) |
| `search` | DevTools text/XPath/CSS search | broad; first match wins |
| `jspath` | a JS path | |
Expand Down Expand Up @@ -292,7 +292,8 @@ A never-settling promise is bounded by `--timeout` (exit 4), and the connection
| `attr get\|list\|set\|rm <selector> [name] [value]` | read/write element attributes |

`click`, `hover`, `dblclick`, `rclick` and `drag` are one driver method behind five names: they resolve the identical occlusion-verified centre and all take `--modifiers` (`ctrl`/`shift`/`alt`/`cmd`, joined with `+`) — `click --modifiers cmd` is the multi-select in a table.
An element that resolves but never presents an unoccluded centre fails as `target_timeout` with `occluded: true`, so it's distinguishable from "not found".
An element that resolves but never presents an unoccluded centre fails as `target_timeout` with `occluded: true`, so it's distinguishable from "not found"; the message names what sat on top (`its centre is covered by DIV name="modalOverlay"`) or says the element measured 0x0, so the next step — dismiss an overlay, wait out a tooltip, re-check the selector — is read from the envelope rather than reproduced under instrumentation.
If the page replaces the element while a verb waits on it (a grid re-rendering its row after a commit), the verb re-resolves the selector and continues on the replacement; only when the replacement never settles either does it fail, as `target_timeout` with `detached: true` — the page is churning, so `wait --stable` before retrying.

`key` takes a named key, a printable character, a chord, or a space-separated sequence of those, and works with no selector at all — which is what makes it usable when nothing is addressable:

Expand All @@ -309,6 +310,7 @@ An element that resolves but never presents an unoccluded centre fails as `targe
| `--delay <dur>` | pause between repeats, for apps that debounce |

`cmd` maps to Meta on every platform — the *page* decides which modifier it listens for, so the CLI never rewrites `cmd` to `ctrl` for you.
The result reports `focused` (role and accessible name of what has focus after the press) and, when that element has one, `focused_id` (its DOM id) — the disambiguator for grids whose inputs all read as `textbox ""`, so a stroke that landed in the wrong cell shows in the envelope rather than only in a later value read-back.
`shift+<character>` presses the character that key actually produces, so `shift+a` is the same press as `A` (and `shift+1` is `!`) rather than a lowercase `a` with a Shift bit set.
An unknown key name is a `usage` error rather than being typed as literal characters.

Expand Down
45 changes: 31 additions & 14 deletions internal/chrome/cdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,19 @@ func cellQuery(cellSel string) func(context.Context, *cdp.Node) ([]cdp.NodeID, e
// when its field has no box lets the caller's pointer sequence land on the cell,
// which is what mounts the input. Grids whose inputs are already visible still
// resolve to the input, so nothing that worked before changes.
//
// Candidates are RANKED, not just filtered, because "in this column" is a
// geometric test and the page has fields outside the grid that happen to share
// an x-range with a column. Workday's global "Search Workday" box sits above
// its Enter Time dialog with its centre inside one day column's tolerance;
// document-order-first picked it (it comes before the grid), it was under the
// modal overlay, and `fill --by cell "Tue, …"` failed as `occluded` on that one
// column and no other. So a candidate inside the header's own grid ranks ahead
// of one outside it, and one whose centre hit-tests to itself ranks ahead of
// one that is covered; document order breaks ties. Off-grid candidates are kept
// as the last resort rather than dropped, because some grids split header and
// body into separate tables and the header's `closest` container then holds no
// inputs at all.
const cellLocatorJS = `(() => {
const col = %[1]s, row = %[2]s;
const norm = s => (s || "").replace(/\s+/g, " ").trim();
Expand All @@ -947,15 +960,27 @@ const cellLocatorJS = `(() => {
const tr = el.closest("[role=row],tr");
return tr && has(tr.textContent, row);
};
// Rank: the header's own grid beats the rest of the page; a centre that
// hit-tests to the element beats one under an overlay; then document order
// (Array.prototype.sort is stable).
const grid = hdr.closest("table,[role=grid],[role=treegrid],[role=table]");
const inGrid = el => !grid || grid.contains(el);
const hitSelf = el => {
const r = el.getBoundingClientRect();
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return !!at && (at === el || el.contains(at));
};
const rank = el => (inGrid(el) ? 0 : 2) + (hitSelf(el) ? 0 : 1);
const byRank = list => list.map((el, i) => ({ el, i, r: rank(el) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.el);
const fields = "input,textarea,select,[contenteditable=true],[role=textbox],[role=spinbutton]";
// A field with its own box: the original path, and still the preferred one.
const direct = [...document.querySelectorAll(fields)]
.filter(el => vis(el) && inCol(el) && inRow(el));
const direct = byRank([...document.querySelectorAll(fields)]
.filter(el => vis(el) && inCol(el) && inRow(el)));
if (direct.length) return direct[0];
// Otherwise find the cell by ITS box and hand back whichever of the field or
// the cell can actually be pointed at.
const cells = [...document.querySelectorAll("[role=gridcell],[role=cell],td")]
.filter(el => vis(el) && inCol(el) && inRow(el));
const cells = byRank([...document.querySelectorAll("[role=gridcell],[role=cell],td")]
.filter(el => vis(el) && inCol(el) && inRow(el)));
for (const cell of cells) {
const f = cell.querySelector(fields);
if (f) return vis(f) ? f : cell;
Expand Down Expand Up @@ -1324,11 +1349,7 @@ func withDialogResult(res map[string]any, sink *dialogSink) map[string]any {
// then sends the text as real keystrokes to the focused element.
func (c *CDP) Type(ctx context.Context, id, selector, text string, q QueryOpts) (map[string]any, error) {
core := chromedp.ActionFunc(func(actx context.Context) error {
nid, err := resolveNodeReady(actx, selector, q)
if err != nil {
return err
}
if err := coordClickNode(actx, nid); err != nil {
if err := coordClickSelector(actx, selector, q, 1); err != nil {
return err
}
return chromedp.KeyEvent(text).Do(actx)
Expand All @@ -1346,12 +1367,8 @@ func (c *CDP) Type(ctx context.Context, id, selector, text string, q QueryOpts)
// a timesheet "0" hour cell) to a new value in one call.
func (c *CDP) Fill(ctx context.Context, id, selector, value string, q QueryOpts) (map[string]any, error) {
core := chromedp.ActionFunc(func(actx context.Context) error {
nid, err := resolveNodeReady(actx, selector, q)
if err != nil {
return err
}
// Triple-click selects all text in the field; typing then replaces it.
if err := coordClickNodeN(actx, nid, 3); err != nil {
if err := coordClickSelector(actx, selector, q, 3); err != nil {
return err
}
return chromedp.KeyEvent(value).Do(actx)
Expand Down
148 changes: 148 additions & 0 deletions internal/chrome/cell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,151 @@ func TestCellAddressingZeroSizeInput(t *testing.T) {
}
}
}

// TestCellAddressingPrefersGridOverOffGridField covers the second shape --by
// cell met in the wild: a field OUTSIDE the grid whose centre-x happens to fall
// inside one column's tolerance. Workday's global "Search Workday" box sits
// above its Enter Time dialog, its centre inside the Tue column's band; being
// earlier in document order it was picked first, it was under the modal
// overlay, and `fill --by cell "Tue, …"` failed as `occluded` on that column and
// no other, in every week.
//
// The fixture makes the off-grid input hit-testable (not covered), which turns
// the old behaviour into the WORSE failure — the value lands in the search box
// and the grid cell stays 0 — so the assertion is deterministic either way:
// ranking the header's own grid first is what makes the grid cell win.
func TestCellAddressingPrefersGridOverOffGridField(t *testing.T) {
if testing.Short() {
t.Skip("skipping live-Chrome integration in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

b, err := launch(true, tmpProfile(t), 0)
if err != nil {
t.Skipf("cannot launch a managed headless Chrome here: %v", err)
}
defer b.Close()

// #search is centred over the Tue column (x 310..390 → centre 350) and comes
// before the table in document order.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `<!doctype html><title>OffGrid</title>
<style>
body{margin:0}
#search{position:absolute;left:310px;top:8px;width:80px;height:28px}
table{position:absolute;left:0;top:80px;border-collapse:collapse}
th,td{width:100px;height:32px;padding:0;text-align:center}
td input{width:80px;box-sizing:border-box}
</style>
<body>
<input id="search" placeholder="Search">
<table>
<thead><tr><th>Task</th><th>Sun, 7/12</th><th>Mon, 7/13</th><th>Tue, 7/14</th></tr></thead>
<tbody>
<tr><th>Regular</th><td><input id="r_sun" value="0"></td><td><input id="r_mon" value="0"></td><td><input id="r_tue" value="0"></td></tr>
</tbody></table></body>`)
}))
defer srv.Close()

id := firstTab(ctx, t, b)
if _, err := b.Navigate(ctx, id, srv.URL); err != nil {
t.Fatalf("Navigate: %v", err)
}

if _, err := b.Fill(ctx, id, "Tue, 7/14", "8", QueryOpts{By: "cell"}); err != nil {
t.Fatalf("Fill cell Tue, 7/14: %v", err)
}
for cellID, want := range map[string]string{"r_tue": "8", "r_sun": "0", "r_mon": "0", "search": ""} {
got := evalString(ctx, t, b, id, fmt.Sprintf("document.getElementById('%s').value", cellID))
if got != want {
t.Errorf("%s = %q, want %q", cellID, got, want)
}
}
}

// TestCellAddressingReResolvesReplacedNode covers the third shape --by cell met
// in the wild: a grid that RE-RENDERS its row after a cell commits. Workday's
// time grid does this after every hour entry, so the input the next fill
// resolved a moment earlier is gone by the time it is measured. Polling that
// node measured 0x0 until the deadline and surfaced as `occluded` — one column
// in five, every row, with no overlay anywhere to dismiss.
//
// The fixture commits on `input`: it covers the row with a "saving" overlay for
// a beat (so the next fill's first measurement is genuinely occluded and it has
// to poll), then rebuilds every input in the row and lifts the overlay. The
// second fill therefore resolves the OLD Tue input, watches it get detached, and
// must re-resolve to the replacement to succeed.
func TestCellAddressingReResolvesReplacedNode(t *testing.T) {
if testing.Short() {
t.Skip("skipping live-Chrome integration in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

b, err := launch(true, tmpProfile(t), 0)
if err != nil {
t.Skipf("cannot launch a managed headless Chrome here: %v", err)
}
defer b.Close()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `<!doctype html><title>Rerender</title>
<style>
body{margin:0}
table{position:absolute;left:0;top:40px;border-collapse:collapse}
th,td{width:100px;height:32px;padding:0;text-align:center}
td input{width:80px;box-sizing:border-box}
#veil{position:absolute;left:0;top:0;width:100vw;height:100vh;background:rgba(0,0,0,.05);display:none}
</style>
<body>
<table>
<thead><tr><th>Task</th><th>Sun, 7/12</th><th>Mon, 7/13</th><th>Tue, 7/14</th></tr></thead>
<tbody>
<tr id="row"><th>Regular</th><td><input id="r_sun" value="0"></td><td><input id="r_mon" value="0"></td><td><input id="r_tue" value="0"></td></tr>
</tbody></table>
<div id="veil"></div>
<script>
// Commit = veil the grid, then rebuild the row's inputs (new elements, same
// ids and values) and lift the veil — the grid under test's shape.
const arm = () => document.querySelectorAll("#row input").forEach(i => i.addEventListener("input", commit, {once: true}));
const commit = () => {
document.getElementById("veil").style.display = "block";
setTimeout(() => {
document.querySelectorAll("#row input").forEach(old => {
const fresh = document.createElement("input");
fresh.id = old.id; fresh.value = old.value;
old.replaceWith(fresh);
});
arm();
document.getElementById("veil").style.display = "none";
}, 400);
};
arm();
</script></body>`)
}))
defer srv.Close()

id := firstTab(ctx, t, b)
if _, err := b.Navigate(ctx, id, srv.URL); err != nil {
t.Fatalf("Navigate: %v", err)
}

if _, err := b.Fill(ctx, id, "Mon, 7/13", "8", QueryOpts{By: "cell"}); err != nil {
t.Fatalf("Fill Mon, 7/13: %v", err)
}
// Straight into the next cell while the row is mid-commit. Bounded so the
// failure mode (polling a detached node until the deadline) fails the test
// in seconds, not the suite's minute.
fctx, fcancel := context.WithTimeout(ctx, 8*time.Second)
defer fcancel()
if _, err := b.Fill(fctx, id, "Tue, 7/14", "8", QueryOpts{By: "cell"}); err != nil {
t.Fatalf("Fill Tue, 7/14 across the row re-render: %v", err)
}
for cellID, want := range map[string]string{"r_mon": "8", "r_tue": "8", "r_sun": "0"} {
got := evalString(ctx, t, b, id, fmt.Sprintf("document.getElementById('%s').value", cellID))
if got != want {
t.Errorf("%s = %q, want %q", cellID, got, want)
}
}
}
19 changes: 18 additions & 1 deletion internal/chrome/geometry.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,24 @@ type nodeBox struct {
// Occluded means the aim point resolved to something else (an overlay, a
// covering panel). Reported rather than fatal for reads.
Occluded bool `json:"occluded"`
// Detached means the element is no longer in the document: the page swapped
// it out (a grid re-rendering its row after a commit) between resolution and
// this measurement. A detached node has no geometry and never will again, so
// waiting on it is pointless — the caller must re-resolve.
Detached bool `json:"detached"`
// At describes what elementFromPoint found at the aim point when it was NOT
// this element — the evidence behind Occluded. Nil when it was this element,
// or when there was no box to test.
At map[string]any `json:"at,omitempty"`
}

// axBoxCoreJS measures `this` and hit-tests its centre. It is a statement list
// spliced into a function body, so both variants below share every line of the
// measurement and differ only in what happens before it.
const axBoxCoreJS = `
if (!this.isConnected) {
return { ok: false, x: 0, y: 0, cx: 0, cy: 0, w: 0, h: 0, occluded: false, detached: true };
}
const r = this.getBoundingClientRect();
if (r.width < 1 || r.height < 1) {
return { ok: false, x: 0, y: 0, cx: 0, cy: 0, w: r.width, h: r.height, occluded: false };
Expand All @@ -60,7 +72,12 @@ const axBoxCoreJS = `
const cy = Math.max(0, Math.min(Math.round(ty), window.innerHeight - 1));
const at = document.elementFromPoint(cx, cy);
const hit = !!at && (at === this || this.contains(at));
return { ok: hit, x: cx, y: cy, cx: tx, cy: ty, w: r.width, h: r.height, occluded: !hit };
const desc = (!hit && at) ? {
tag: at.tagName, id: at.id || undefined,
role: at.getAttribute("role") || undefined,
name: (at.getAttribute("aria-label") || at.getAttribute("data-automation-id") || "").trim() || undefined,
} : undefined;
return { ok: hit, x: cx, y: cy, cx: tx, cy: ty, w: r.width, h: r.height, occluded: !hit, at: desc };
`

// nodeCoordJS scrolls the element into view, then measures — the pointer path.
Expand Down
28 changes: 23 additions & 5 deletions internal/chrome/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,7 @@ func (c *CDP) Key(ctx context.Context, id, selector string, keys []KeyStroke, op

core := chromedp.ActionFunc(func(actx context.Context) error {
if selector != "" {
nid, err := resolveNodeReady(actx, selector, opts.Query)
if err != nil {
return err
}
if err := coordClickNode(actx, nid); err != nil {
if err := coordClickSelector(actx, selector, opts.Query, 1); err != nil {
return err
}
}
Expand Down Expand Up @@ -74,6 +70,9 @@ func (c *CDP) Key(ctx context.Context, id, selector string, keys []KeyStroke, op
if f := c.focusedDesc(ctx, id); f != "" {
res["focused"] = f
}
if fid := c.focusedID(ctx, id); fid != "" {
res["focused_id"] = fid
}
return withDialogResult(res, sink), nil
}

Expand Down Expand Up @@ -174,6 +173,25 @@ func unmodifiedText(k KeyStroke) string {
// the FIRST node carrying the focused state, and the document root carries it
// whenever the window has focus — which bringToFront has just arranged. The
// useful answer is the element inside the document, which appears later.
// focusedID is the DOM id of the element that has focus after the press, when
// it has one — the disambiguator `focused` lacks. Grid cells and other unlabelled
// inputs all read as `textbox ""` in the accessibility tree, so a stroke that
// landed in the wrong cell of a row is invisible in `focused` and only shows up
// in the value read-back; a page that gives its inputs ids (Workday's time grid
// does) makes the target checkable straight from the envelope. Best-effort like
// `focused`: any failure, or an element with no id, omits the field.
func (c *CDP) focusedID(ctx context.Context, id string) string {
fctx, cancel := context.WithTimeout(ctx, focusedReadTimeout)
defer cancel()
var fid string
err := c.run(fctx, id, chromedp.Evaluate(
`(() => { const a = document.activeElement; return a && a !== document.body ? (a.id || "") : ""; })()`, &fid))
if err != nil {
return ""
}
return fid
}

func (c *CDP) focusedDesc(ctx context.Context, id string) string {
fctx, cancel := context.WithTimeout(ctx, focusedReadTimeout)
defer cancel()
Expand Down
5 changes: 5 additions & 0 deletions internal/chrome/keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,11 @@ func TestKeyLive(t *testing.T) {
} else if !strings.Contains(f, "textbox") || !strings.Contains(f, "Field") {
t.Errorf("focused = %q, want it to describe the focused textbox", f)
}
// focused_id names the element by DOM id — the disambiguator for grids
// whose inputs all read as `textbox ""`.
if fid, _ := res["focused_id"].(string); fid != "q" {
t.Errorf("focused_id = %q, want %q", fid, "q")
}
if log := readLog(t); len(log) != 1 || log[0].Key != "a" || log[0].Code != "KeyA" {
t.Errorf("keydown log = %+v, want one {key:a, code:KeyA} — a page reading event.code must still see the press", log)
}
Expand Down
Loading
Loading