diff --git a/package/package.json b/package/package.json index 4e9f4fa..d02ad17 100644 --- a/package/package.json +++ b/package/package.json @@ -1,6 +1,6 @@ { "name": "agentation", - "version": "2.3.2", + "version": "2.3.3-alex", "description": "Visual feedback for AI coding agents", "sideEffects": false, "license": "PolyForm-Shield-1.0.0", diff --git a/package/src/components/page-toolbar-css/__tests__/deep-select.test.tsx b/package/src/components/page-toolbar-css/__tests__/deep-select.test.tsx new file mode 100644 index 0000000..ee303a3 --- /dev/null +++ b/package/src/components/page-toolbar-css/__tests__/deep-select.test.tsx @@ -0,0 +1,556 @@ +/** @vitest-environment jsdom */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + render, + screen, + fireEvent, + waitFor, + act, + cleanup, +} from "@testing-library/react"; +import { PageFeedbackToolbarCSS } from "../index"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockTarget( + tag: string = "button", + text: string = "Click me", +): HTMLElement { + const el = document.createElement(tag); + el.textContent = text; + el.setAttribute("data-mock-target", "true"); + document.body.appendChild(el); + return el; +} + +function mockRect( + el: HTMLElement, + rect: Partial = {}, +): void { + vi.spyOn(el, "getBoundingClientRect").mockReturnValue({ + left: 100, + top: 100, + right: 300, + bottom: 150, + width: 200, + height: 50, + x: 100, + y: 100, + toJSON: () => ({}), + ...rect, + } as DOMRect); +} + +async function activateToolbar() { + const activateButton = screen.getByTitle("Start feedback mode"); + fireEvent.click(activateButton); + await waitFor(() => { + const toolbar = document.querySelector("[data-feedback-toolbar]"); + expect(toolbar).toBeTruthy(); + }); +} + +function clickAtPoint( + x: number, + y: number, + options: Partial = {}, +) { + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + ...options, + }); + document.body.dispatchEvent(event); +} + +// --------------------------------------------------------------------------- +// Global Mocks +// --------------------------------------------------------------------------- + +const mockWriteText = vi.fn().mockResolvedValue(undefined); + +class MockEventSource { + addEventListener = vi.fn(); + removeEventListener = vi.fn(); + close = vi.fn(); + constructor(_url: string) {} +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + + Object.defineProperty(navigator, "clipboard", { + value: { writeText: mockWriteText }, + writable: true, + configurable: true, + }); + mockWriteText.mockClear(); + + // jsdom does not implement elementFromPoint + document.elementFromPoint = vi.fn().mockReturnValue(null); + + // jsdom does not implement elementsFromPoint + document.elementsFromPoint = vi.fn().mockReturnValue([]); + + vi.stubGlobal("EventSource", MockEventSource); +}); + +afterEach(() => { + cleanup(); + document.querySelectorAll("[data-feedback-toolbar]").forEach((el) => el.remove()); + document.querySelectorAll("[data-annotation-marker]").forEach((el) => el.remove()); + document.querySelectorAll("[data-annotation-popup]").forEach((el) => el.remove()); + document.getElementById("feedback-cursor-styles")?.remove(); + document.querySelectorAll("[data-mock-target]").forEach((el) => el.remove()); + localStorage.clear(); + sessionStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +// ============================================================================= +// Deep Select (Pierce Mode) Tests +// ============================================================================= + +describe("Deep select / Pierce mode", () => { + // =========================================================================== + // 1. Pierce mode activation via Cmd/Ctrl + hover + // =========================================================================== + + describe("Pierce mode activation via mousemove", () => { + it("uses pierceElementFromPoint when metaKey is held during hover", async () => { + render(); + await activateToolbar(); + + // Create a content element behind an overlay + const contentEl = createMockTarget("p", "Real content"); + mockRect(contentEl); + + // Create an overlay div that intercepts pointer events + const overlay = createMockTarget("div", ""); + mockRect(overlay, { width: 500, height: 500 }); + + // elementFromPoint returns the overlay (top element) + vi.spyOn(document, "elementFromPoint").mockReturnValue(overlay); + // elementsFromPoint returns stack: overlay, then contentEl + vi.spyOn(document, "elementsFromPoint").mockReturnValue([ + overlay, + contentEl, + document.body, + document.documentElement, + ]); + + // Hover with metaKey held (Cmd on Mac) + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + metaKey: true, + }); + }); + + // The hover label should appear (the element path or name tooltip) + // and it should contain the deep select indicator + await waitFor(() => { + // The component renders hover info with the pierce mode indicator + const allText = document.body.textContent || ""; + // Pierce mode should identify the content element, not the overlay + // The "deep select" indicator text is rendered when isPiercing is true + expect(allText).toContain("deep select"); + }); + }); + + it("uses pierceElementFromPoint when ctrlKey is held during hover", async () => { + render(); + await activateToolbar(); + + const contentEl = createMockTarget("button", "Action"); + mockRect(contentEl); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(contentEl); + + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + ctrlKey: true, + }); + }); + + await waitFor(() => { + const allText = document.body.textContent || ""; + expect(allText).toContain("deep select"); + }); + }); + + it("does NOT show deep select indicator during normal hover (no modifier key)", async () => { + render(); + await activateToolbar(); + + const mockEl = createMockTarget("button", "Normal hover"); + mockRect(mockEl); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(mockEl); + + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + }); + }); + + // Wait for state to settle + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + const allText = document.body.textContent || ""; + expect(allText).not.toContain("deep select"); + }); + }); + + // =========================================================================== + // 2. Pierce mode — dashed border style + // =========================================================================== + + describe("Dashed border on pierce hover", () => { + it("renders dashed border-style when isPiercing is true", async () => { + render(); + await activateToolbar(); + + const mockEl = createMockTarget("button", "Pierced element"); + mockRect(mockEl); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(mockEl); + + // Hover with metaKey to activate pierce mode + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + metaKey: true, + }); + }); + + await waitFor(() => { + // The highlight rectangle should have dashed border + const toolbarPortals = document.querySelectorAll("[data-feedback-toolbar]"); + let foundDashed = false; + for (const portal of toolbarPortals) { + const divs = portal.querySelectorAll("div"); + for (const div of divs) { + if (div.style.borderStyle === "dashed") { + foundDashed = true; + break; + } + } + if (foundDashed) break; + } + expect(foundDashed).toBe(true); + }); + }); + + it("does NOT render dashed border during normal hover", async () => { + render(); + await activateToolbar(); + + const mockEl = createMockTarget("button", "Normal element"); + mockRect(mockEl); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(mockEl); + + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + }); + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + const toolbarPortals = document.querySelectorAll("[data-feedback-toolbar]"); + let foundDashed = false; + for (const portal of toolbarPortals) { + const divs = portal.querySelectorAll("div"); + for (const div of divs) { + if (div.style.borderStyle === "dashed") { + foundDashed = true; + } + } + } + expect(foundDashed).toBe(false); + }); + }); + + // =========================================================================== + // 3. Pierce mode — two-pass element discovery + // =========================================================================== + + describe("Two-pass pierce algorithm", () => { + it("pass 1: finds element with direct text content behind an overlay", async () => { + render(); + await activateToolbar(); + + // Create a paragraph with real text content + const textEl = createMockTarget("p", "Price: $54"); + mockRect(textEl); + + // Create an empty overlay div + const overlay = document.createElement("div"); + overlay.setAttribute("data-mock-target", "true"); + document.body.appendChild(overlay); + mockRect(overlay, { width: 1000, height: 1000 }); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(overlay); + vi.spyOn(document, "elementsFromPoint").mockReturnValue([ + overlay, + textEl, + document.body, + document.documentElement, + ]); + + // Pierce mode hover + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + metaKey: true, + }); + }); + + await waitFor(() => { + const allText = document.body.textContent || ""; + // The hover info should show info about the text element, not the overlay + expect(allText).toContain("deep select"); + }); + }); + + it("pass 2: falls back to smallest visible element when no text content found", async () => { + render(); + await activateToolbar(); + + // Create a small colored bar (visual-only, no text) + const bar = document.createElement("div"); + bar.setAttribute("data-mock-target", "true"); + bar.className = "timeline-bar"; + document.body.appendChild(bar); + mockRect(bar, { width: 50, height: 10 }); + + // Create a large overlay + const overlay = document.createElement("div"); + overlay.setAttribute("data-mock-target", "true"); + document.body.appendChild(overlay); + mockRect(overlay, { width: 500, height: 500, left: 0, top: 0, right: 500, bottom: 500 }); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(overlay); + vi.spyOn(document, "elementsFromPoint").mockReturnValue([ + overlay, + bar, + document.body, + document.documentElement, + ]); + + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + metaKey: true, + }); + }); + + await waitFor(() => { + const allText = document.body.textContent || ""; + expect(allText).toContain("deep select"); + }); + }); + + it("returns the top element directly when it has content and is visible", async () => { + render(); + await activateToolbar(); + + // Top element already has content — no need to pierce further + const contentEl = createMockTarget("button", "Click me"); + mockRect(contentEl); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(contentEl); + + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + metaKey: true, + }); + }); + + await waitFor(() => { + const allText = document.body.textContent || ""; + expect(allText).toContain("deep select"); + expect(allText).toContain("Click me"); + }); + }); + }); + + // =========================================================================== + // 4. Pierce mode click — creating annotations through overlays + // =========================================================================== + + describe("Pierce mode click", () => { + it("uses pierceElementFromPoint when Cmd/Ctrl is held during click (without Shift)", async () => { + render(); + await activateToolbar(); + + const contentEl = createMockTarget("button", "Submit"); + mockRect(contentEl); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(contentEl); + + await act(async () => { + // Click with metaKey but NOT shiftKey + clickAtPoint(200, 125, { metaKey: true }); + }); + + // Should open annotation popup + await waitFor(() => { + const popup = document.querySelector("[data-annotation-popup]"); + expect(popup).toBeTruthy(); + }); + }); + + it("uses pierceElementFromPoint for Cmd+Shift+click (multi-select)", async () => { + render(); + await activateToolbar(); + + const el1 = createMockTarget("button", "First"); + mockRect(el1); + vi.spyOn(document, "elementFromPoint").mockReturnValue(el1); + + // Cmd+Shift+click for multi-select + await act(async () => { + clickAtPoint(200, 125, { metaKey: true, shiftKey: true }); + }); + + // Should attempt multi-element selection (adds element to multi-select list) + // Wait for any state updates + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + // The handler ran without error (we're testing the code path is exercised) + expect(true).toBe(true); + }); + }); + + // =========================================================================== + // 5. pierceElementFromPoint returns null when elementFromPoint returns null + // =========================================================================== + + describe("Pierce edge cases", () => { + it("handles null from elementFromPoint gracefully", async () => { + render(); + await activateToolbar(); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(null); + + // Pierce hover should not crash + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + metaKey: true, + }); + }); + + // No crash means success + expect(true).toBe(true); + }); + + it("skips invisible elements during pierce", async () => { + render(); + await activateToolbar(); + + // Create an invisible element + const invisible = document.createElement("div"); + invisible.setAttribute("data-mock-target", "true"); + invisible.textContent = "Hidden text"; + document.body.appendChild(invisible); + mockRect(invisible, { width: 200, height: 50 }); + // Mock checkVisibility to return false + invisible.checkVisibility = vi.fn().mockReturnValue(false); + + // Create a visible content element + const visible = createMockTarget("p", "Visible text"); + mockRect(visible, { width: 200, height: 50 }); + + // Create overlay + const overlay = document.createElement("div"); + overlay.setAttribute("data-mock-target", "true"); + document.body.appendChild(overlay); + mockRect(overlay, { width: 500, height: 500 }); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(overlay); + vi.spyOn(document, "elementsFromPoint").mockReturnValue([ + overlay, + invisible, + visible, + document.body, + document.documentElement, + ]); + + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + metaKey: true, + }); + }); + + await waitFor(() => { + const allText = document.body.textContent || ""; + expect(allText).toContain("deep select"); + }); + }); + + it("pierces through shadow DOM during pierce mode", async () => { + render(); + await activateToolbar(); + + // Create a shadow host element + const host = document.createElement("div"); + host.setAttribute("data-mock-target", "true"); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: "open" }); + const innerBtn = document.createElement("button"); + innerBtn.textContent = "Shadow button"; + shadow.appendChild(innerBtn); + + mockRect(host); + mockRect(innerBtn); + + // elementFromPoint returns the host + vi.spyOn(document, "elementFromPoint").mockReturnValue(host); + + // Mock shadow root's elementFromPoint to return the inner button + shadow.elementFromPoint = vi.fn().mockReturnValue(innerBtn); + + await act(async () => { + fireEvent.mouseMove(document.body, { + clientX: 200, + clientY: 125, + metaKey: true, + }); + }); + + await waitFor(() => { + const allText = document.body.textContent || ""; + expect(allText).toContain("deep select"); + }); + }); + }); +}); diff --git a/package/src/index.ts b/package/src/index.ts index 1d86210..fb69b9e 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -48,3 +48,5 @@ export { // Types export type { Annotation } from "./types"; + +console.log('HEY') diff --git a/package/src/utils/__tests__/element-identification.test.ts b/package/src/utils/__tests__/element-identification.test.ts index bba97d0..f161393 100644 --- a/package/src/utils/__tests__/element-identification.test.ts +++ b/package/src/utils/__tests__/element-identification.test.ts @@ -379,6 +379,93 @@ describe("identifyElement", () => { document.body.appendChild(nav); expect(identifyElement(nav).name).toBe("nav"); }); + + // Deep select feature: direct text content preferred over class names + describe("direct text content (deep select)", () => { + it("prefers direct text content over class names for div", () => { + const div = document.createElement("div"); + div.className = "styles_productPrice_abc123"; + div.appendChild(document.createTextNode("$54")); + document.body.appendChild(div); + expect(identifyElement(div).name).toBe('"$54"'); + }); + + it("prefers direct text content over class names for section", () => { + const section = document.createElement("section"); + section.className = "hero-banner"; + section.appendChild(document.createTextNode("Welcome")); + document.body.appendChild(section); + expect(identifyElement(section).name).toBe('"Welcome"'); + }); + + it("concatenates multiple direct text nodes", () => { + const div = document.createElement("div"); + div.appendChild(document.createTextNode("Hello")); + div.appendChild(document.createElement("span")); // child element in between + div.appendChild(document.createTextNode("World")); + document.body.appendChild(div); + expect(identifyElement(div).name).toBe('"Hello World"'); + }); + + it("ignores whitespace-only text nodes", () => { + const div = document.createElement("div"); + div.className = "card-wrapper"; + div.appendChild(document.createTextNode(" ")); + div.appendChild(document.createTextNode("\n")); + document.body.appendChild(div); + // No meaningful direct text, falls back to class name + const name = identifyElement(div).name; + expect(name).toContain("card"); + }); + + it("falls back to class names when direct text is too long (>= 50 chars)", () => { + const div = document.createElement("div"); + div.className = "description-block"; + div.appendChild(document.createTextNode("A".repeat(50))); + document.body.appendChild(div); + const name = identifyElement(div).name; + expect(name).toContain("description"); + }); + + it("does not skip class names when there is no direct text (only child elements)", () => { + const div = document.createElement("div"); + div.className = "nav-container"; + const child = document.createElement("span"); + child.textContent = "Link text"; + div.appendChild(child); + document.body.appendChild(div); + // The text is in a child element, not a direct text node + const name = identifyElement(div).name; + expect(name).toContain("nav"); + }); + + it("still uses aria-label over direct text content", () => { + const div = document.createElement("div"); + div.setAttribute("aria-label", "Price display"); + div.appendChild(document.createTextNode("$54")); + document.body.appendChild(div); + expect(identifyElement(div).name).toBe("div [Price display]"); + }); + + it("still uses role over direct text content", () => { + const div = document.createElement("div"); + div.setAttribute("role", "status"); + div.appendChild(document.createTextNode("Loading...")); + document.body.appendChild(div); + expect(identifyElement(div).name).toBe("status"); + }); + + it("works with all generic container tags", () => { + const tags = ["div", "section", "article", "nav", "header", "footer", "aside", "main"]; + for (const tag of tags) { + const el = document.createElement(tag); + el.appendChild(document.createTextNode("Content")); + document.body.appendChild(el); + expect(identifyElement(el).name).toBe('"Content"'); + el.remove(); + } + }); + }); }); it("returns tag name for unknown elements", () => { diff --git a/package/src/utils/__tests__/react-detection.test.ts b/package/src/utils/__tests__/react-detection.test.ts index 595d382..59261a2 100644 --- a/package/src/utils/__tests__/react-detection.test.ts +++ b/package/src/utils/__tests__/react-detection.test.ts @@ -201,6 +201,49 @@ describe("react-detection", () => { expect(result.components).not.toContain("ThemeProvider"); }); + // Deep select feature: video framework internals filtered out + it("mode: filtered skips video framework exact names (SeriesSequence, AbsoluteFill)", () => { + const series = createMockFiber("SeriesSequence"); + const fill = createMockFiber("AbsoluteFill", series); + const app = createMockFiber("App", fill); + const element = createElementWithFiber(app); + + const result = getReactComponentName(element, { mode: "filtered" }); + expect(result.components).not.toContain("SeriesSequence"); + expect(result.components).not.toContain("AbsoluteFill"); + expect(result.components).toContain("App"); + }); + + it("mode: filtered skips Remotion-prefixed patterns", () => { + const remotion = createMockFiber("RemotionPlayer"); + const app = createMockFiber("App", remotion); + const element = createElementWithFiber(app); + + const result = getReactComponentName(element, { mode: "filtered" }); + expect(result.components).not.toContain("RemotionPlayer"); + expect(result.components).toContain("App"); + }); + + it("mode: filtered skips TransitionSeries patterns", () => { + const transition = createMockFiber("TransitionSeriesSequence"); + const app = createMockFiber("App", transition); + const element = createElementWithFiber(app); + + const result = getReactComponentName(element, { mode: "filtered" }); + expect(result.components).not.toContain("TransitionSeriesSequence"); + expect(result.components).toContain("App"); + }); + + it("mode: filtered skips RefForwarding patterns", () => { + const ref = createMockFiber("RefForwardingComponent"); + const app = createMockFiber("App", ref); + const element = createElementWithFiber(app); + + const result = getReactComponentName(element, { mode: "filtered" }); + expect(result.components).not.toContain("RefForwardingComponent"); + expect(result.components).toContain("App"); + }); + it("mode: all includes more components", () => { const app = createMockFiber("App"); const layout = createMockFiber("Layout", app); @@ -229,6 +272,12 @@ describe("react-detection", () => { expect(DEFAULT_SKIP_EXACT.has("Route")).toBe(true); expect(DEFAULT_SKIP_EXACT.has("Outlet")).toBe(true); }); + + // Deep select feature: video framework internals + it("includes video framework internals", () => { + expect(DEFAULT_SKIP_EXACT.has("SeriesSequence")).toBe(true); + expect(DEFAULT_SKIP_EXACT.has("AbsoluteFill")).toBe(true); + }); }); describe("DEFAULT_SKIP_PATTERNS", () => { @@ -261,6 +310,28 @@ describe("react-detection", () => { const matches = DEFAULT_SKIP_PATTERNS.some((p) => p.test("ClientProfile")); expect(matches).toBe(false); }); + + // Deep select feature: video framework and React internal patterns + it("matches RefForwarding patterns (React.forwardRef wrappers)", () => { + const matches = DEFAULT_SKIP_PATTERNS.some((p) => p.test("RefForwardingComponent")); + expect(matches).toBe(true); + }); + + it("matches Remotion-prefixed internals", () => { + expect(DEFAULT_SKIP_PATTERNS.some((p) => p.test("RemotionPlayer"))).toBe(true); + expect(DEFAULT_SKIP_PATTERNS.some((p) => p.test("RemotionRoot"))).toBe(true); + }); + + it("matches TransitionSeries wrappers", () => { + expect(DEFAULT_SKIP_PATTERNS.some((p) => p.test("TransitionSeriesSequence"))).toBe(true); + expect(DEFAULT_SKIP_PATTERNS.some((p) => p.test("TransitionSeries"))).toBe(true); + }); + + it("does not match user components with similar names", () => { + // Should not false-positive on user components + expect(DEFAULT_SKIP_PATTERNS.some((p) => p.test("VideoPlayer"))).toBe(false); + expect(DEFAULT_SKIP_PATTERNS.some((p) => p.test("Timeline"))).toBe(false); + }); }); describe("smart mode", () => {