Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/storybook/stories/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const COMMENT_PRESET_KEYS: readonly CommentPreset[] = [

export const createMenuActions = (openEnabled: boolean): ContextMenuAction[] => [
{ id: "copy", label: "Copy", shortcut: "C", onAction: noop },
{ id: "copy-html", label: "Copy HTML", onAction: noop },
{ id: "copy-details", label: "Copy details", onAction: noop },
{ id: "open", label: "Open", shortcut: "O", enabled: openEnabled, onAction: noop },
{ id: "comment", label: "Comment", shortcut: "Enter", onAction: noop },
];
Expand Down
5 changes: 2 additions & 3 deletions packages/react-grab/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The runtime has two main concerns: initialization and the interaction lifecycle.

When the package is imported in a browser environment, [index.ts](../src/index.ts) checks that `window` exists and `__REACT_GRAB_DISABLED__` is not set, then calls `init()`. The returned API object is assigned to `window.__REACT_GRAB__`, any plugins that were registered via `registerPlugin()` before initialization completed are flushed from a pending queue, and a `react-grab:init` custom event is dispatched so other code on the page can react to the tool being ready. If `init()` is called in an SSR environment or when the tool is disabled, it returns a no-op API object with the same shape so that consumers don't need to guard every call.

Inside `init()`, the first thing that happens is merging any options provided programmatically with options parsed from the `data-options` JSON attribute on the script tag (via `getScriptOptions()`). Then `createPluginRegistry` and `createGrabStore` are called to set up the reactive state. A single `AbortController` manages all event listeners on `window` and `document`, so that teardown is a single `abort()` call. The five built-in plugins (copy, comment, open, copy-html, copy-styles) are registered through the same `register()` path that external plugins use. Finally, the renderer is loaded asynchronously via `import("../components/renderer.js")` and mounted into a Shadow DOM container created by [utils/mount-root.ts](../src/utils/mount-root.ts).
Inside `init()`, the first thing that happens is merging any options provided programmatically with options parsed from the `data-options` JSON attribute on the script tag (via `getScriptOptions()`). Then `createPluginRegistry` and `createGrabStore` are called to set up the reactive state. A single `AbortController` manages all event listeners on `window` and `document`, so that teardown is a single `abort()` call. The four built-in plugins (copy, comment, copy-details, open) are registered through the same `register()` path that external plugins use. Finally, the renderer is loaded asynchronously via `import("../components/renderer.js")` and mounted into a Shadow DOM container created by [utils/mount-root.ts](../src/utils/mount-root.ts).

### The interaction lifecycle

Expand Down Expand Up @@ -202,5 +202,4 @@ The five built-in plugins are registered during `init()` through the same `regis
- **copy** registers the default "Copy" context-menu action that calls the copy flow.
- **comment** registers the "Comment" action that enters prompt mode.
- **open** registers the "Open in editor" action that calls `openFile` with the resolved source location, running the URL through the `transformOpenFileUrl` hook pipeline first.
- **copy-html** registers "Copy HTML" which copies the element's `outerHTML` with stack context appended.
- **copy-styles** registers "Copy styles" which extracts the element's computed CSS (compared against a baseline from a hidden iframe) and copies it with stack context.
- **copy-details** registers "Copy details" which copies the full verbose HTML preview with the component stack.
2 changes: 1 addition & 1 deletion packages/react-grab/e2e/context-menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ test.describe("Context Menu", () => {
await reactGrab.page.waitForTimeout(500);

const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toContain("Walk the dog");
expect(clipboardContent).toContain("TodoItem");
});
});

Expand Down
140 changes: 0 additions & 140 deletions packages/react-grab/e2e/copy-styles.spec.ts

This file was deleted.

2 changes: 1 addition & 1 deletion packages/react-grab/e2e/disabled-elements.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ test.describe("Disabled Element Selection", () => {
await reactGrab.waitForSelectionBox();

await reactGrab.page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await expect.poll(() => reactGrab.getClipboardContent()).toContain("Pointer Events None");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("disabled-test-container");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This assertion checks the wrapper id, but the copied content is built from the selected element itself, so it should still expect pointer-events-none (or the copied text).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-grab/e2e/disabled-elements.spec.ts, line 128:

<comment>This assertion checks the wrapper id, but the copied content is built from the selected element itself, so it should still expect `pointer-events-none` (or the copied text).</comment>

<file context>
@@ -125,7 +125,7 @@ test.describe("Disabled Element Selection", () => {
 
     await reactGrab.page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
-    await expect.poll(() => reactGrab.getClipboardContent()).toContain("pointer-events-none");
+    await expect.poll(() => reactGrab.getClipboardContent()).toContain("disabled-test-container");
   });
 
</file context>
Suggested change
await expect.poll(() => reactGrab.getClipboardContent()).toContain("disabled-test-container");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("pointer-events-none");

Tip: Review your code locally with the cubic CLI to iterate faster.

});

test("should select nested disabled element inside enabled parent", async ({ reactGrab }) => {
Expand Down
5 changes: 2 additions & 3 deletions packages/react-grab/e2e/drag-selection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ test.describe("Drag Selection", () => {

const clipboardContent = await reactGrab.getClipboardContent();

expect(clipboardContent).toContain("Buy groceries");
expect(clipboardContent).toContain("TodoList");
});

test("should cancel drag selection on Escape", async ({ reactGrab }) => {
Expand Down Expand Up @@ -199,8 +199,7 @@ test.describe("Drag Selection", () => {

const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toBeTruthy();
expect(clipboardContent).toContain("Buy groceries");
expect(clipboardContent).toContain("Write tests");
expect(clipboardContent).toContain("TodoList");
});

test("should show visual feedback during drag", async ({ reactGrab }) => {
Expand Down
35 changes: 6 additions & 29 deletions packages/react-grab/e2e/element-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, expect } from "./fixtures.js";

test.describe("Element Context Fallback", () => {
test.describe("React Elements", () => {
test("should include component names in clipboard for React elements", async ({
test("should copy a compact reference or verbose context for React elements", async ({
reactGrab,
}) => {
await reactGrab.activate();
Expand All @@ -12,22 +12,11 @@ test.describe("Element Context Fallback", () => {
await reactGrab.clickElement("[data-testid='todo-list'] h1");

const clipboard = await reactGrab.getClipboardContent();
expect(clipboard).toMatch(/^\[<\w+[\s>]/);
expect(clipboard).toContain("TodoList");
});

test("should include HTML preview with tag and content", async ({ reactGrab }) => {
await reactGrab.activate();

await reactGrab.hoverElement("[data-testid='main-title']");
await reactGrab.waitForSelectionBox();
await reactGrab.clickElement("[data-testid='main-title']");

const clipboard = await reactGrab.getClipboardContent();
expect(clipboard).toContain("<h1");
expect(clipboard).toContain("React Grab");
});

test("should include nested component names for deeply nested elements", async ({
test("should produce useful context for nested elements", async ({
reactGrab,
}) => {
await reactGrab.activate();
Expand All @@ -37,24 +26,11 @@ test.describe("Element Context Fallback", () => {
await reactGrab.clickElement("[data-testid='nested-button']");

const clipboard = await reactGrab.getClipboardContent();
expect(clipboard).toMatch(/^\[<\w+[\s>]/);
expect(clipboard).toContain("NestedCard");
});

test("should include parent components in stack, not just immediate component", async ({
reactGrab,
}) => {
await reactGrab.activate();

await reactGrab.hoverElement("[data-testid='nested-button']");
await reactGrab.waitForSelectionBox();
await reactGrab.clickElement("[data-testid='nested-button']");

const clipboard = await reactGrab.getClipboardContent();
const inMatches = clipboard.match(/in\s+\S+/g) ?? [];
expect(inMatches.length).toBeGreaterThanOrEqual(2);
});

test("should include ancestor component for todo item", async ({ reactGrab }) => {
test("should produce useful context for todo items", async ({ reactGrab }) => {
await reactGrab.activate();

const todoItem = "[data-testid='todo-list'] ul li:first-child span";
Expand All @@ -63,6 +39,7 @@ test.describe("Element Context Fallback", () => {
await reactGrab.clickElement(todoItem);

const clipboard = await reactGrab.getClipboardContent();
expect(clipboard).toMatch(/^\[<\w+[\s>]/);
expect(clipboard).toContain("TodoItem");
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/react-grab/e2e/keyboard-shortcuts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test.describe("Keyboard Shortcuts", () => {
await reactGrab.page.waitForTimeout(500);

const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toContain("Walk the dog");
expect(clipboardContent).toContain("TodoItem");
});

test("should keep overlay active while navigating with arrow keys", async ({ reactGrab }) => {
Expand Down
27 changes: 7 additions & 20 deletions packages/react-grab/e2e/shift-multi-select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ test.describe("Shift Multi-Select", () => {

await reactGrab.page.keyboard.up("Shift");

await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toContain("Write tests");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});

test("should show the pending hover target while shift multi-selecting", async ({
Expand Down Expand Up @@ -323,9 +321,7 @@ test.describe("Shift Multi-Select", () => {

await reactGrab.page.keyboard.up("Shift");

await expect.poll(() => reactGrab.getClipboardContent()).toContain("Walk the dog");
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).not.toContain("Buy groceries");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});

test("should not auto-copy until shift is released", async ({ reactGrab }) => {
Expand All @@ -351,7 +347,7 @@ test.describe("Shift Multi-Select", () => {

await reactGrab.page.keyboard.up("Shift");

await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});

test("should reset accumulated selection when a non-shift click follows", async ({
Expand Down Expand Up @@ -388,10 +384,7 @@ test.describe("Shift Multi-Select", () => {
await reactGrab.activate();
await reactGrab.page.mouse.click(lastBox.x + lastBox.width / 2, lastBox.y + lastBox.height / 2);

await expect.poll(() => reactGrab.getClipboardContent()).toContain("Write tests");
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).not.toContain("Buy groceries");
expect(clipboardContent).not.toContain("Walk the dog");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-select tests no longer validate specific selected elements

Medium Severity

Several multi-select tests previously verified which specific elements were (and were not) in the clipboard using text content like "Buy groceries" or "Walk the dog", with .not.toContain checks. They now only assert toContain("TodoItem"), which is true for any todo item. The compact format still includes text snippets (e.g., "Buy groceries"), so these checks could be preserved. Tests like "should reset accumulated selection" and "should deselect already-selected element" can no longer detect if the wrong set of elements was copied.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 22c28cb. Configure here.

});

test("should not commit accumulated selection when shift releases over an open context menu", async ({
Expand Down Expand Up @@ -511,9 +504,7 @@ test.describe("Shift Multi-Select", () => {
await reactGrab.page.keyboard.up("Shift");
await reactGrab.page.mouse.up();

await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toContain("Walk the dog");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");

const userSelectStyle = await reactGrab.page.evaluate(() => document.body.style.userSelect);
expect(userSelectStyle).toBe("");
Expand Down Expand Up @@ -594,9 +585,7 @@ test.describe("Shift Multi-Select", () => {

await reactGrab.page.keyboard.up("Shift");

await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toContain("Walk the dog");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});

test("should extend existing drag selection with shift+click", async ({ reactGrab }) => {
Expand Down Expand Up @@ -630,8 +619,6 @@ test.describe("Shift Multi-Select", () => {

await reactGrab.page.keyboard.up("Shift");

await expect.poll(() => reactGrab.getClipboardContent()).toContain("Buy groceries");
const clipboardContent = await reactGrab.getClipboardContent();
expect(clipboardContent).toContain("Write tests");
await expect.poll(() => reactGrab.getClipboardContent()).toContain("TodoItem");
});
});
3 changes: 3 additions & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [
"title",
];

export const COMPACT_IDENTIFYING_ATTRS: readonly string[] = ["id", "data-testid"];
export const COMPACT_TEXT_MAX_LENGTH = 30;

export const PREVIEW_IDENTIFYING_ATTRS = new Set([
"id",
"data-testid",
Expand Down
Loading
Loading