From e5697d6f5c68c0f7d2a98148f6f30a6eeebd180e Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 4 Apr 2026 18:17:04 +0300 Subject: [PATCH] fix(web-test): reliable arrow-key scroll for off-screen spreadsheet cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites scrollSpreadsheetToCell with fixes for multiple issues discovered during E2E testing: - Use Playwright boundingBox (page-level coords) instead of frame-internal getBoundingClientRect for visibility checks — frame's clientWidth is wider than the actual visible iframe area clipped by parent elements - Use iframe element's boundingBox to determine visible region — cells behind the section panel (x < iframeBox.x) were incorrectly considered "visible" and focus clicks hit the section panel instead of the spreadsheet - Use div[y]+div[x] attribute selectors instead of div.RxCy CSS classes — the RxCy class numbering differs from y/x attribute values - Accept cellLoc parameter from caller instead of re-searching — avoids selector mismatch and handles cells missing from some rows - Native click through mxlCurrBody overlay (page.mouse.click) for focus — frame.locator().click() bypasses overlay causing header/data desync, page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus - Pick rightmost/leftmost fully-visible cell for focus based on scroll direction — each arrow press immediately triggers platform scroll Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 120 +++++++++++++------- 1 file changed, 76 insertions(+), 44 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index c4783b24..9b715864 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -1126,57 +1126,89 @@ function buildSpreadsheetMapping(allCells) { /** * Scroll SpreadsheetDocument to make a cell visible using arrow keys. * Uses native platform scroll — keeps headers, data, and scrollbar synchronized. - * Clicks a visible cell for focus, then ArrowRight/ArrowLeft to bring target into view. + * + * How it works: + * 1. Check target cell visibility via Playwright boundingBox (page-level coords). + * 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay. + * This is the same native click that clickSpreadsheetCell uses — it gives keyboard + * focus to the spreadsheet and keeps headers/data/scrollbar in sync. + * (frame.locator().click() bypasses overlay → desyncs frozen headers; + * page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.) + * 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport. + * + * @param {Frame} frame - Playwright Frame containing the spreadsheet cells + * @param {number} physRow - physical row (y attribute) in the frame + * @param {number} physCol - physical column (x attribute) in the frame + * @param {Locator} cellLoc - Playwright locator for the target cell (from caller) */ -async function scrollSpreadsheetToCell(frame, physRow, physCol) { - const getRect = async () => frame.evaluate(`(() => { - const el = document.querySelector('div.R${physRow}C${physCol}'); - if (!el) return null; - const r = el.getBoundingClientRect(); - const vw = document.documentElement.clientWidth || document.body.clientWidth; - return { left: r.left, right: r.right, vw }; - })()`); +async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) { + const pageVw = await page.evaluate('window.innerWidth'); + // Get iframe bounds — the actual visible region on page. + // The iframe may extend behind the section panel on the left, so cells with + // x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel. + const frameElm = await frame.frameElement(); + const frameBox = await frameElm.boundingBox(); + const visLeft = frameBox ? frameBox.x : 0; + const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw; - let rect = await getRect(); - if (!rect) return; - const isFullyVisible = (r) => r.left >= 0 && r.right <= r.vw; - if (isFullyVisible(rect)) return; + const getBox = async () => { + try { return await cellLoc.boundingBox({ timeout: 500 }); } + catch { return null; } + }; + const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight; - // Click a visible cell to establish focus. - // For ArrowRight: click leftmost visible cell (maximum room to scroll right). - // For ArrowLeft: click leftmost visible cell too (NOT rightmost — re-clicking breaks scroll context). - const direction = rect.right > rect.vw ? 'ArrowRight' : 'ArrowLeft'; - const vpWidth = await page.evaluate('window.innerWidth'); - const allCellLocs = frame.locator('div[x]'); - const cellCount = await allCellLocs.count(); - const candidates = []; - for (let ci = 0; ci < cellCount; ci++) { - const box = await allCellLocs.nth(ci).boundingBox(); - if (box && box.x >= 0 && (box.x + box.width) <= vpWidth && box.width > 5) { - candidates.push({ ci, box }); + let box = await getBox(); + if (!box) return; // cell not in DOM + if (isFullyVisible(box)) return; + + const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft'; + + // Find a fully-visible cell to click for focus. + // Prefer cells in the target row (scrollable area), fall back to any row. + const targetRowSel = `div[y="${physRow}"] div[x]`; + const anyRowSel = 'div[x]'; + let focusClicked = false; + for (const sel of [targetRowSel, anyRowSel]) { + const locs = frame.locator(sel); + const count = await locs.count(); + const candidates = []; + for (let ci = 0; ci < count; ci++) { + const b = await locs.nth(ci).boundingBox(); + if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) { + candidates.push({ ci, box: b }); + } } - } - if (candidates.length > 0) { + if (candidates.length === 0) continue; candidates.sort((a, b) => a.box.x - b.box.x); - const pick = candidates[0]; // always leftmost — safest for focus + // ArrowRight → rightmost fully-visible (each press scrolls right immediately) + // ArrowLeft → leftmost fully-visible (each press scrolls left immediately) + const pick = direction === 'ArrowRight' + ? candidates[candidates.length - 1] + : candidates[0]; + // Native click through overlay — gives keyboard focus + no header desync. await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2); await page.waitForTimeout(100); + focusClicked = true; + break; } + if (!focusClicked) return; // no visible cells — can't scroll - // Arrow keys until cell is fully visible or we're stuck at document edge. - let prevCx = (rect.left + rect.right) / 2; - let scrollStarted = false; + // Arrow keys until cell is fully visible or we detect no progress. + const MAX_STALE = 5; // bail out if arrows aren't scrolling (lost focus?) + let prevCx = box.x + box.width / 2; + let staleCount = 0; for (let i = 0; i < 100; i++) { await page.keyboard.press(direction); await page.waitForTimeout(50); - rect = await getRect(); - if (!rect) break; - if (isFullyVisible(rect)) break; - const cx = (rect.left + rect.right) / 2; + box = await getBox(); + if (!box) break; + if (isFullyVisible(box)) break; + const cx = box.x + box.width / 2; if (Math.abs(cx - prevCx) >= 1) { - scrollStarted = true; - } else if (scrollStarted) { - break; // scroll was moving, now stopped — reached document edge + staleCount = 0; + } else { + staleCount++; + if (staleCount >= MAX_STALE) break; } prevCx = cx; } @@ -1245,13 +1277,13 @@ async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { // Get bounding box and click via page.mouse (bypasses mxlCurrBody overlay) const frame = page.frames()[frameIndex]; - const cellDiv = frame.locator(`div.R${physRow}C${physCol}`).first(); + // Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs. + const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first(); // Scroll cell into view using arrow keys — the only reliable way to scroll // 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar. - // First click a visible cell to focus the spreadsheet, then arrow-key to target. - await scrollSpreadsheetToCell(frame, physRow, physCol); + await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv); const box = await cellDiv.boundingBox(); - if (!box) throw new Error(`clickElement: cell R${physRow}C${physCol} not visible (no bounding box).`); + if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`); const x = box.x + box.width / 2; const y = box.y + box.height / 2; @@ -1298,8 +1330,8 @@ async function findSpreadsheetCellByText(formNum, searchText) { const frame = page.frames()[frameIndex]; // Scroll cell into view using native arrow-key mechanism - await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c); - const cellDiv = frame.locator(`div.R${found.cell.r}C${found.cell.c}`).first(); + const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first(); + await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv); const box = await cellDiv.boundingBox(); if (!box) return null;