diff --git a/apps/extension/src/content/__tests__/record-capture.test.ts b/apps/extension/src/content/__tests__/record-capture.test.ts index 38ac5d7..90536f7 100644 --- a/apps/extension/src/content/__tests__/record-capture.test.ts +++ b/apps/extension/src/content/__tests__/record-capture.test.ts @@ -8,6 +8,44 @@ vi.stubGlobal("chrome", { }, }); +function mockRect( + el: Element, + rect: { left: number; top: number; width: number; height: number }, +): void { + vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ + x: rect.left, + y: rect.top, + top: rect.top, + left: rect.left, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + width: rect.width, + height: rect.height, + toJSON: () => ({}), + }); +} + +function mockHoverStyle(pointerElements: Element[], positionedElements: Element[] = []): void { + vi.spyOn(window, "getComputedStyle").mockImplementation((el) => { + const style = { + cursor: pointerElements.includes(el) ? "pointer" : "", + display: "block", + pointerEvents: "auto", + position: positionedElements.includes(el) ? "absolute" : "static", + visibility: "visible", + } as CSSStyleDeclaration; + return style; + }); +} + +function mouseOver(el: Element): void { + el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); +} + +function click(el: Element): void { + el.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); +} + describe("handleRecordContentMessage stop/cancel", () => { it("ignores STOP when no recording is active", () => { const dispose = vi.fn(); @@ -156,4 +194,1363 @@ describe("record-capture semantic", () => { target: { name: "下一步", role: "button" }, }); }); + + it("records a hover trigger before clicking its revealed menu item", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-menu", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".user-menu")!; + const item = document.querySelector("a")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + mockRect(menu, { left: 820, top: 48, width: 160, height: 80 }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "Open user navigation menu" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { role: "link", name: "My profile" }, + }); + }); + + it("records hover for compact topbar image buttons before menu item clicks", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-topbar", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".user-menu")!; + const item = document.querySelector("a")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + mockRect(menu, { left: 820, top: 48, width: 160, height: 80 }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "image" }, + }); + }); + + it("records hover when the revealed menu item is inside the hover root", () => { + document.body.innerHTML = ` + + `; + const capture = startRecordCapture("rec-hover-contained-menu", (step) => steps.push(step)); + const trigger = document.querySelector('[role="button"]')!; + const menu = document.querySelector("ul")!; + const item = document.querySelector("a")!; + mockRect(trigger, { left: 900, top: 8, width: 32, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 160, height: 80 }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "image" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { role: "link", name: "My profile" }, + }); + }); + + it("does not let a revealed topbar menu link replace the pending hover trigger", () => { + document.body.innerHTML = ` + + image + + + `; + const capture = startRecordCapture("rec-hover-topbar-link", (step) => steps.push(step)); + const trigger = document.querySelector('a[aria-label="image"]')!; + const menu = document.querySelector(".user-menu")!; + const item = document.querySelector("ul a")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + vi.spyOn(item, "getBoundingClientRect").mockReturnValue({ + x: 820, + y: 72, + top: 72, + left: 820, + right: 916, + bottom: 104, + width: 96, + height: 32, + toJSON: () => ({}), + }); + mockRect(menu, { left: 820, top: 48, width: 160, height: 80 }); + vi.spyOn(window, "getComputedStyle").mockImplementation((el) => { + const style = { + cursor: el === item ? "pointer" : "", + pointerEvents: "auto", + } as CSSStyleDeclaration; + return style; + }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + item.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "link", name: "image" }, + }); + }); + + it("keeps the first hover trigger over a later lower-score accepted hover", () => { + document.body.innerHTML = ` + + My profile + `; + const capture = startRecordCapture("rec-hover-latch", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const laterHover = document.querySelector("a")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + vi.spyOn(laterHover, "getBoundingClientRect").mockReturnValue({ + x: 820, + y: 72, + top: 72, + left: 820, + right: 916, + bottom: 104, + width: 96, + height: 32, + toJSON: () => ({}), + }); + vi.spyOn(window, "getComputedStyle").mockImplementation((el) => { + const style = { + cursor: el === laterHover ? "pointer" : "", + pointerEvents: "auto", + } as CSSStyleDeclaration; + return style; + }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + laterHover.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + laterHover.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "Open user navigation menu" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { role: "button", name: "My profile" }, + }); + }); + + it("keeps the menu opener hover when moving over an accepted menu item", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-menu-opener", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const item = document.querySelector("li")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 80 }); + mockRect(item, { left: 820, top: 48, width: 160, height: 32 }); + mockHoverStyle([item]); + + mouseOver(trigger); + mouseOver(item); + click(item); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { tag: "li", name: "文档C+D" }, + }); + }); + + it("does not latch a plain div action item inside a hover surface", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-menu-div-action", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const item = document.querySelector(".tg-menu-item")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 80 }); + mockRect(item, { left: 820, top: 48, width: 160, height: 32 }); + mockHoverStyle([item]); + + mouseOver(trigger); + mouseOver(item); + click(item); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { tag: "div", name: "文档C+D" }, + }); + }); + + it("keeps the opener hover when a surface action is clicked after the short latch window", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-07T03:43:36.000Z")); + try { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-menu-div-action-slow", (step) => + steps.push(step), + ); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const item = document.querySelector(".tg-menu-item")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 80 }); + mockRect(item, { left: 820, top: 48, width: 160, height: 32 }); + mockHoverStyle([item]); + + mouseOver(trigger); + vi.setSystemTime(new Date("2026-08-07T03:43:47.000Z")); + mouseOver(item); + click(item); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { tag: "div", name: "文档C+D" }, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("infers a non-policy opener hover from the later surface action", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-infer-opener", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const item = document.querySelector(".tg-menu-item")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 80 }); + mockRect(item, { left: 820, top: 48, width: 160, height: 32 }); + mockHoverStyle([trigger, item]); + + mouseOver(trigger); + mouseOver(item); + click(item); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { tag: "div", name: "文档C+D" }, + }); + }); + + it("does not record a strong-looking surface item when clicking that item itself", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-false-submenu", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const item = document.querySelector("li")!; + const itemInner = document.querySelector("li div")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 80 }); + mockRect(item, { left: 820, top: 48, width: 160, height: 32 }); + mockHoverStyle([item, itemInner]); + + mouseOver(trigger); + mouseOver(item); + click(itemInner); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { tag: "div", name: "文档C+D" }, + }); + }); + + it("records cascaded hover triggers before clicking inside the final surface", () => { + document.body.innerHTML = ` + + + + `; + const capture = startRecordCapture("rec-hover-cascade", (step) => steps.push(step)); + const trigger = document.querySelector("button[aria-label]")!; + const menu = document.querySelector(".create-menu")!; + const submenu = document.querySelector(".template-submenu")!; + const nestedTrigger = document.querySelector("li")!; + const finalAction = document.querySelector(".template-submenu button")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 80 }); + mockRect(nestedTrigger, { left: 820, top: 48, width: 160, height: 32 }); + mockHoverStyle([nestedTrigger, finalAction]); + + mouseOver(trigger); + mockRect(submenu, { left: 984, top: 48, width: 160, height: 80 }); + mockRect(finalAction, { left: 984, top: 48, width: 136, height: 32 }); + mouseOver(nestedTrigger); + click(finalAction); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "hover", + target: { tag: "li", name: "更多模板" }, + }); + expect(steps[2]).toMatchObject({ + op: "click", + target: { role: "button", name: "Blank doc" }, + }); + }); + + it("infers the full hover opener chain for nested menu actions", () => { + document.body.innerHTML = ` + + + + `; + const capture = startRecordCapture("rec-hover-nested-infer", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const submenu = document.querySelector(".format-submenu")!; + const nestedTrigger = document.querySelector("li")!; + const finalAction = document.querySelector("span")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 80 }); + mockRect(nestedTrigger, { left: 820, top: 48, width: 160, height: 32 }); + mockHoverStyle([trigger, nestedTrigger, finalAction]); + + mouseOver(trigger); + mockRect(submenu, { left: 984, top: 48, width: 160, height: 80 }); + mockRect(finalAction, { left: 984, top: 48, width: 136, height: 32 }); + mouseOver(nestedTrigger); + mouseOver(finalAction); + click(finalAction); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "hover", + target: { tag: "li", name: "文档C+D" }, + }); + expect(steps[2]).toMatchObject({ + op: "click", + target: { tag: "span", name: "Markdown" }, + }); + }); + + it("uses the nested hover trigger label without descendant submenu text", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-nested-compact-label", (step) => + steps.push(step), + ); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const nestedTrigger = document.querySelector("li")!; + const submenu = document.querySelector("li > div")!; + const finalAction = document.querySelector("li > div div")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 80 }); + mockRect(nestedTrigger, { left: 820, top: 48, width: 160, height: 32 }); + mockHoverStyle([trigger, nestedTrigger, finalAction], [submenu]); + + mouseOver(trigger); + mockRect(submenu, { left: 984, top: 48, width: 180, height: 220 }); + mockRect(finalAction, { left: 984, top: 48, width: 136, height: 32 }); + mouseOver(nestedTrigger); + mouseOver(finalAction); + click(finalAction); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "hover", + target: { tag: "li", name: "多维表格" }, + }); + expect(JSON.stringify(steps[1]?.target)).not.toContain("看板视图"); + expect(steps[2]).toMatchObject({ + op: "click", + target: { tag: "div", name: "看板视图" }, + }); + }); + + it("does not let a stale pass-through shortcut hover claim a later submenu", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-07T06:39:40.000Z")); + try { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-stale-shortcut", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const shortcut = document.querySelector(".text-font-tips")!; + const nestedTrigger = document.querySelector(".create-menu-item-vika")!; + const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!; + const finalAction = document.querySelector(".t-dropdown__submenu-wrapper span")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 160 }); + mockRect(shortcut, { left: 950, top: 56, width: 29, height: 22 }); + mockRect(nestedTrigger, { left: 820, top: 92, width: 160, height: 32 }); + mockHoverStyle([trigger, shortcut, nestedTrigger, finalAction], [submenu]); + + mouseOver(trigger); + mouseOver(shortcut); + mockRect(submenu, { left: 984, top: 92, width: 160, height: 80 }); + mockRect(finalAction, { left: 984, top: 92, width: 136, height: 32 }); + mouseOver(nestedTrigger); + vi.runOnlyPendingTimers(); + mouseOver(finalAction); + click(finalAction); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "hover", + target: { tag: "li", name: "多维表格" }, + }); + expect(JSON.stringify(steps)).not.toContain("C+D"); + expect(steps[2]).toMatchObject({ + op: "click", + target: { tag: "span", name: "看板视图" }, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("does not let an item inside a newly opened menu claim the parent surface", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-07T07:12:08.000Z")); + try { + document.body.innerHTML = ` + + +
+ 看板视图 +
+ `; + const capture = startRecordCapture("rec-hover-parent-surface-owner", (step) => + steps.push(step), + ); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const passThrough = document.querySelector(".create-menu-item-doc")!; + const nestedTrigger = document.querySelector(".create-menu-item-vika")!; + const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!; + const finalAction = document.querySelector(".t-dropdown__submenu-wrapper span")!; + mockRect(trigger, { left: 42, top: 72, width: 60, height: 32 }); + mockRect(passThrough, { left: 17, top: 113, width: 184, height: 34 }); + mockRect(nestedTrigger, { left: 17, top: 228, width: 184, height: 34 }); + mockHoverStyle([trigger, passThrough, nestedTrigger, finalAction]); + + mouseOver(trigger); + mockRect(menu, { left: 8, top: 104, width: 201, height: 439 }); + mouseOver(passThrough); + vi.runOnlyPendingTimers(); + mockRect(submenu, { left: 201, top: 213, width: 114, height: 267 }); + mockRect(finalAction, { left: 209, top: 257, width: 97, height: 34 }); + mouseOver(nestedTrigger); + vi.runOnlyPendingTimers(); + mouseOver(finalAction); + click(finalAction); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "hover", + target: { tag: "li", name: "多维表格" }, + }); + expect(JSON.stringify(steps)).not.toContain("文档C+D"); + } finally { + vi.useRealTimers(); + } + }); + + it("infers an unowned existing parent surface when a nested submenu is opened", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-07T07:28:10.000Z")); + try { + document.body.innerHTML = ` + + +
+ 看板视图 +
+ `; + const capture = startRecordCapture("rec-hover-existing-parent-surface", (step) => + steps.push(step), + ); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const passThrough = document.querySelector(".create-menu-item-doc")!; + const nestedTrigger = document.querySelector(".create-menu-item-vika")!; + const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!; + const finalAction = document.querySelector(".t-dropdown__submenu-wrapper span")!; + mockRect(trigger, { left: 42, top: 72, width: 60, height: 32 }); + mockRect(menu, { left: 8, top: 104, width: 201, height: 439 }); + mockRect(passThrough, { left: 17, top: 113, width: 184, height: 34 }); + mockRect(nestedTrigger, { left: 17, top: 228, width: 184, height: 34 }); + mockHoverStyle([trigger, passThrough, nestedTrigger, finalAction]); + + mouseOver(passThrough); + mouseOver(trigger); + vi.runOnlyPendingTimers(); + mockRect(submenu, { left: 201, top: 213, width: 114, height: 267 }); + mockRect(finalAction, { left: 209, top: 257, width: 97, height: 34 }); + mouseOver(nestedTrigger); + vi.runOnlyPendingTimers(); + mouseOver(finalAction); + click(finalAction); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "hover", + target: { tag: "li", name: "多维表格" }, + }); + expect(JSON.stringify(steps)).not.toContain("文档C+D"); + } finally { + vi.useRealTimers(); + } + }); + + it("assigns a side submenu to the vertically aligned hover trigger", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-07T06:55:38.000Z")); + try { + document.body.innerHTML = ` + + +
+
  • + 看板视图 +
  • +
    + `; + const capture = startRecordCapture("rec-hover-side-submenu-alignment", (step) => + steps.push(step), + ); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const passThrough = document.querySelector(".create-menu-item-doc")!; + const nestedTrigger = document.querySelector(".create-menu-item-vika")!; + const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!; + const finalAction = document.querySelector(".t-dropdown__submenu-wrapper span")!; + mockRect(trigger, { left: 42, top: 72, width: 60, height: 32 }); + mockRect(menu, { left: 8, top: 104, width: 201, height: 439 }); + mockRect(passThrough, { left: 17, top: 113, width: 184, height: 34 }); + mockRect(nestedTrigger, { left: 17, top: 228, width: 184, height: 34 }); + mockHoverStyle([trigger, passThrough, nestedTrigger, finalAction]); + + mouseOver(trigger); + mouseOver(passThrough); + mockRect(submenu, { left: 201, top: 213, width: 114, height: 267 }); + mockRect(finalAction, { left: 209, top: 257, width: 97, height: 34 }); + mouseOver(nestedTrigger); + mouseOver(finalAction); + vi.runOnlyPendingTimers(); + click(finalAction); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "hover", + target: { tag: "li", name: "多维表格" }, + }); + expect(JSON.stringify(steps)).not.toContain("文档C+D"); + expect(steps[2]).toMatchObject({ + op: "click", + target: { tag: "span", name: "看板视图" }, + }); + } finally { + vi.useRealTimers(); + } + }); + + it("outputs only one parent path for noisy nested menu hover movement", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-07T07:02:36.000Z")); + try { + document.body.innerHTML = ` + + +
    +
    +
    + 表格视图 +
    +
    +
    + `; + const capture = startRecordCapture("rec-hover-noisy-nested-path", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const passThrough = document.querySelector(".create-menu-item-doc")!; + const nestedTrigger = document.querySelector(".create-menu-item-vika")!; + const nestedInner = document.querySelector(".t-dropdown__item-content")!; + const submenu = document.querySelector(".t-dropdown__submenu-wrapper")!; + const finalItem = document.querySelector(".create-submenu-item-vika-kanban")!; + const finalHoverItem = document.querySelector(".create-submenu-item-label")!; + const finalAction = document.querySelector(".create-submenu-item-vika-kanban")!; + mockRect(trigger, { left: 42, top: 72, width: 60, height: 32 }); + mockRect(menu, { left: 8, top: 104, width: 201, height: 439 }); + mockRect(passThrough, { left: 17, top: 113, width: 184, height: 34 }); + mockRect(nestedTrigger, { left: 17, top: 228, width: 184, height: 34 }); + mockRect(nestedInner, { left: 25, top: 234, width: 168, height: 22 }); + mockHoverStyle([ + trigger, + passThrough, + nestedTrigger, + nestedInner, + finalItem, + finalHoverItem, + finalAction, + ]); + + mouseOver(trigger); + mouseOver(nestedTrigger); + mouseOver(nestedInner); + mockRect(submenu, { left: 201, top: 213, width: 114, height: 267 }); + mockRect(finalItem, { left: 209, top: 257, width: 97, height: 34 }); + mockRect(finalHoverItem, { left: 209, top: 257, width: 97, height: 34 }); + mockRect(finalAction, { left: 209, top: 257, width: 97, height: 34 }); + mouseOver(passThrough); + mouseOver(nestedInner); + mouseOver(finalHoverItem); + vi.runOnlyPendingTimers(); + click(finalAction); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "hover", + target: { name: "多维表格" }, + }); + expect(JSON.stringify(steps)).not.toContain("文档C+D"); + expect(JSON.stringify(steps)).not.toContain('"表格视图","tag":"div"'); + expect(steps.filter((step) => step.op === "hover")).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + it("does not infer sibling items in the same surface as hover openers", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-same-surface-action", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".create-menu")!; + const category = document.querySelector("li")!; + const finalAction = document.querySelector("span")!; + mockRect(trigger, { left: 900, top: 8, width: 60, height: 32 }); + mockRect(menu, { left: 820, top: 48, width: 320, height: 80 }); + mockRect(category, { left: 820, top: 48, width: 160, height: 32 }); + mockRect(finalAction, { left: 984, top: 48, width: 136, height: 32 }); + mockHoverStyle([trigger, category, finalAction]); + + mouseOver(trigger); + mouseOver(category); + mouseOver(finalAction); + click(finalAction); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "新建" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { tag: "span", name: "Markdown" }, + }); + }); + + it("records avatar div hover with a semantic image target", () => { + document.body.innerHTML = ` +
    + +
    + + `; + const capture = startRecordCapture("rec-hover-avatar-div", (step) => steps.push(step)); + const trigger = document.querySelector(".tg-avatar")!; + const image = document.querySelector("img")!; + const menu = document.querySelector(".user-menu")!; + const item = document.querySelector("a")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + vi.spyOn(image, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + mockRect(menu, { left: 820, top: 48, width: 160, height: 80 }); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + cursor: "pointer", + pointerEvents: "auto", + } as CSSStyleDeclaration); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + image.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + item.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { tag: "img", role: "img", name: "image" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { role: "link", name: "My profile" }, + }); + }); + + it("does not let unrelated topbar hovers or menu pass-through items own an avatar menu", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-07T08:08:45.000Z")); + try { + document.body.innerHTML = ` + +
    + +
    + + `; + const star = document.querySelector("button")!; + const avatar = document.querySelector(".tg-avatar")!; + const image = document.querySelector("img")!; + const menu = document.querySelector(".user-menu")!; + const profile = document.querySelector('a[href="/u/me"]')!; + const groups = document.querySelector('a[href="/dashboard/groups"]')!; + let menuRect = { left: 0, top: 0, width: 0, height: 0 }; + let profileRect = { left: 0, top: 0, width: 0, height: 0 }; + let groupsRect = { left: 0, top: 0, width: 0, height: 0 }; + mockRect(star, { left: 760, top: 8, width: 60, height: 32 }); + mockRect(avatar, { left: 900, top: 8, width: 32, height: 32 }); + mockRect(image, { left: 900, top: 8, width: 32, height: 32 }); + vi.spyOn(menu, "getBoundingClientRect").mockImplementation( + () => + ({ + x: menuRect.left, + y: menuRect.top, + top: menuRect.top, + left: menuRect.left, + right: menuRect.left + menuRect.width, + bottom: menuRect.top + menuRect.height, + width: menuRect.width, + height: menuRect.height, + toJSON: () => ({}), + }) as DOMRect, + ); + vi.spyOn(profile, "getBoundingClientRect").mockImplementation( + () => + ({ + x: profileRect.left, + y: profileRect.top, + top: profileRect.top, + left: profileRect.left, + right: profileRect.left + profileRect.width, + bottom: profileRect.top + profileRect.height, + width: profileRect.width, + height: profileRect.height, + toJSON: () => ({}), + }) as DOMRect, + ); + vi.spyOn(groups, "getBoundingClientRect").mockImplementation( + () => + ({ + x: groupsRect.left, + y: groupsRect.top, + top: groupsRect.top, + left: groupsRect.left, + right: groupsRect.left + groupsRect.width, + bottom: groupsRect.top + groupsRect.height, + width: groupsRect.width, + height: groupsRect.height, + toJSON: () => ({}), + }) as DOMRect, + ); + mockHoverStyle([star, avatar, image, profile, groups]); + const capture = startRecordCapture("rec-hover-avatar-menu-pass-through", (step) => + steps.push(step), + ); + + mouseOver(star); + mouseOver(avatar); + mouseOver(image); + menuRect = { left: 820, top: 48, width: 160, height: 96 }; + profileRect = { left: 830, top: 56, width: 120, height: 28 }; + groupsRect = { left: 830, top: 88, width: 120, height: 28 }; + vi.runOnlyPendingTimers(); + mouseOver(profile); + mouseOver(groups); + click(groups); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { tag: "img", role: "img", name: "image" }, + }); + expect(steps[1]).toMatchObject({ + op: "click", + target: { role: "link", name: "My groups" }, + }); + expect(JSON.stringify(steps)).not.toContain("Star"); + expect(JSON.stringify(steps)).not.toContain("My profile"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not record hover before an unrelated page click", () => { + document.body.innerHTML = ` + +
    + Projects +
    + `; + const capture = startRecordCapture("rec-hover-unrelated-click", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const link = document.querySelector("main a")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["click"]); + expect(steps[0]).toMatchObject({ + op: "click", + target: { role: "link", name: "Projects" }, + }); + }); + + it("records hover before filling a field inside the hover surface", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-fill-surface", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".user-menu")!; + const input = document.querySelector("input")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 96 }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + input.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + input.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + input.value = "Ada"; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "fill"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "Open user navigation menu" }, + }); + expect(steps[1]).toMatchObject({ + op: "fill", + target: { tag: "input" }, + value: "Ada", + }); + }); + + it("records hover before selecting inside the hover surface", () => { + document.body.innerHTML = ` + + + `; + const capture = startRecordCapture("rec-hover-select-surface", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const menu = document.querySelector(".user-menu")!; + const select = document.querySelector("select")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + mockRect(menu, { left: 820, top: 48, width: 180, height: 96 }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + select.value = "away"; + select.dispatchEvent(new Event("change", { bubbles: true })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "select"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "Open user navigation menu" }, + }); + expect(steps[1]).toMatchObject({ + op: "select", + target: { role: "combobox", name: "Status" }, + values: ["away"], + labels: ["Away"], + }); + }); + + it("records hover before clicking an unlabelled positioned floating surface", () => { + document.body.innerHTML = ` + +
    + My profile +
    + `; + const capture = startRecordCapture("rec-hover-plain-floating-surface", (step) => + steps.push(step), + ); + const trigger = document.querySelector("button")!; + const surface = document.querySelector("div")!; + const link = document.querySelector("a")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + vi.spyOn(surface, "getBoundingClientRect").mockReturnValue({ + x: 820, + y: 48, + top: 48, + left: 820, + right: 980, + bottom: 128, + width: 160, + height: 80, + toJSON: () => ({}), + }); + vi.spyOn(window, "getComputedStyle").mockImplementation((el) => { + const style = { + cursor: "", + display: "block", + pointerEvents: "auto", + position: el === surface ? "absolute" : "static", + visibility: "visible", + } as CSSStyleDeclaration; + return style; + }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + target: { role: "button", name: "Open user navigation menu" }, + }); + }); + + it("does not treat ordinary document-flow divs as hover surfaces", () => { + document.body.innerHTML = ` + +
    + My profile +
    + `; + const capture = startRecordCapture("rec-hover-plain-flow-surface", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const link = document.querySelector("a")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + cursor: "", + display: "block", + pointerEvents: "auto", + position: "static", + visibility: "visible", + } as CSSStyleDeclaration); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["click"]); + }); + + it("does not treat unlabelled sticky containers as hover surfaces", () => { + document.body.innerHTML = ` + +
    + My profile +
    + `; + const capture = startRecordCapture("rec-hover-sticky-surface", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + const surface = document.querySelector("div")!; + const link = document.querySelector("a")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + vi.spyOn(surface, "getBoundingClientRect").mockReturnValue({ + x: 820, + y: 48, + top: 48, + left: 820, + right: 980, + bottom: 128, + width: 160, + height: 80, + toJSON: () => ({}), + }); + vi.spyOn(window, "getComputedStyle").mockImplementation((el) => { + const style = { + cursor: "", + display: "block", + pointerEvents: "auto", + position: el === surface ? "sticky" : "static", + visibility: "visible", + } as CSSStyleDeclaration; + return style; + }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + link.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["click"]); + }); + + it("does not record hover before clicking the same trigger", () => { + document.body.innerHTML = ` + + `; + const capture = startRecordCapture("rec-hover-same-trigger", (step) => steps.push(step)); + const trigger = document.querySelector("button")!; + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + x: 900, + y: 8, + top: 8, + left: 900, + right: 932, + bottom: 40, + width: 32, + height: 32, + toJSON: () => ({}), + }); + + trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + trigger.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["click"]); + }); + + it("does not record hover for ordinary controls without popup signals", () => { + const capture = startRecordCapture("rec-hover-noise", (step) => steps.push(step)); + const button = document.querySelector("button")!; + + button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + button.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps.map((s) => s.op)).toEqual(["click"]); + }); }); diff --git a/apps/extension/src/content/__tests__/record-hover-surface.test.ts b/apps/extension/src/content/__tests__/record-hover-surface.test.ts new file mode 100644 index 0000000..08b8c96 --- /dev/null +++ b/apps/extension/src/content/__tests__/record-hover-surface.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { isHoverSurfaceCandidateElement, isLikelyHoverSurfaceOwner } from "../record-hover-surface"; + +function mockRect( + el: Element, + rect: { left: number; top: number; width: number; height: number }, +): void { + vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ + x: rect.left, + y: rect.top, + top: rect.top, + left: rect.left, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + width: rect.width, + height: rect.height, + toJSON: () => ({}), + }); +} + +describe("record-hover-surface", () => { + it("recognizes popover containers without treating topbar wrappers or menu items as surfaces", () => { + document.body.innerHTML = ` + +
    +
    +
    +
  • + My profile +
  • +
  • + My groups +
  • +
    +
    +
    + `; + + expect(isHoverSurfaceCandidateElement(document.querySelector(".header-menu")!)).toBe(false); + expect(isHoverSurfaceCandidateElement(document.querySelector(".tg-popover")!)).toBe(false); + expect(isHoverSurfaceCandidateElement(document.querySelector(".header-menu__item")!)).toBe( + false, + ); + expect( + isHoverSurfaceCandidateElement(document.querySelector(".header-dropdown-menu__link")!), + ).toBe(false); + expect(isHoverSurfaceCandidateElement(document.querySelector(".tg-popover__popper")!)).toBe( + true, + ); + expect(isHoverSurfaceCandidateElement(document.querySelector(".tg-popover__content")!)).toBe( + true, + ); + expect( + isHoverSurfaceCandidateElement(document.querySelector(".tg-popover__content-body")!), + ).toBe(true); + }); + + it("does not treat elements covered by an open surface as the surface owner", () => { + document.body.innerHTML = ` + + Fork + +
    + `; + const avatar = document.querySelector(".avatar")!; + const fork = document.querySelector(".fork")!; + const clone = document.querySelector(".clone")!; + const surface = document.querySelector(".tg-popover__content-body")!; + mockRect(avatar, { left: 1340, top: 11, width: 26, height: 26 }); + mockRect(fork, { left: 1275, top: 56, width: 69, height: 28 }); + mockRect(clone, { left: 1159, top: 111, width: 180, height: 30 }); + mockRect(surface, { left: 1111, top: 44, width: 240, height: 496 }); + + expect(isLikelyHoverSurfaceOwner(avatar, surface)).toBe(true); + expect(isLikelyHoverSurfaceOwner(fork, surface)).toBe(false); + expect(isLikelyHoverSurfaceOwner(clone, surface)).toBe(false); + }); + + it("does not treat fixed navigation containers as plain floating surfaces", () => { + document.body.innerHTML = ` + +
    + `; + const navbar = document.querySelector(".navbar-app")!; + const floating = document.querySelector(".plain-floating")!; + mockRect(navbar, { left: 0, top: 0, width: 1386, height: 51 }); + mockRect(floating, { left: 1111, top: 44, width: 240, height: 496 }); + vi.spyOn(window, "getComputedStyle").mockImplementation((el) => { + const style = { + cursor: "", + display: "block", + pointerEvents: "auto", + position: el === navbar ? "fixed" : "absolute", + visibility: "visible", + } as CSSStyleDeclaration; + return style; + }); + + expect(isHoverSurfaceCandidateElement(navbar)).toBe(false); + expect(isHoverSurfaceCandidateElement(floating)).toBe(true); + }); +}); diff --git a/apps/extension/src/content/record-capture.ts b/apps/extension/src/content/record-capture.ts index 12bf6af..53488d7 100644 --- a/apps/extension/src/content/record-capture.ts +++ b/apps/extension/src/content/record-capture.ts @@ -1,4 +1,17 @@ -import { describeEventTarget, describeTarget, type TargetDescriptor } from "@/lib/describe-target"; +import { + describeEventTarget, + describeTarget, + resolveClickableElement, + resolveHoverElement, + type TargetDescriptor, +} from "@/lib/describe-target"; +import { + evaluateHoverTrigger, + type HoverTriggerDecision, + type HoverTriggerRect, + hasDirectHoverInteractiveSignal, + hasStrongHoverExpansionSignal, +} from "@/lib/hover-trigger-policy"; import { isRecordCancelMessage, isRecordStartMessage, @@ -15,6 +28,14 @@ import { type RecordStopMessage, } from "@/lib/record-bridge"; import { shouldRecordPress } from "@/lib/trace-reducer"; +import { + closestHoverSurfaceCandidate, + collectHoverSurfaceStates, + decideHoverSurfaceRelation, + type HoverSurfaceState, + isHoverSurfaceCandidateElement, + isLikelyHoverSurfaceOwner, +} from "./record-hover-surface"; const pendingStepSends = new Map>>(); const failedStepDeliveries = new Set(); @@ -49,8 +70,29 @@ interface FillSession { lastValue: string; } +interface HoverCandidate { + element: Element; + target: TargetDescriptor; + recordedAt: number; + score: number; + eligible: boolean; +} + +interface HoverSurfaceNode { + element: Element; + signature: string; + owner: HoverCandidate; + parent?: HoverSurfaceNode; +} + type FillableElement = HTMLInputElement | HTMLTextAreaElement | HTMLElement; +const HOVER_BEFORE_CLICK_MAX_MS = 10_000; +const HOVER_SURFACE_CONTEXT_MAX_MS = 30_000; +const HOVER_REPLACE_SCORE_MARGIN = 50; +const HOVER_CANDIDATE_LIMIT = 24; +const HOVER_TRIGGER_LABEL_MAX = 48; + function eventTarget(event: Event): EventTarget | null { return event.composedPath()[0] ?? event.target; } @@ -122,6 +164,173 @@ function fillableValue(el: FillableElement): string { return el.textContent ?? ""; } +function hoverTriggerAttrs(el: Element): Record { + return { + id: el.id || undefined, + class: typeof el.className === "string" ? el.className || undefined : undefined, + "data-testid": el.getAttribute("data-testid") ?? undefined, + "data-test": el.getAttribute("data-test") ?? undefined, + "data-cy": el.getAttribute("data-cy") ?? undefined, + "aria-label": el.getAttribute("aria-label") ?? undefined, + "aria-haspopup": el.getAttribute("aria-haspopup") ?? undefined, + "aria-controls": el.getAttribute("aria-controls") ?? undefined, + "aria-expanded": el.getAttribute("aria-expanded") ?? undefined, + "aria-hidden": el.getAttribute("aria-hidden") ?? undefined, + "aria-disabled": el.getAttribute("aria-disabled") ?? undefined, + contenteditable: el.getAttribute("contenteditable") ?? undefined, + disabled: el.hasAttribute("disabled") ? "" : undefined, + hidden: el.hasAttribute("hidden") ? "" : undefined, + inert: el.hasAttribute("inert") ? "" : undefined, + onclick: el.getAttribute("onclick") ?? undefined, + onmouseenter: el.getAttribute("onmouseenter") ?? undefined, + onmouseover: el.getAttribute("onmouseover") ?? undefined, + role: el.getAttribute("role") ?? undefined, + tabindex: el.getAttribute("tabindex") ?? undefined, + title: el.getAttribute("title") ?? undefined, + }; +} + +function hoverTriggerRect(el: Element): HoverTriggerRect { + const rect = el.getBoundingClientRect(); + return { x: rect.left, y: rect.top, w: rect.width, h: rect.height }; +} + +function hoverTriggerStyle(el: Element): { cursor?: string; pointerEvents?: string } { + if (!(el instanceof HTMLElement)) return {}; + const style = getComputedStyle(el); + return { cursor: style.cursor, pointerEvents: style.pointerEvents }; +} + +function hasHoverGraphicDescendant(el: Element): boolean { + return el.querySelector("img,svg,use,path,i") !== null; +} + +function normalizeLabelText(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncateLabel(value: string, max = HOVER_TRIGGER_LABEL_MAX): string { + const normalized = normalizeLabelText(value); + if (normalized.length <= max) return normalized; + return `${normalized.slice(0, max - 1)}…`; +} + +function collectHoverTriggerLabelText(root: Element): string { + let text = ""; + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + text += ` ${node.textContent ?? ""}`; + return; + } + if (!(node instanceof Element)) return; + if (node !== root && isHoverSurfaceCandidateElement(node)) { + return; + } + for (const child of node.childNodes) visit(child); + }; + for (const child of root.childNodes) visit(child); + return normalizeLabelText(text); +} + +function compactHoverTargetName(el: Element, desc: TargetDescriptor): TargetDescriptor { + if (!desc.name) return desc; + const fullText = normalizeLabelText(el.textContent ?? ""); + const compactName = collectHoverTriggerLabelText(el); + if (!compactName || compactName === desc.name) return desc; + const compactComparable = compactName.replace(/\s+/g, ""); + const descComparable = desc.name.replace(/…$/, "").replace(/\s+/g, ""); + const fullTextComparable = fullText.replace(/\s+/g, ""); + if ( + descComparable.startsWith(compactComparable) && + fullTextComparable.startsWith(descComparable) && + compactName.length < desc.name.length + ) { + return { ...desc, name: truncateLabel(compactName) }; + } + return desc; +} + +function isWeakHoverTarget(target: TargetDescriptor): boolean { + return !target.role && !target.name && target.tag === "div"; +} + +function looksLikeAvatarElement(el: Element): boolean { + const className = typeof el.className === "string" ? el.className : ""; + return /\b(avatar|user-avatar)\b/i.test(className); +} + +function normalizeHoverTarget( + el: Element, + desc: TargetDescriptor, + decision: HoverTriggerDecision, +): TargetDescriptor { + if (desc.role === "img" && !desc.name && looksLikeAvatarElement(el)) { + return { ...desc, name: "image" }; + } + if ( + isWeakHoverTarget(desc) && + (looksLikeAvatarElement(el) || + (hasHoverGraphicDescendant(el) && decision.reasons.includes("icon-only"))) + ) { + return { tag: "img", role: "img", name: "image" }; + } + return desc; +} + +function hoverTriggerSignals(el: Element, desc: TargetDescriptor) { + if (!(el instanceof HTMLElement)) return null; + const style = hoverTriggerStyle(el); + return { + tag: el.tagName.toLowerCase(), + role: desc.role, + label: desc.name, + attrs: hoverTriggerAttrs(el), + rect: hoverTriggerRect(el), + cursor: style.cursor, + pointerEvents: style.pointerEvents, + hasGraphicDescendant: hasHoverGraphicDescendant(el), + }; +} + +function hoverCandidateFromEvent(target: EventTarget | null): HoverCandidate | null { + if (!(target instanceof Element)) return null; + const element = resolveHoverElement(target); + if (!element) return null; + const desc = describeTarget(element); + const signals = hoverTriggerSignals(element, desc); + if (!signals) return null; + const decision = evaluateHoverTrigger(signals); + const hasExpansionSignal = hasStrongHoverExpansionSignal(signals); + const hasDirectSignal = hasDirectHoverInteractiveSignal(signals); + if (!decision.eligible && !hasExpansionSignal && !hasDirectSignal) return null; + if (!decision.eligible && !desc.name && !desc.role) return null; + const normalizedTarget = normalizeHoverTarget( + element, + compactHoverTargetName(element, desc), + decision, + ); + const now = Date.now(); + return { + element, + target: normalizedTarget, + recordedAt: now, + score: decision.score, + eligible: decision.eligible, + }; +} + +function shouldReplaceHoverCandidate( + current: HoverCandidate, + next: HoverCandidate, + now: number, +): boolean { + if (next.element === current.element) return false; + if (now - current.recordedAt > HOVER_BEFORE_CLICK_MAX_MS) return true; + if (current.element.contains(next.element)) return false; + if (next.element.contains(current.element)) return true; + return next.score >= current.score + HOVER_REPLACE_SCORE_MARGIN; +} + /** Clicks that only pick an autocomplete/suggestion value — not a semantic submit action. */ function isInputCompletionClick(target: EventTarget | null, session: FillSession | null): boolean { if (!session || !(target instanceof Element)) return false; @@ -168,10 +377,17 @@ export function startRecordCapture( const emitStep = (step: RecordStepPayload) => { sendStep({ page_url: location.href, ...step }); }; + const hoverSurfaceStateMap = (states: HoverSurfaceState[]): Map => + new Map(states.map((state) => [state.element, state.signature])); let fillSession: FillSession | null = null; let composing = false; let lastUrl = location.href; let keyboardActivation: { target: EventTarget | null; recordedAt: number } | undefined; + let pendingHover: HoverCandidate | null = null; + const recentHoverCandidates: HoverCandidate[] = []; + const emittedHoverElements = new WeakSet(); + let hoverSurfaceStates = hoverSurfaceStateMap(collectHoverSurfaceStates()); + const hoverSurfaceNodes = new Map(); let generatedControlClick: Element | null = null; let navigationActionPending = false; let navigationActionVersion = 0; @@ -237,6 +453,223 @@ export function startRecordCapture( }); }; + const rememberHoverCandidate = (hover: HoverCandidate) => { + const duplicate = recentHoverCandidates.findIndex( + (candidate) => candidate.element === hover.element, + ); + if (duplicate >= 0) recentHoverCandidates.splice(duplicate, 1); + recentHoverCandidates.push(hover); + if (recentHoverCandidates.length > HOVER_CANDIDATE_LIMIT) { + recentHoverCandidates.splice(0, recentHoverCandidates.length - HOVER_CANDIDATE_LIMIT); + } + }; + + const surfaceArea = (el: Element): number => { + const rect = el.getBoundingClientRect(); + return rect.width * rect.height; + }; + + const smallestContaining = ( + target: Element, + items: Iterable, + elementFor: (item: T) => Element, + ): T | undefined => { + let best: T | undefined; + for (const item of items) { + const element = elementFor(item); + if (!element.contains(target)) continue; + if (!best || surfaceArea(element) < surfaceArea(elementFor(best))) { + best = item; + } + } + return best; + }; + + const ownedSurfaceContaining = (target: Element): HoverSurfaceNode | undefined => + smallestContaining(target, hoverSurfaceNodes.values(), (node) => node.element); + + const surfaceStateContaining = ( + target: Element, + states: HoverSurfaceState[], + ): HoverSurfaceState | undefined => smallestContaining(target, states, (state) => state.element); + + const inferOwnerForSurface = ( + surface: Element, + now: number, + before?: HoverCandidate, + ): HoverCandidate | undefined => { + for (let index = recentHoverCandidates.length - 1; index >= 0; index -= 1) { + const candidate = recentHoverCandidates[index]; + if (!candidate) continue; + if (before && candidate.element === before.element) continue; + if (before && candidate.recordedAt > before.recordedAt) continue; + if (surface.contains(candidate.element)) continue; + if (now - candidate.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) continue; + if (isLikelyHoverSurfaceOwner(candidate.element, surface)) return candidate; + } + return undefined; + }; + + const nodeForUnownedSurface = ( + surfaceState: HoverSurfaceState, + currentStates: HoverSurfaceState[], + now: number, + ): HoverSurfaceNode | undefined => { + const owner = inferOwnerForSurface(surfaceState.element, now); + if (!owner) return undefined; + const parent = parentSurfaceNodeForOwner(owner, currentStates, now); + const node: HoverSurfaceNode = { + element: surfaceState.element, + signature: surfaceState.signature, + owner, + ...(parent ? { parent } : {}), + }; + hoverSurfaceNodes.set(surfaceState.element, node); + return node; + }; + + const ownedSurfaceForAction = (target: Element): HoverSurfaceNode | undefined => { + const closestSurface = closestHoverSurfaceCandidate(target); + if (closestSurface) { + const owned = hoverSurfaceNodes.get(closestSurface); + if (owned) return owned; + } + const ownedContaining = ownedSurfaceContaining(target); + if (ownedContaining) return ownedContaining; + const now = Date.now(); + const currentStates = collectHoverSurfaceStates(); + pruneGoneHoverSurfaces(currentStates); + const surfaceState = closestSurface + ? currentStates.find((state) => state.element === closestSurface) + : surfaceStateContaining(target, currentStates); + hoverSurfaceStates = hoverSurfaceStateMap(currentStates); + if (!surfaceState) return undefined; + return nodeForUnownedSurface(surfaceState, currentStates, now); + }; + + const pruneGoneHoverSurfaces = (currentStates: HoverSurfaceState[]) => { + const current = new Set(currentStates.map((state) => state.element)); + for (const element of hoverSurfaceNodes.keys()) { + if (!current.has(element)) hoverSurfaceNodes.delete(element); + } + }; + + const parentSurfaceNodeForOwner = ( + owner: HoverCandidate, + currentStates: HoverSurfaceState[], + now: number, + ): HoverSurfaceNode | undefined => { + const ownedParent = ownedSurfaceContaining(owner.element); + if (ownedParent) return ownedParent; + const parentState = surfaceStateContaining(owner.element, currentStates); + if (!parentState) return undefined; + const parentOwner = inferOwnerForSurface(parentState.element, now, owner); + if (!parentOwner) return undefined; + const parentNode: HoverSurfaceNode = { + element: parentState.element, + signature: parentState.signature, + owner: parentOwner, + }; + hoverSurfaceNodes.set(parentState.element, parentNode); + return parentNode; + }; + + const createSurfaceNode = ( + state: HoverSurfaceState, + owner: HoverCandidate, + currentStates: HoverSurfaceState[], + now: number, + ): HoverSurfaceNode | undefined => { + const parent = parentSurfaceNodeForOwner(owner, currentStates, now); + if (parent?.element === state.element) return undefined; + if (now - owner.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) return undefined; + if (!isLikelyHoverSurfaceOwner(owner.element, state.element)) return undefined; + const node: HoverSurfaceNode = { + element: state.element, + signature: state.signature, + owner, + ...(parent ? { parent } : {}), + }; + hoverSurfaceNodes.set(state.element, node); + return node; + }; + + const bindChangedHoverSurfaces = ( + previousStates: Map, + currentStates: HoverSurfaceState[], + now: number, + ) => { + for (const state of currentStates) { + if (previousStates.get(state.element) === state.signature) continue; + const owner = inferOwnerForSurface(state.element, now); + if (!owner) continue; + createSurfaceNode(state, owner, currentStates, now); + } + }; + + const refreshHoverSurfaces = (previousStates = hoverSurfaceStates) => { + const now = Date.now(); + const currentStates = collectHoverSurfaceStates(); + pruneGoneHoverSurfaces(currentStates); + bindChangedHoverSurfaces(previousStates, currentStates, now); + hoverSurfaceStates = hoverSurfaceStateMap(currentStates); + }; + + const scheduleHoverSurfaceRefresh = (previousStates: Map) => { + setTimeout(() => refreshHoverSurfaces(previousStates), 0); + }; + + const emitHoverStep = (hover: HoverCandidate) => { + if (emittedHoverElements.has(hover.element)) return; + emitStep({ + op: "hover", + target: hover.target, + }); + emittedHoverElements.add(hover.element); + }; + + const surfaceOwnerChain = ( + surface: HoverSurfaceNode, + actionElement: Element, + now: number, + ): HoverCandidate[] => { + const chain: HoverCandidate[] = []; + const selected = new WeakSet(); + const surfaces: HoverSurfaceNode[] = []; + let node: HoverSurfaceNode | undefined = surface; + while (node && surfaces.length < 4) { + surfaces.unshift(node); + node = node.parent; + } + for (const owned of surfaces) { + const owner = owned.owner; + if (owner.element === actionElement || actionElement.contains(owner.element)) { + continue; + } + if (selected.has(owner.element)) continue; + if (emittedHoverElements.has(owner.element)) continue; + if (now - owner.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) continue; + selected.add(owner.element); + chain.push(owner); + } + return chain; + }; + + const containedHoverOwnerForAction = ( + actionElement: Element, + now: number, + ): HoverCandidate | undefined => { + for (let index = recentHoverCandidates.length - 1; index >= 0; index -= 1) { + const candidate = recentHoverCandidates[index]; + if (!candidate) continue; + if (candidate.element === actionElement) continue; + if (!candidate.element.contains(actionElement)) continue; + if (now - candidate.recordedAt > HOVER_SURFACE_CONTEXT_MAX_MS) continue; + return candidate; + } + return undefined; + }; + const emitClick = (event: MouseEvent) => { // Only record clicks an LLM can re-identify (named interactive controls). const target = describeEventTarget(eventTarget(event)); @@ -249,6 +682,54 @@ export function startRecordCapture( }); }; + const emitHoverCandidateBeforeAction = (actionTarget: EventTarget | null) => { + if (!(actionTarget instanceof Element)) return; + const actionElement = resolveClickableElement(actionTarget) ?? actionTarget; + const surface = ownedSurfaceForAction(actionElement); + const now = Date.now(); + const containedOwner = surface ? undefined : containedHoverOwnerForAction(actionElement, now); + const hoverChain = surface + ? surfaceOwnerChain(surface, actionElement, now) + : containedOwner + ? [containedOwner] + : []; + if (hoverChain.length === 0) { + pendingHover = null; + return; + } + for (const hover of hoverChain) emitHoverStep(hover); + pendingHover = null; + }; + + const onMouseOver = (event: MouseEvent) => { + const target = eventTarget(event); + if (isOverlayTarget(target)) return; + const now = Date.now(); + if (pendingHover && target instanceof Element && pendingHover.element.contains(target)) { + if (now - pendingHover.recordedAt <= HOVER_BEFORE_CLICK_MAX_MS) return; + pendingHover = null; + } + const candidate = hoverCandidateFromEvent(target); + if (candidate) { + const previousSurfaceStates = new Map(hoverSurfaceStates); + rememberHoverCandidate(candidate); + refreshHoverSurfaces(previousSurfaceStates); + scheduleHoverSurfaceRefresh(previousSurfaceStates); + } + const hover = candidate?.eligible ? candidate : null; + if (pendingHover && target instanceof Element) { + const relation = decideHoverSurfaceRelation({ triggerElement: pendingHover.element }, target); + if (relation.related && now - pendingHover.recordedAt <= HOVER_BEFORE_CLICK_MAX_MS) { + return; + } + } + if (!hover) return; + if (pendingHover && !shouldReplaceHoverCandidate(pendingHover, hover, now)) { + return; + } + pendingHover = hover; + }; + const onClick = (event: MouseEvent) => { if (event.button !== 0) return; const target = eventTarget(event); @@ -278,6 +759,7 @@ export function startRecordCapture( const fillable = fillableFromTarget(target); if (fillable) { + emitHoverCandidateBeforeAction(fillable); ensureFillSession(fillable); return; } @@ -285,6 +767,7 @@ export function startRecordCapture( if (target instanceof Element) { const nearbyFillable = nearbyFillableFromSearchChrome(target); if (nearbyFillable) { + emitHoverCandidateBeforeAction(nearbyFillable); ensureFillSession(nearbyFillable); return; } @@ -304,6 +787,7 @@ export function startRecordCapture( commitFillSession(); if (target instanceof Element && target.closest("select")) return; + emitHoverCandidateBeforeAction(target); emitClick(event); }; @@ -345,6 +829,7 @@ export function startRecordCapture( commitFillSession(); const target = eventTarget(event); if (target instanceof HTMLSelectElement) { + emitHoverCandidateBeforeAction(target); const values = Array.from(target.selectedOptions).map((opt) => opt.value); const labels = Array.from(target.selectedOptions).map((opt) => (opt.label || opt.textContent || opt.value).trim(), @@ -398,6 +883,7 @@ export function startRecordCapture( } const desc = describeEventTarget(target); if (!desc && !event.key) return; + emitHoverCandidateBeforeAction(target); emitStep({ op: "press", key: event.key, @@ -408,6 +894,7 @@ export function startRecordCapture( }; document.addEventListener("click", onClick, true); + document.addEventListener("mouseover", onMouseOver, true); document.addEventListener("focusin", onFocusIn, true); document.addEventListener("focusout", onFocusOut, true); document.addEventListener("input", onInput, true); @@ -438,6 +925,7 @@ export function startRecordCapture( dispose() { commitFillSession(); document.removeEventListener("click", onClick, true); + document.removeEventListener("mouseover", onMouseOver, true); document.removeEventListener("focusin", onFocusIn, true); document.removeEventListener("focusout", onFocusOut, true); document.removeEventListener("input", onInput, true); diff --git a/apps/extension/src/content/record-hover-surface.ts b/apps/extension/src/content/record-hover-surface.ts new file mode 100644 index 0000000..a47b426 --- /dev/null +++ b/apps/extension/src/content/record-hover-surface.ts @@ -0,0 +1,237 @@ +export interface HoverSurfaceDecision { + related: boolean; + reason?: string; + surface?: Element; +} + +export interface HoverSurfaceContext { + triggerElement: Element; +} + +export interface HoverSurfaceState { + element: Element; + signature: string; +} + +const HOVER_SURFACE_SELECTOR = [ + '[role="menu"]', + '[role="menubar"]', + '[role="listbox"]', + '[role="dialog"]', + '[role="tooltip"]', + '[aria-modal="true"]', + "[data-popper-placement]", + "[data-floating-ui-placement]", + "[data-headlessui-state]", + "[data-radix-popper-content-wrapper]", +].join(","); + +const HOVER_SURFACE_WORD_RE = + /(^|[^a-z0-9])(dropdown-menu|drop-down-menu|menu-list|popover__(popper|content|content-body)|popover-content|popper|popup|flyout|submenu|submenu-wrapper|context-menu|account-menu|user-menu|avatar-menu)(?=[^a-z0-9]|$)/i; +const HOVER_SURFACE_ITEM_RE = + /(^|[^a-z0-9])((dropdown|menu|submenu|context-menu)(-|__?)?(item|link)|item-(text|label|content)|item__?(text|label|content))(?=[^a-z0-9]|$)/i; +const HOVER_SURFACE_MAX_AXIS_GAP = 96; +const HOVER_SURFACE_MAX_DIAGONAL_GAP = 16; +const HOVER_SURFACE_MAX_VIEWPORT_AREA_RATIO = 0.5; + +function elementTokenText(el: Element): string { + return [ + el.id, + typeof el.className === "string" ? el.className : "", + el.getAttribute("data-testid"), + el.getAttribute("data-test"), + el.getAttribute("data-cy"), + el.getAttribute("aria-label"), + el.getAttribute("role"), + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" "); +} + +export function isRecognizedHoverSurfaceElement(el: Element): boolean { + if (el.matches(HOVER_SURFACE_SELECTOR)) return true; + const tokenText = elementTokenText(el); + if (HOVER_SURFACE_WORD_RE.test(tokenText) && !HOVER_SURFACE_ITEM_RE.test(tokenText)) { + return true; + } + return false; +} + +function isVisibleSurfaceElement(el: Element): boolean { + if (!(el instanceof HTMLElement)) return false; + const style = getComputedStyle(el); + if (style.pointerEvents === "none" || style.visibility === "hidden" || style.display === "none") { + return false; + } + return rectFor(el) !== null; +} + +export function closestRecognizedHoverSurface(element: Element): Element | null { + let node: Element | null = element; + let depth = 0; + while (node && node !== document.body && node !== document.documentElement && depth < 10) { + if (isRecognizedHoverSurfaceElement(node)) return node; + node = node.parentElement; + depth += 1; + } + return null; +} + +export function isPositionedFloatingElement(el: Element): boolean { + if (!(el instanceof HTMLElement)) return false; + const style = getComputedStyle(el); + if (style.position !== "absolute") return false; + if (style.pointerEvents === "none" || style.visibility === "hidden" || style.display === "none") { + return false; + } + const rect = rectFor(el); + if (!rect) return false; + const viewportArea = window.innerWidth * window.innerHeight; + const area = rect.width * rect.height; + return ( + area > 0 && (!viewportArea || area <= viewportArea * HOVER_SURFACE_MAX_VIEWPORT_AREA_RATIO) + ); +} + +export function isHoverSurfaceCandidateElement(el: Element): boolean { + return isRecognizedHoverSurfaceElement(el) || isPositionedFloatingElement(el); +} + +export function closestHoverSurfaceCandidate(element: Element): Element | null { + let node: Element | null = element; + let depth = 0; + while (node && node !== document.body && node !== document.documentElement && depth < 10) { + if (isHoverSurfaceCandidateElement(node)) return node; + node = node.parentElement; + depth += 1; + } + return null; +} + +function closestFloatingSurface(clickElement: Element): Element | null { + let node: Element | null = clickElement; + let depth = 0; + while (node && node !== document.body && node !== document.documentElement && depth < 10) { + if (isPositionedFloatingElement(node)) return node; + node = node.parentElement; + depth += 1; + } + return null; +} + +function hoverSurfaceSignature(el: Element): string { + const rect = rectFor(el); + return [ + rect + ? `${Math.round(rect.left)},${Math.round(rect.top)},${Math.round(rect.width)},${Math.round(rect.height)}` + : "", + (el.textContent ?? "").trim().slice(0, 512), + ].join("|"); +} + +export function collectHoverSurfaceStates(): HoverSurfaceState[] { + const states: HoverSurfaceState[] = []; + for (const el of document.querySelectorAll("*")) { + if (isHoverSurfaceCandidateElement(el) && isVisibleSurfaceElement(el)) { + states.push({ element: el, signature: hoverSurfaceSignature(el) }); + } + } + return states; +} + +function rectFor(el: Element): DOMRect | null { + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + return rect; +} + +function surfaceRelationMetrics(surface: Element, trigger: Element) { + const surfaceRect = rectFor(surface); + const triggerRect = rectFor(trigger); + if (!surfaceRect || !triggerRect) return null; + const horizontalOverlap = + Math.min(triggerRect.right, surfaceRect.right) - Math.max(triggerRect.left, surfaceRect.left); + const verticalOverlap = + Math.min(triggerRect.bottom, surfaceRect.bottom) - Math.max(triggerRect.top, surfaceRect.top); + const horizontalGap = Math.max( + 0, + triggerRect.left - surfaceRect.right, + surfaceRect.left - triggerRect.right, + ); + const verticalGap = Math.max( + 0, + triggerRect.top - surfaceRect.bottom, + surfaceRect.top - triggerRect.bottom, + ); + return { + surfaceRect, + triggerRect, + horizontalOverlap, + verticalOverlap, + horizontalGap, + verticalGap, + }; +} + +function isNearTrigger(surface: Element, trigger: Element): boolean { + const metrics = surfaceRelationMetrics(surface, trigger); + if (!metrics) return true; + const { horizontalOverlap, verticalOverlap, horizontalGap, verticalGap } = metrics; + if (horizontalOverlap > 0 && verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true; + if (verticalOverlap > 0 && horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP) return true; + return ( + horizontalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP && verticalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP + ); +} + +export function isLikelyHoverSurfaceOwner(trigger: Element, surface: Element): boolean { + const metrics = surfaceRelationMetrics(surface, trigger); + if (!metrics) return true; + const { + surfaceRect, + triggerRect, + horizontalOverlap, + verticalOverlap, + horizontalGap, + verticalGap, + } = metrics; + const opensVertically = + horizontalOverlap > 0 && + verticalGap <= HOVER_SURFACE_MAX_AXIS_GAP && + (triggerRect.bottom <= surfaceRect.top + 8 || surfaceRect.bottom <= triggerRect.top + 8); + const opensSideways = + verticalOverlap > 0 && + horizontalGap <= HOVER_SURFACE_MAX_AXIS_GAP && + (triggerRect.right <= surfaceRect.left + 8 || surfaceRect.right <= triggerRect.left + 8); + const opensFromCorner = + horizontalGap > 0 && + verticalGap > 0 && + horizontalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP && + verticalGap <= HOVER_SURFACE_MAX_DIAGONAL_GAP; + return opensVertically || opensSideways || opensFromCorner; +} + +export function decideHoverSurfaceRelation( + context: HoverSurfaceContext, + clickElement: Element, +): HoverSurfaceDecision { + const clickSurface = closestRecognizedHoverSurface(clickElement); + if (clickSurface && isNearTrigger(clickSurface, context.triggerElement)) { + return { related: true, reason: "click-inside-hover-surface", surface: clickSurface }; + } + + const floatingSurface = closestFloatingSurface(clickElement); + if (floatingSurface && isNearTrigger(floatingSurface, context.triggerElement)) { + return { + related: true, + reason: "click-inside-near-floating-surface", + surface: floatingSurface, + }; + } + + if (context.triggerElement.contains(clickElement) && clickElement !== context.triggerElement) { + return { related: true, reason: "click-inside-trigger-contained-surface" }; + } + + return { related: false, reason: "click-outside-hover-surface" }; +} diff --git a/apps/extension/src/lib/__tests__/hover-trigger-policy.test.ts b/apps/extension/src/lib/__tests__/hover-trigger-policy.test.ts new file mode 100644 index 0000000..dd73c24 --- /dev/null +++ b/apps/extension/src/lib/__tests__/hover-trigger-policy.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { evaluateHoverTrigger } from "../hover-trigger-policy"; + +describe("evaluateHoverTrigger", () => { + it("accepts explicit popup triggers", () => { + const decision = evaluateHoverTrigger({ + tag: "button", + role: "button", + label: "Open user navigation menu", + attrs: { "aria-haspopup": "menu", "aria-expanded": "false" }, + rect: { x: 900, y: 8, w: 32, h: 32 }, + cursor: "pointer", + pointerEvents: "auto", + }); + + expect(decision.eligible).toBe(true); + expect(decision.reasons).toEqual( + expect.arrayContaining(["aria-haspopup", "collapsed", "popup-signal"]), + ); + }); + + it("accepts compact topbar image buttons without popup attributes", () => { + const decision = evaluateHoverTrigger({ + tag: "button", + role: "button", + label: "image", + attrs: {}, + rect: { x: 900, y: 8, w: 32, h: 32 }, + pointerEvents: "auto", + hasGraphicDescendant: true, + }); + + expect(decision.eligible).toBe(true); + expect(decision.reasons).toEqual(expect.arrayContaining(["icon-only", "topbar", "compact"])); + }); + + it("accepts compact topbar image links without popup attributes", () => { + const decision = evaluateHoverTrigger({ + tag: "a", + role: "link", + label: "image", + attrs: {}, + rect: { x: 900, y: 8, w: 32, h: 32 }, + pointerEvents: "auto", + hasGraphicDescendant: true, + }); + + expect(decision.eligible).toBe(true); + expect(decision.reasons).toEqual(expect.arrayContaining(["icon-only", "topbar", "compact"])); + }); + + it("rejects topbar menu links without surface trigger evidence", () => { + const decision = evaluateHoverTrigger({ + tag: "a", + role: "link", + label: "My profile", + attrs: {}, + rect: { x: 820, y: 72, w: 96, h: 32 }, + cursor: "pointer", + pointerEvents: "auto", + }); + + expect(decision.eligible).toBe(false); + }); + + it("rejects text navigation items even when their attributes contain popup words", () => { + const decision = evaluateHoverTrigger({ + tag: "a", + role: "link", + label: "My profile", + attrs: { class: "dropdown-item profile-link" }, + rect: { x: 820, y: 72, w: 96, h: 32 }, + cursor: "pointer", + pointerEvents: "auto", + }); + + expect(decision.eligible).toBe(false); + }); + + it("rejects labelled dropdown list items as hover triggers", () => { + const decision = evaluateHoverTrigger({ + tag: "li", + label: "文档C+D", + attrs: { class: "dropdown-item", tabindex: "0" }, + rect: { x: 820, y: 48, w: 160, h: 32 }, + cursor: "pointer", + pointerEvents: "auto", + }); + + expect(decision.eligible).toBe(false); + }); + + it("rejects ordinary controls without hover popup signals", () => { + const decision = evaluateHoverTrigger({ + tag: "button", + role: "button", + label: "Search", + attrs: {}, + rect: { x: 100, y: 240, w: 120, h: 36 }, + pointerEvents: "auto", + }); + + expect(decision.eligible).toBe(false); + }); + + it("rejects unsafe or non-visible triggers", () => { + expect( + evaluateHoverTrigger({ + tag: "input", + attrs: {}, + rect: { x: 10, y: 10, w: 80, h: 24 }, + pointerEvents: "auto", + }).eligible, + ).toBe(false); + expect( + evaluateHoverTrigger({ + tag: "button", + role: "button", + attrs: { hidden: "" }, + rect: { x: 10, y: 10, w: 80, h: 24 }, + pointerEvents: "auto", + }).eligible, + ).toBe(false); + }); +}); diff --git a/apps/extension/src/lib/__tests__/trace-reducer.test.ts b/apps/extension/src/lib/__tests__/trace-reducer.test.ts index 777a86c..8cc773c 100644 --- a/apps/extension/src/lib/__tests__/trace-reducer.test.ts +++ b/apps/extension/src/lib/__tests__/trace-reducer.test.ts @@ -130,6 +130,30 @@ describe("reduceTraceSteps", () => { }); }); + it("keeps hover steps before menu clicks", () => { + const { steps } = reduceTraceSteps( + [ + { + op: "hover", + target: { tag: "span", role: "button", name: "Account" }, + page_url: "https://example.com/app", + }, + { + op: "click", + target: { tag: "a", role: "link", name: "Profile" }, + page_url: "https://example.com/app", + }, + ], + "https://example.com/app", + ); + expect(steps.map((s) => s.op)).toEqual(["hover", "click"]); + expect(steps[0]).toMatchObject({ + op: "hover", + page: "p1", + target: { name: "Account" }, + }); + }); + it("resolveTraceStartUrl prefers explicit start URL", () => { expect( resolveTraceStartUrl( diff --git a/apps/extension/src/lib/describe-target.ts b/apps/extension/src/lib/describe-target.ts index 7d3898a..0a1fcb2 100644 --- a/apps/extension/src/lib/describe-target.ts +++ b/apps/extension/src/lib/describe-target.ts @@ -228,6 +228,20 @@ export function resolveClickableElement(target: Element): Element | null { return null; } +export function resolveHoverElement(target: Element): Element | null { + const clickable = resolveClickableElement(target); + if (clickable) return clickable; + + let node: Element | null = target; + let depth = 0; + while (node && node !== document.body && node !== document.documentElement && depth < 8) { + if (looksClickable(node)) return node; + node = node.parentElement; + depth += 1; + } + return null; +} + /** * Teachable clicks: the LLM must get a label it can later find via snapshot * (visible name), or at least a form `name_attr` for checkbox/radio. diff --git a/apps/extension/src/lib/hover-trigger-policy.ts b/apps/extension/src/lib/hover-trigger-policy.ts new file mode 100644 index 0000000..23922b4 --- /dev/null +++ b/apps/extension/src/lib/hover-trigger-policy.ts @@ -0,0 +1,178 @@ +export interface HoverTriggerRect { + x: number; + y: number; + w: number; + h: number; +} + +export interface HoverTriggerSignals { + tag: string; + role?: string; + label?: string; + attrs?: Record; + rect?: HoverTriggerRect | null; + cursor?: string; + pointerEvents?: string; + hasGraphicDescendant?: boolean; + cssHoverMatch?: boolean; +} + +export interface HoverTriggerDecision { + eligible: boolean; + score: number; + reasons: string[]; +} + +const HOVER_TRIGGER_MIN_SCORE = 45; +const HOVER_POPUP_SIGNAL_RE = + /(menu|dropdown|popover|popup|avatar|profile|account|user|more|ellipsis|caret)/; +const HOVER_TEXT_ITEM_RE = /(^|[\s_-])(dropdown-item|menu-item|context-menu-item|option)($|[\s_-])/; + +function attr(signals: HoverTriggerSignals, name: string): string | undefined { + return signals.attrs?.[name.toLowerCase()]; +} + +export function isUnsafeHoverTrigger(signals: HoverTriggerSignals): boolean { + const tag = signals.tag.toLowerCase(); + if (["input", "textarea", "select", "option"].includes(tag)) return true; + if ((attr(signals, "contenteditable") ?? "").toLowerCase() === "true") return true; + if (attr(signals, "disabled") !== undefined || attr(signals, "inert") !== undefined) return true; + return (attr(signals, "aria-disabled") ?? "").toLowerCase() === "true"; +} + +export function hasHoverPopupSignal(signals: HoverTriggerSignals): boolean { + const attrs = signals.attrs ?? {}; + const haystack = [ + attrs.id, + attrs.class, + attrs["data-testid"], + attrs["data-test"], + attrs["data-cy"], + attrs["aria-label"], + attrs["aria-haspopup"], + attrs["aria-controls"], + attrs.title, + signals.role === "button" ? signals.label : undefined, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return HOVER_POPUP_SIGNAL_RE.test(haystack); +} + +export function hasStrongHoverExpansionSignal(signals: HoverTriggerSignals): boolean { + return ( + signals.cssHoverMatch === true || + attr(signals, "aria-haspopup") !== undefined || + (attr(signals, "aria-expanded") ?? "").toLowerCase() === "false" + ); +} + +export function hasDirectHoverInteractiveSignal(signals: HoverTriggerSignals): boolean { + const tag = signals.tag.toLowerCase(); + const role = (signals.role ?? "").toLowerCase(); + return ( + ["button", "a", "summary"].includes(tag) || + ["button", "link", "menuitem", "tab", "combobox"].includes(role) || + attr(signals, "tabindex") !== undefined || + signals.cursor === "pointer" || + attr(signals, "onclick") !== undefined || + attr(signals, "onmouseenter") !== undefined || + attr(signals, "onmouseover") !== undefined || + attr(signals, "aria-haspopup") !== undefined || + attr(signals, "aria-expanded") === "false" + ); +} + +export function evaluateHoverTrigger(signals: HoverTriggerSignals): HoverTriggerDecision { + const rect = signals.rect; + if ( + !rect || + rect.w <= 0 || + rect.h <= 0 || + signals.pointerEvents === "none" || + attr(signals, "hidden") !== undefined || + attr(signals, "inert") !== undefined || + (attr(signals, "aria-hidden") ?? "").toLowerCase() === "true" || + isUnsafeHoverTrigger(signals) + ) { + return { eligible: false, score: 0, reasons: [] }; + } + + const area = rect.w * rect.h; + if (area <= 0 || area > 160_000) { + return { eligible: false, score: 0, reasons: [] }; + } + + const reasons: string[] = []; + let score = 0; + const tag = signals.tag.toLowerCase(); + const role = (signals.role ?? "").toLowerCase(); + const label = signals.label?.trim(); + const graphic = signals.hasGraphicDescendant === true; + const directInteractive = hasDirectHoverInteractiveSignal(signals); + const popupSignal = hasHoverPopupSignal(signals); + const isTextNavigationItem = + (tag === "a" || + tag === "li" || + role === "link" || + role === "option" || + role.startsWith("menuitem") || + HOVER_TEXT_ITEM_RE.test(attr(signals, "class") ?? "")) && + !graphic && + !!label && + label.toLowerCase() !== "image"; + const compactTopbarIcon = + (role === "button" || role === "link" || tag === "button" || tag === "a") && + graphic && + rect.y < 120 && + rect.w <= 96 && + rect.h <= 96 && + (!label || label.toLowerCase() === "image"); + const surfaceTriggerEvidence = + (popupSignal && !isTextNavigationItem) || + compactTopbarIcon || + hasStrongHoverExpansionSignal(signals); + if (!directInteractive || !surfaceTriggerEvidence) { + return { eligible: false, score: 0, reasons: [] }; + } + + if (attr(signals, "aria-haspopup") !== undefined) { + score += 80; + reasons.push("aria-haspopup"); + } + if ((attr(signals, "aria-expanded") ?? "").toLowerCase() === "false") { + score += 55; + reasons.push("collapsed"); + } + if (popupSignal) { + score += 45; + reasons.push("popup-signal"); + } + if (tag === "button" || role === "button") { + score += 30; + reasons.push("button"); + } + if (signals.cursor === "pointer") { + score += 25; + reasons.push("pointer"); + } + if (graphic && (!label || label.toLowerCase() === "image")) { + score += 40; + reasons.push("icon-only"); + } + if (rect.y < 120) { + score += 25; + reasons.push("topbar"); + } + if (rect.w <= 80 && rect.h <= 80) { + score += 20; + reasons.push("compact"); + } + if (signals.cssHoverMatch) { + score += 40; + reasons.push("css-hover"); + } + + return { eligible: score >= HOVER_TRIGGER_MIN_SCORE, score, reasons }; +} diff --git a/apps/extension/src/lib/record-bridge.ts b/apps/extension/src/lib/record-bridge.ts index cb5d446..cb7eb88 100644 --- a/apps/extension/src/lib/record-bridge.ts +++ b/apps/extension/src/lib/record-bridge.ts @@ -33,7 +33,7 @@ export interface RecordStartMessage { } export interface RecordStepPayload { - op: "click" | "fill" | "press" | "select" | "navigate"; + op: "click" | "hover" | "fill" | "press" | "select" | "navigate"; target?: TargetDescriptor; value?: string; key?: string; diff --git a/apps/extension/src/lib/recording-step-buffer.ts b/apps/extension/src/lib/recording-step-buffer.ts index 0c7c26e..c1c5086 100644 --- a/apps/extension/src/lib/recording-step-buffer.ts +++ b/apps/extension/src/lib/recording-step-buffer.ts @@ -21,6 +21,14 @@ function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null { ...(pageUrl ? { page_url: pageUrl } : {}), } : null; + case "hover": + return payload.target + ? { + op: "hover", + target: payload.target, + ...(pageUrl ? { page_url: pageUrl } : {}), + } + : null; case "fill": return payload.target ? { diff --git a/apps/extension/src/lib/trace-reducer.ts b/apps/extension/src/lib/trace-reducer.ts index e3572ee..6da0c3d 100644 --- a/apps/extension/src/lib/trace-reducer.ts +++ b/apps/extension/src/lib/trace-reducer.ts @@ -139,6 +139,13 @@ function toV2Step( }, effectForNavigation(step.navigated_to, urlToId), ); + case "hover": + return { + op: "hover", + id, + page, + target: step.target, + }; case "fill": return { op: "fill", diff --git a/apps/extension/src/tools/__tests__/dispatcher.test.ts b/apps/extension/src/tools/__tests__/dispatcher.test.ts index 060b30a..cb4e5ea 100644 --- a/apps/extension/src/tools/__tests__/dispatcher.test.ts +++ b/apps/extension/src/tools/__tests__/dispatcher.test.ts @@ -9,6 +9,8 @@ import type { } from "@/transport/types"; import { ToolDispatcher } from "../dispatcher"; +type TestDispatcherCdp = NonNullable[0]["cdp"]>; + function fakeTransport() { const handlers = new Set(); const stateHandlers = new Set(); @@ -129,7 +131,7 @@ describe("ToolDispatcher", () => { setUserAgentOverride: vi.fn(async () => {}), setTouchEmulationEnabled: vi.fn(async () => {}), }; - const dispatcher = new ToolDispatcher({ transport, sessions, cdp }); + const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp }); dispatcher.start(); deliver(makeRequest("tool.console", { session_id: "aa11" })); @@ -172,7 +174,7 @@ describe("ToolDispatcher", () => { setUserAgentOverride: vi.fn(async () => {}), setTouchEmulationEnabled: vi.fn(async () => {}), }; - const dispatcher = new ToolDispatcher({ transport, sessions, cdp }); + const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp }); dispatcher.start(); deliver(makeRequest("tool.session_stop", { session_id: "aa11" })); @@ -311,6 +313,277 @@ describe("ToolDispatcher", () => { expect(onBrowserControlResumed).toHaveBeenCalledWith("aa11"); }); + it("reasserts remembered hover before follow-up work and releases only after actions", async () => { + vi.stubGlobal("chrome", { + tabs: { + sendMessage: vi.fn(async () => undefined), + }, + }); + const { transport } = fakeTransport(); + const sessions = new SessionManager({ + agentWindow: { + create: vi.fn(async () => 1), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => {}), + }, + }); + const order: string[] = []; + const cdp = { + send: vi.fn(async () => { + order.push("hover"); + return {} as T; + }), + detachSession: vi.fn(async () => {}), + ensureNetworkCapture: vi.fn(async () => {}), + networkEntriesSince: vi.fn(() => ({ + tab_id: 7, + entries: [], + next_since: 0, + truncated: false, + })), + setDeviceMetricsOverride: vi.fn(async () => {}), + clearDeviceMetricsOverride: vi.fn(async () => {}), + setUserAgentOverride: vi.fn(async () => {}), + setTouchEmulationEnabled: vi.fn(async () => {}), + }; + const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp }); + + ( + dispatcher as unknown as { rememberHover: (sessionId: string, result: unknown) => unknown } + ).rememberHover("aa11", { + tab_id: 7, + x: 10, + y: 20, + }); + const helpers = dispatcher as unknown as { + withHoverReassert: ( + params: { session_id: string; tab_id?: number }, + work: () => Promise, + options?: { releaseAfter?: boolean }, + ) => Promise; + setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise; + }; + + await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => { + order.push("observe"); + return {}; + }); + expect(order).toEqual(["hover", "observe"]); + expect(chrome.tabs.sendMessage).not.toHaveBeenCalledWith( + 7, + expect.objectContaining({ enabled: false }), + ); + + await helpers.setHoverBypass("aa11", 7, true); + await helpers.withHoverReassert( + { session_id: "aa11", tab_id: 7 }, + async () => { + order.push("click"); + return {}; + }, + { releaseAfter: true }, + ); + + expect(cdp.send).toHaveBeenLastCalledWith(7, "Input.dispatchMouseEvent", { + type: "mouseMoved", + x: 10, + y: 20, + }); + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( + 7, + expect.objectContaining({ enabled: false }), + ); + }); + + it("does not reassert or disable hover latches from another session", async () => { + vi.stubGlobal("chrome", { + tabs: { + sendMessage: vi.fn(async () => undefined), + }, + }); + const { transport } = fakeTransport(); + const sessions = new SessionManager({ + agentWindow: { + create: vi.fn(async () => 1), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => {}), + }, + }); + const cdp = { + send: vi.fn(async () => ({}) as T), + detachSession: vi.fn(async () => {}), + ensureNetworkCapture: vi.fn(async () => {}), + networkEntriesSince: vi.fn(() => ({ + tab_id: 7, + entries: [], + next_since: 0, + truncated: false, + })), + setDeviceMetricsOverride: vi.fn(async () => {}), + clearDeviceMetricsOverride: vi.fn(async () => {}), + setUserAgentOverride: vi.fn(async () => {}), + setTouchEmulationEnabled: vi.fn(async () => {}), + }; + const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp }); + const helpers = dispatcher as unknown as { + rememberHover: (sessionId: string, result: unknown) => unknown; + setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise; + withHoverReassert: ( + params: { session_id: string; tab_id?: number }, + work: () => Promise, + options?: { releaseAfter?: boolean }, + ) => Promise; + releaseHoverLatch: (sessionId?: string, tabId?: number) => Promise; + }; + + helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 }); + await helpers.setHoverBypass("aa11", 7, true); + + await helpers.withHoverReassert({ session_id: "bb22", tab_id: 7 }, async () => ({}), { + releaseAfter: true, + }); + + expect(cdp.send).not.toHaveBeenCalled(); + expect(chrome.tabs.sendMessage).not.toHaveBeenCalledWith( + 7, + expect.objectContaining({ enabled: false }), + ); + + await helpers.releaseHoverLatch("aa11", 7); + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( + 7, + expect.objectContaining({ enabled: false }), + ); + }); + + it("transfers hover overlay bypass ownership between sessions", async () => { + vi.stubGlobal("chrome", { + tabs: { + sendMessage: vi.fn(async () => undefined), + }, + }); + const { transport } = fakeTransport(); + const sessions = new SessionManager({ + agentWindow: { + create: vi.fn(async () => 1), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => {}), + }, + }); + const dispatcher = new ToolDispatcher({ transport, sessions }); + const helpers = dispatcher as unknown as { + setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise; + }; + + await helpers.setHoverBypass("aa11", 7, true); + await helpers.setHoverBypass("bb22", 7, true); + await helpers.setHoverBypass("aa11", 7, false); + expect(chrome.tabs.sendMessage).not.toHaveBeenCalledWith( + 7, + expect.objectContaining({ enabled: false }), + ); + + await helpers.setHoverBypass("bb22", 7, false); + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( + 7, + expect.objectContaining({ enabled: false }), + ); + expect(chrome.tabs.sendMessage).toHaveBeenCalledTimes(2); + }); + + it("limits default-target hover reassertion to the active tab", async () => { + vi.stubGlobal("chrome", { + tabs: { + query: vi.fn(async () => [{ id: 9, windowId: 4242, active: true }]), + get: vi.fn(async (tabId: number) => ({ id: tabId, windowId: 4242, active: tabId === 9 })), + sendMessage: vi.fn(async () => undefined), + }, + }); + const { transport } = fakeTransport(); + const sessions = new SessionManager({ + agentWindow: { + create: vi.fn(async () => 4242), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => {}), + }, + }); + await sessions.start("aa11"); + const cdp = { + send: vi.fn(async () => ({}) as T), + detachSession: vi.fn(async () => {}), + ensureNetworkCapture: vi.fn(async () => {}), + networkEntriesSince: vi.fn(() => ({ + tab_id: 9, + entries: [], + next_since: 0, + truncated: false, + })), + setDeviceMetricsOverride: vi.fn(async () => {}), + clearDeviceMetricsOverride: vi.fn(async () => {}), + setUserAgentOverride: vi.fn(async () => {}), + setTouchEmulationEnabled: vi.fn(async () => {}), + }; + const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp }); + const helpers = dispatcher as unknown as { + rememberHover: (sessionId: string, result: unknown) => unknown; + withHoverReassert: ( + params: { session_id: string; tab_id?: number }, + work: () => Promise, + ) => Promise; + }; + + helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 }); + helpers.rememberHover("aa11", { tab_id: 9, x: 30, y: 40 }); + + await helpers.withHoverReassert({ session_id: "aa11" }, async () => ({})); + + expect(cdp.send).toHaveBeenCalledTimes(1); + expect(cdp.send).toHaveBeenCalledWith(9, "Input.dispatchMouseEvent", { + type: "mouseMoved", + x: 30, + y: 40, + }); + }); + + it("releases the current session hover latch before navigation-style work", async () => { + vi.stubGlobal("chrome", { + tabs: { + sendMessage: vi.fn(async () => undefined), + }, + }); + const { transport } = fakeTransport(); + const sessions = new SessionManager({ + agentWindow: { + create: vi.fn(async () => 1), + remove: vi.fn(async () => {}), + ensureActiveTab: vi.fn(async () => {}), + }, + }); + const dispatcher = new ToolDispatcher({ transport, sessions }); + const helpers = dispatcher as unknown as { + rememberHover: (sessionId: string, result: unknown) => unknown; + setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise; + withHoverReleaseForRequest: ( + params: { session_id: string; tab_id?: number }, + work: () => Promise, + ) => Promise; + withHoverReassert: ( + params: { session_id: string; tab_id?: number }, + work: () => Promise, + ) => Promise; + }; + helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 }); + await helpers.setHoverBypass("aa11", 7, true); + + await helpers.withHoverReleaseForRequest({ session_id: "aa11", tab_id: 7 }, async () => ({})); + await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({})); + + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( + 7, + expect.objectContaining({ enabled: false }), + ); + }); + it("disconnects the transport when send() fails so keepalive can rebuild", async () => { const { transport, deliver } = fakeTransport(); const sessions = new SessionManager({ diff --git a/apps/extension/src/tools/__tests__/interaction.test.ts b/apps/extension/src/tools/__tests__/interaction.test.ts index de56af4..6f8eeed 100644 --- a/apps/extension/src/tools/__tests__/interaction.test.ts +++ b/apps/extension/src/tools/__tests__/interaction.test.ts @@ -4,6 +4,7 @@ import type { CdpRunner } from "@/tools/shared"; import { handleClick, handleFill, + handleHover, handlePress, handleSelect, modifiersBitfield, @@ -435,6 +436,50 @@ describe("handleClick", () => { }); }); +describe("handleHover", () => { + it("keeps overlay bypass enabled after a successful hover when requested", async () => { + const bypassOverlay = vi.fn().mockResolvedValue(undefined); + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + const ctx = await sm.start("aa11"); + ctx.refStore.set("e3", 1234, { tabId: 4 }); + const fake = makeFakeCdp({ + "DOM.scrollIntoViewIfNeeded": () => ({}), + "DOM.getContentQuads": () => ({ quads: [[10, 20, 110, 20, 110, 60, 10, 60]] }), + "Runtime.evaluate": (params: unknown) => { + const expr = String((params as { expression?: string })?.expression ?? ""); + if (expr.includes("overlayHostPresent") && !expr.includes("hitIndex")) { + return { result: { value: { overlayHostPresent: true, overlayHostConnected: true } } }; + } + if (expr.includes("hitIndex")) { + return { + result: { + value: { overlayHostPresent: true, overlayHostConnected: true, hitIndex: 0 }, + }, + }; + } + throw new Error(`unexpected Runtime.evaluate: ${expr.slice(0, 80)}`); + }, + "Input.dispatchMouseEvent": () => ({}), + }); + + const res = await handleHover( + sm, + { session_id: "aa11", ref: "@e3", settle_ms: 0 }, + { + cdp: fake.cdp, + tabsApi: fake.tabsApi, + bypassOverlay, + keepOverlayBypassAfterHover: true, + }, + ); + + if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`); + expect(res).toMatchObject({ tab_id: 4, used_ref: "e3", x: 60, y: 40 }); + expect(bypassOverlay).toHaveBeenCalledTimes(1); + expect(bypassOverlay).toHaveBeenCalledWith(4, true); + }); +}); + describe("handleFill", () => { it("returns not_found for unknown ref", async () => { const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); diff --git a/apps/extension/src/tools/__tests__/observation.test.ts b/apps/extension/src/tools/__tests__/observation.test.ts index d6cd621..a85fd64 100644 --- a/apps/extension/src/tools/__tests__/observation.test.ts +++ b/apps/extension/src/tools/__tests__/observation.test.ts @@ -1430,7 +1430,7 @@ describe("buildVomScene", () => { expect(renderVom(scene).text).toContain("[§ active: Reviews (12)]"); }); - it("maps hover surface probes to matching node labels", () => { + it("maps hover surface probes to matching backend node ids", () => { const axNodes: CdpAxNode[] = [ { nodeId: "1", @@ -1451,7 +1451,7 @@ describe("buildVomScene", () => { iframeNodes: new Map(), excludedBackendNodeIds: new Set(), surfaceProbes: [ - { triggerLabel: "Products", triggerAction: "hover", subItems: ["Shoes", "Bags"] }, + { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Shoes", "Bags"] }, ], nodes: [ { @@ -1480,7 +1480,260 @@ describe("buildVomScene", () => { expect(scene.surfaces).toEqual([ { triggerId: 20, triggerAction: "hover", subItems: ["Shoes", "Bags"] }, ]); - expect(renderVom(scene).text).toContain('@e1 button "Products" [hover: Shoes | Bags]'); + expect(renderVom(scene).text).toContain('@e1 button "Products" [hover first: Shoes | Bags]'); + }); + + it("maps hover surface probes to rendered descendants in the same trigger subtree", () => { + const axNodes: CdpAxNode[] = [ + { + nodeId: "1", + role: { type: "role", value: "RootWebArea" }, + backendDOMNodeId: 10, + childIds: ["2"], + }, + { + nodeId: "2", + parentId: "1", + role: { type: "role", value: "button" }, + name: { type: "computedString", value: "image" }, + backendDOMNodeId: 21, + }, + ]; + const scene = buildVomScene(axNodes, { + viewport: { width: 1000, height: 800 }, + iframeNodes: new Map(), + excludedBackendNodeIds: new Set(), + surfaceProbes: [ + { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] }, + ], + nodes: [ + { + backendNodeId: 10, + parentBackendNodeId: null, + tag: "body", + attrs: {}, + rect: { x: 0, y: 0, w: 1000, h: 800 }, + paintOrder: 0, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 20, + parentBackendNodeId: 10, + tag: "div", + attrs: { class: "tg-avatar" }, + rect: { x: 900, y: 10, w: 30, h: 30 }, + paintOrder: 1, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 21, + parentBackendNodeId: 20, + tag: "div", + attrs: { class: "tg-avatar__inner" }, + rect: { x: 902, y: 12, w: 26, h: 26 }, + paintOrder: 2, + position: "static", + pointerEvents: "auto", + }, + ], + }); + + expect(renderVom(scene).text).toContain('@e1 button "image" [hover first: My profile]'); + }); + + it("deduplicates hover surface probes by original trigger backend id", () => { + const axNodes: CdpAxNode[] = [ + { + nodeId: "1", + role: { type: "role", value: "RootWebArea" }, + backendDOMNodeId: 10, + childIds: ["2"], + }, + { + nodeId: "2", + parentId: "1", + role: { type: "role", value: "button" }, + name: { type: "computedString", value: "image" }, + backendDOMNodeId: 21, + }, + ]; + const scene = buildVomScene(axNodes, { + viewport: { width: 1000, height: 800 }, + iframeNodes: new Map(), + excludedBackendNodeIds: new Set(), + surfaceProbes: [ + { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] }, + { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Sign out"] }, + { triggerBackendNodeId: 21, triggerAction: "hover", subItems: ["Settings"] }, + ], + nodes: [ + { + backendNodeId: 10, + parentBackendNodeId: null, + tag: "body", + attrs: {}, + rect: { x: 0, y: 0, w: 1000, h: 800 }, + paintOrder: 0, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 20, + parentBackendNodeId: 10, + tag: "div", + attrs: { class: "tg-avatar" }, + rect: { x: 900, y: 10, w: 30, h: 30 }, + paintOrder: 1, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 21, + parentBackendNodeId: 20, + tag: "div", + attrs: { class: "tg-avatar__inner" }, + rect: { x: 902, y: 12, w: 26, h: 26 }, + paintOrder: 2, + position: "static", + pointerEvents: "auto", + }, + ], + }); + + expect(scene.surfaces).toEqual([ + { triggerId: 21, triggerAction: "hover", subItems: ["My profile"] }, + ]); + }); + + it("maps hover surface probes to DOM-recovered custom controls", () => { + const axNodes: CdpAxNode[] = [ + { + nodeId: "1", + role: { type: "role", value: "RootWebArea" }, + backendDOMNodeId: 10, + childIds: ["2"], + }, + { + nodeId: "2", + parentId: "1", + role: { type: "role", value: "img" }, + name: { type: "computedString", value: "image" }, + backendDOMNodeId: 21, + }, + ]; + const scene = buildVomScene(axNodes, { + viewport: { width: 1000, height: 800 }, + iframeNodes: new Map(), + excludedBackendNodeIds: new Set(), + surfaceProbes: [ + { + triggerBackendNodeId: 20, + triggerPoint: { x: 915, y: 25 }, + triggerAction: "hover", + subItems: ["My profile", "Sign out"], + }, + ], + nodes: [ + { + backendNodeId: 10, + parentBackendNodeId: null, + tag: "body", + attrs: {}, + rect: { x: 0, y: 0, w: 1000, h: 800 }, + paintOrder: 0, + position: "static", + pointerEvents: "auto", + cursor: "auto", + }, + { + backendNodeId: 20, + parentBackendNodeId: 10, + tag: "div", + attrs: { class: "tg-avatar" }, + rect: { x: 900, y: 10, w: 30, h: 30 }, + paintOrder: 1, + position: "static", + pointerEvents: "auto", + cursor: "pointer", + }, + { + backendNodeId: 21, + parentBackendNodeId: 20, + tag: "div", + attrs: { class: "tg-avatar__inner" }, + rect: { x: 902, y: 12, w: 26, h: 26 }, + paintOrder: 2, + position: "static", + pointerEvents: "auto", + cursor: "pointer", + }, + ], + }); + + expect(scene.surfaces).toEqual([ + { triggerId: 21, triggerAction: "hover", subItems: ["My profile", "Sign out"] }, + ]); + expect(renderVom(scene).text).toContain( + '@e1 button "image" [hover first: My profile | Sign out]', + ); + }); + + it("does not attach hover probes to distant geometry matches", () => { + const axNodes: CdpAxNode[] = [ + { + nodeId: "1", + role: { type: "role", value: "RootWebArea" }, + backendDOMNodeId: 10, + childIds: ["2"], + }, + { + nodeId: "2", + parentId: "1", + role: { type: "role", value: "link" }, + name: { type: "computedString", value: "0" }, + backendDOMNodeId: 42, + }, + ]; + const scene = buildVomScene(axNodes, { + viewport: { width: 1000, height: 800 }, + iframeNodes: new Map(), + excludedBackendNodeIds: new Set(), + surfaceProbes: [ + { + triggerBackendNodeId: 999, + triggerPoint: { x: 900, y: 20 }, + triggerAction: "hover", + subItems: ["My profile"], + }, + ], + nodes: [ + { + backendNodeId: 10, + parentBackendNodeId: null, + tag: "body", + attrs: {}, + rect: { x: 0, y: 0, w: 1000, h: 800 }, + paintOrder: 0, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 42, + parentBackendNodeId: 10, + tag: "a", + attrs: {}, + rect: { x: 860, y: 120, w: 40, h: 30 }, + paintOrder: 1, + position: "static", + pointerEvents: "auto", + }, + ], + }); + + expect(scene.surfaces).toBeUndefined(); + expect(renderVom(scene).text).not.toContain("[hover first:"); }); it("enriches names and active scope signals from AX properties", () => { @@ -1953,7 +2206,7 @@ describe("handleSnapshot", () => { }); const res = await handleObserve( sm, - { session_id: "aa11" }, + { session_id: "aa11", debug_surfaces: true }, { cdp: { send: send as unknown as ( @@ -1974,6 +2227,7 @@ describe("handleSnapshot", () => { ); if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`); + expect(res.debug).toEqual({ surface_probes: [] }); expect(send).toHaveBeenCalledWith( 4, "Runtime.evaluate", diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index dd419fb..26ec56d 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -8,6 +8,8 @@ import type { EvaluateParams, FillParams, GetHtmlParams, + HoverParams, + HoverResult, NavigateBackParams, NavigateForwardParams, NavigateParams, @@ -33,7 +35,7 @@ import { handleConsole } from "./console"; import { type EmulateCdpRunner, handleEmulate } from "./emulate"; import { handleEvaluate } from "./evaluate"; import { handleRequestHelp } from "./human-loop"; -import { handleClick, handleFill, handlePress, handleSelect } from "./interaction"; +import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction"; import { handleNavigate, handleNavigateBack, @@ -56,7 +58,7 @@ import { type SessionStartParams, type SessionStopParams, } from "./session"; -import { chromeTabsApi } from "./shared"; +import { chromeTabsApi, lookupSession, resolveTargetTab } from "./shared"; import { type BorrowConfirmationApprover, handleTabBorrow, @@ -81,6 +83,18 @@ type DispatcherCdpRunner = CdpRunner & detachSession(sessionId: string): Promise; }; +interface HoverLatch { + sessionId: string; + tabId: number; + x: number; + y: number; +} + +interface HoverLatchScope { + session_id: string; + tab_id?: number; +} + export interface DispatcherDeps { transport: Transport; sessions: SessionManager; @@ -126,6 +140,8 @@ export class ToolDispatcher { private readonly approveBorrow?: BorrowConfirmationApprover; private readonly helpNotificationCopy?: () => { title: string; body: string }; private subscription: { dispose(): void } | null = null; + private readonly hoverBypassTabs = new Map(); + private readonly hoverLatches = new Map(); /** * Per-rpc-id `AbortController` registry. Populated inside * [`dispatch`] before we await the tool handler and torn down in @@ -271,16 +287,20 @@ export class ToolDispatcher { switch (req.method) { case "tool.session_start": return handleSessionStart(this.sessions, req.params as SessionStartParams); - case "tool.session_stop": + case "tool.session_stop": { + await this.releaseHoverLatch((req.params as SessionStopParams).session_id); return handleSessionStop(this.sessions, req.params as SessionStopParams, { cdp: this.cdp, }); + } case "tool.tab_list": return handleTabList(this.sessions, req.params as TabListParams); case "tool.tab_create": return handleTabCreate(this.sessions, req.params as TabCreateParams); case "tool.tab_close": - return handleTabClose(this.sessions, req.params as TabCloseParams); + return this.withHoverReleaseForRequest(req.params as TabCloseParams, () => + handleTabClose(this.sessions, req.params as TabCloseParams), + ); case "tool.tab_select": return handleTabSelect(this.sessions, req.params as TabSelectParams); case "tool.tab_borrow": @@ -319,17 +339,31 @@ export class ToolDispatcher { this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined, ); case "tool.snapshot": - return handleSnapshot( - this.sessions, - req.params as SnapshotParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined, + return this.withHoverReassert(req.params as SnapshotParams, () => + handleSnapshot( + this.sessions, + req.params as SnapshotParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined, + ), ); - case "tool.observe": - return handleObserve( - this.sessions, - req.params as ObserveParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined, + case "tool.observe": { + const params = req.params as ObserveParams; + const hoverScope = await this.resolveHoverLatchScope(params); + return this.withHoverReassert(params, () => + handleObserve( + this.sessions, + params, + this.cdp + ? { + cdp: this.cdp, + tabsApi: chromeTabsCaptureApi, + conditionalSurfaceProbe: !this.hasHoverLatchForScope(hoverScope), + hoverProbeBypassOverlay: bypassOverlay, + } + : undefined, + ), ); + } case "tool.get_html": return handleGetHtml( this.sessions, @@ -337,68 +371,95 @@ export class ToolDispatcher { this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined, ); case "tool.navigate": - return handleNavigate( - this.sessions, - req.params as NavigateParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + return this.withHoverReleaseForRequest(req.params as NavigateParams, () => + handleNavigate( + this.sessions, + req.params as NavigateParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + ), ); case "tool.navigate_back": - return handleNavigateBack( - this.sessions, - req.params as NavigateBackParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + return this.withHoverReleaseForRequest(req.params as NavigateBackParams, () => + handleNavigateBack( + this.sessions, + req.params as NavigateBackParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + ), ); case "tool.navigate_forward": - return handleNavigateForward( - this.sessions, - req.params as NavigateForwardParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + return this.withHoverReleaseForRequest(req.params as NavigateForwardParams, () => + handleNavigateForward( + this.sessions, + req.params as NavigateForwardParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + ), ); case "tool.reload": - return handleReload( - this.sessions, - req.params as ReloadParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + return this.withHoverReleaseForRequest(req.params as ReloadParams, () => + handleReload( + this.sessions, + req.params as ReloadParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + ), ); case "tool.click": - return handleClick( - this.sessions, + return this.withHoverReassert( req.params as ClickParams, + () => + handleClick( + this.sessions, + req.params as ClickParams, + this.cdp + ? { + cdp: this.cdp, + tabsApi: chromeTabsApi, + signal, + bypassOverlay, + } + : undefined, + ), + { releaseAfter: true }, + ); + case "tool.hover": { + const result = await handleHover( + this.sessions, + req.params as HoverParams, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal, - bypassOverlay: async (tabId, enabled) => { - try { - await chrome.tabs.sendMessage(tabId, { - type: OVERLAY_AUTOMATION_BYPASS, - enabled, - }); - } catch { - // Content script may be unavailable on restricted pages. - } - }, + bypassOverlay: (tabId, enabled) => + this.setHoverBypass((req.params as HoverParams).session_id, tabId, enabled), + keepOverlayBypassAfterHover: true, } : undefined, ); + return this.rememberHover((req.params as HoverParams).session_id, result); + } case "tool.fill": - return handleFill( - this.sessions, - req.params as FillParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + return this.withHoverReleaseForRequest(req.params as FillParams, () => + handleFill( + this.sessions, + req.params as FillParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + ), ); case "tool.press": - return handlePress( - this.sessions, - req.params as PressParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + return this.withHoverReleaseForRequest(req.params as PressParams, () => + handlePress( + this.sessions, + req.params as PressParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + ), ); case "tool.select": - return handleSelect( - this.sessions, - req.params as SelectParams, - this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + return this.withHoverReleaseForRequest(req.params as SelectParams, () => + handleSelect( + this.sessions, + req.params as SelectParams, + this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, + ), ); case "tool.evaluate": return handleEvaluate( @@ -471,6 +532,114 @@ export class ToolDispatcher { } satisfies RpcError; } } + + private async setHoverBypass(sessionId: string, tabId: number, enabled: boolean): Promise { + const owner = this.hoverBypassTabs.get(tabId); + if (enabled) { + if (owner === sessionId) return; + if (owner === undefined) await bypassOverlay(tabId, true); + this.hoverBypassTabs.set(tabId, sessionId); + } else { + if (owner !== sessionId) return; + await bypassOverlay(tabId, false); + this.hoverBypassTabs.delete(tabId); + } + } + + private rememberHover(sessionId: string, result: HoverResult | RpcError): HoverResult | RpcError { + if (!isRpcError(result)) { + this.hoverLatches.set(result.tab_id, { + sessionId, + tabId: result.tab_id, + x: result.x, + y: result.y, + }); + } + return result; + } + + private hasHoverLatchForScope(scope: HoverLatchScope): boolean { + return this.hoverLatchesForRequest(scope).length > 0; + } + + private async withHoverReassert( + params: { session_id: string; tab_id?: number }, + work: () => Promise, + options: { releaseAfter?: boolean } = {}, + ): Promise { + const scope = await this.resolveHoverLatchScope(params); + await this.reassertHover(scope); + try { + return await work(); + } finally { + if (options.releaseAfter) { + await this.releaseHoverLatch(scope.session_id, scope.tab_id); + } + } + } + + private async withHoverReleaseForRequest( + params: { session_id: string; tab_id?: number }, + work: () => Promise, + ): Promise { + const scope = await this.resolveHoverLatchScope(params); + await this.releaseHoverLatch(scope.session_id, scope.tab_id); + return work(); + } + + private async resolveHoverLatchScope(params: { + session_id: string; + tab_id?: number; + }): Promise { + if (params.tab_id !== undefined) return params; + const ctx = lookupSession(this.sessions, params, "hover latch"); + if (isRpcError(ctx)) return params; + const target = await resolveTargetTab(this.sessions, ctx, undefined, chromeTabsApi); + if (isRpcError(target)) return params; + return { session_id: params.session_id, tab_id: target.tabId }; + } + + private hoverLatchesForRequest(params: { session_id: string; tab_id?: number }): HoverLatch[] { + return [...this.hoverLatches.values()].filter((latch) => { + if (latch.sessionId !== params.session_id) return false; + return params.tab_id === undefined || latch.tabId === params.tab_id; + }); + } + + private async reassertHover(params: { session_id: string; tab_id?: number }): Promise { + if (!this.cdp) return; + await Promise.all( + this.hoverLatchesForRequest(params).map((latch) => + this.cdp!.send(latch.tabId, "Input.dispatchMouseEvent", { + type: "mouseMoved", + x: latch.x, + y: latch.y, + }).catch((err) => { + console.debug("[bsk dispatcher] hover reassert failed", err); + this.hoverLatches.delete(latch.tabId); + }), + ), + ); + } + + private async releaseHoverLatch(sessionId?: string, tabId?: number): Promise { + const matchesScope = (entrySessionId: string, entryTabId: number): boolean => { + if (sessionId !== undefined && entrySessionId !== sessionId) return false; + return tabId === undefined || entryTabId === tabId; + }; + const tabs = new Set(); + for (const [bypassTabId, bypassSessionId] of this.hoverBypassTabs) { + if (!matchesScope(bypassSessionId, bypassTabId)) continue; + tabs.add(bypassTabId); + this.hoverBypassTabs.delete(bypassTabId); + } + for (const latch of this.hoverLatches.values()) { + if (!matchesScope(latch.sessionId, latch.tabId)) continue; + tabs.add(latch.tabId); + this.hoverLatches.delete(latch.tabId); + } + await Promise.all([...tabs].map((tabId) => bypassOverlay(tabId, false))); + } } function isRpcError(v: unknown): v is RpcError { @@ -497,6 +666,7 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null { case "tool.navigate_forward": case "tool.reload": case "tool.click": + case "tool.hover": case "tool.fill": case "tool.press": case "tool.select": @@ -512,6 +682,17 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null { } } +async function bypassOverlay(tabId: number, enabled: boolean): Promise { + try { + await chrome.tabs.sendMessage(tabId, { + type: OVERLAY_AUTOMATION_BYPASS, + enabled, + }); + } catch { + // Content script may be unavailable on restricted pages. + } +} + /** * Resolves never; rejects with `AbortLikeError` as soon as the signal * fires (or immediately if it is already aborted). Used by the diff --git a/apps/extension/src/tools/interaction.ts b/apps/extension/src/tools/interaction.ts index 87decea..ee66dac 100644 --- a/apps/extension/src/tools/interaction.ts +++ b/apps/extension/src/tools/interaction.ts @@ -17,6 +17,8 @@ import type { ClickResult, FillParams, FillResult, + HoverParams, + HoverResult, KeyModifier, MouseButton, PressParams, @@ -53,9 +55,12 @@ export interface InteractionDeps { defaultTimeoutMs?: number; /** Temporarily disable overlay click blocker during CDP automation. */ bypassOverlay?: (tabId: number, enabled: boolean) => Promise; + /** Keep hover hit-testing active for the caller's next observation/action. */ + keepOverlayBypassAfterHover?: boolean; } const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_HOVER_SETTLE_MS = 200; let defaultDeps: { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } | null = null; function getDefaultDeps(): { cdp: ChromiumCdp; tabsApi: ChromeTabsApi } { @@ -100,6 +105,27 @@ function throwIfAborted(signal: AbortSignal | undefined): RpcError | null { return null; } +function isAbortLikeError(err: unknown): boolean { + return err instanceof DOMException && err.name === "AbortError"; +} + +async function wait(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new DOMException("aborted", "AbortError"); + await new Promise((resolve, reject) => { + const cleanup = () => signal?.removeEventListener("abort", onAbort); + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + cleanup(); + reject(new DOMException("aborted", "AbortError")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + /** * Resolve `{ref?, selector?}` into a `backendNodeId`. Returns an * `RpcError` if the caller supplied neither (or both), or if neither @@ -286,7 +312,104 @@ export async function handleClick( message: err instanceof Error ? err.message : String(err), }; } finally { - if (automationBypassEnabled && deps.bypassOverlay) { + if (automationBypassEnabled && deps.bypassOverlay && !deps.keepOverlayBypassAfterHover) { + try { + await deps.bypassOverlay(target.tabId, false); + } catch (err) { + console.debug("[bsk interaction] overlay bypass disable failed", err); + } + } + } + + return attachDialogs(deps.cdp, target.tabId, dialogCursor, { + tab_id: target.tabId, + used_ref: node.usedRef, + used_selector: node.usedSelector, + x: centre.x, + y: centre.y, + }); +} + +// --------------------------------------------------------------------------- +// tool.hover +// --------------------------------------------------------------------------- + +export async function handleHover( + manager: SessionManager, + params: HoverParams, + deps: InteractionDeps = getDefaultDeps(), +): Promise { + const ctxOrErr = lookupSession(manager, params, "hover"); + if (isRpcError(ctxOrErr)) return ctxOrErr; + const ctx = ctxOrErr; + const aborted = throwIfAborted(deps.signal); + if (aborted) return aborted; + const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi); + if (isRpcError(target)) return target; + const denied = enforceAgentWindow(ctx, target, "hover"); + if (denied) return denied; + const dialogCursor = markDialogCursor(deps.cdp, target.tabId); + + const node = await resolveBackendNode(deps.cdp, ctx, target, params, "hover"); + if (isRpcError(node)) return node; + + try { + deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId); + const scrollErr = await scrollNodeIntoView(deps.cdp, target.tabId, node.backendNodeId); + if (scrollErr) return scrollErr; + } catch (err) { + return { + code: "cdp_failed", + message: err instanceof Error ? err.message : String(err), + }; + } + + const centre = await nodeCentre(deps.cdp, target.tabId, node.backendNodeId); + if (isRpcError(centre)) return centre; + + if (throwIfAborted(deps.signal)) { + return { code: "cancelled", message: "hover aborted" }; + } + + const modifiers = modifiersBitfield(params.modifiers); + const overlayBlocking = await checkOverlayAtPoint(deps.cdp, target.tabId, centre.x, centre.y); + let automationBypassEnabled = false; + let hoverCompleted = false; + if (overlayBlocking && deps.bypassOverlay) { + try { + await deps.bypassOverlay(target.tabId, true); + automationBypassEnabled = true; + } catch (err) { + console.debug("[bsk interaction] overlay bypass enable failed", err); + } + } + + try { + await deps.cdp.send(target.tabId, "Input.dispatchMouseEvent", { + type: "mouseMoved", + x: centre.x, + y: centre.y, + modifiers, + }); + const settleMs = params.settle_ms ?? DEFAULT_HOVER_SETTLE_MS; + if (settleMs > 0) { + await wait(settleMs, deps.signal); + } + hoverCompleted = true; + } catch (err) { + if (isAbortLikeError(err)) { + return { code: "cancelled", message: "hover aborted" }; + } + return { + code: "cdp_failed", + message: err instanceof Error ? err.message : String(err), + }; + } finally { + if ( + automationBypassEnabled && + deps.bypassOverlay && + (!deps.keepOverlayBypassAfterHover || !hoverCompleted) + ) { try { await deps.bypassOverlay(target.tabId, false); } catch (err) { diff --git a/apps/extension/src/tools/observation.ts b/apps/extension/src/tools/observation.ts index 48fe799..bde04f9 100644 --- a/apps/extension/src/tools/observation.ts +++ b/apps/extension/src/tools/observation.ts @@ -5,7 +5,9 @@ import { type ActiveScopeBlock, + applyVomInteractionRecovery, type CondSurface, + isVomReferenceNode, renderVom, type VomNode, type VomScene, @@ -908,39 +910,28 @@ function buildActiveScopeBlocks(nodes: VomNode[], signals: VomNodeDomSignals): A return blocks; } -function labelForSurfaceMatch(node: VomNode): string { - return ( - cleanAttr(node.name) ?? - cleanAttr(node.text) ?? - cleanAttr(node.attrs?.["aria-label"]) ?? - cleanAttr(node.attrs?.title) ?? - "" - ); -} - function buildConditionalSurfaces(nodes: VomNode[], captured: CapturedViewModel): CondSurface[] { const probes = captured.surfaceProbes ?? []; if (probes.length === 0) return []; const surfaces: CondSurface[] = []; - const nodesWithLabels = nodes - .map((node) => ({ node, key: normalizeProbeKey(labelForSurfaceMatch(node)) })) - .filter((entry) => entry.key.length > 0); + const recoveredNodes = applyVomInteractionRecovery(nodes); + const signals = capturedOnlySignals(captured.nodes); const used = new Set(); for (const probe of probes) { - const probeKey = normalizeProbeKey(probe.triggerLabel); - if (!probeKey || probe.subItems.length === 0) continue; - const match = - nodesWithLabels.find((entry) => entry.key === probeKey && !used.has(entry.node.id)) ?? - nodesWithLabels.find( - (entry) => - !used.has(entry.node.id) && - (probeKey.startsWith(entry.key) || entry.key.startsWith(probeKey)), - ); + if (probe.subItems.length === 0 || used.has(probe.triggerBackendNodeId)) continue; + const match = findSurfaceTriggerNode( + probe.triggerBackendNodeId, + recoveredNodes, + signals, + probe.triggerPoint, + ); if (!match) continue; - used.add(match.node.id); + if (used.has(match.id)) continue; + used.add(probe.triggerBackendNodeId); + used.add(match.id); surfaces.push({ - triggerId: match.node.id, + triggerId: match.id, triggerAction: probe.triggerAction, subItems: probe.subItems, }); @@ -948,6 +939,77 @@ function buildConditionalSurfaces(nodes: VomNode[], captured: CapturedViewModel) return surfaces; } +function findSurfaceTriggerNode( + triggerBackendNodeId: number, + nodes: VomNode[], + signals: VomNodeDomSignals, + triggerPoint?: { x: number; y: number }, +): VomNode | undefined { + const renderedById = new Map(nodes.map((node) => [node.id, node])); + const exact = renderedById.get(triggerBackendNodeId); + if (exact && isSurfaceAttachableNode(exact)) return exact; + + const domDescendant = nodes.find( + (node) => + isSurfaceAttachableNode(node) && + (node.domParentId === triggerBackendNodeId || + node.domAncestorIds?.includes(triggerBackendNodeId) === true), + ); + if (domDescendant) return domDescendant; + + const queue = [...(signals.childrenByParentId.get(triggerBackendNodeId) ?? [])]; + while (queue.length > 0) { + const child = queue.shift() as CapturedNode; + const rendered = renderedById.get(child.backendNodeId); + if (rendered && isSurfaceAttachableNode(rendered)) return rendered; + queue.push(...(signals.childrenByParentId.get(child.backendNodeId) ?? [])); + } + + let current = signals.capturedByBackendId.get(triggerBackendNodeId); + while (current?.parentBackendNodeId !== null && current?.parentBackendNodeId !== undefined) { + const parent = renderedById.get(current.parentBackendNodeId); + if (parent && isSurfaceAttachableNode(parent)) return parent; + current = signals.capturedByBackendId.get(current.parentBackendNodeId); + } + + if (triggerPoint) { + return findSurfaceNodeByPoint(triggerPoint, nodes, signals); + } + + return undefined; +} + +function isSurfaceAttachableNode(node: VomNode): boolean { + return isVomReferenceNode(node); +} + +function findSurfaceNodeByPoint( + point: { x: number; y: number }, + nodes: VomNode[], + signals: VomNodeDomSignals, +): VomNode | undefined { + let best: { node: VomNode; score: number } | undefined; + for (const node of nodes) { + if (!isSurfaceAttachableNode(node)) continue; + const rect = node.rect ?? signals.capturedByBackendId.get(node.id)?.rect; + if (!rect) continue; + const contains = + point.x >= rect.x && + point.x <= rect.x + rect.w && + point.y >= rect.y && + point.y <= rect.y + rect.h; + const centerX = rect.x + rect.w / 2; + const centerY = rect.y + rect.h / 2; + const distance = Math.hypot(point.x - centerX, point.y - centerY); + if (!contains && distance > 40) continue; + const score = contains ? distance : distance + 1_000; + if (!best || score < best.score) { + best = { node, score }; + } + } + return best?.node; +} + function axNodeInOverlaySubtree( axNode: CdpAxNode, axById: Map, @@ -1072,6 +1134,8 @@ export interface SnapshotDeps { get(tabId: number): Promise; query(q: chrome.tabs.QueryInfo): Promise; }; + conditionalSurfaceProbe?: boolean; + hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise; } let defaultDeps: SnapshotDeps | null = null; @@ -1217,9 +1281,13 @@ async function captureForVom( cdp: CdpRunner, tabId: number, conditionalSurfaceProbe: boolean, + hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise, ): Promise { try { - return await captureViewModel(cdp, tabId, { conditionalSurfaceProbe }); + return await captureViewModel(cdp, tabId, { + conditionalSurfaceProbe, + hoverProbeBypassOverlay, + }); } catch { return fallbackCapturedViewModel(cdp, tabId); } @@ -1258,7 +1326,14 @@ async function handleVomObservation( {}, ); const axNodes = result.nodes ?? []; - const captured = await captureForVom(deps.cdp, target.tabId, conditionalSurfaceProbe); + const effectiveConditionalSurfaceProbe = + deps.conditionalSurfaceProbe ?? conditionalSurfaceProbe; + const captured = await captureForVom( + deps.cdp, + target.tabId, + effectiveConditionalSurfaceProbe, + deps.hoverProbeBypassOverlay, + ); const scene = buildVomScene(axNodes, captured, { pageUrl: target.url }); const rendered = renderVom(scene, { maxDepth: params.max_depth, @@ -1275,6 +1350,19 @@ async function handleVomObservation( ref_count: rendered.refs.length, tab_id: target.tabId, truncated: rendered.truncated, + ...(toolName === "observe" && (params as ObserveParams).debug_surfaces + ? { + debug: { + surface_probes: (captured.surfaceProbes ?? []).map((probe) => ({ + trigger_backend_node_id: probe.triggerBackendNodeId, + ...(probe.triggerPoint ? { trigger_point: probe.triggerPoint } : {}), + trigger_action: probe.triggerAction, + sub_items: probe.subItems, + ...(probe.confidence ? { confidence: probe.confidence } : {}), + })), + }, + } + : {}), }); } catch (err) { return { diff --git a/apps/extension/src/tools/vom/__tests__/capture.test.ts b/apps/extension/src/tools/vom/__tests__/capture.test.ts index 2fe1f2e..bc4cd2a 100644 --- a/apps/extension/src/tools/vom/__tests__/capture.test.ts +++ b/apps/extension/src/tools/vom/__tests__/capture.test.ts @@ -51,6 +51,156 @@ function fakeSnapshotReply() { }; } +function hoverTriggerSnapshotReply() { + const S = [ + "html", + "body", + "button", + "class", + "user-avatar", + "position", + "static", + "pointer-events", + "auto", + "cursor", + "pointer", + ]; + const i = (s: string) => S.indexOf(s); + return { + strings: S, + documents: [ + { + nodes: { + parentIndex: [-1, 0, 1], + nodeType: [1, 1, 1], + nodeName: [i("html"), i("body"), i("button")], + backendNodeId: [10, 11, 12], + attributes: [[], [], [i("class"), i("user-avatar")]], + }, + layout: { + nodeIndex: [1, 2], + styles: [ + [i("static"), i("auto"), i("pointer")], + [i("static"), i("auto"), i("pointer")], + ], + bounds: [ + [0, 0, 1000, 800], + [940, 16, 32, 32], + ], + paintOrders: [0, 1], + }, + }, + ], + }; +} + +function twoHoverTriggerSnapshotReply() { + const S = [ + "html", + "body", + "button", + "class", + "user-avatar", + "secondary-dropdown-trigger", + "position", + "static", + "pointer-events", + "auto", + "cursor", + "pointer", + ]; + const i = (s: string) => S.indexOf(s); + return { + strings: S, + documents: [ + { + nodes: { + parentIndex: [-1, 0, 1, 1], + nodeType: [1, 1, 1, 1], + nodeName: [i("html"), i("body"), i("button"), i("button")], + backendNodeId: [10, 11, 12, 13], + attributes: [ + [], + [], + [i("class"), i("user-avatar")], + [i("class"), i("secondary-dropdown-trigger")], + ], + }, + layout: { + nodeIndex: [1, 2, 3], + styles: [ + [i("static"), i("auto"), i("pointer")], + [i("static"), i("auto"), i("pointer")], + [i("static"), i("auto"), i("pointer")], + ], + bounds: [ + [0, 0, 1000, 800], + [940, 16, 32, 32], + [100, 120, 32, 32], + ], + paintOrders: [0, 1, 2], + }, + }, + ], + }; +} + +function nestedHoverTriggerSnapshotReply() { + const S = [ + "html", + "body", + "div", + "img", + "class", + "tg-avatar", + "tg-avatar__inner", + "tg-avatar__image", + "position", + "static", + "pointer-events", + "auto", + "cursor", + "pointer", + ]; + const i = (s: string) => S.indexOf(s); + return { + strings: S, + documents: [ + { + nodes: { + parentIndex: [-1, 0, 1, 2, 3], + nodeType: [1, 1, 1, 1, 1], + nodeName: [i("html"), i("body"), i("div"), i("div"), i("img")], + backendNodeId: [10, 11, 12, 13, 14], + attributes: [ + [], + [], + [i("class"), i("tg-avatar")], + [i("class"), i("tg-avatar__inner")], + [i("class"), i("tg-avatar__image")], + ], + }, + layout: { + nodeIndex: [1, 2, 3, 4], + styles: [ + [i("static"), i("auto"), i("pointer")], + [i("static"), i("auto"), i("pointer")], + [i("static"), i("auto"), i("pointer")], + [i("static"), i("auto"), i("pointer")], + ], + bounds: [ + [0, 0, 1000, 800], + [940, 16, 32, 32], + [942, 18, 28, 28], + [944, 20, 24, 24], + ], + paintOrders: [0, 1, 2, 3], + }, + }, + ], + }; +} + function makeCdp(snapshot: unknown) { return { send: vi.fn(async (_tab: number, method: string) => { @@ -126,11 +276,11 @@ describe("captureViewModel", () => { }); it("runs hover surface probes when explicitly enabled", async () => { - let runtimeCalls = 0; + let hoverStateCalls = 0; const cdp = { send: vi.fn(async (_tabId: number, method: string, params?: object) => { if (method === "DOMSnapshot.enable") return {}; - if (method === "DOMSnapshot.captureSnapshot") return fakeSnapshotReply(); + if (method === "DOMSnapshot.captureSnapshot") return hoverTriggerSnapshotReply(); if (method === "Page.getLayoutMetrics") { return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, @@ -141,22 +291,23 @@ describe("captureViewModel", () => { if (expression.includes("input,textarea,select")) { return { result: { value: { controls: [], childFrames: [] } } }; } - runtimeCalls += 1; - return runtimeCalls === 1 - ? { - result: { - value: [ - { - triggerSel: ".menu", - affectedSub: " .items", - label: "Products", - x: 50, - y: 20, - }, - ], - }, - } - : { result: { value: ["Shoes", "Bags"] } }; + if (expression.includes("document.styleSheets")) { + return { result: { value: [] } }; + } + if (expression.includes("querySelectorAll(selectors)")) { + hoverStateCalls += 1; + return hoverStateCalls === 1 + ? { result: { value: [] } } + : { + result: { + value: [ + { text: "My profile", role: "", tag: "a", x: 900, y: 60 }, + { text: "Sign out", role: "", tag: "a", x: 900, y: 90 }, + ], + }, + }; + } + throw new Error(`unexpected Runtime.evaluate: ${expression.slice(0, 80)}`); } if (method === "Input.dispatchMouseEvent") return {}; throw new Error(`unexpected ${method}`); @@ -166,12 +317,18 @@ describe("captureViewModel", () => { const result = await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true }); expect(result.surfaceProbes).toEqual([ - { triggerLabel: "Products", triggerAction: "hover", subItems: ["Shoes", "Bags"] }, + { + triggerBackendNodeId: 12, + triggerPoint: { x: 956, y: 32 }, + triggerAction: "hover", + subItems: ["My profile", "Sign out"], + confidence: "high", + }, ]); expect(cdp.send).toHaveBeenCalledWith( 4, "Input.dispatchMouseEvent", - expect.objectContaining({ type: "mouseMoved", x: 50, y: 20 }), + expect.objectContaining({ type: "mouseMoved", x: 956, y: 32 }), ); expect(cdp.send).toHaveBeenCalledWith( 4, @@ -180,6 +337,91 @@ describe("captureViewModel", () => { ); }); + it("uses a fresh baseline for each hover candidate", async () => { + let hoverStateCalls = 0; + const cdp = { + send: vi.fn(async (_tabId: number, method: string, params?: object) => { + if (method === "DOMSnapshot.enable") return {}; + if (method === "DOMSnapshot.captureSnapshot") return twoHoverTriggerSnapshotReply(); + if (method === "Page.getLayoutMetrics") { + return { + cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, + }; + } + if (method === "Runtime.evaluate") { + const expression = (params as { expression?: string } | undefined)?.expression ?? ""; + if (expression.includes("input,textarea,select")) { + return { result: { value: { controls: [], childFrames: [] } } }; + } + if (expression.includes("document.styleSheets")) { + return { result: { value: [] } }; + } + if (expression.includes("querySelectorAll(selectors)")) { + hoverStateCalls += 1; + const visibleMenu = [ + { text: "My profile", role: "", tag: "a", x: 900, y: 60 }, + { text: "Sign out", role: "", tag: "a", x: 900, y: 90 }, + ]; + return hoverStateCalls === 1 + ? { result: { value: [] } } + : { result: { value: visibleMenu } }; + } + throw new Error(`unexpected Runtime.evaluate: ${expression.slice(0, 80)}`); + } + if (method === "Input.dispatchMouseEvent") return {}; + throw new Error(`unexpected ${method}`); + }) as unknown as (tabId: number, method: string, params?: object) => Promise, + }; + + const result = await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true }); + + expect(result.surfaceProbes).toEqual([ + expect.objectContaining({ + triggerBackendNodeId: 12, + subItems: ["My profile", "Sign out"], + }), + ]); + expect(hoverStateCalls).toBeGreaterThanOrEqual(4); + }); + + it("deduplicates nested hover candidates for one visual trigger", async () => { + let hoverMoves = 0; + const cdp = { + send: vi.fn(async (_tabId: number, method: string, params?: object) => { + if (method === "DOMSnapshot.enable") return {}; + if (method === "DOMSnapshot.captureSnapshot") return nestedHoverTriggerSnapshotReply(); + if (method === "Page.getLayoutMetrics") { + return { + cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 }, + }; + } + if (method === "Runtime.evaluate") { + const expression = (params as { expression?: string } | undefined)?.expression ?? ""; + if (expression.includes("input,textarea,select")) { + return { result: { value: { controls: [], childFrames: [] } } }; + } + if (expression.includes("document.styleSheets")) { + return { result: { value: [] } }; + } + if (expression.includes("querySelectorAll(selectors)")) { + return { result: { value: [] } }; + } + throw new Error(`unexpected Runtime.evaluate: ${expression.slice(0, 80)}`); + } + if (method === "Input.dispatchMouseEvent") { + const point = params as { x?: number; y?: number }; + if (point.x !== -10 && point.x !== 0) hoverMoves += 1; + return {}; + } + throw new Error(`unexpected ${method}`); + }) as unknown as (tabId: number, method: string, params?: object) => Promise, + }; + + await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true }); + + expect(hoverMoves).toBe(1); + }); + it("excludes the agent's own overlay shadow host and its inlined shadow subtree", async () => { // DOMSnapshot inlines an open shadow root's content as descendants of the // host. The agent's WXT overlay host carries a fixed full-viewport diff --git a/apps/extension/src/tools/vom/capture.ts b/apps/extension/src/tools/vom/capture.ts index d8358f5..0f56577 100644 --- a/apps/extension/src/tools/vom/capture.ts +++ b/apps/extension/src/tools/vom/capture.ts @@ -5,6 +5,7 @@ // `styles` columns are [position, pointer-events, cursor] in that order. import type { Rect, Viewport } from "@browser-skill/vom"; +import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy"; import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge"; import type { CdpRunner } from "../shared"; @@ -45,9 +46,11 @@ export interface CapturedNode { export type CapturedIframeNodes = Map; export interface CapturedSurfaceProbe { - triggerLabel: string; + triggerBackendNodeId: number; + triggerPoint?: { x: number; y: number }; triggerAction: "hover" | "focus" | string; subItems: string[]; + confidence?: "high" | "medium" | "low"; } export interface CapturedViewModel { @@ -61,6 +64,7 @@ export interface CapturedViewModel { export interface CaptureViewModelOptions { conditionalSurfaceProbe?: boolean; + hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise; } /** Sparse array format Chrome uses for infrequently-set per-node fields. */ @@ -225,11 +229,12 @@ interface RuntimeEvaluateReply { } interface HoverCandidate { - triggerSel: string; - affectedSub: string; - label: string; + backendNodeId: number; + label?: string; x: number; y: number; + score: number; + reasons: string[]; } interface CdpDomNode { @@ -322,17 +327,19 @@ function collectBackendIdsFromDomNode(node: CdpDomNode | undefined, out: Set(reply: RuntimeEvaluateReply): T | undefined { return reply.result?.value as T | undefined; } -function hoverCssScanExpression(): string { +function hoverCssTriggerScanExpression(): string { return `(() => { const visibilityProps = ["display", "visibility", "opacity", "maxHeight", "height", "overflow"]; - const pairs = []; + const centres = []; const seenRules = new Set(); for (const sheet of Array.from(document.styleSheets)) { let rules; @@ -348,90 +355,73 @@ function hoverCssScanExpression(): string { const hoverIndex = part.indexOf(":hover"); if (hoverIndex < 0) continue; const triggerSel = part.slice(0, hoverIndex).trim(); - const affectedSub = part.slice(hoverIndex + 6).trim(); if (!triggerSel) continue; - const key = triggerSel + "||" + affectedSub; - if (seenRules.has(key)) continue; - seenRules.add(key); - pairs.push({ triggerSel, affectedSub }); + if (seenRules.has(triggerSel)) continue; + seenRules.add(triggerSel); + let elements; + try { elements = Array.from(document.querySelectorAll(triggerSel)); } catch { continue; } + for (const el of elements) { + if (!(el instanceof HTMLElement)) continue; + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none" || style.pointerEvents === "none") continue; + centres.push({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }); + if (centres.length >= 24) return centres; + } } } } - - const candidates = []; - const seenLabels = new Set(); - const isUnsafeTrigger = (el) => { - if (!(el instanceof HTMLElement)) return true; - const tag = el.tagName.toLowerCase(); - if (["input", "textarea", "select", "option"].includes(tag)) return true; - if (el.isContentEditable) return true; - if (el.hasAttribute("disabled") || el.hasAttribute("inert")) return true; - if ((el.getAttribute("aria-disabled") || "").toLowerCase() === "true") return true; - return false; - }; - for (const pair of pairs) { - let elements; - try { elements = Array.from(document.querySelectorAll(pair.triggerSel)); } catch { continue; } - for (const el of elements) { - if (!(el instanceof Element)) continue; - if (isUnsafeTrigger(el)) continue; - const rect = el.getBoundingClientRect(); - const style = getComputedStyle(el); - if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none" || style.pointerEvents === "none") continue; - const label = (el.textContent || "").replace(/\\s+/g, " ").trim(); - if (!label || seenLabels.has(label)) continue; - seenLabels.add(label); - candidates.push({ - triggerSel: pair.triggerSel, - affectedSub: pair.affectedSub, - label, - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - }); - if (candidates.length >= ${MAX_HOVER_TRIGGERS}) return candidates; - } - } - return candidates; + return centres; })()`; } -function hoverCollectExpression(candidate: HoverCandidate): string { +interface HoverRuntimeItem { + text: string; + role: string; + tag: string; + x: number; + y: number; +} + +function hoverStateExpression(): string { return `(() => { - const triggerSel = ${JSON.stringify(candidate.triggerSel)}; - const rawAffectedSub = ${JSON.stringify(candidate.affectedSub)}; - const x = ${JSON.stringify(candidate.x)}; - const y = ${JSON.stringify(candidate.y)}; - const hit = document.elementFromPoint(x, y); - const trigger = hit instanceof Element ? hit.closest(triggerSel) : null; - if (!trigger) return []; - - const affectedSub = rawAffectedSub.replace(/^[>+~]\\s*/, "").trim(); - let targets = []; - if (!affectedSub) { - targets = [trigger]; - } else { - try { targets = Array.from(trigger.querySelectorAll(affectedSub)); } catch { targets = []; } - } const items = []; const seen = new Set(); - const push = (value) => { - const text = String(value || "").replace(/\\s+/g, " ").trim(); + const selectors = [ + "a", + "button", + "[role='menuitem']", + "[role='menuitemcheckbox']", + "[role='menuitemradio']", + "[role='option']", + "[role='tab']", + "[role='link']", + "[role='button']" + ].join(","); + const push = (el) => { + if (!(el instanceof HTMLElement)) return; + const style = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return; + const text = String( + el.getAttribute("aria-label") || + el.getAttribute("title") || + el.textContent || + "" + ).replace(/\\s+/g, " ").trim(); if (!text || seen.has(text)) return; seen.add(text); - items.push(text); + items.push({ + text, + role: (el.getAttribute("role") || "").toLowerCase(), + tag: el.tagName.toLowerCase(), + x: rect.left, + y: rect.top, + }); }; - for (const target of targets) { - if (!(target instanceof Element)) continue; - const style = getComputedStyle(target); - if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") continue; - const clickable = target.querySelectorAll('a, button, [role="menuitem"], [role="option"]'); - if (clickable.length > 0) { - for (const child of Array.from(clickable)) push(child.textContent); - } else { - push(target.textContent); - } - } - return items.slice(0, 12); + for (const el of Array.from(document.querySelectorAll(selectors))) push(el); + return items.slice(0, 400); })()`; } @@ -453,45 +443,217 @@ async function clearHover(cdp: CdpRunner, tabId: number): Promise { ); } -async function probeHoverSurfaces(cdp: CdpRunner, tabId: number): Promise { +function capturedText(node: CapturedNode): string | undefined { + const value = + node.attrs["aria-label"] ?? node.attrs.title ?? node.attrs.alt ?? node.textContent ?? ""; + const clean = value.replace(/\s+/g, " ").trim(); + return clean || undefined; +} + +function hasGraphicDescendant( + node: CapturedNode, + childrenByParentId: Map, + depth = 0, +): boolean { + if (depth > 3) return false; + for (const child of childrenByParentId.get(node.backendNodeId) ?? []) { + const tag = child.tag.toLowerCase(); + if (["img", "svg", "use", "path", "i"].includes(tag)) return true; + if (hasGraphicDescendant(child, childrenByParentId, depth + 1)) return true; + } + return false; +} + +function roleOf(node: CapturedNode): string { + return (node.attrs.role ?? "").toLowerCase(); +} + +function scoreHoverCandidate( + node: CapturedNode, + childrenByParentId: Map, + cssHoverPoints: Array<{ x: number; y: number }>, +): HoverCandidate | null { + const rect = node.rect; + if (!rect) return null; + const label = capturedText(node); + const cssHoverMatch = cssHoverPoints.some( + (point) => + point.x >= rect.x && + point.x <= rect.x + rect.w && + point.y >= rect.y && + point.y <= rect.y + rect.h, + ); + const decision = evaluateHoverTrigger({ + tag: node.tag, + role: roleOf(node), + label, + attrs: node.attrs, + rect, + cursor: node.cursor, + pointerEvents: node.pointerEvents, + hasGraphicDescendant: hasGraphicDescendant(node, childrenByParentId), + cssHoverMatch, + }); + + if (!decision.eligible) return null; + return { + backendNodeId: node.backendNodeId, + label, + x: rect.x + rect.w / 2, + y: rect.y + rect.h / 2, + score: decision.score, + reasons: decision.reasons, + }; +} + +function buildHoverCandidates( + nodes: CapturedNode[], + cssHoverPoints: Array<{ x: number; y: number }>, +): HoverCandidate[] { + const childrenByParentId = new Map(); + const parentByBackendId = new Map(); + for (const node of nodes) { + parentByBackendId.set(node.backendNodeId, node.parentBackendNodeId); + if (node.parentBackendNodeId === null) continue; + const children = childrenByParentId.get(node.parentBackendNodeId) ?? []; + children.push(node); + childrenByParentId.set(node.parentBackendNodeId, children); + } + + const candidates = nodes + .map((node) => scoreHoverCandidate(node, childrenByParentId, cssHoverPoints)) + .filter((candidate): candidate is HoverCandidate => candidate !== null) + .sort((a, b) => b.score - a.score); + + const deduped: HoverCandidate[] = []; + const seen = new Set(); + for (const candidate of candidates) { + if (seen.has(candidate.backendNodeId)) continue; + if (deduped.some((existing) => sameHoverCluster(existing, candidate, parentByBackendId))) { + continue; + } + seen.add(candidate.backendNodeId); + deduped.push(candidate); + if (deduped.length >= MAX_HOVER_TRIGGERS) break; + } + return deduped; +} + +function sameHoverCluster( + a: HoverCandidate, + b: HoverCandidate, + parentByBackendId: Map, +): boolean { + if (Math.hypot(a.x - b.x, a.y - b.y) <= 8) return true; + return ( + isBackendAncestor(a.backendNodeId, b.backendNodeId, parentByBackendId) || + isBackendAncestor(b.backendNodeId, a.backendNodeId, parentByBackendId) + ); +} + +function isBackendAncestor( + ancestorId: number, + nodeId: number, + parentByBackendId: Map, +): boolean { + let parentId = parentByBackendId.get(nodeId); + let guard = 0; + while (parentId !== null && parentId !== undefined && guard < parentByBackendId.size) { + if (parentId === ancestorId) return true; + parentId = parentByBackendId.get(parentId); + guard += 1; + } + return false; +} + +function diffHoverItems(before: HoverRuntimeItem[], after: HoverRuntimeItem[]): string[] { + const beforeKeys = new Set(before.map((item) => item.text.toLowerCase())); + const out: string[] = []; + const seen = new Set(); + for (const item of after) { + const text = item.text.replace(/\s+/g, " ").trim(); + const key = text.toLowerCase(); + if (!text || beforeKeys.has(key) || seen.has(key)) continue; + seen.add(key); + out.push(text); + if (out.length >= MAX_HOVER_SUB_ITEMS) break; + } + return out; +} + +function confidenceForHover( + candidate: HoverCandidate, + subItems: string[], +): "high" | "medium" | "low" { + if (subItems.length >= 2 && candidate.score >= 80) return "high"; + if (subItems.length >= 2 || candidate.score >= 80) return "medium"; + return "low"; +} + +async function probeHoverSurfaces( + cdp: CdpRunner, + tabId: number, + nodes: CapturedNode[], + options: CaptureViewModelOptions, +): Promise { const started = Date.now(); try { - const scan = await cdp.send(tabId, "Runtime.evaluate", { - expression: hoverCssScanExpression(), + const cssScan = await cdp.send(tabId, "Runtime.evaluate", { + expression: hoverCssTriggerScanExpression(), returnByValue: true, }); - const candidates = runtimeValue(scan) ?? []; + const cssHoverPoints = runtimeValue>(cssScan) ?? []; + const candidates = buildHoverCandidates(nodes, cssHoverPoints); + if (candidates.length === 0) return []; + const results: CapturedSurfaceProbe[] = []; - const seen = new Set(); - for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) { - if (Date.now() - started > MAX_HOVER_PROBE_MS) break; - if (!candidate.label || seen.has(candidate.label)) continue; - try { - await cdp.send(tabId, "Input.dispatchMouseEvent", { - type: "mouseMoved", - x: candidate.x, - y: candidate.y, - }); - await wait(HOVER_SETTLE_MS); - const collected = await cdp.send(tabId, "Runtime.evaluate", { - expression: hoverCollectExpression(candidate), - returnByValue: true, - }); - const subItems = (runtimeValue(collected) ?? []) - .map((item) => item.replace(/\s+/g, " ").trim()) - .filter(Boolean); - if (subItems.length === 0) continue; - seen.add(candidate.label); - results.push({ - triggerLabel: candidate.label, - triggerAction: "hover", - subItems, - }); - } catch { - continue; - } finally { - await clearHover(cdp, tabId); + const seen = new Set(); + await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined); + try { + for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) { + if (Date.now() - started > MAX_HOVER_PROBE_MS) break; + if (results.length >= MAX_HOVER_SURFACES) break; + if (seen.has(candidate.backendNodeId)) continue; + try { + await clearHover(cdp, tabId); + await wait(HOVER_SETTLE_MS); + const baselineReply = await cdp.send(tabId, "Runtime.evaluate", { + expression: hoverStateExpression(), + returnByValue: true, + }); + const baselineItems = runtimeValue(baselineReply) ?? []; + + await cdp.send(tabId, "Input.dispatchMouseEvent", { + type: "mouseMoved", + x: candidate.x, + y: candidate.y, + }); + await wait(HOVER_SETTLE_MS); + const collected = await cdp.send(tabId, "Runtime.evaluate", { + expression: hoverStateExpression(), + returnByValue: true, + }); + const subItems = diffHoverItems( + baselineItems, + runtimeValue(collected) ?? [], + ); + if (subItems.length === 0) continue; + seen.add(candidate.backendNodeId); + results.push({ + triggerBackendNodeId: candidate.backendNodeId, + triggerPoint: { x: candidate.x, y: candidate.y }, + triggerAction: "hover", + subItems, + confidence: confidenceForHover(candidate, subItems), + }); + } catch { + continue; + } finally { + await clearHover(cdp, tabId); + } } + } finally { + await options.hoverProbeBypassOverlay?.(tabId, false).catch(() => undefined); } return results; } catch (err) { @@ -797,7 +959,9 @@ export async function captureViewModel( await enrichFormControlStates(cdp, tabId, [nodes, ...iframeNodes.values()]); - const surfaceProbes = options.conditionalSurfaceProbe ? await probeHoverSurfaces(cdp, tabId) : []; + const surfaceProbes = options.conditionalSurfaceProbe + ? await probeHoverSurfaces(cdp, tabId, nodes, options) + : []; return { nodes, viewport, iframeNodes, surfaceProbes, excludedBackendNodeIds }; } diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index a335747..4533d7d 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -314,8 +314,21 @@ export interface SnapshotResult { dialogs?: JavaScriptDialogInfo[]; } -export type ObserveParams = SnapshotParams; -export type ObserveResult = SnapshotResult; +export interface ObserveParams extends SnapshotParams { + debug_surfaces?: boolean; +} + +export interface ObserveResult extends SnapshotResult { + debug?: { + surface_probes?: Array<{ + trigger_backend_node_id: number; + trigger_point?: { x: number; y: number }; + trigger_action: string; + sub_items: string[]; + confidence?: string; + }>; + }; +} export interface GetHtmlParams { session_id: string; @@ -410,6 +423,25 @@ export interface ClickResult { dialogs?: JavaScriptDialogInfo[]; } +export interface HoverParams { + session_id: string; + ref?: string; + selector?: string; + tab_id?: number; + modifiers?: KeyModifier[]; + settle_ms?: number; + timeout_ms?: number; +} + +export interface HoverResult { + tab_id: number; + used_ref?: string; + used_selector?: string; + x: number; + y: number; + dialogs?: JavaScriptDialogInfo[]; +} + export interface FillParams { session_id: string; value: string; @@ -668,6 +700,11 @@ export type DraftTraceStep = navigated_to?: string; page_url?: string; } + | { + op: "hover"; + target: TargetDescriptor; + page_url?: string; + } | { op: "fill"; target: TargetDescriptor; @@ -701,6 +738,7 @@ export type DraftTraceStep = export type Step = | ({ op: "navigate" } & StepCommon & { to: string }) | ({ op: "click" } & StepCommon & { target: TargetDescriptor }) + | ({ op: "hover" } & StepCommon & { target: TargetDescriptor }) | ({ op: "fill" } & StepCommon & { target: TargetDescriptor; value: string; diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 3c36961..9a1b6a8 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -67,22 +67,25 @@ Write operations only affect tabs in the **Agent Window** (or tabs you **borrowe ``` bsk navigate --session -bsk snapshot --session → aria tree with @e1, @e2, … refs -bsk observe --session → semantic VOM view; may reveal hover/focus surfaces -bsk click @e3 --session → or bsk fill, bsk select, bsk press -bsk snapshot --session → again after navigation / DOM change +bsk observe --session → primary semantic VOM view; reveals hover/focus surfaces +bsk snapshot --session → static aria tree fallback when VOM is insufficient +bsk hover @e3 --session → reveal hover-triggered menus before re-observing/clicking +bsk click @e4 --session → or bsk fill, bsk select, bsk press +bsk observe --session → again after navigation / DOM change ``` **Refs invalidate after navigation** — always re-snapshot before clicking, filling, or selecting on a new page. Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / `--selector` when ambiguous (`bsk click --help`). +When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover --session `, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action. + ## Observation priority -Start with `bsk snapshot` to understand page structure, text, controls, and element refs. Use `bsk observe` when semantic VOM output or conditional hover/focus surfaces would materially help. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question: +Start with `bsk observe` to understand page structure, text, controls, element refs, and conditional hover/focus surfaces. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question: -1. `bsk snapshot` — strict static page understanding and interaction planning -2. `bsk observe` — semantic VOM observation; may run bounded perception probes such as hover-surface discovery +1. `bsk observe` — primary semantic VOM observation; may run bounded perception probes such as hover-surface discovery +2. `bsk snapshot` — strict static accessibility tree fallback 3. `bsk get-html` — when hidden DOM, metadata, or markup details are required 4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab. @@ -186,6 +189,7 @@ bsk emulate --session --off | Command | Summary | |---------|---------| | `bsk click ` | Click element (`--button`, `--click-count`, `--modifiers`) | +| `bsk hover ` | Move the mouse to an element and wait for hover UI to settle (`--settle`, `--modifiers`) | | `bsk fill --value ` | Clear and type into input | | `bsk select --value ` | Set `` option(s) by `value` (repeat `--value` for multi-select) | | `bsk press ` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) |