Repository: aidenybai/react-grab Branch: main Commit: 30fc8d321fc5 Files: 499 Total size: 2.2 MB Directory structure: gitextract_xtlqk229/ ├── .changeset/ │ ├── README.md │ └── config.json ├── .github/ │ └── workflows/ │ ├── code-quality.yml │ ├── publish-any-commit.yml │ ├── pullfrog.yml │ ├── test-build.yml │ ├── test-cli.yml │ └── test-e2e.yml ├── .gitignore ├── .oxfmtrc.json ├── AGENTS.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── packages/ │ ├── cli/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── commands/ │ │ │ │ ├── add.ts │ │ │ │ ├── configure.ts │ │ │ │ ├── init.ts │ │ │ │ └── remove.ts │ │ │ └── utils/ │ │ │ ├── cli-helpers.ts │ │ │ ├── constants.ts │ │ │ ├── detect.ts │ │ │ ├── diff.ts │ │ │ ├── handle-error.ts │ │ │ ├── highlighter.ts │ │ │ ├── install-mcp.ts │ │ │ ├── install.ts │ │ │ ├── is-non-interactive.ts │ │ │ ├── logger.ts │ │ │ ├── prompts.ts │ │ │ ├── spinner.ts │ │ │ ├── templates.ts │ │ │ └── transform.ts │ │ ├── test/ │ │ │ ├── configure.test.ts │ │ │ ├── detect.test.ts │ │ │ ├── diff.test.ts │ │ │ ├── install-mcp.test.ts │ │ │ ├── install.test.ts │ │ │ ├── templates.test.ts │ │ │ └── transform.test.ts │ │ ├── tsconfig.json │ │ ├── tsup.config.ts │ │ └── vitest.config.ts │ ├── design-system/ │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── e2e-playground/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ ├── index.css │ │ │ └── main.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── grab/ │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── bin/ │ │ │ └── cli.js │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── build.js │ │ └── tsconfig.json │ ├── gym/ │ │ ├── .gitignore │ │ ├── app/ │ │ │ ├── api/ │ │ │ │ └── provider/ │ │ │ │ └── [name]/ │ │ │ │ └── route.ts │ │ │ ├── dashboard/ │ │ │ │ ├── data.json │ │ │ │ └── page.tsx │ │ │ ├── freeze-demo/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── login/ │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ └── playground/ │ │ │ └── page.tsx │ │ ├── components/ │ │ │ ├── agent-playground.tsx │ │ │ ├── app-sidebar.tsx │ │ │ ├── chart-area-interactive.tsx │ │ │ ├── counter.tsx │ │ │ ├── data-table.tsx │ │ │ ├── login-form.tsx │ │ │ ├── nav-user.tsx │ │ │ ├── search-form.tsx │ │ │ ├── section-cards.tsx │ │ │ ├── sheet-demo.tsx │ │ │ ├── site-header.tsx │ │ │ ├── theme-provider.tsx │ │ │ ├── theme-toggle.tsx │ │ │ ├── ui/ │ │ │ │ ├── avatar.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── chart.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── drawer.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── field.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── sheet.tsx │ │ │ │ ├── sidebar.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── toggle-group.tsx │ │ │ │ ├── toggle.tsx │ │ │ │ └── tooltip.tsx │ │ │ └── version-switcher.tsx │ │ ├── components.json │ │ ├── hooks/ │ │ │ └── use-mobile.ts │ │ ├── instrumentation-client.ts │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── next-env.d.ts │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── scripts/ │ │ │ └── start-all-servers.js │ │ └── tsconfig.json │ ├── mcp/ │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── client.ts │ │ │ ├── constants.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── provider-amp/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── client.ts │ │ │ ├── handler.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── provider-claude-code/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── client.ts │ │ │ ├── handler.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── provider-codex/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── client.ts │ │ │ ├── handler.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── provider-copilot/ │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── client.ts │ │ │ ├── handler.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── provider-cursor/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── client.ts │ │ │ ├── handler.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── provider-droid/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── client.ts │ │ │ ├── handler.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── provider-gemini/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── client.ts │ │ │ ├── handler.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── provider-opencode/ │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── cli.ts │ │ │ ├── client.ts │ │ │ ├── constants.ts │ │ │ ├── handler.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── react-grab/ │ │ ├── .oxlintrc.json │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── bin/ │ │ │ └── cli.js │ │ ├── e2e/ │ │ │ ├── activation-key-config.spec.ts │ │ │ ├── activation.spec.ts │ │ │ ├── agent-integration.spec.ts │ │ │ ├── agent-resume-race.spec.ts │ │ │ ├── api-methods.spec.ts │ │ │ ├── clear-history-prompt.spec.ts │ │ │ ├── context-menu.spec.ts │ │ │ ├── copy-feedback.spec.ts │ │ │ ├── copy-styles.spec.ts │ │ │ ├── disabled-elements.spec.ts │ │ │ ├── drag-selection.spec.ts │ │ │ ├── edge-cases.spec.ts │ │ │ ├── element-context.spec.ts │ │ │ ├── event-callbacks.spec.ts │ │ │ ├── fixtures.ts │ │ │ ├── focus-trap.spec.ts │ │ │ ├── freeze-animations.spec.ts │ │ │ ├── freeze-updates.spec.ts │ │ │ ├── history-items.spec.ts │ │ │ ├── history-reacquire.spec.ts │ │ │ ├── hold-activation.spec.ts │ │ │ ├── input-mode.spec.ts │ │ │ ├── keyboard-navigation.spec.ts │ │ │ ├── keyboard-shortcuts.spec.ts │ │ │ ├── open-file.spec.ts │ │ │ ├── overlay-filtering.spec.ts │ │ │ ├── prompt-mode.spec.ts │ │ │ ├── selection.spec.ts │ │ │ ├── ssr.spec.ts │ │ │ ├── theme-customization.spec.ts │ │ │ ├── toggle-position-stability.spec.ts │ │ │ ├── toolbar-menu.spec.ts │ │ │ ├── toolbar-selection-hover.spec.ts │ │ │ ├── toolbar.spec.ts │ │ │ ├── touch-mode.spec.ts │ │ │ ├── viewport.spec.ts │ │ │ └── visual-feedback.spec.ts │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── scripts/ │ │ │ ├── css-rem-to-px.mjs │ │ │ └── postinstall.cjs │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── clear-history-prompt.tsx │ │ │ │ ├── context-menu.tsx │ │ │ │ ├── history-dropdown.tsx │ │ │ │ ├── icons/ │ │ │ │ │ ├── icon-check.tsx │ │ │ │ │ ├── icon-chevron.tsx │ │ │ │ │ ├── icon-clock.tsx │ │ │ │ │ ├── icon-copy.tsx │ │ │ │ │ ├── icon-ellipsis.tsx │ │ │ │ │ ├── icon-loader.tsx │ │ │ │ │ ├── icon-open.tsx │ │ │ │ │ ├── icon-reply.tsx │ │ │ │ │ ├── icon-retry.tsx │ │ │ │ │ ├── icon-return.tsx │ │ │ │ │ ├── icon-select.tsx │ │ │ │ │ ├── icon-submit.tsx │ │ │ │ │ └── icon-trash.tsx │ │ │ │ ├── kbd.tsx │ │ │ │ ├── overlay-canvas.tsx │ │ │ │ ├── renderer.tsx │ │ │ │ ├── selection-label/ │ │ │ │ │ ├── arrow-navigation-menu.tsx │ │ │ │ │ ├── arrow.tsx │ │ │ │ │ ├── bottom-section.tsx │ │ │ │ │ ├── completion-view.tsx │ │ │ │ │ ├── discard-prompt.tsx │ │ │ │ │ ├── error-view.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── tag-badge.tsx │ │ │ │ ├── toolbar/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── state.ts │ │ │ │ │ ├── toolbar-content.tsx │ │ │ │ │ └── toolbar-menu.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── constants.ts │ │ │ ├── core/ │ │ │ │ ├── agent/ │ │ │ │ │ ├── manager.ts │ │ │ │ │ └── session.ts │ │ │ │ ├── arrow-navigation.ts │ │ │ │ ├── auto-scroll.ts │ │ │ │ ├── context.ts │ │ │ │ ├── copy.ts │ │ │ │ ├── events.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── keyboard-handlers.ts │ │ │ │ ├── log-intro.ts │ │ │ │ ├── logo-svg.ts │ │ │ │ ├── noop-api.ts │ │ │ │ ├── plugin-registry.ts │ │ │ │ ├── plugins/ │ │ │ │ │ ├── comment.ts │ │ │ │ │ ├── copy-html.ts │ │ │ │ │ ├── copy-styles.ts │ │ │ │ │ ├── copy.ts │ │ │ │ │ ├── create-pending-selection-plugin.ts │ │ │ │ │ └── open.ts │ │ │ │ ├── store.ts │ │ │ │ └── theme.ts │ │ │ ├── index.ts │ │ │ ├── primitives.ts │ │ │ ├── styles.css │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── append-stack-context.ts │ │ │ ├── auto-resize-textarea.ts │ │ │ ├── clamp-to-viewport.ts │ │ │ ├── cn.ts │ │ │ ├── combine-bounds.ts │ │ │ ├── confirmation-focus-manager.ts │ │ │ ├── copy-content.ts │ │ │ ├── create-anchored-dropdown.ts │ │ │ ├── create-bounds-from-drag-rect.ts │ │ │ ├── create-element-bounds.ts │ │ │ ├── create-element-selector.ts │ │ │ ├── create-menu-highlight.ts │ │ │ ├── create-style-element.ts │ │ │ ├── create-toolbar-drag.ts │ │ │ ├── extract-element-css.ts │ │ │ ├── format-relative-time.ts │ │ │ ├── format-shortcut.ts │ │ │ ├── freeze-animations.ts │ │ │ ├── freeze-gsap.ts │ │ │ ├── freeze-pseudo-states.ts │ │ │ ├── freeze-updates.ts │ │ │ ├── generate-id.ts │ │ │ ├── generate-snippet.ts │ │ │ ├── get-anchored-dropdown-position.ts │ │ │ ├── get-arrow-size.ts │ │ │ ├── get-bounds-center.ts │ │ │ ├── get-element-at-position.ts │ │ │ ├── get-element-center.ts │ │ │ ├── get-elements-in-drag.ts │ │ │ ├── get-next-base-path.ts │ │ │ ├── get-script-options.ts │ │ │ ├── get-tag-display.ts │ │ │ ├── get-tag-name.ts │ │ │ ├── get-visible-bounds-center.ts │ │ │ ├── get-visual-viewport.ts │ │ │ ├── history-storage.ts │ │ │ ├── invalidate-interaction-caches.ts │ │ │ ├── is-c-like-key.ts │ │ │ ├── is-element-connected.ts │ │ │ ├── is-element-visible.ts │ │ │ ├── is-enter-code.ts │ │ │ ├── is-event-from-overlay.ts │ │ │ ├── is-extension-context.ts │ │ │ ├── is-keyboard-event-triggered-by-input.ts │ │ │ ├── is-mac.ts │ │ │ ├── is-root-element.ts │ │ │ ├── is-target-key-combination.ts │ │ │ ├── is-valid-grabbable-element.ts │ │ │ ├── join-snippets.ts │ │ │ ├── key-matches-code.ts │ │ │ ├── lerp.ts │ │ │ ├── log-recoverable-error.ts │ │ │ ├── mount-root.ts │ │ │ ├── native-raf.ts │ │ │ ├── normalize-error.ts │ │ │ ├── on-idle.ts │ │ │ ├── open-file.ts │ │ │ ├── overlay-color.ts │ │ │ ├── parse-activation-key.ts │ │ │ ├── recalculate-session-position.ts │ │ │ ├── register-overlay-dismiss.ts │ │ │ ├── resolve-action-enabled.ts │ │ │ ├── safe-polygon.ts │ │ │ ├── strip-translate-from-transform.ts │ │ │ ├── supports-display-p3.ts │ │ │ ├── suppress-menu-event.ts │ │ │ ├── toolbar-layout.ts │ │ │ ├── toolbar-position.ts │ │ │ └── truncate-string.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── relay/ │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── client.ts │ │ │ ├── connection.ts │ │ │ ├── index.ts │ │ │ ├── protocol.ts │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── shadcn-registry/ │ │ ├── package.json │ │ ├── r/ │ │ │ └── react-grab.json │ │ ├── registry/ │ │ │ └── react-grab.tsx │ │ ├── registry.json │ │ └── scripts/ │ │ └── build.js │ ├── utils/ │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── server.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── web-extension/ │ │ ├── .gitignore │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── package.sh │ │ ├── src/ │ │ │ ├── background/ │ │ │ │ └── service-worker.ts │ │ │ ├── constants.ts │ │ │ ├── content/ │ │ │ │ ├── bridge.ts │ │ │ │ └── react-grab.ts │ │ │ └── manifest.json │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── website/ │ ├── .gitignore │ ├── .oxlintrc.json │ ├── AGENTS.md │ ├── app/ │ │ ├── api/ │ │ │ ├── og/ │ │ │ │ └── route.tsx │ │ │ ├── report-cli/ │ │ │ │ └── route.ts │ │ │ └── version/ │ │ │ └── route.ts │ │ ├── blog/ │ │ │ ├── 1-0/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── agent/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── bets/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── intro/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── changelog/ │ │ │ └── page.tsx │ │ ├── design-system/ │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ ├── open-file/ │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── privacy/ │ │ │ └── page.tsx │ │ ├── robots.ts │ │ └── sitemap.ts │ ├── components/ │ │ ├── benchmark-tooltip.tsx │ │ ├── benchmarks/ │ │ │ ├── benchmark-charts.tsx │ │ │ ├── benchmark-detailed-table.tsx │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── blocks/ │ │ │ ├── grep-search-group.tsx │ │ │ ├── grep-tool-call-block.tsx │ │ │ ├── message-block.tsx │ │ │ ├── read-tool-call-block.tsx │ │ │ ├── streaming-text.tsx │ │ │ └── thought-block.tsx │ │ ├── blog-article-layout.tsx │ │ ├── demo-footer.tsx │ │ ├── github-button.tsx │ │ ├── grab-element-button.tsx │ │ ├── homepage-demo.tsx │ │ ├── hotkey-context.tsx │ │ ├── icons/ │ │ │ ├── icon-claude.tsx │ │ │ ├── icon-codex.tsx │ │ │ ├── icon-copilot.tsx │ │ │ ├── icon-cursor.tsx │ │ │ ├── icon-droid.tsx │ │ │ ├── icon-github.tsx │ │ │ ├── icon-nextjs.tsx │ │ │ ├── icon-opencode.tsx │ │ │ ├── icon-tanstack.tsx │ │ │ ├── icon-vite.tsx │ │ │ ├── icon-vscode.tsx │ │ │ ├── icon-webstorm.tsx │ │ │ └── icon-zed.tsx │ │ ├── install-tabs.tsx │ │ ├── mobile-demo-animation.tsx │ │ ├── react-grab-logo.tsx │ │ ├── table-of-contents.tsx │ │ ├── ui/ │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── data-table-card.tsx │ │ │ └── scrollable.tsx │ │ ├── user-message.tsx │ │ └── view-docs-button.tsx │ ├── components.json │ ├── constants.ts │ ├── hooks/ │ │ └── use-stream.ts │ ├── instrumentation-client.ts │ ├── lib/ │ │ ├── api-helpers.ts │ │ └── shiki.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public/ │ │ ├── agent.webm │ │ ├── demo.webm │ │ ├── install.md │ │ ├── llms.txt │ │ ├── r/ │ │ │ ├── index.json │ │ │ └── react-grab.json │ │ ├── results.json │ │ └── test-cases.json │ ├── tsconfig.json │ └── utils/ │ ├── cn.ts │ ├── detect-mobile.ts │ ├── get-key-from-code.ts │ ├── hotkey-to-string.ts │ └── parse-changelog.ts ├── pnpm-workspace.yaml ├── turbo.json └── vercel.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .changeset/README.md ================================================ # Changesets Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) ================================================ FILE: .changeset/config.json ================================================ { "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], "linked": [ [ "react-grab", "grab", "@react-grab/claude-code", "@react-grab/cursor", "@react-grab/opencode", "@react-grab/codex", "@react-grab/gemini", "@react-grab/amp", "@react-grab/droid", "@react-grab/copilot", "@react-grab/cli", "@react-grab/utils", "@react-grab/relay" ] ], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ "@react-grab/website", "@react-grab/web-extension", "@react-grab/gym", "@react-grab/design-system" ] } ================================================ FILE: .github/workflows/code-quality.yml ================================================ name: Code Quality on: push: branches: [main] pull_request: branches: [main] jobs: lint: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install - name: Lint run: pnpm lint typecheck: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install - name: Typecheck run: pnpm typecheck ================================================ FILE: .github/workflows/publish-any-commit.yml ================================================ name: Publish Any Commit on: [push, pull_request] permissions: {} jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - run: corepack enable - uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install - name: Build run: pnpm build - run: | pnpm dlx pkg-pr-new publish \ ./packages/react-grab \ ./packages/cli \ ./packages/grab \ ./packages/relay \ ./packages/utils \ ./packages/provider-amp \ ./packages/provider-claude-code \ ./packages/provider-codex \ ./packages/provider-cursor \ ./packages/provider-copilot \ ./packages/provider-droid \ ./packages/provider-gemini \ ./packages/provider-opencode ================================================ FILE: .github/workflows/pullfrog.yml ================================================ # PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED name: Pullfrog run-name: ${{ inputs.name || github.workflow }} on: workflow_dispatch: inputs: prompt: type: string description: Agent prompt name: type: string description: Run name permissions: id-token: write contents: write pull-requests: write issues: write actions: read checks: read jobs: pullfrog: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 with: fetch-depth: 1 - name: Run agent uses: pullfrog/pullfrog@v0 with: prompt: ${{ inputs.prompt }} env: # add any additional keys your agent(s) need # optionally, comment out any you won't use ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} ================================================ FILE: .github/workflows/test-build.yml ================================================ name: Test Build on: push: branches: [main] pull_request: branches: [main] jobs: test-build: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install - name: Build run: pnpm build ================================================ FILE: .github/workflows/test-cli.yml ================================================ name: Test CLI on: push: branches: [main] pull_request: branches: [main] jobs: test-cli: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install - name: Build run: pnpm build - name: Run CLI tests run: pnpm --filter @react-grab/cli test ================================================ FILE: .github/workflows/test-e2e.yml ================================================ name: Test E2E on: push: branches: [main] pull_request: branches: [main] jobs: test-e2e: runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false matrix: shardIndex: [1, 2, 3, 4] shardTotal: [4] steps: - name: Checkout uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install - name: Install Playwright browsers run: pnpm --filter react-grab exec playwright install chromium --with-deps - name: Build run: pnpm build - name: Run E2E tests run: pnpm --filter react-grab exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} ================================================ FILE: .gitignore ================================================ node_modules .DS_Store .env .turbo dist .next **/*.tgz coverage react-grab-extension.zip tsup.config.bundled_*.mjs packages/website/public/script.js test-results playwright-report .vercel .cursor/debug.log .cursor meta.json ================================================ FILE: .oxfmtrc.json ================================================ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "tabWidth": 2, "singleQuote": false, "printWidth": 80, "ignorePatterns": [ ".next", "node_modules", "dist", "build", ".turbo", "pnpm-lock.yaml" ] } ================================================ FILE: AGENTS.md ================================================ ## General Rules - MUST: Use @antfu/ni. Use `ni` to install, `nr SCRIPT_NAME` to run. `nun` to uninstall. - MUST: Use TypeScript interfaces over types. - MUST: Keep all types in the global scope. - MUST: Use arrow functions over function declarations - MUST: Never comment unless absolutely necessary. - If the code is a hack (like a setTimeout or potentially confusing code), it must be prefixed with // HACK: reason for hack - Do not delete descriptive comments >3 lines without confirming with the user - MUST: Use kebab-case for files - MUST: Use descriptive names for variables (avoid shorthands, or 1-2 character names). - Example: for .map(), you can use `innerX` instead of `x` - Example: instead of `moved` use `didPositionChange` - MUST: Frequently re-evaluate and refactor variable names to be more accurate and descriptive. - MUST: Do not type cast ("as") unless absolutely necessary - MUST: Remove unused code and don't repeat yourself. - MUST: Always search the codebase, think of many solutions, then implement the most _elegant_ solution. - MUST: Put all magic numbers in `constants.ts` using `SCREAMING_SNAKE_CASE` with unit suffixes (`_MS`, `_PX`). - MUST: Put small, focused utility functions in `utils/` with one utility per file. - MUST: Use Boolean over !!. ## SolidJS Rules ### Mental Model - MUST: Treat components as setup functions that run ONCE, not render functions. - MUST: Place reactive work in primitives (`createMemo`, `createEffect`, ``, ``), not component body. - MUST: Access signals only inside reactive contexts (JSX expressions, effects, memos). ### Reactivity - MUST: Call signals as functions: `count()` not `count`. - MUST: Use functional updates when new state depends on old: `setCount((prev) => prev + 1)`. - MUST: Keep signals atomic (one per value) — one big state object loses granularity. - MUST: Use derived functions `() => count() * 2` for cheap/infrequent derivations. - MUST: Use `createMemo(() => ...)` for expensive/frequent derivations — caches result. - MUST: Use `createEffect` for side effects only (DOM, localStorage, subscriptions). - MUST: Call `onCleanup(() => ...)` inside effects for subscriptions/intervals/listeners. - MUST: Use path syntax for store updates: `setStore("users", 0, "name", "Jane")`. - MUST: Wrap store props in arrow for `on()`: `on(() => store.value, fn)` not `on(store.value, fn)`. - SHOULD: Use `{ equals: false }` for trigger signals that always notify. - SHOULD: Use `batch(() => { ... })` when updating multiple signals outside event handlers. - SHOULD: Use `on(dep, fn)` for explicit effect dependencies. - SHOULD: Use `untrack(() => value())` to read without subscribing. - SHOULD: Use `createStore({ ... })` for nested objects with fine-grained reactivity. - SHOULD: Use `produce(draft => { ... })` for complex store mutations. - NEVER: Derive state via `createEffect(() => setX(y()))` — use memo or derived function. - NEVER: Place side effects inside `createMemo` — causes infinite loops/crashes. ### Props - MUST: Access props via `props.title`, not destructuring. - SHOULD: Wrap in getter if needed: `const title = () => props.title`. - SHOULD: Use `splitProps(props, ["keys"])` to separate local from pass-through props. - SHOULD: Use `mergeProps(defaults, props)` for default values. - SHOULD: Use `children(() => props.children)` only when transforming, otherwise `{props.children}`. - NEVER: Destructure props `({ title })` — breaks reactivity. ### Control Flow - MUST: Use `` for object arrays — item is value, index is signal. - MUST: Use `` for primitives/inputs — item is signal, index is number. - MUST: Use `` for async, not ``. - MUST: Access resource states via `data()`, `data.loading`, `data.error`, `data.latest`. - SHOULD: Use `` for conditionals. - SHOULD: Use `` callback for type narrowing: `{(v) =>
{v().name}
}`. - SHOULD: Use `/` for multiple conditions. - SHOULD: Use `createResource(source, fetcher)` for reactive async data. - SHOULD: Use ` ...}>` for render errors. - NEVER: Use `.map()` in JSX — use `` or ``. - NEVER: Rely on ErrorBoundary for event handler or setTimeout errors — use try/catch. ### JSX & DOM - MUST: Use `class` not `className`. - MUST: Combine static `class="btn"` with reactive `classList={{ active: isActive() }}`. - MUST: Use `onClick` for delegated events; `on:click` for native (element-level). - MUST: Condition inside handler since events are not reactive: `onClick={() => props.onClick?.()}`. - MUST: Read refs in `onMount` or effects — refs connect after render. - MUST: Call `onCleanup` inside directives for cleanup. - SHOULD: Use `on:click` for `stopPropagation`, capture, passive, or custom events. - SHOULD: Use `style={{ color: color(), "--css-var": value() }}` for inline styles. - SHOULD: Type refs as `let el: HTMLElement | undefined` with guard. - SHOULD: Use `use:directiveName={accessor}` for reusable DOM behaviors. - NEVER: Mix reactive `class={x()}` with `classList`. ## Testing Run dev `packages/cli` with: ```bash npm_command=exec node packages/cli/dist/cli.js ``` Run checks always before committing with: ```bash pnpm test # runs e2e tests pnpm lint pnpm typecheck # runs type checking pnpm format ``` ## Development instructions This is a pnpm + Turborepo monorepo (19 packages under `packages/`). No external services (databases, Docker, etc.) are required. ### Build before test `pnpm build` must complete before `pnpm test` or `pnpm lint` — Turborepo `dependsOn` enforces this, but be aware that `pnpm test` will rebuild if the build cache is cold. After modifying source files, always rebuild before running tests. ### Approved build scripts The root `package.json` has `pnpm.onlyBuiltDependencies` configured for `@parcel/watcher`, `esbuild`, `sharp`, `spawn-sync`, and `unrs-resolver`. Without this, `pnpm install` silently skips their native builds and downstream packages may fail. ### Playwright E2E tests (`pnpm test` at root) run Playwright against the `e2e-playground` Vite dev server on port 5175 (auto-started by the Playwright config). Chromium must be installed: `npx --prefix packages/react-grab playwright install chromium --with-deps`. ### Key commands reference See root `package.json` scripts and `CONTRIBUTING.md` for the full list. Quick reference: - **Install**: `ni` (or `pnpm install`) - **Build**: `nr build` (or `pnpm build`) - **Dev watch**: `nr dev` (or `pnpm dev`) — watches core packages - **Test**: `pnpm test` — runs Playwright E2E + Vitest CLI tests - **Lint**: `pnpm lint` — oxlint on react-grab package - **Typecheck**: `pnpm typecheck` — tsc on react-grab package - **Format**: `pnpm format` — oxfmt - **CLI dev**: `npm_command=exec node packages/cli/dist/cli.js` - **E2E playground**: `pnpm --filter @react-grab/e2e-playground dev` (port 5175) ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to React Grab Thanks for your interest in contributing to React Grab! This document provides guidelines and instructions for contributing. ## Getting Started ### Prerequisites - Node.js >= 18 - pnpm >= 8 ### Setup 1. Fork and clone the repository: ```bash git clone https://github.com/YOUR_USERNAME/react-grab.git cd react-grab ``` 2. Install dependencies using [@antfu/ni](https://github.com/antfu/ni): ```bash ni ``` 3. Build all packages: ```bash nr build ``` 4. Start development mode: ```bash nr dev ``` ## Project Structure ``` packages/ ├── react-grab/ # Core library ├── grab/ # Bundled package (library + CLI, published as `grab`) ├── cli/ # CLI implementation (@react-grab/cli) ├── provider-cursor/ # Cursor agent integration ├── provider-claude-code/ # Claude Code integration ├── provider-opencode/ # OpenCode integration ├── provider-codex/ # OpenAI Codex integration ├── provider-gemini/ # Google Gemini CLI integration ├── provider-amp/ # Amp SDK integration ├── provider-droid/ # Droid integration ├── provider-copilot/ # Copilot integration ├── website/ # Documentation site (react-grab.com) ├── e2e-playground/ # E2E test target app ├── gym/ # Agent testing playground └── web-extension/ # Browser extension ``` ## Development Workflow ### Running the Gym Test agent provider integrations in the gym: ```bash pnpm --filter @react-grab/gym dev:claude # Claude Code pnpm --filter @react-grab/gym dev:cursor # Cursor pnpm --filter @react-grab/gym dev:opencode # OpenCode pnpm --filter @react-grab/gym dev:codex # Codex pnpm --filter @react-grab/gym dev:gemini # Gemini pnpm --filter @react-grab/gym dev:amp # Amp pnpm --filter @react-grab/gym dev:droid # Droid pnpm --filter @react-grab/gym dev:copilot # Copilot ``` The gym runs at `http://localhost:5174` and lets you test react-grab's agent provider API with multiple backends. ### Running Tests ```bash # Run CLI tests pnpm --filter @react-grab/cli test ``` ### Linting & Formatting ```bash nr lint # Check for lint errors nr lint:fix # Fix lint errors nr format # Format code with oxfmt ``` ## Code Style - **Use TypeScript interfaces** over types - **Use arrow functions** over function declarations - **Use kebab-case** for file names - **Use descriptive variable names** — avoid shorthands or 1-2 character names - Example: `innerElement` instead of `el` - Example: `didPositionChange` instead of `moved` - **Avoid type casting** (`as`) unless absolutely necessary - **Keep interfaces/types** at the global scope - **Remove unused code** and follow DRY principles - **Avoid comments** unless absolutely necessary - If a hack is required, prefix with `// HACK: reason for hack` ## Submitting Changes ### Creating a Pull Request 1. Create a new branch: ```bash git checkout -b feat/your-feature-name ``` 2. Make your changes and commit with a descriptive message: ```bash git commit -m "feat: add new feature" ``` 3. Push to your fork and open a pull request ### Commit Convention We use conventional commits: - `feat:` — New feature - `fix:` — Bug fix - `docs:` — Documentation changes - `chore:` — Maintenance tasks - `refactor:` — Code refactoring - `test:` — Test additions or changes ### Adding a Changeset For changes that affect published packages, add a changeset: ```bash nr changeset ``` Follow the prompts to describe your changes. This helps maintain accurate changelogs. ## Reporting Issues Found a bug? Have a feature request? [Open an issue](https://github.com/aidenybai/react-grab/issues) with: - Clear description of the problem or request - Steps to reproduce (for bugs) - Expected vs actual behavior - Environment details (OS, browser, Node version) ## Community - Join our [Discord](https://discord.com/invite/G7zxfUzkm7) to discuss ideas and get help - Check existing [issues](https://github.com/aidenybai/react-grab/issues) before opening new ones ## License By contributing, you agree that your contributions will be licensed under the MIT License. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Aiden Bai Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # React Grab [![size](https://img.shields.io/bundlephobia/minzip/react-grab?label=gzip&style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/package/react-grab) [![version](https://img.shields.io/npm/v/react-grab?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-grab) [![downloads](https://img.shields.io/npm/dt/react-grab.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-grab) Select context for coding agents directly from your website How? Point at any element and press **⌘C** (Mac) or **Ctrl+C** (Windows/Linux) to copy the file name, React component, and HTML source code. It makes tools like Cursor, Claude Code, Copilot run up to [**3× faster**](https://react-grab.com/blog/intro) and more accurate. ### [Try out a demo! →](https://react-grab.com) ![React Grab Demo](https://github.com/aidenybai/react-grab/blob/main/packages/website/public/demo.gif?raw=true) ## Install Run this command at your project root (where `next.config.ts` or `vite.config.ts` is located): ```bash npx -y grab@latest init ``` ## Connect to MCP ```bash npx -y grab@latest add mcp ``` ## Usage Once installed, hover over any UI element in your browser and press: - **⌘C** (Cmd+C) on Mac - **Ctrl+C** on Windows/Linux This copies the element's context (file name, React component, and HTML source code) to your clipboard ready to paste into your coding agent. For example: ```js Forgot your password? in LoginForm at components/login-form.tsx:46:19 ``` ## Manual Installation If you're using a React framework or build tool, view instructions below: #### Next.js (App router) Add this inside of your `app/layout.tsx`: ```jsx import Script from "next/script"; export default function RootLayout({ children }) { return ( {process.env.NODE_ENV === "development" && (
`; mockExistsSync.mockImplementation((path) => { const pathStr = String(path); return pathStr.endsWith("index.html") || pathStr.endsWith("main.tsx"); }); mockReadFileSync.mockImplementation((path) => { if (String(path).endsWith("index.html")) return indexWithReactGrab; return `import React from "react";`; }); const result = previewTransform("/test", "vite", "unknown", "none", false); expect(result.success).toBe(true); expect(result.noChanges).toBe(true); }); it("should add agent to existing React Grab in index.html", () => { const indexWithReactGrab = `
`; mockExistsSync.mockImplementation((path) => { const pathStr = String(path); return pathStr.endsWith("index.html") || pathStr.endsWith("main.tsx"); }); mockReadFileSync.mockImplementation((path) => { if (String(path).endsWith("index.html")) return indexWithReactGrab; return `import React from "react";`; }); const result = previewTransform("/test", "vite", "unknown", "cursor", true); expect(result.success).toBe(true); expect(result.newContent).toContain("@react-grab/cursor"); expect(result.filePath).toContain("index.html"); }); }); describe("previewTransform - Webpack edge cases", () => { it("should fail when entry file not found", () => { mockExistsSync.mockReturnValue(false); const result = previewTransform( "/test", "webpack", "unknown", "none", false, ); expect(result.success).toBe(false); expect(result.message).toContain("Could not find entry file"); }); it("should add agent to existing Webpack installation", () => { const entryWithReactGrab = `if (process.env.NODE_ENV === "development") { import("react-grab"); } import React from "react"; import ReactDOM from "react-dom/client";`; mockExistsSync.mockImplementation((path) => String(path).endsWith("index.tsx"), ); mockReadFileSync.mockReturnValue(entryWithReactGrab); const result = previewTransform( "/test", "webpack", "unknown", "opencode", true, ); expect(result.success).toBe(true); expect(result.newContent).toContain("@react-grab/opencode"); }); }); describe("previewTransform - Unknown framework", () => { it("should fail for unknown framework", () => { const result = previewTransform( "/test", "unknown", "unknown", "none", false, ); expect(result.success).toBe(false); expect(result.message).toContain("Unknown framework"); }); }); describe("applyTransform", () => { it("should write file when result has newContent and file is writable", () => { mockAccessSync.mockImplementation(() => undefined); const result = { success: true, filePath: "/test/file.tsx", message: "Test", originalContent: "old", newContent: "new", }; const writeResult = applyTransform(result); expect(writeResult.success).toBe(true); expect(mockWriteFileSync).toHaveBeenCalledWith("/test/file.tsx", "new"); }); it("should return error when file is not writable", () => { mockAccessSync.mockImplementation(() => { throw new Error("EACCES"); }); const result = { success: true, filePath: "/test/file.tsx", message: "Test", originalContent: "old", newContent: "new", }; const writeResult = applyTransform(result); expect(writeResult.success).toBe(false); expect(writeResult.error).toContain("Cannot write to"); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); it("should not write file when result has no newContent", () => { const result = { success: true, filePath: "/test/file.tsx", message: "Test", noChanges: true, }; const writeResult = applyTransform(result); expect(writeResult.success).toBe(true); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); it("should not write file when result is not successful", () => { const result = { success: false, filePath: "", message: "Error", }; const writeResult = applyTransform(result); expect(writeResult.success).toBe(true); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); it("should return error when writeFileSync throws", () => { mockAccessSync.mockImplementation(() => undefined); mockWriteFileSync.mockImplementation(() => { throw new Error("Disk full"); }); const result = { success: true, filePath: "/test/file.tsx", message: "Test", originalContent: "old", newContent: "new", }; const writeResult = applyTransform(result); expect(writeResult.success).toBe(false); expect(writeResult.error).toContain("Failed to write to"); expect(writeResult.error).toContain("Disk full"); }); it("should not write when filePath is empty", () => { const result = { success: true, filePath: "", message: "Test", originalContent: "old", newContent: "new", }; const writeResult = applyTransform(result); expect(writeResult.success).toBe(true); expect(mockWriteFileSync).not.toHaveBeenCalled(); }); }); describe("previewPackageJsonTransform", () => { const packageJsonContent = JSON.stringify( { name: "my-app", scripts: { dev: "next dev --turbopack", build: "next build", }, }, null, 2, ); it("should add agent prefix to dev script", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonContent); const result = previewPackageJsonTransform("/test", "cursor", []); expect(result.success).toBe(true); expect(result.newContent).toContain("npx @react-grab/cursor@latest &&"); expect(result.newContent).toContain("next dev --turbopack"); }); it("should add claude-code prefix to dev script", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonContent); const result = previewPackageJsonTransform("/test", "claude-code", []); expect(result.success).toBe(true); expect(result.newContent).toContain( "npx @react-grab/claude-code@latest &&", ); }); it("should add opencode prefix to dev script", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonContent); const result = previewPackageJsonTransform("/test", "opencode", []); expect(result.success).toBe(true); expect(result.newContent).toContain("npx @react-grab/opencode@latest &&"); }); it("should skip when agent is none", () => { const result = previewPackageJsonTransform("/test", "none", []); expect(result.success).toBe(true); expect(result.noChanges).toBe(true); }); it("should skip package.json when agent is mcp", () => { const result = previewPackageJsonTransform("/test", "mcp", []); expect(result.success).toBe(true); expect(result.noChanges).toBe(true); expect(result.message).toContain("MCP"); }); it("should not duplicate if agent is already configured", () => { const packageJsonWithAgent = JSON.stringify( { name: "my-app", scripts: { dev: "npx @react-grab/cursor@latest && next dev --turbopack", }, }, null, 2, ); mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonWithAgent); const result = previewPackageJsonTransform("/test", "cursor", []); expect(result.success).toBe(true); expect(result.noChanges).toBe(true); }); it("should not add another agent if one is already installed", () => { const packageJsonWithAgent = JSON.stringify( { name: "my-app", scripts: { dev: "npx @react-grab/claude-code@latest && next dev", }, }, null, 2, ); mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonWithAgent); const result = previewPackageJsonTransform("/test", "cursor", [ "claude-code", ]); expect(result.success).toBe(true); expect(result.noChanges).toBe(true); }); it("should fail when package.json not found", () => { mockExistsSync.mockReturnValue(false); const result = previewPackageJsonTransform("/test", "cursor", []); expect(result.success).toBe(false); expect(result.message).toContain("Could not find package.json"); }); it("should return warning when no dev script exists", () => { const packageJsonNoDev = JSON.stringify( { name: "my-app", scripts: { build: "next build", }, }, null, 2, ); mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonNoDev); const result = previewPackageJsonTransform("/test", "cursor", []); expect(result.success).toBe(true); expect(result.noChanges).toBe(true); expect(result.warning).toContain( "Could not inject agent into package.json", ); expect(result.warning).toContain("npx @react-grab/cursor@latest"); }); it("should use dev* script when no exact dev script exists", () => { const packageJsonDevVariant = JSON.stringify( { name: "my-app", scripts: { "dev:server": "next dev", build: "next build", }, }, null, 2, ); mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonDevVariant); const result = previewPackageJsonTransform("/test", "cursor", []); expect(result.success).toBe(true); expect(result.newContent).toContain("npx @react-grab/cursor@latest &&"); expect(result.newContent).toContain('"dev:server"'); expect(result.message).toContain("dev:server"); }); it("should fail when package.json is invalid JSON", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue("{ invalid json }"); const result = previewPackageJsonTransform("/test", "cursor", []); expect(result.success).toBe(false); expect(result.message).toContain("Failed to parse package.json"); }); it("should use bunx for bun package manager", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonContent); const result = previewPackageJsonTransform("/test", "cursor", [], "bun"); expect(result.success).toBe(true); expect(result.newContent).toContain("bunx @react-grab/cursor@latest &&"); expect(result.newContent).toContain("next dev --turbopack"); }); it("should use pnpm dlx for pnpm package manager", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonContent); const result = previewPackageJsonTransform("/test", "cursor", [], "pnpm"); expect(result.success).toBe(true); expect(result.newContent).toContain( "pnpm dlx @react-grab/cursor@latest &&", ); expect(result.newContent).toContain("next dev --turbopack"); }); it("should use npx for npm package manager", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonContent); const result = previewPackageJsonTransform("/test", "cursor", [], "npm"); expect(result.success).toBe(true); expect(result.newContent).toContain("npx @react-grab/cursor@latest &&"); expect(result.newContent).toContain("next dev --turbopack"); }); it("should use npx for yarn package manager", () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonContent); const result = previewPackageJsonTransform("/test", "cursor", [], "yarn"); expect(result.success).toBe(true); expect(result.newContent).toContain("npx @react-grab/cursor@latest &&"); expect(result.newContent).toContain("next dev --turbopack"); }); it("should detect existing bunx prefix and not duplicate", () => { const packageJsonWithBunx = JSON.stringify( { name: "my-app", scripts: { dev: "bunx @react-grab/cursor@latest && next dev", }, }, null, 2, ); mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonWithBunx); const result = previewPackageJsonTransform("/test", "cursor", [], "npm"); expect(result.success).toBe(true); expect(result.noChanges).toBe(true); }); it("should detect existing pnpm dlx prefix and not duplicate", () => { const packageJsonWithPnpm = JSON.stringify( { name: "my-app", scripts: { dev: "pnpm dlx @react-grab/cursor@latest && next dev", }, }, null, 2, ); mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonWithPnpm); const result = previewPackageJsonTransform("/test", "cursor", [], "bun"); expect(result.success).toBe(true); expect(result.noChanges).toBe(true); }); it("should detect existing yarn dlx prefix and not duplicate", () => { const packageJsonWithYarnDlx = JSON.stringify( { name: "my-app", scripts: { dev: "yarn dlx @react-grab/cursor@latest && next dev", }, }, null, 2, ); mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonWithYarnDlx); const result = previewPackageJsonTransform("/test", "cursor", [], "npm"); expect(result.success).toBe(true); expect(result.noChanges).toBe(true); }); it("should show correct package manager command in warning when no dev script", () => { const packageJsonNoDev = JSON.stringify( { name: "my-app", scripts: { build: "next build", }, }, null, 2, ); mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(packageJsonNoDev); const bunResult = previewPackageJsonTransform("/test", "cursor", [], "bun"); expect(bunResult.warning).toContain("bunx @react-grab/cursor@latest"); const pnpmResult = previewPackageJsonTransform( "/test", "cursor", [], "pnpm", ); expect(pnpmResult.warning).toContain("pnpm dlx @react-grab/cursor@latest"); }); }); describe("previewAgentRemoval", () => { it("should remove MCP script from Next.js layout", () => { const layoutWithMcp = `import Script from "next/script"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( ================================================ FILE: packages/e2e-playground/package.json ================================================ { "name": "@react-grab/e2e-playground", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "clsx": "^2.1.1", "react": "19.1.2", "react-dom": "19.1.2", "react-grab": "workspace:*", "tailwind-merge": "^2.6.0" }, "devDependencies": { "@tailwindcss/vite": "4.0.0-beta.8", "@types/react": "^19.0.4", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "4.0.0-beta.8", "typescript": "^5.7.3", "vite": "^6.0.2" } } ================================================ FILE: packages/e2e-playground/src/App.tsx ================================================ import { useState, useRef, useEffect } from "react"; interface Todo { id: number; title: string; } const TodoItem = ({ todo }: { todo: Todo }) => { return (
  • {todo.title}
  • ); }; const TodoList = () => { const todos: Todo[] = [ { id: 1, title: "Buy groceries" }, { id: 2, title: "Walk the dog" }, { id: 3, title: "Read a book" }, { id: 4, title: "Write code" }, { id: 5, title: "Exercise" }, { id: 6, title: "Call mom" }, { id: 7, title: "Write tests" }, ]; return (

    Todo List

      {todos.map((todo) => ( ))}
    ); }; const NestedCard = ({ title, children, }: { title: string; children: React.ReactNode; }) => { return (

    {title}

    {children}
    ); }; const DeeplyNested = () => { return (

    This is deeply nested content

    ); }; const FormSection = () => { const [inputValue, setInputValue] = useState(""); const [textareaValue, setTextareaValue] = useState(""); return (

    Form Elements

    setInputValue(event.target.value)} className="border rounded px-3 py-2 w-full" placeholder="Type something..." data-testid="test-input" />
    `; document.body.insertBefore(container, document.body.firstChild); }, CONTAINER_ID); }); test.afterEach(async ({ reactGrab }) => { await reactGrab.page.evaluate((containerId) => { document.getElementById(containerId)?.remove(); }, CONTAINER_ID); }); test("should select disabled button element", async ({ reactGrab }) => { await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='disabled-button']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should select disabled input element", async ({ reactGrab }) => { await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='disabled-input']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should select disabled textarea element", async ({ reactGrab }) => { await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='disabled-textarea']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should select disabled select element", async ({ reactGrab }) => { await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='disabled-select']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should select element with pointer-events none", async ({ reactGrab, }) => { await reactGrab.page.evaluate(() => { const container = document.getElementById("disabled-test-container"); const pointerEventsNoneElement = document.createElement("div"); pointerEventsNoneElement.setAttribute( "data-testid", "pointer-events-none", ); pointerEventsNoneElement.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0; margin-top: 10px;"; pointerEventsNoneElement.textContent = "Pointer Events None Element"; container?.appendChild(pointerEventsNoneElement); }); await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='pointer-events-none']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should copy element with pointer-events none via click", async ({ reactGrab, }) => { await reactGrab.page.evaluate(() => { const container = document.getElementById("disabled-test-container"); const pointerEventsNoneElement = document.createElement("div"); pointerEventsNoneElement.setAttribute( "data-testid", "pointer-events-none", ); pointerEventsNoneElement.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0; margin-top: 10px;"; pointerEventsNoneElement.textContent = "Pointer Events None Content"; container?.appendChild(pointerEventsNoneElement); }); await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='pointer-events-none']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); 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"); }); test("should select nested disabled element inside enabled parent", async ({ reactGrab, }) => { await reactGrab.page.evaluate(() => { const container = document.getElementById("disabled-test-container"); const wrapper = document.createElement("div"); wrapper.setAttribute("data-testid", "enabled-wrapper"); wrapper.style.cssText = "padding: 20px; background: #e0e0e0; margin-top: 10px;"; wrapper.innerHTML = ` Enabled wrapper `; container?.appendChild(wrapper); }); await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='nested-disabled-button']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should include disabled elements in drag selection", async ({ reactGrab, }) => { await reactGrab.activate(); const containerBounds = await reactGrab.getElementBounds( "#disabled-test-container", ); if (!containerBounds) throw new Error("Could not get container bounds"); await reactGrab.page.mouse.move( containerBounds.x - 10, containerBounds.y - 10, ); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move( containerBounds.x + containerBounds.width + 10, containerBounds.y + containerBounds.height + 10, { steps: 10 }, ); await reactGrab.page.mouse.up(); await reactGrab.page.waitForTimeout(500); await expect .poll(async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.count; }) .toBeGreaterThanOrEqual(1); }); }); test.describe("Pointer Events None - Arrow Navigation", () => { test.beforeEach(async ({ reactGrab }) => { await reactGrab.page.evaluate((containerId) => { const container = document.createElement("div"); container.id = containerId; document.body.insertBefore(container, document.body.firstChild); }, CONTAINER_ID); }); test.afterEach(async ({ reactGrab }) => { await reactGrab.page.evaluate((containerId) => { document.getElementById(containerId)?.remove(); }, CONTAINER_ID); }); test("should support ArrowUp from pointer-events none element", async ({ reactGrab, }) => { await reactGrab.page.evaluate((containerId) => { const container = document.getElementById(containerId); const parent = document.createElement("div"); parent.setAttribute("data-testid", "arrow-up-parent"); parent.style.cssText = "padding: 40px; background: #d0d0d0; margin-top: 10px;"; const child = document.createElement("div"); child.setAttribute("data-testid", "arrow-up-child"); child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;"; child.textContent = "Pointer Events None Child"; parent.appendChild(child); container?.appendChild(parent); }, CONTAINER_ID); await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='arrow-up-child']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); expect(await reactGrab.isSelectionBoxVisible()).toBe(true); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); expect(await reactGrab.isSelectionBoxVisible()).toBe(true); }); test("should support ArrowDown back to pointer-events none element", async ({ reactGrab, }) => { await reactGrab.page.evaluate((containerId) => { const container = document.getElementById(containerId); const parent = document.createElement("div"); parent.setAttribute("data-testid", "arrow-down-parent"); parent.style.cssText = "padding: 40px; background: #d0d0d0; margin-top: 10px;"; const child = document.createElement("div"); child.setAttribute("data-testid", "arrow-down-child"); child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;"; child.textContent = "Pointer Events None Child"; parent.appendChild(child); container?.appendChild(parent); }, CONTAINER_ID); await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='arrow-down-child']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.waitForSelectionBox(); expect(await reactGrab.isSelectionBoxVisible()).toBe(true); }); test("should support round-trip navigation", async ({ reactGrab }) => { await reactGrab.page.evaluate((containerId) => { const container = document.getElementById(containerId); const parent = document.createElement("div"); parent.setAttribute("data-testid", "round-trip-parent"); parent.style.cssText = "padding: 40px; background: #d0d0d0; margin-top: 10px;"; const child = document.createElement("div"); child.setAttribute("data-testid", "round-trip-child"); child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;"; child.textContent = "Pointer Events None Child"; parent.appendChild(child); container?.appendChild(parent); }, CONTAINER_ID); await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='round-trip-child']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); expect(await reactGrab.isSelectionBoxVisible()).toBe(true); await reactGrab.pressArrowDown(); await reactGrab.waitForSelectionBox(); expect(await reactGrab.isSelectionBoxVisible()).toBe(true); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); expect(await reactGrab.isSelectionBoxVisible()).toBe(true); }); test("should navigate through nested pointer-events none elements", async ({ reactGrab, }) => { await reactGrab.page.evaluate((containerId) => { const container = document.getElementById(containerId); const grandparent = document.createElement("div"); grandparent.setAttribute("data-testid", "nested-grandparent"); grandparent.style.cssText = "padding: 60px; background: #c0c0c0; margin-top: 10px;"; const parent = document.createElement("div"); parent.setAttribute("data-testid", "nested-parent"); parent.style.cssText = "pointer-events: none; padding: 40px; background: #d0d0d0;"; const child = document.createElement("div"); child.setAttribute("data-testid", "nested-child"); child.style.cssText = "pointer-events: none; padding: 20px; background: #f0f0f0;"; child.textContent = "Deeply Nested Pointer Events None"; parent.appendChild(child); grandparent.appendChild(parent); container?.appendChild(grandparent); }, CONTAINER_ID); await reactGrab.activate(); const bounds = await reactGrab.getElementBounds( "[data-testid='nested-child']", ); if (!bounds) throw new Error("Could not get element bounds"); await reactGrab.page.mouse.move( bounds.x + bounds.width / 2, bounds.y + bounds.height / 2, ); await reactGrab.waitForSelectionBox(); expect(await reactGrab.isSelectionBoxVisible()).toBe(true); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); expect(await reactGrab.isSelectionBoxVisible()).toBe(true); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); expect(await reactGrab.isSelectionBoxVisible()).toBe(true); }); }); ================================================ FILE: packages/react-grab/e2e/drag-selection.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Drag Selection", () => { test("should create drag box when clicking and dragging", async ({ reactGrab, }) => { await reactGrab.activate(); const firstItem = reactGrab.page.locator("li").first(); const firstBox = await firstItem.boundingBox(); if (!firstBox) throw new Error("Could not get bounding box"); const startX = firstBox.x - 20; const startY = firstBox.y - 20; await reactGrab.page.mouse.move(startX, startY); await reactGrab.page.mouse.down(); await reactGrab.page.waitForTimeout(50); await reactGrab.page.mouse.move(startX + 100, startY + 100, { steps: 5 }); await reactGrab.page.waitForTimeout(100); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); await reactGrab.page.mouse.up(); }); test("should select multiple elements within drag bounds", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.dragSelect("li:first-child", "li:nth-child(3)"); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); expect(clipboardContent.length).toBeGreaterThan(0); }); test("should copy all selected elements to clipboard", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.dragSelect("li:first-child", "li:nth-child(5)"); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toContain("Buy groceries"); }); test("should cancel drag selection on Escape", async ({ reactGrab }) => { await reactGrab.activate(); const firstItem = reactGrab.page.locator("li").first(); const firstBox = await firstItem.boundingBox(); if (!firstBox) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(firstBox.x - 10, firstBox.y - 10); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(firstBox.x + 200, firstBox.y + 200, { steps: 5, }); await reactGrab.pressEscape(); await reactGrab.page.mouse.up(); await reactGrab.page.waitForTimeout(100); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(false); }); test("should not trigger drag for small movements", async ({ reactGrab }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); const centerX = box.x + box.width / 2; const centerY = box.y + box.height / 2; await reactGrab.page.mouse.move(centerX, centerY); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(centerX + 1, centerY + 1); await reactGrab.page.mouse.up(); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); }); test("should deactivate after drag selection in toggle mode", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.dragSelect("li:first-child", "li:nth-child(2)"); await reactGrab.page.waitForTimeout(2000); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(false); }); test("should handle drag across entire list", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.dragSelect( "[data-testid='todo-list'] li:first-child", "[data-testid='todo-list'] li:last-child", ); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); expect(clipboardContent).toContain("Buy groceries"); expect(clipboardContent).toContain("Write tests"); }); test("should show visual feedback during drag", async ({ reactGrab }) => { await reactGrab.activate(); const firstItem = reactGrab.page.locator("li").first(); const lastItem = reactGrab.page.locator("li").last(); const startBox = await firstItem.boundingBox(); const endBox = await lastItem.boundingBox(); if (!startBox || !endBox) throw new Error("Could not get bounding boxes"); await reactGrab.page.mouse.move(startBox.x - 10, startBox.y - 10); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move( endBox.x + endBox.width + 10, endBox.y + endBox.height + 10, { steps: 10 }, ); const hasContent = await reactGrab.page.evaluate(() => { const host = document.querySelector("[data-react-grab]"); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector("[data-react-grab]"); return root !== null && root.innerHTML.length > 0; }); expect(hasContent).toBe(true); await reactGrab.page.mouse.up(); }); }); test.describe("Drag Selection with Scroll", () => { test("should handle drag selection with scroll offset", async ({ reactGrab, }) => { await reactGrab.scrollPage(100); await reactGrab.page.waitForTimeout(100); await reactGrab.activate(); await reactGrab.dragSelect("li:first-child", "li:nth-child(2)"); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); }); test("should maintain drag while scrolling", async ({ reactGrab }) => { await reactGrab.activate(); const firstItem = reactGrab.page.locator("li").first(); const firstBox = await firstItem.boundingBox(); if (!firstBox) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(firstBox.x - 10, firstBox.y - 10); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(firstBox.x + 100, firstBox.y + 100, { steps: 5, }); await reactGrab.scrollPage(50); await reactGrab.page.waitForTimeout(100); await reactGrab.page.mouse.up(); const state = await reactGrab.getState(); expect(state).toBeDefined(); }); test("should select elements after scrolling down", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.scrollPage(300); await reactGrab.page.waitForTimeout(200); const listItems = reactGrab.page.locator("li"); const count = await listItems.count(); if (count > 0) { await reactGrab.dragSelect("li:first-child", "li:nth-child(2)"); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); } }); test("drag bounds should exist during drag operation", async ({ reactGrab, }) => { await reactGrab.activate(); const firstItem = reactGrab.page.locator("li").first(); const firstBox = await firstItem.boundingBox(); if (!firstBox) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(firstBox.x - 10, firstBox.y - 10); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(firstBox.x + 200, firstBox.y + 200, { steps: 5, }); await reactGrab.page.waitForTimeout(100); const bounds = await reactGrab.getDragBoxBounds(); expect(bounds).not.toBeNull(); await reactGrab.page.mouse.up(); }); test("drag selection should work in scrollable container", async ({ reactGrab, }) => { await reactGrab.activate(); const scrollContainer = reactGrab.page.locator( "[data-testid='scroll-container']", ); const box = await scrollContainer.boundingBox(); if (box) { await reactGrab.page.mouse.move(box.x + 10, box.y + 10); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 200, box.y + 100, { steps: 5 }); await reactGrab.page.mouse.up(); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); } }); }); ================================================ FILE: packages/react-grab/e2e/edge-cases.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Edge Cases", () => { test.describe("Element Removal", () => { test("should handle element removed during hover", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='dynamic-element-1']"); await reactGrab.waitForSelectionBox(); await reactGrab.removeElement("[data-testid='dynamic-element-1']"); await reactGrab.page.waitForTimeout(200); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); test("should handle element removed during drag", async ({ reactGrab }) => { await reactGrab.activate(); const element = reactGrab.page.locator( "[data-testid='dynamic-element-1']", ); const box = await element.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x - 10, box.y - 10); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 50, box.y + 50, { steps: 3 }); await reactGrab.removeElement("[data-testid='dynamic-element-1']"); await reactGrab.page.waitForTimeout(100); await reactGrab.page.mouse.up(); const isActive = await reactGrab.isOverlayVisible(); expect(typeof isActive).toBe("boolean"); }); test("should recover after target element is removed", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='toggleable-element']"); await reactGrab.waitForSelectionBox(); await reactGrab.removeElement("[data-testid='toggleable-element']"); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); }); test.describe("Rapid Actions", () => { test("should handle rapid activation/deactivation cycles", async ({ reactGrab, }) => { for (let i = 0; i < 10; i++) { await reactGrab.activate(); await reactGrab.page.waitForTimeout(20); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(20); } const state = await reactGrab.getState(); expect(typeof state.isActive).toBe("boolean"); }); test("should handle rapid hover changes", async ({ reactGrab }) => { await reactGrab.activate(); const elements = [ "li:first-child", "li:nth-child(2)", "li:nth-child(3)", "h1", "ul", ]; for (const selector of elements) { await reactGrab.hoverElement(selector); await reactGrab.page.waitForTimeout(10); } const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); test("should handle rapid clicks", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); for (let i = 0; i < 5; i++) { await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(50); } await reactGrab.page.waitForTimeout(500); const clipboard = await reactGrab.getClipboardContent(); expect(clipboard).toBeTruthy(); }); test("should handle rapid toggle calls", async ({ reactGrab }) => { for (let i = 0; i < 8; i++) { await reactGrab.toggle(); await reactGrab.page.waitForTimeout(30); } const state = await reactGrab.getState(); expect(typeof state.isActive).toBe("boolean"); }); }); test.describe("Visibility Changes", () => { test("should handle tab visibility change", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.page.evaluate(() => { document.dispatchEvent(new Event("visibilitychange")); Object.defineProperty(document, "hidden", { value: true, writable: true, }); document.dispatchEvent(new Event("visibilitychange")); }); await reactGrab.page.waitForTimeout(100); await reactGrab.page.evaluate(() => { Object.defineProperty(document, "hidden", { value: false, writable: true, }); document.dispatchEvent(new Event("visibilitychange")); }); const state = await reactGrab.getState(); expect(state).toBeDefined(); }); test("should handle window blur and focus", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.evaluate(() => { window.dispatchEvent(new Event("blur")); }); await reactGrab.page.waitForTimeout(100); await reactGrab.page.evaluate(() => { window.dispatchEvent(new Event("focus")); }); await reactGrab.page.waitForTimeout(100); const state = await reactGrab.getState(); expect(state).toBeDefined(); }); }); test.describe("Scroll and Resize", () => { test("should handle scroll during drag operation", async ({ reactGrab, }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x, box.y); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 50, box.y + 50, { steps: 3 }); await reactGrab.scrollPage(100); await reactGrab.page.mouse.up(); const state = await reactGrab.getState(); expect(state).toBeDefined(); }); test("should handle resize during selection", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.setViewportSize(800, 600); await reactGrab.page.waitForTimeout(200); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); await reactGrab.setViewportSize(1280, 720); }); test("should handle rapid scroll events", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); for (let i = 0; i < 5; i++) { await reactGrab.page.evaluate(() => { window.scrollBy(0, 50); }); await reactGrab.page.waitForTimeout(20); } await reactGrab.page.waitForTimeout(200); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); }); test.describe("Memory and Cleanup", () => { test("dispose should clean up properly", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.dispose(); await reactGrab.page.waitForTimeout(200); const canReinit = await reactGrab.page.evaluate(() => { const initFn = (window as { initReactGrab?: () => void }).initReactGrab; return typeof initFn === "function"; }); expect(canReinit).toBe(true); }); test("should allow reinitialization after dispose", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.dispose(); await reactGrab.reinitialize(); await reactGrab.activate(); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); test("double initialization should be prevented", async ({ reactGrab }) => { await reactGrab.reinitialize(); await reactGrab.page.waitForTimeout(200); const hostCount = await reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-react-grab]").length; }); expect(hostCount).toBe(1); }); }); test.describe("Focus Management", () => { test("should restore focus to previously focused element", async ({ reactGrab, }) => { await reactGrab.page.click("[data-testid='test-input']"); await reactGrab.page.waitForTimeout(100); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.clickElement("li:first-child"); await expect .poll( () => reactGrab.page.evaluate(() => document.activeElement?.getAttribute("data-testid"), ), { timeout: 5000 }, ) .toBe("test-input"); }); }); test.describe("Context Menu Edge Cases", () => { test("should handle context menu on removed element", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='dynamic-element-3']"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("[data-testid='dynamic-element-3']"); await reactGrab.removeElement("[data-testid='dynamic-element-3']"); await reactGrab.page.waitForTimeout(100); await reactGrab.pressEscape(); const isActive = await reactGrab.isOverlayVisible(); expect(typeof isActive).toBe("boolean"); }); }); test.describe("Copy Edge Cases", () => { test("should handle copy during visibility change", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.evaluate(() => { document.dispatchEvent(new Event("visibilitychange")); }); await reactGrab.page.waitForTimeout(500); const clipboard = await reactGrab.getClipboardContent(); expect(clipboard).toBeTruthy(); }); }); test.describe("Viewport Edge Cases", () => { test("should handle elements outside viewport", async ({ reactGrab }) => { await reactGrab.activate(); const footer = reactGrab.page.locator("[data-testid='footer']"); await footer.scrollIntoViewIfNeeded(); await reactGrab.page.waitForTimeout(200); await reactGrab.hoverElement("[data-testid='footer']"); await reactGrab.waitForSelectionBox(); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); test("should handle zero-dimension elements gracefully", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.page.mouse.move(100, 100); await reactGrab.page.waitForTimeout(100); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); test("should handle invisible elements", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.page.mouse.move(200, 200); await reactGrab.page.waitForTimeout(100); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); }); test.describe("State Consistency", () => { test("getState should be consistent across calls", async ({ reactGrab, }) => { await reactGrab.activate(); const state1 = await reactGrab.getState(); const state2 = await reactGrab.getState(); expect(state1.isActive).toBe(state2.isActive); expect(state1.isDragging).toBe(state2.isDragging); expect(state1.isCopying).toBe(state2.isCopying); }); test("state should be correct after complex interaction sequence", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.page.waitForTimeout(100); await reactGrab.rightClickElement("li:nth-child(2)"); await reactGrab.page.waitForTimeout(100); const state = await reactGrab.getState(); expect(state.isActive).toBe(true); }); }); }); ================================================ FILE: packages/react-grab/e2e/element-context.spec.ts ================================================ 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 ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] h1"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("[data-testid='todo-list'] h1"); const clipboard = await reactGrab.getClipboardContent(); 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(" { 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(); expect(clipboard).toContain("NestedCard"); }); }); test.describe("Non-React Elements Fallback", () => { test("should fallback to HTML for plain DOM elements without React fiber", async ({ reactGrab, }) => { await reactGrab.page.evaluate(() => { const plainElement = document.createElement("div"); plainElement.id = "plain-dom-element"; plainElement.className = "test-class"; plainElement.textContent = "Plain DOM content"; document.body.appendChild(plainElement); }); await reactGrab.activate(); await reactGrab.hoverElement("#plain-dom-element"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("#plain-dom-element"); const clipboard = await reactGrab.getClipboardContent(); expect(clipboard).toContain("plain-dom-element"); expect(clipboard).toContain("Plain DOM content"); }); test("should include priority attrs for SVG elements", async ({ reactGrab, }) => { await reactGrab.page.evaluate(() => { const svgElement = document.createElementNS( "http://www.w3.org/2000/svg", "svg", ); svgElement.id = "test-svg-icon"; svgElement.setAttribute("class", "icon-class"); svgElement.setAttribute("aria-label", "Close the modal dialog"); svgElement.setAttribute("viewBox", "0 0 24 24"); svgElement.style.width = "50px"; svgElement.style.height = "50px"; document.body.appendChild(svgElement); }); await reactGrab.activate(); await reactGrab.hoverElement("#test-svg-icon"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("#test-svg-icon"); const clipboard = await reactGrab.getClipboardContent(); expect(clipboard).toContain(" { await reactGrab.page.evaluate(() => { const longElement = document.createElement("div"); longElement.id = "long-dom-element"; longElement.className = "a".repeat(300); longElement.textContent = "b".repeat(300); document.body.appendChild(longElement); }); await reactGrab.activate(); await reactGrab.hoverElement("#long-dom-element"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("#long-dom-element"); const clipboard = await reactGrab.getClipboardContent(); expect(clipboard).toContain("long-dom-element"); expect(clipboard.length).toBeLessThanOrEqual(510); }); }); }); ================================================ FILE: packages/react-grab/e2e/event-callbacks.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Event Callbacks", () => { test.beforeEach(async ({ reactGrab }) => { await reactGrab.setupCallbackTracking(); await reactGrab.clearCallbackHistory(); }); test.describe("Activation Callbacks", () => { test("onActivate should fire when overlay is activated", async ({ reactGrab, }) => { await reactGrab.activate(); const args = await reactGrab.waitForCallback("onActivate", 2000); expect(args).toBeDefined(); }); test("onDeactivate should fire when overlay is deactivated", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); await reactGrab.deactivate(); const args = await reactGrab.waitForCallback("onDeactivate", 2000); expect(args).toBeDefined(); }); test("onActivate should fire before onDeactivate in activation cycle", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.deactivate(); const history = await reactGrab.getCallbackHistory(); const activateIndex = history.findIndex((c) => c.name === "onActivate"); const deactivateIndex = history.findIndex( (c) => c.name === "onDeactivate", ); expect(activateIndex).toBeLessThan(deactivateIndex); }); test("onActivate should only fire once per activation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.page.waitForTimeout(200); const history = await reactGrab.getCallbackHistory(); const activateCalls = history.filter((c) => c.name === "onActivate"); expect(activateCalls.length).toBe(1); }); }); test.describe("Element Interaction Callbacks", () => { test("onElementHover should fire when hovering over elements", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); await reactGrab.hoverElement("li:first-child"); await expect .poll( async () => { const history = await reactGrab.getCallbackHistory(); const hoverCalls = history.filter( (c) => c.name === "onElementHover", ); return hoverCalls.length; }, { timeout: 2000 }, ) .toBeGreaterThan(0); }); test("onElementHover should receive element as argument", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); await reactGrab.hoverElement("h1"); await expect .poll( async () => { const history = await reactGrab.getCallbackHistory(); const hoverCalls = history.filter( (c) => c.name === "onElementHover", ); return hoverCalls.length; }, { timeout: 5000 }, ) .toBeGreaterThan(0); }); test("onElementSelect should fire when element is clicked", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clearCallbackHistory(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(300); const history = await reactGrab.getCallbackHistory(); const selectCalls = history.filter((c) => c.name === "onElementSelect"); expect(selectCalls.length).toBeGreaterThan(0); }); test("onElementHover should fire for different elements", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); await reactGrab.hoverElement("h1"); await reactGrab.page.waitForTimeout(100); await reactGrab.hoverElement("li:first-child"); await reactGrab.page.waitForTimeout(100); await reactGrab.hoverElement("ul"); await reactGrab.page.waitForTimeout(100); const history = await reactGrab.getCallbackHistory(); const hoverCalls = history.filter((c) => c.name === "onElementHover"); expect(hoverCalls.length).toBeGreaterThanOrEqual(3); }); }); test.describe("Drag Callbacks", () => { test("onDragStart should fire when drag begins", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x - 10, box.y - 10); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 100, box.y + 100, { steps: 5 }); const history = await reactGrab.getCallbackHistory(); const dragStartCalls = history.filter((c) => c.name === "onDragStart"); expect(dragStartCalls.length).toBe(1); await reactGrab.page.mouse.up(); }); test("onDragEnd should fire when drag completes", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.dragSelect("li:first-child", "li:nth-child(3)"); await reactGrab.page.waitForTimeout(300); const history = await reactGrab.getCallbackHistory(); const dragEndCalls = history.filter((c) => c.name === "onDragEnd"); expect(dragEndCalls.length).toBeGreaterThanOrEqual(1); }); test("onDragStart should include coordinates", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); const startX = box.x - 10; const startY = box.y - 10; await reactGrab.page.mouse.move(startX, startY); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(startX + 100, startY + 100, { steps: 5 }); await reactGrab.page.mouse.up(); const history = await reactGrab.getCallbackHistory(); const dragStartCalls = history.filter((c) => c.name === "onDragStart"); expect(dragStartCalls.length).toBeGreaterThan(0); }); }); test.describe("Copy Callbacks", () => { test("onBeforeCopy should fire before clipboard write", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); await reactGrab.clearCallbackHistory(); await reactGrab.clickElement("h1"); await reactGrab.page.waitForTimeout(500); const history = await reactGrab.getCallbackHistory(); const beforeCopyCalls = history.filter((c) => c.name === "onBeforeCopy"); expect(beforeCopyCalls.length).toBeGreaterThan(0); }); test("onAfterCopy should fire after clipboard write", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clearCallbackHistory(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(500); const history = await reactGrab.getCallbackHistory(); const afterCopyCalls = history.filter((c) => c.name === "onAfterCopy"); expect(afterCopyCalls.length).toBeGreaterThan(0); }); test("onCopySuccess should fire with content on successful copy", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); await reactGrab.clearCallbackHistory(); await reactGrab.clickElement("h1"); await reactGrab.page.waitForTimeout(500); const history = await reactGrab.getCallbackHistory(); const successCalls = history.filter((c) => c.name === "onCopySuccess"); expect(successCalls.length).toBeGreaterThan(0); }); test("copy callbacks should fire in correct order", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clearCallbackHistory(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(500); const history = await reactGrab.getCallbackHistory(); const beforeIndex = history.findIndex((c) => c.name === "onBeforeCopy"); const afterIndex = history.findIndex((c) => c.name === "onAfterCopy"); if (beforeIndex !== -1 && afterIndex !== -1) { expect(beforeIndex).toBeLessThan(afterIndex); } }); }); test.describe("State Change Callback", () => { test("onStateChange should fire on activation", async ({ reactGrab }) => { await reactGrab.clearCallbackHistory(); await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); const history = await reactGrab.getCallbackHistory(); const stateChangeCalls = history.filter( (c) => c.name === "onStateChange", ); expect(stateChangeCalls.length).toBeGreaterThan(0); }); test("onStateChange should fire on deactivation", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(100); const history = await reactGrab.getCallbackHistory(); const stateChangeCalls = history.filter( (c) => c.name === "onStateChange", ); expect(stateChangeCalls.length).toBeGreaterThan(0); }); test("onStateChange should fire during drag", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x - 10, box.y - 10); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 100, box.y + 100, { steps: 5 }); await reactGrab.page.waitForTimeout(100); await reactGrab.page.mouse.up(); const history = await reactGrab.getCallbackHistory(); const stateChangeCalls = history.filter( (c) => c.name === "onStateChange", ); expect(stateChangeCalls.length).toBeGreaterThan(0); }); }); test.describe("UI Element Callbacks", () => { test("onSelectionBox should fire when selection box appears", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.waitForTimeout(100); const history = await reactGrab.getCallbackHistory(); const selectionBoxCalls = history.filter( (c) => c.name === "onSelectionBox", ); expect(selectionBoxCalls.length).toBeGreaterThan(0); }); test("onDragBox should fire during drag selection", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.clearCallbackHistory(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x - 20, box.y - 20); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 150, box.y + 150, { steps: 10 }); await reactGrab.page.waitForTimeout(100); await reactGrab.page.mouse.up(); const history = await reactGrab.getCallbackHistory(); const dragBoxCalls = history.filter((c) => c.name === "onDragBox"); expect(dragBoxCalls.length).toBeGreaterThan(0); }); test("onGrabbedBox should fire when element is grabbed", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clearCallbackHistory(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(300); const history = await reactGrab.getCallbackHistory(); const grabbedBoxCalls = history.filter((c) => c.name === "onGrabbedBox"); expect(grabbedBoxCalls.length).toBeGreaterThan(0); }); }); test.describe("Context Menu Callback", () => { test("onContextMenu should fire on right-click", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clearCallbackHistory(); await reactGrab.rightClickElement("li:first-child"); const history = await reactGrab.getCallbackHistory(); const contextMenuCalls = history.filter( (c) => c.name === "onContextMenu", ); expect(contextMenuCalls.length).toBe(1); }); }); test.describe("Callback Integrity", () => { test("callbacks should not fire when overlay is inactive", async ({ reactGrab, }) => { await reactGrab.clearCallbackHistory(); await reactGrab.hoverElement("li:first-child"); await reactGrab.page.waitForTimeout(100); const history = await reactGrab.getCallbackHistory(); const hoverCalls = history.filter((c) => c.name === "onElementHover"); expect(hoverCalls.length).toBe(0); }); test("callbacks should include timestamps", async ({ reactGrab }) => { await reactGrab.clearCallbackHistory(); await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); const history = await reactGrab.getCallbackHistory(); expect(history.length).toBeGreaterThan(0); expect(history[0].timestamp).toBeDefined(); expect(typeof history[0].timestamp).toBe("number"); }); test("multiple callbacks should maintain order", async ({ reactGrab }) => { await reactGrab.clearCallbackHistory(); await reactGrab.activate(); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("h1"); await reactGrab.page.waitForTimeout(500); const history = await reactGrab.getCallbackHistory(); for (let i = 1; i < history.length; i++) { expect(history[i].timestamp).toBeGreaterThanOrEqual( history[i - 1].timestamp, ); } }); }); }); ================================================ FILE: packages/react-grab/e2e/fixtures.ts ================================================ import { test as base, expect, Page, Locator } from "@playwright/test"; const ATTRIBUTE_NAME = "data-react-grab"; const DEFAULT_KEY_HOLD_DURATION_MS = 200; const ACTIVATION_BUFFER_MS = 200; const PAGE_SETUP_MAX_ATTEMPTS = 2; const PAGE_SETUP_NAVIGATION_TIMEOUT_MS = 8_000; const PAGE_SETUP_API_TIMEOUT_MS = 8_000; const MODIFIER_KEY = process.platform === "darwin" ? "Meta" : "Control"; interface ContextMenuInfo { isVisible: boolean; tagBadgeText: string | null; menuItems: string[]; position: { x: number; y: number } | null; } interface SelectionLabelInfo { isVisible: boolean; tagName: string | null; componentName: string | null; status: string | null; elementsCount: number | null; filePath: string | null; } interface SelectionLabelBounds { label: { x: number; y: number; width: number; height: number }; arrow: { x: number; y: number; width: number; height: number } | null; viewport: { width: number; height: number }; } interface ToolbarInfo { isVisible: boolean; isCollapsed: boolean; isVertical: boolean; position: { x: number; y: number } | null; dimensions: { width: number; height: number } | null; snapEdge: string | null; } interface AgentSessionInfo { id: string; status: string; isStreaming: boolean; error: string | null; prompt: string; } interface LabelInstanceInfo { id: string; status: string; tagName: string; componentName?: string; createdAt: number; } interface ReactGrabState { isActive: boolean; isDragging: boolean; isCopying: boolean; isPromptMode: boolean; targetElement: boolean; dragBounds: { x: number; y: number; width: number; height: number } | null; grabbedBoxes: Array<{ id: string; bounds: { x: number; y: number; width: number; height: number }; createdAt: number; }>; labelInstances: LabelInstanceInfo[]; } interface GrabbedBoxInfo { count: number; boxes: Array<{ id: string; bounds: { x: number; y: number; width: number; height: number }; }>; } interface HistoryDropdownInfo { isVisible: boolean; itemCount: number; } interface ToolbarMenuInfo { isVisible: boolean; itemCount: number; itemLabels: string[]; } export interface ReactGrabPageObject { page: Page; modifierKey: "Meta" | "Control"; activate: () => Promise; activateViaKeyboard: () => Promise; deactivate: () => Promise; holdToActivate: (durationMs?: number) => Promise; isOverlayVisible: () => Promise; getOverlayHost: () => Locator; getShadowRoot: () => Promise; hoverElement: (selector: string) => Promise; clickElement: (selector: string) => Promise; rightClickElement: (selector: string) => Promise; rightClickAtPosition: (x: number, y: number) => Promise; dragSelect: (startSelector: string, endSelector: string) => Promise; getClipboardContent: () => Promise; captureNextClipboardWrites: () => Promise>; waitForSelectionBox: () => Promise; waitForSelectionSource: () => Promise; isContextMenuVisible: () => Promise; getContextMenuInfo: () => Promise; isContextMenuItemEnabled: (label: string) => Promise; clickContextMenuItem: (label: string) => Promise; isSelectionBoxVisible: () => Promise; pressEscape: () => Promise; pressArrowDown: () => Promise; pressArrowUp: () => Promise; pressArrowLeft: () => Promise; pressArrowRight: () => Promise; pressEnter: () => Promise; pressKey: (key: string) => Promise; pressKeyCombo: (modifiers: string[], key: string) => Promise; pressModifierKeyCombo: (key: string) => Promise; scrollPage: (deltaY: number) => Promise; enterPromptMode: (selector: string) => Promise; isPromptModeActive: () => Promise; typeInInput: (text: string) => Promise; getInputValue: () => Promise; submitInput: () => Promise; clearInput: () => Promise; isPendingDismissVisible: () => Promise; isToolbarVisible: () => Promise; isToolbarCollapsed: () => Promise; getToolbarInfo: () => Promise; clickToolbarToggle: () => Promise; clickToolbarCollapse: () => Promise; dragToolbar: (deltaX: number, deltaY: number) => Promise; clickToolbarEnabled: () => Promise; dragToolbarFromButton: ( buttonSelector: string, deltaX: number, deltaY: number, ) => Promise; isToolbarMenuButtonVisible: () => Promise; clickToolbarMenuButton: () => Promise; isToolbarMenuVisible: () => Promise; getToolbarMenuInfo: () => Promise; clickToolbarMenuItem: (actionId: string) => Promise; isHistoryButtonVisible: () => Promise; hasUnreadHistoryIndicator: () => Promise; clickHistoryButton: () => Promise; isHistoryDropdownVisible: () => Promise; getHistoryDropdownInfo: () => Promise; clickHistoryItem: (index: number) => Promise; clickHistoryItemRemove: (index: number) => Promise; clickHistoryItemCopy: (index: number) => Promise; clickHistoryCopyAll: () => Promise; clickHistoryClear: () => Promise; hoverHistoryItem: (index: number) => Promise; hoverHistoryButton: () => Promise; hoverCopyAllButton: () => Promise; clickToolbarCopyAll: () => Promise; isToolbarCopyAllVisible: () => Promise; isClearHistoryPromptVisible: () => Promise; confirmClearHistoryPrompt: () => Promise; cancelClearHistoryPrompt: () => Promise; getHistoryDropdownPosition: () => Promise<{ left: number; top: number; } | null>; getSelectionLabelInfo: () => Promise; getSelectionLabelBounds: () => Promise; isSelectionLabelVisible: () => Promise; waitForSelectionLabel: () => Promise; getLabelStatusText: () => Promise; getGrabbedBoxInfo: () => Promise; getLabelInstancesInfo: () => Promise; isGrabbedBoxVisible: () => Promise; getDragBoxBounds: () => Promise<{ x: number; y: number; width: number; height: number; } | null>; getSelectionBoxBounds: () => Promise<{ x: number; y: number; width: number; height: number; } | null>; getState: () => Promise; toggle: () => Promise; dispose: () => Promise; copyElementViaApi: (selector: string) => Promise; setAgent: (options: Record) => Promise; updateOptions: (options: Record) => Promise; reinitialize: (options?: Record) => Promise; setupMockAgent: (options?: { delay?: number; error?: string; statusUpdates?: string[]; }) => Promise; getAgentSessions: () => Promise; isAgentSessionVisible: () => Promise; waitForAgentSession: (timeout?: number) => Promise; waitForAgentComplete: (timeout?: number) => Promise; clickAgentDismiss: () => Promise; clickAgentUndo: () => Promise; clickAgentRetry: () => Promise; clickAgentAbort: () => Promise; confirmAgentAbort: () => Promise; cancelAgentAbort: () => Promise; touchStart: (x: number, y: number) => Promise; touchMove: (x: number, y: number) => Promise; touchEnd: (x: number, y: number) => Promise; touchTap: (selector: string) => Promise; touchDrag: ( startX: number, startY: number, endX: number, endY: number, ) => Promise; isTouchMode: () => Promise; setViewportSize: (width: number, height: number) => Promise; getViewportSize: () => Promise<{ width: number; height: number }>; removeElement: (selector: string) => Promise; hideElement: (selector: string) => Promise; showElement: (selector: string) => Promise; getElementBounds: ( selector: string, ) => Promise<{ x: number; y: number; width: number; height: number } | null>; isDropdownOpen: () => Promise; openDropdown: () => Promise; setupCallbackTracking: () => Promise; getCallbackHistory: () => Promise< Array<{ name: string; args: unknown[]; timestamp: number }> >; clearCallbackHistory: () => Promise; waitForCallback: (name: string, timeout?: number) => Promise; } const createReactGrabPageObject = (page: Page): ReactGrabPageObject => { const getOverlayHost = () => page.locator(`[${ATTRIBUTE_NAME}]`).first(); const getShadowRoot = async () => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); return host?.shadowRoot?.querySelector(`[${attrName}]`) ?? null; }, ATTRIBUTE_NAME); }; const isOverlayVisible = async () => { return page.evaluate(() => { const api = (window as { __REACT_GRAB__?: { isActive: () => boolean } }) .__REACT_GRAB__; return api?.isActive() ?? false; }); }; const waitForActive = async (expectedState: boolean) => { await page.waitForFunction( (expected) => { const api = (window as { __REACT_GRAB__?: { isActive: () => boolean } }) .__REACT_GRAB__; return api?.isActive() === expected; }, expectedState, { timeout: 5000 }, ); }; const holdToActivate = async (durationMs = DEFAULT_KEY_HOLD_DURATION_MS) => { await page.click("body"); await page.keyboard.down(MODIFIER_KEY); await page.keyboard.down("c"); await page.waitForTimeout(durationMs + ACTIVATION_BUFFER_MS); }; const activate = async () => { await page.evaluate(() => { const api = (window as { __REACT_GRAB__?: { activate: () => void } }) .__REACT_GRAB__; api?.activate(); }); await waitForActive(true); }; const activateViaKeyboard = async () => { await holdToActivate(); await page.keyboard.up("c"); await page.keyboard.up(MODIFIER_KEY); await waitForActive(true); }; const deactivate = async () => { await page.keyboard.press("Escape"); await waitForActive(false); }; const hoverElement = async (selector: string) => { const element = page.locator(selector).first(); await element.hover({ force: true }); await page.waitForTimeout(250); }; const clickElement = async (selector: string) => { const element = page.locator(selector).first(); await element.click({ force: true }); }; const dragSelect = async (startSelector: string, endSelector: string) => { const startElement = page.locator(startSelector).first(); const endElement = page.locator(endSelector).last(); const startBox = await startElement.boundingBox(); const endBox = await endElement.boundingBox(); if (!startBox || !endBox) { throw new Error("Could not get bounding boxes for drag selection"); } const startX = startBox.x - 10; const startY = startBox.y - 10; const endX = endBox.x + endBox.width + 10; const endY = endBox.y + endBox.height + 10; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(endX, endY, { steps: 10 }); await page.mouse.up(); }; const getClipboardContent = async () => { return page.evaluate(() => navigator.clipboard.readText()); }; const captureNextClipboardWrites = async () => { return page.evaluate(() => { return new Promise>((resolve) => { const originalSetData = DataTransfer.prototype.setData; const clipboardWrites: Record = {}; DataTransfer.prototype.setData = function ( type: string, value: string, ) { clipboardWrites[type] = value; return originalSetData.call(this, type, value); }; const cleanup = () => { DataTransfer.prototype.setData = originalSetData; resolve(clipboardWrites); }; const safetyTimeout = setTimeout(cleanup, 5000); document.addEventListener( "copy", () => { clearTimeout(safetyTimeout); queueMicrotask(cleanup); }, { once: true, capture: true }, ); }); }); }; const waitForSelectionBox = async () => { await page.waitForFunction( () => { const api = ( window as { __REACT_GRAB__?: { getState: () => { isSelectionBoxVisible: boolean; targetElement: unknown; }; }; } ).__REACT_GRAB__; const state = api?.getState(); return state?.isSelectionBoxVisible || state?.targetElement !== null; }, undefined, { timeout: 10_000 }, ); }; const waitForSelectionSource = async () => { await page.waitForFunction( () => { const api = ( window as { __REACT_GRAB__?: { getState: () => { selectionFilePath: string | null }; }; } ).__REACT_GRAB__; return api?.getState()?.selectionFilePath !== null; }, undefined, { timeout: 5000 }, ); }; const pressEscape = async () => { await page.keyboard.press("Escape"); }; const pressArrowDown = async () => { await page.keyboard.press("ArrowDown"); }; const pressArrowUp = async () => { await page.keyboard.press("ArrowUp"); }; const pressArrowLeft = async () => { await page.keyboard.press("ArrowLeft"); }; const pressArrowRight = async () => { await page.keyboard.press("ArrowRight"); }; const pressEnter = async () => { await page.keyboard.press("Enter"); }; const pressKey = async (key: string) => { await page.keyboard.press(key); }; const pressKeyCombo = async (modifiers: string[], key: string) => { for (const modifier of modifiers) { await page.keyboard.down(modifier); } await page.keyboard.press(key); for (const modifier of [...modifiers].reverse()) { await page.keyboard.up(modifier); } }; const pressModifierKeyCombo = async (key: string) => { await page.keyboard.down(MODIFIER_KEY); await page.keyboard.press(key); await page.keyboard.up(MODIFIER_KEY); }; const waitForContextMenu = async (visible: boolean) => { await page.waitForFunction( ({ attrName, expectedVisible }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return !expectedVisible; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return !expectedVisible; const menuItem = root.querySelector("[data-react-grab-menu-item]"); return expectedVisible ? menuItem !== null : menuItem === null; }, { attrName: ATTRIBUTE_NAME, expectedVisible: visible }, { timeout: 2000 }, ); }; const rightClickElement = async (selector: string) => { const element = page.locator(selector).first(); await element.click({ button: "right", force: true }); const isActive = await isOverlayVisible(); if (isActive) { await waitForContextMenu(true); } }; const rightClickAtPosition = async (x: number, y: number) => { await page.mouse.click(x, y, { button: "right" }); const isActive = await isOverlayVisible(); if (isActive) { await waitForContextMenu(true); } }; const isContextMenuVisible = async () => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const menuItem = root.querySelector("[data-react-grab-menu-item]"); return menuItem !== null; }, ATTRIBUTE_NAME); }; const clickContextMenuItem = async (label: string) => { await page.evaluate( ({ attrName, itemLabel }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) throw new Error("No shadow root found"); const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) throw new Error("No inner root found"); const button = root.querySelector( `[data-react-grab-menu-item="${itemLabel.toLowerCase()}"]`, ); if (!button) throw new Error(`Context menu item "${itemLabel}" not found`); button.click(); }, { attrName: ATTRIBUTE_NAME, itemLabel: label }, ); await waitForContextMenu(false); }; const getContextMenuInfo = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return { isVisible: false, tagBadgeText: null, menuItems: [], position: null, }; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return { isVisible: false, tagBadgeText: null, menuItems: [], position: null, }; const contextMenu = root.querySelector( "[data-react-grab-context-menu]", ); if (!contextMenu) return { isVisible: false, tagBadgeText: null, menuItems: [], position: null, }; const menuItemButtons = Array.from( contextMenu.querySelectorAll( "[data-react-grab-menu-item]", ), ); const menuItems = menuItemButtons.map((btn) => { const item = btn.dataset.reactGrabMenuItem ?? ""; return item.charAt(0).toUpperCase() + item.slice(1); }); const tagBadgeElement = contextMenu.querySelector("span"); const tagBadgeText = tagBadgeElement?.textContent?.trim() ?? null; const style = contextMenu.style; const position = style.left && style.top ? { x: parseFloat(style.left), y: parseFloat(style.top) } : null; return { isVisible: true, tagBadgeText, menuItems, position }; }, ATTRIBUTE_NAME); }; const isContextMenuItemEnabled = async (label: string): Promise => { return page.evaluate( ({ attrName, itemLabel }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const button = root.querySelector( `[data-react-grab-menu-item="${itemLabel.toLowerCase()}"]`, ); return button ? !button.disabled : false; }, { attrName: ATTRIBUTE_NAME, itemLabel: label }, ); }; const isSelectionBoxVisible = async (): Promise => { return page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { isSelectionBoxVisible: boolean }; }; } ).__REACT_GRAB__; return api?.getState()?.isSelectionBoxVisible ?? false; }); }; const scrollPage = async (deltaY: number) => { const scrollBefore = await page.evaluate(() => window.scrollY); await page.mouse.wheel(0, deltaY); await page .waitForFunction( (prevScroll) => window.scrollY !== prevScroll, scrollBefore, { timeout: 2000 }, ) .catch(() => { // Scroll may not change if at edge of page, that's okay }); }; const waitForPromptMode = async (active: boolean) => { await page.waitForFunction( (expected) => { const api = ( window as { __REACT_GRAB__?: { getState: () => { isPromptMode: boolean } }; } ).__REACT_GRAB__; return api?.getState()?.isPromptMode === expected; }, active, { timeout: 2000 }, ); }; const enterPromptMode = async (selector: string) => { await activate(); await hoverElement(selector); const isSelected = await page .waitForFunction( () => { const api = ( window as { __REACT_GRAB__?: { getState: () => { isSelectionBoxVisible: boolean; targetElement: unknown; }; }; } ).__REACT_GRAB__; const state = api?.getState(); return state?.isSelectionBoxVisible || state?.targetElement !== null; }, undefined, { timeout: 2000 }, ) .then(() => true) .catch(() => false); if (!isSelected) { await hoverElement(selector); await waitForSelectionBox(); } await rightClickElement(selector); await clickContextMenuItem("Edit"); await waitForPromptMode(true); }; const isPromptModeActive = async (): Promise => { return page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { isPromptMode: boolean } }; } ).__REACT_GRAB__; return api?.getState()?.isPromptMode ?? false; }); }; const typeInInput = async (text: string) => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const textarea = root.querySelector( "[data-react-grab-input]", ); if (textarea) { textarea.focus(); } }, ATTRIBUTE_NAME); await page.keyboard.insertText(text); }; const getInputValue = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return ""; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return ""; const textarea = root.querySelector( "textarea[data-react-grab-ignore-events]", ) as HTMLTextAreaElement; return textarea?.value ?? ""; }, ATTRIBUTE_NAME); }; const submitInput = async () => { await page.keyboard.press("Enter"); }; const clearInput = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const textarea = root.querySelector( "textarea[data-react-grab-ignore-events]", ) as HTMLTextAreaElement; if (textarea) { textarea.value = ""; textarea.dispatchEvent(new Event("input", { bubbles: true })); } }, ATTRIBUTE_NAME); }; const isPendingDismissVisible = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const discardPrompt = root.querySelector( "[data-react-grab-discard-prompt]", ); return discardPrompt !== null; }, ATTRIBUTE_NAME); }; const isToolbarVisible = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const toolbar = root.querySelector( "[data-react-grab-toolbar]", ); if (!toolbar) return false; const computedStyle = window.getComputedStyle(toolbar); return computedStyle.opacity !== "0" && computedStyle.display !== "none"; }, ATTRIBUTE_NAME); }; const isToolbarCollapsed = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const toolbar = root.querySelector( "[data-react-grab-toolbar]", ); if (!toolbar) return false; const computedStyle = window.getComputedStyle(toolbar); return computedStyle.cursor === "pointer"; }, ATTRIBUTE_NAME); }; const getToolbarInfo = async (): Promise => { const defaultInfo: ToolbarInfo = { isVisible: false, isCollapsed: false, isVertical: false, position: null, dimensions: null, snapEdge: null, }; return page.evaluate( ({ attrName, fallback }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return fallback; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return fallback; const toolbar = root.querySelector( "[data-react-grab-toolbar]", ); if (!toolbar) return fallback; const computedStyle = window.getComputedStyle(toolbar); const transform = toolbar.style.transform; const translateMatch = transform.match( /translate\((-?\d+(?:\.\d+)?)px,\s*(-?\d+(?:\.\d+)?)px\)/, ); const position = translateMatch ? { x: parseFloat(translateMatch[1]), y: parseFloat(translateMatch[2]), } : null; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const rect = toolbar.getBoundingClientRect(); const dimensions = { width: rect.width, height: rect.height }; let snapEdge: string | null = null; if (position) { const SNAP_THRESHOLD = 30; if (position.y <= SNAP_THRESHOLD) snapEdge = "top"; else if (position.y + rect.height >= viewportHeight - SNAP_THRESHOLD) snapEdge = "bottom"; else if (position.x <= SNAP_THRESHOLD) snapEdge = "left"; else if (position.x + rect.width >= viewportWidth - SNAP_THRESHOLD) snapEdge = "right"; } const isCollapsed = computedStyle.cursor === "pointer"; const innerDiv = toolbar.querySelector("div"); const innerStyle = innerDiv ? window.getComputedStyle(innerDiv) : null; const isVertical = innerStyle?.flexDirection === "column"; return { isVisible: computedStyle.opacity !== "0", isCollapsed, isVertical, position, dimensions, snapEdge, }; }, { attrName: ATTRIBUTE_NAME, fallback: defaultInfo }, ); }; const clickToolbarToggle = async () => { const wasActive = await isOverlayVisible(); await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const toggleButton = root.querySelector( "[data-react-grab-toolbar-toggle]", ); toggleButton?.click(); }, ATTRIBUTE_NAME); await waitForActive(!wasActive); }; const clickToolbarCollapse = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const collapseButton = root.querySelector( "[data-react-grab-toolbar-collapse]", ); collapseButton?.click(); }, ATTRIBUTE_NAME); }; const clickToolbarEnabled = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const enabledButton = root.querySelector( "[data-react-grab-toolbar-enabled]", ); enabledButton?.click(); }, ATTRIBUTE_NAME); }; const dragToolbar = async (deltaX: number, deltaY: number) => { const toolbarRect = await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const toolbar = root.querySelector( "[data-react-grab-toolbar]", ); if (!toolbar) return null; const rect = toolbar.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }, ATTRIBUTE_NAME); if (!toolbarRect) return; const startX = toolbarRect.x + toolbarRect.width / 2; const startY = toolbarRect.y + toolbarRect.height / 2; const endX = startX + deltaX; const endY = startY + deltaY; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(endX, endY, { steps: 10 }); await page.mouse.up(); // HACK: Wait for snap animation to complete await page.waitForTimeout(300); }; const dragToolbarFromButton = async ( buttonSelector: string, deltaX: number, deltaY: number, ) => { const buttonRect = await page.evaluate( ({ attrName, selector }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const button = root.querySelector(selector); if (!button) return null; const rect = button.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }, { attrName: ATTRIBUTE_NAME, selector: buttonSelector }, ); if (!buttonRect) return; const startX = buttonRect.x + buttonRect.width / 2; const startY = buttonRect.y + buttonRect.height / 2; const endX = startX + deltaX; const endY = startY + deltaY; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(endX, endY, { steps: 10 }); await page.mouse.up(); // HACK: Wait for snap animation to complete await page.waitForTimeout(300); }; const isToolbarMenuButtonVisible = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const menuButton = root.querySelector( "[data-react-grab-toolbar-menu]", ); if (!menuButton) return false; const gridParent = menuButton.parentElement?.parentElement; if (!gridParent) return false; const computedStyle = window.getComputedStyle(gridParent); return computedStyle.opacity !== "0"; }, ATTRIBUTE_NAME); }; const waitForToolbarMenu = async (visible: boolean) => { await page.waitForFunction( ({ attrName, expectedVisible }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return !expectedVisible; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return !expectedVisible; const menu = root.querySelector( "[data-react-grab-toolbar-menu]", ); if (!expectedVisible) { const dropdown = root.querySelector( "div[data-react-grab-toolbar-menu]:not([data-react-grab-toolbar])", ); return dropdown === null; } if (!menu) return false; const dropdowns = root.querySelectorAll( "[data-react-grab-toolbar-menu]", ); for (let i = 0; i < dropdowns.length; i++) { const dropdown = dropdowns[i]; if (dropdown.classList.contains("fixed")) { return getComputedStyle(dropdown).pointerEvents !== "none"; } } return false; }, { attrName: ATTRIBUTE_NAME, expectedVisible: visible }, { timeout: 2000 }, ); }; const clickToolbarMenuButton = async () => { const wasOpen = await isToolbarMenuVisible(); await clickShadowRootButton("[data-react-grab-toolbar-menu]"); await waitForToolbarMenu(!wasOpen); }; const isToolbarMenuVisible = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const dropdowns = root.querySelectorAll( "[data-react-grab-toolbar-menu]", ); for (let i = 0; i < dropdowns.length; i++) { const dropdown = dropdowns[i]; if ( dropdown.classList.contains("fixed") && getComputedStyle(dropdown).pointerEvents !== "none" ) { return true; } } return false; }, ATTRIBUTE_NAME); }; const getToolbarMenuInfo = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return { isVisible: false, itemCount: 0, itemLabels: [] }; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return { isVisible: false, itemCount: 0, itemLabels: [] }; const dropdowns = root.querySelectorAll( "[data-react-grab-toolbar-menu]", ); for (let i = 0; i < dropdowns.length; i++) { const dropdown = dropdowns[i]; if (dropdown.classList.contains("fixed")) { const items = dropdown.querySelectorAll( "[data-react-grab-menu-item]", ); const itemLabels = Array.from(items).map( (item) => item.textContent?.trim() ?? "", ); return { isVisible: getComputedStyle(dropdown).pointerEvents !== "none", itemCount: items.length, itemLabels, }; } } return { isVisible: false, itemCount: 0, itemLabels: [] }; }, ATTRIBUTE_NAME); }; const clickToolbarMenuItem = async (actionId: string) => { await page.evaluate( ({ attrName, itemId }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const button = root.querySelector( `[data-react-grab-menu-item="${itemId}"]`, ); button?.click(); }, { attrName: ATTRIBUTE_NAME, itemId: actionId }, ); }; const isHistoryButtonVisible = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const historyButton = root.querySelector( "[data-react-grab-toolbar-history]", ); if (!historyButton) return false; const gridParent = historyButton.parentElement?.parentElement; if (!gridParent) return false; const computedStyle = window.getComputedStyle(gridParent); return computedStyle.opacity !== "0"; }, ATTRIBUTE_NAME); }; const hasUnreadHistoryIndicator = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const historyButton = root.querySelector( "[data-react-grab-toolbar-history]", ); if (!historyButton) return false; const unreadDot = historyButton.querySelector( "[data-react-grab-unread-indicator]", ); return unreadDot !== null; }, ATTRIBUTE_NAME); }; const waitForHistoryDropdown = async (visible: boolean) => { await page.waitForFunction( ({ attrName, expectedVisible }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return !expectedVisible; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return !expectedVisible; const dropdown = root.querySelector( "[data-react-grab-history-dropdown]", ); if (!expectedVisible) return dropdown === null; if (!dropdown) return false; return getComputedStyle(dropdown).pointerEvents !== "none"; }, { attrName: ATTRIBUTE_NAME, expectedVisible: visible }, { timeout: 5000 }, ); }; const clickShadowRootButton = async (selector: string) => { await page.evaluate( ({ attrName, buttonSelector }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; root.querySelector(buttonSelector)?.click(); }, { attrName: ATTRIBUTE_NAME, buttonSelector: selector }, ); }; const clickHistoryButton = async () => { const wasOpen = await isHistoryDropdownVisible(); await clickShadowRootButton("[data-react-grab-toolbar-history]"); await waitForHistoryDropdown(!wasOpen); }; const isHistoryDropdownVisible = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const dropdown = root.querySelector("[data-react-grab-history-dropdown]"); return dropdown !== null; }, ATTRIBUTE_NAME); }; const getHistoryDropdownInfo = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return { isVisible: false, itemCount: 0 }; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return { isVisible: false, itemCount: 0 }; const dropdown = root.querySelector("[data-react-grab-history-dropdown]"); if (!dropdown) return { isVisible: false, itemCount: 0 }; return { isVisible: true, itemCount: dropdown.querySelectorAll("[data-react-grab-history-item]") .length, }; }, ATTRIBUTE_NAME); }; const clickHistoryItem = async (index: number) => { await page.evaluate( ({ attrName, itemIndex }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const items = root.querySelectorAll( "[data-react-grab-history-item]", ); items[itemIndex]?.click(); }, { attrName: ATTRIBUTE_NAME, itemIndex: index }, ); }; const clickHistoryItemRemove = async (index: number) => { await page.evaluate( ({ attrName, itemIndex }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const items = root.querySelectorAll("[data-react-grab-history-item]"); const item = items[itemIndex]; if (!item) return; const removeButton = item.querySelector( "[data-react-grab-history-item-remove]", ); removeButton?.click(); }, { attrName: ATTRIBUTE_NAME, itemIndex: index }, ); }; const clickHistoryItemCopy = async (index: number) => { await page.evaluate( ({ attrName, itemIndex }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const items = root.querySelectorAll("[data-react-grab-history-item]"); const item = items[itemIndex]; if (!item) return; const copyButton = item.querySelector( "[data-react-grab-history-item-copy]", ); copyButton?.click(); }, { attrName: ATTRIBUTE_NAME, itemIndex: index }, ); }; const clickHistoryCopyAll = async () => { await clickShadowRootButton("[data-react-grab-history-copy-all]"); }; const clickHistoryClear = async () => { await clickShadowRootButton("[data-react-grab-history-clear]"); await waitForHistoryDropdown(false); }; const hoverHistoryItem = async (index: number) => { const itemRect = await page.evaluate( ({ attrName, itemIndex }) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const items = root.querySelectorAll("[data-react-grab-history-item]"); const button = items[itemIndex]; if (!button) return null; const rect = button.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, }; }, { attrName: ATTRIBUTE_NAME, itemIndex: index }, ); if (itemRect) { await page.mouse.move( itemRect.x + itemRect.width / 2, itemRect.y + itemRect.height / 2, ); await page.waitForTimeout(100); } }; const hoverHistoryButton = async () => { const buttonRect = await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const button = root.querySelector( "[data-react-grab-toolbar-history]", ); if (!button) return null; const rect = button.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }, ATTRIBUTE_NAME); if (buttonRect) { await page.mouse.move( buttonRect.x + buttonRect.width / 2, buttonRect.y + buttonRect.height / 2, ); await page.waitForTimeout(100); } }; const hoverCopyAllButton = async () => { const buttonRect = await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const button = root.querySelector( "[data-react-grab-history-copy-all]", ); if (!button) return null; const rect = button.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }, ATTRIBUTE_NAME); if (buttonRect) { await page.mouse.move( buttonRect.x + buttonRect.width / 2, buttonRect.y + buttonRect.height / 2, ); await page.waitForTimeout(100); } }; const clickToolbarCopyAll = async () => { await clickShadowRootButton("[data-react-grab-toolbar-copy-all]"); }; const isToolbarCopyAllVisible = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const copyAllButton = root.querySelector( "[data-react-grab-toolbar-copy-all]", ); if (!copyAllButton) return false; const gridParent = copyAllButton.parentElement?.parentElement; if (!gridParent) return false; const computedStyle = window.getComputedStyle(gridParent); return computedStyle.opacity !== "0"; }, ATTRIBUTE_NAME); }; const isClearHistoryPromptVisible = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const prompt = root.querySelector( "[data-react-grab-clear-history-prompt]", ); if (!prompt) return false; return getComputedStyle(prompt).pointerEvents !== "none"; }, ATTRIBUTE_NAME); }; const confirmClearHistoryPrompt = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const prompt = root.querySelector( "[data-react-grab-clear-history-prompt]", ); if (!prompt) return; const yesButton = prompt.querySelector( "[data-react-grab-discard-yes]", ); yesButton?.click(); }, ATTRIBUTE_NAME); }; const cancelClearHistoryPrompt = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const prompt = root.querySelector( "[data-react-grab-clear-history-prompt]", ); if (!prompt) return; const noButton = prompt.querySelector( "[data-react-grab-discard-no]", ); noButton?.click(); }, ATTRIBUTE_NAME); }; const getHistoryDropdownPosition = async (): Promise<{ left: number; top: number; } | null> => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const dropdown = root.querySelector( "[data-react-grab-history-dropdown]", ); if (!dropdown) return null; return { left: parseFloat(dropdown.style.left), top: parseFloat(dropdown.style.top), }; }, ATTRIBUTE_NAME); }; const getSelectionLabelInfo = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return { isVisible: false, tagName: null, componentName: null, status: null, elementsCount: null, filePath: null, }; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return { isVisible: false, tagName: null, componentName: null, status: null, elementsCount: null, filePath: null, }; const label = root.querySelector("[data-react-grab-selection-label]"); if (!label) return { isVisible: false, tagName: null, componentName: null, status: null, elementsCount: null, filePath: null, }; let tagName: string | null = null; let componentName: string | null = null; let elementsCount: number | null = null; const allSpans = Array.from(label.querySelectorAll("span")); for (const span of allSpans) { const spanText = span.textContent?.trim() ?? ""; if (spanText.includes("elements")) { const match = spanText.match(/(\d+)\s*elements/); elementsCount = match ? parseInt(match[1], 10) : null; } else if (spanText.includes(".")) { const parts = spanText.split("."); componentName = parts[0] ?? null; tagName = parts[1] ?? null; } else if (spanText && !spanText.includes("Editing") && !tagName) { tagName = spanText; } } const statusElement = label.querySelector(".animate-pulse"); const status = statusElement ? "copying" : "idle"; return { isVisible: true, tagName, componentName, status, elementsCount, filePath: null, }; }, ATTRIBUTE_NAME); }; const getSelectionLabelBounds = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const label = root.querySelector( "[data-react-grab-selection-label]", ); if (!label) return null; const toRect = (rect: DOMRect) => ({ x: rect.x, y: rect.y, width: rect.width, height: rect.height, }); const arrowElement = label.querySelector( "[data-react-grab-arrow]", ); return { label: toRect(label.getBoundingClientRect()), arrow: arrowElement ? toRect(arrowElement.getBoundingClientRect()) : null, viewport: { width: window.innerWidth, height: window.innerHeight }, }; }, ATTRIBUTE_NAME); }; const isSelectionLabelVisible = async (): Promise => { const info = await getSelectionLabelInfo(); return info.isVisible; }; const waitForSelectionLabel = async () => { await page.waitForFunction( (attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const label = root.querySelector("[data-react-grab-selection-label]"); return label !== null; }, ATTRIBUTE_NAME, { timeout: 2000 }, ); }; const getLabelStatusText = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const pulsingElements = Array.from( root.querySelectorAll(".animate-pulse"), ); for (let i = 0; i < pulsingElements.length; i++) { const element = pulsingElements[i]; const text = element.textContent?.trim(); if (text) return text; } const completedTexts = ["Copied", "Completed", "Done"]; for (let i = 0; i < completedTexts.length; i++) { const text = completedTexts[i]; if (root.textContent?.includes(text)) return text; } return null; }, ATTRIBUTE_NAME); }; const getGrabbedBoxInfo = async (): Promise => { return page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { grabbedBoxes: Array<{ id: string; bounds: { x: number; y: number; width: number; height: number }; createdAt: number; }>; }; }; } ).__REACT_GRAB__; const state = api?.getState(); const grabbedBoxes = state?.grabbedBoxes ?? []; return { count: grabbedBoxes.length, boxes: grabbedBoxes.map((box) => ({ id: box.id, bounds: box.bounds, })), }; }); }; const getLabelInstancesInfo = async (): Promise => { return page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { labelInstances: Array<{ id: string; status: string; tagName: string; componentName?: string; createdAt: number; }>; }; }; } ).__REACT_GRAB__; const state = api?.getState(); return (state?.labelInstances ?? []).map((instance) => ({ id: instance.id, status: instance.status, tagName: instance.tagName, componentName: instance.componentName, createdAt: instance.createdAt, })); }); }; const isGrabbedBoxVisible = async (): Promise => { return page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { grabbedBoxes: Array<{ id: string }>; }; }; } ).__REACT_GRAB__; const state = api?.getState(); return (state?.grabbedBoxes?.length ?? 0) > 0; }); }; const getDragBoxBounds = async (): Promise<{ x: number; y: number; width: number; height: number; } | null> => { return page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { isDragBoxVisible: boolean; dragBounds: { x: number; y: number; width: number; height: number; } | null; }; }; } ).__REACT_GRAB__; const state = api?.getState(); if (!state?.isDragBoxVisible || !state?.dragBounds) return null; return state.dragBounds; }); }; const getSelectionBoxBounds = async (): Promise<{ x: number; y: number; width: number; height: number; } | null> => { return page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { isSelectionBoxVisible: boolean; targetElement: Element | null; }; }; } ).__REACT_GRAB__; const state = api?.getState(); if (!state?.isSelectionBoxVisible || !state?.targetElement) return null; const rect = state.targetElement.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; } return null; }); }; const getState = async (): Promise => { return page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => ReactGrabState } } ).__REACT_GRAB__; const state = api?.getState(); return ( state ?? { isActive: false, isDragging: false, isCopying: false, isPromptMode: false, targetElement: false, dragBounds: null, grabbedBoxes: [], labelInstances: [], } ); }); }; const toggle = async () => { const wasActive = await isOverlayVisible(); await page.evaluate(() => { const api = (window as { __REACT_GRAB__?: { toggle: () => void } }) .__REACT_GRAB__; api?.toggle(); }); await waitForActive(!wasActive); }; const dispose = async () => { await page.evaluate(() => { const api = (window as { __REACT_GRAB__?: { dispose: () => void } }) .__REACT_GRAB__; api?.dispose(); }); }; const copyElementViaApi = async (selector: string): Promise => { return page.evaluate(async (sel) => { const api = ( window as { __REACT_GRAB__?: { copyElement: (el: Element) => Promise }; } ).__REACT_GRAB__; const element = document.querySelector(sel); if (!element || !api) return false; return api.copyElement(element); }, selector); }; const setAgent = async (options: Record) => { await page.evaluate((opts) => { const api = ( window as { __REACT_GRAB__?: { unregisterPlugin: (name: string) => void; registerPlugin: (plugin: { name: string; actions: Array>; }) => void; }; } ).__REACT_GRAB__; api?.unregisterPlugin("test-agent"); const agent = opts; api?.registerPlugin({ name: "test-agent", actions: [ { id: "edit-with-test-agent", label: "Edit", shortcut: "Enter", onAction: (context: { enterPromptMode?: (agent?: Record) => void; }) => { context.enterPromptMode?.(agent); }, agent, }, ], }); }, options); }; const updateOptions = async (options: Record) => { await page.evaluate((opts) => { const api = ( window as { __REACT_GRAB__?: { setOptions: (o: Record) => void; unregisterPlugin: (name: string) => void; registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; const pluginKeys = ["theme", "actions"]; const hookKeys = [ "onActivate", "onDeactivate", "onElementHover", "onElementSelect", "onDragStart", "onDragEnd", "onBeforeCopy", "onAfterCopy", "onCopySuccess", "onCopyError", "onStateChange", "onPromptModeChange", "onSelectionBox", "onDragBox", "onGrabbedBox", "onContextMenu", "onOpenFile", "onElementLabel", ]; const pluginOpts: Record = {}; const hooks: Record = {}; const regularOpts: Record = {}; for (const [key, value] of Object.entries(opts)) { if (pluginKeys.includes(key)) { pluginOpts[key] = value; } else if (hookKeys.includes(key)) { hooks[key] = value; } else { regularOpts[key] = value; } } if (Object.keys(regularOpts).length > 0) { api?.setOptions(regularOpts); } if (Object.keys(pluginOpts).length > 0 || Object.keys(hooks).length > 0) { api?.unregisterPlugin("test-options"); api?.registerPlugin({ name: "test-options", ...pluginOpts, ...(Object.keys(hooks).length > 0 ? { hooks } : {}), }); } }, options); }; const reinitialize = async (options?: Record) => { await page.evaluate((opts) => { const existingApi = ( window as { __REACT_GRAB__?: { dispose: () => void } } ).__REACT_GRAB__; existingApi?.dispose(); const initFn = ( window as { initReactGrab?: (o?: Record) => void } ).initReactGrab; initFn?.(opts); }, options); await page.waitForFunction( () => { const api = (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__; return api !== undefined; }, undefined, { timeout: 5000 }, ); }; const setupMockAgent = async (options?: { delay?: number; error?: string; statusUpdates?: string[]; }) => { await page.evaluate((opts) => { const delay = opts?.delay ?? 500; const error = opts?.error; const statusUpdates = opts?.statusUpdates ?? [ "Processing...", "Almost done...", ]; const mockProvider = { async *send() { for (let i = 0; i < statusUpdates.length; i++) { yield statusUpdates[i]; await new Promise((resolve) => setTimeout(resolve, delay / statusUpdates.length), ); } if (error) { throw new Error(error); } yield "Completed"; }, supportsFollowUp: true, undo: async () => {}, canUndo: () => true, redo: async () => {}, canRedo: () => true, }; const api = ( window as { __REACT_GRAB__?: { unregisterPlugin: (name: string) => void; registerPlugin: (plugin: { name: string; actions: Array>; }) => void; }; } ).__REACT_GRAB__; api?.unregisterPlugin("mock-agent"); const agent = { provider: mockProvider }; api?.registerPlugin({ name: "mock-agent", actions: [ { id: "edit-with-mock-agent", label: "Edit", shortcut: "Enter", onAction: (context: { enterPromptMode?: (agent?: Record) => void; }) => { context.enterPromptMode?.(agent); }, agent, }, ], }); }, options); }; const getAgentSessions = async (): Promise => { return page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return []; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return []; const sessions: AgentSessionInfo[] = []; const sessionElements = root.querySelectorAll( "[data-react-grab-ignore-events]", ); sessionElements.forEach((element) => { const textContent = element.textContent ?? ""; if ( textContent.includes("Processing") || textContent.includes("Completed") || textContent.includes("Error") ) { const statusMatch = textContent.match( /(Processing|Completed|Error|Grabbing)/, ); sessions.push({ id: `session-${sessions.length}`, status: statusMatch?.[1] ?? "unknown", isStreaming: textContent.includes("Processing") || textContent.includes("Grabbing"), error: textContent.includes("Error") ? textContent : null, prompt: "", }); } }); return sessions; }, ATTRIBUTE_NAME); }; const isAgentSessionVisible = async (): Promise => { const sessions = await getAgentSessions(); return sessions.length > 0; }; const waitForAgentSession = async (timeout = 5000) => { await page.waitForFunction( (attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const sessionElements = Array.from( root.querySelectorAll("[data-react-grab-ignore-events]"), ); for (let i = 0; i < sessionElements.length; i++) { const text = sessionElements[i].textContent ?? ""; if ( text.includes("Processing") || text.includes("Completed") || text.includes("Error") || text.includes("Grabbing") ) { return true; } } return false; }, ATTRIBUTE_NAME, { timeout }, ); }; const waitForAgentComplete = async (timeout = 10000) => { await page.waitForFunction( (attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const sessionElements = Array.from( root.querySelectorAll("[data-react-grab-ignore-events]"), ); let hasSession = false; let isStreaming = false; for (let i = 0; i < sessionElements.length; i++) { const text = sessionElements[i].textContent ?? ""; if ( text.includes("Processing") || text.includes("Completed") || text.includes("Error") || text.includes("Grabbing") ) { hasSession = true; if (text.includes("Processing") || text.includes("Grabbing")) { isStreaming = true; } } } return hasSession && !isStreaming; }, ATTRIBUTE_NAME, { timeout }, ); }; const clickAgentDismiss = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const dismissButton = root.querySelector( "[data-react-grab-dismiss]", ); dismissButton?.click(); }, ATTRIBUTE_NAME); }; const clickAgentUndo = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const undoButton = root.querySelector( "[data-react-grab-undo]", ); undoButton?.click(); }, ATTRIBUTE_NAME); }; const clickAgentRetry = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const retryButton = root.querySelector( "[data-react-grab-retry]", ); retryButton?.click(); }, ATTRIBUTE_NAME); }; const clickAgentAbort = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const abortButton = root.querySelector( "[data-react-grab-abort]", ); abortButton?.click(); }, ATTRIBUTE_NAME); }; const confirmAgentAbort = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const yesButton = root.querySelector( "[data-react-grab-discard-yes]", ); yesButton?.click(); }, ATTRIBUTE_NAME); }; const cancelAgentAbort = async () => { await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const noButton = root.querySelector( "[data-react-grab-discard-no]", ); noButton?.click(); }, ATTRIBUTE_NAME); }; const dispatchPointerEvent = async ( type: "pointerdown" | "pointermove" | "pointerup", x: number, y: number, pointerId = 1, ) => { await page.evaluate( ({ type, x, y, pointerId }) => { const target = document.elementFromPoint(x, y) || document.body; const pointerEvent = new PointerEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, screenX: x, screenY: y, pointerId, pointerType: "touch", isPrimary: true, button: type === "pointermove" ? -1 : 0, buttons: type === "pointerup" ? 0 : 1, }); target.dispatchEvent(pointerEvent); }, { type, x, y, pointerId }, ); }; const touchStart = async (x: number, y: number) => { await dispatchPointerEvent("pointerdown", x, y); }; const touchMove = async (x: number, y: number) => { await dispatchPointerEvent("pointermove", x, y); }; const touchEnd = async (x: number, y: number) => { await dispatchPointerEvent("pointerup", x, y); }; const touchTap = async (selector: string) => { const element = page.locator(selector).first(); const box = await element.boundingBox(); if (box) { await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); } }; const touchDrag = async ( startX: number, startY: number, endX: number, endY: number, ) => { await dispatchPointerEvent("pointerdown", startX, startY); const steps = 10; for (let i = 1; i <= steps; i++) { const currentX = startX + ((endX - startX) * i) / steps; const currentY = startY + ((endY - startY) * i) / steps; await dispatchPointerEvent("pointermove", currentX, currentY); } await dispatchPointerEvent("pointerup", endX, endY); }; const isTouchMode = async (): Promise => { return page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { isTouchMode?: boolean } }; } ).__REACT_GRAB__; return ( (api?.getState() as { isTouchMode?: boolean })?.isTouchMode ?? false ); }); }; const setViewportSize = async (width: number, height: number) => { await page.setViewportSize({ width, height }); await page.waitForFunction( ({ expectedWidth, expectedHeight }) => window.innerWidth === expectedWidth && window.innerHeight === expectedHeight, { expectedWidth: width, expectedHeight: height }, { timeout: 2000 }, ); await page.evaluate(() => { window.dispatchEvent(new Event("resize")); }); }; const getViewportSize = async (): Promise<{ width: number; height: number; }> => { return page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, })); }; const removeElement = async (selector: string) => { await page.evaluate((sel) => { const element = document.querySelector(sel); element?.remove(); }, selector); }; const hideElement = async (selector: string) => { await page.evaluate((sel) => { const element = document.querySelector(sel) as HTMLElement; if (element) element.style.display = "none"; }, selector); }; const showElement = async (selector: string) => { await page.evaluate((sel) => { const element = document.querySelector(sel) as HTMLElement; if (element) element.style.display = ""; }, selector); }; const getElementBounds = async ( selector: string, ): Promise<{ x: number; y: number; width: number; height: number; } | null> => { const element = page.locator(selector).first(); const box = await element.boundingBox(); return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null; }; const isDropdownOpen = async (): Promise => { const dropdownMenu = page.locator('[data-testid="dropdown-menu"]'); return dropdownMenu.isVisible(); }; const openDropdown = async () => { const trigger = page.locator('[data-testid="dropdown-trigger"]'); await trigger.click(); await page.waitForSelector('[data-testid="dropdown-menu"]', { state: "visible", timeout: 2000, }); }; const setupCallbackTracking = async () => { await page.evaluate(() => { ( window as { __CALLBACK_HISTORY__?: Array<{ name: string; args: unknown[]; timestamp: number; }>; } ).__CALLBACK_HISTORY__ = []; const trackCallback = (name: string) => (...args: unknown[]) => { ( window as { __CALLBACK_HISTORY__?: Array<{ name: string; args: unknown[]; timestamp: number; }>; } ).__CALLBACK_HISTORY__?.push({ name, args, timestamp: Date.now() }); }; const api = ( window as { __REACT_GRAB__?: { unregisterPlugin: (name: string) => void; registerPlugin: (plugin: { name: string; hooks: Record; }) => void; }; } ).__REACT_GRAB__; api?.unregisterPlugin("callback-tracking"); api?.registerPlugin({ name: "callback-tracking", hooks: { onActivate: trackCallback("onActivate"), onDeactivate: trackCallback("onDeactivate"), onElementHover: trackCallback("onElementHover"), onElementSelect: trackCallback("onElementSelect"), onDragStart: trackCallback("onDragStart"), onDragEnd: trackCallback("onDragEnd"), onBeforeCopy: trackCallback("onBeforeCopy"), onAfterCopy: trackCallback("onAfterCopy"), onCopySuccess: trackCallback("onCopySuccess"), onCopyError: trackCallback("onCopyError"), onStateChange: trackCallback("onStateChange"), onPromptModeChange: trackCallback("onPromptModeChange"), onSelectionBox: trackCallback("onSelectionBox"), onDragBox: trackCallback("onDragBox"), onGrabbedBox: trackCallback("onGrabbedBox"), onContextMenu: trackCallback("onContextMenu"), onOpenFile: trackCallback("onOpenFile"), }, }); }); }; const getCallbackHistory = async (): Promise< Array<{ name: string; args: unknown[]; timestamp: number }> > => { return page.evaluate(() => { return ( ( window as { __CALLBACK_HISTORY__?: Array<{ name: string; args: unknown[]; timestamp: number; }>; } ).__CALLBACK_HISTORY__ ?? [] ); }); }; const clearCallbackHistory = async () => { await page.evaluate(() => { ( window as { __CALLBACK_HISTORY__?: Array<{ name: string; args: unknown[]; timestamp: number; }>; } ).__CALLBACK_HISTORY__ = []; }); }; const waitForCallback = async ( name: string, timeout = 5000, ): Promise => { await page.waitForFunction( (callbackName) => { const history = (window as { __CALLBACK_HISTORY__?: Array<{ name: string }> }) .__CALLBACK_HISTORY__ ?? []; return history.some((c) => c.name === callbackName); }, name, { timeout }, ); const history = await getCallbackHistory(); const callback = history.find((c) => c.name === name); return callback?.args ?? []; }; return { page, modifierKey: MODIFIER_KEY, activate, activateViaKeyboard, deactivate, holdToActivate, isOverlayVisible, getOverlayHost, getShadowRoot, hoverElement, clickElement, rightClickElement, rightClickAtPosition, dragSelect, getClipboardContent, captureNextClipboardWrites, waitForSelectionBox, waitForSelectionSource, isContextMenuVisible, getContextMenuInfo, isContextMenuItemEnabled, clickContextMenuItem, isSelectionBoxVisible, pressEscape, pressArrowDown, pressArrowUp, pressArrowLeft, pressArrowRight, pressEnter, pressKey, pressKeyCombo, pressModifierKeyCombo, scrollPage, enterPromptMode, isPromptModeActive, typeInInput, getInputValue, submitInput, clearInput, isPendingDismissVisible, isToolbarVisible, isToolbarCollapsed, getToolbarInfo, clickToolbarToggle, clickToolbarCollapse, clickToolbarEnabled, dragToolbar, dragToolbarFromButton, isToolbarMenuButtonVisible, clickToolbarMenuButton, isToolbarMenuVisible, getToolbarMenuInfo, clickToolbarMenuItem, isHistoryButtonVisible, hasUnreadHistoryIndicator, clickHistoryButton, isHistoryDropdownVisible, getHistoryDropdownInfo, clickHistoryItem, clickHistoryItemRemove, clickHistoryItemCopy, clickHistoryCopyAll, clickHistoryClear, hoverHistoryItem, hoverHistoryButton, hoverCopyAllButton, clickToolbarCopyAll, isToolbarCopyAllVisible, isClearHistoryPromptVisible, confirmClearHistoryPrompt, cancelClearHistoryPrompt, getHistoryDropdownPosition, getSelectionLabelInfo, getSelectionLabelBounds, isSelectionLabelVisible, waitForSelectionLabel, getLabelStatusText, getGrabbedBoxInfo, getLabelInstancesInfo, isGrabbedBoxVisible, getDragBoxBounds, getSelectionBoxBounds, getState, toggle, dispose, copyElementViaApi, setAgent, updateOptions, reinitialize, setupMockAgent, getAgentSessions, isAgentSessionVisible, waitForAgentSession, waitForAgentComplete, clickAgentDismiss, clickAgentUndo, clickAgentRetry, clickAgentAbort, confirmAgentAbort, cancelAgentAbort, touchStart, touchMove, touchEnd, touchTap, touchDrag, isTouchMode, setViewportSize, getViewportSize, removeElement, hideElement, showElement, getElementBounds, isDropdownOpen, openDropdown, setupCallbackTracking, getCallbackHistory, clearCallbackHistory, waitForCallback, }; }; export const test = base.extend<{ reactGrab: ReactGrabPageObject }>({ reactGrab: async ({ page }, use) => { const waitForApiReady = async () => { await page.waitForFunction( () => { const api = (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__; return api !== undefined; }, undefined, { timeout: PAGE_SETUP_API_TIMEOUT_MS }, ); }; const initializePage = async () => { let lastError: unknown; for ( let attemptIndex = 0; attemptIndex < PAGE_SETUP_MAX_ATTEMPTS; attemptIndex++ ) { if (page.isClosed()) { throw new Error("Browser page closed during reactGrab fixture setup"); } try { await page.goto("/", { waitUntil: "domcontentloaded", timeout: PAGE_SETUP_NAVIGATION_TIMEOUT_MS, }); await waitForApiReady(); return; } catch (error) { lastError = error; if (page.isClosed()) { throw lastError; } if (attemptIndex === PAGE_SETUP_MAX_ATTEMPTS - 1) { throw lastError; } // HACK: brief backoff helps when dev server is under heavy parallel load. await new Promise((resolve) => { setTimeout(resolve, 250 * (attemptIndex + 1)); }); } } }; await initializePage(); const reactGrab = createReactGrabPageObject(page); await use(reactGrab); }, }); export { expect }; ================================================ FILE: packages/react-grab/e2e/focus-trap.spec.ts ================================================ import { test, expect } from "./fixtures.js"; const FOCUS_TRAP_CONTAINER_ID = "focus-trap-test-container"; const injectFocusTrap = async (page: import("@playwright/test").Page) => { await page.evaluate((containerId) => { const container = document.createElement("div"); container.id = containerId; container.innerHTML = `

    Focus-trapped Modal

    `; document.body.appendChild(container); const modal = document.getElementById("focus-trap-modal")!; const focusableSelector = 'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'; const getFocusableElements = () => Array.from(modal.querySelectorAll(focusableSelector)) as HTMLElement[]; const focusInHandler = (event: FocusEvent) => { const target = event.target as Node; if (!modal.contains(target)) { event.stopImmediatePropagation(); const focusable = getFocusableElements(); if (focusable.length > 0) { focusable[0].focus(); } } }; document.addEventListener("focusin", focusInHandler, true); const keydownHandler = (event: KeyboardEvent) => { if (event.key !== "Tab") return; const focusable = getFocusableElements(); if (focusable.length === 0) return; const firstElement = focusable[0]; const lastElement = focusable[focusable.length - 1]; if (event.shiftKey) { if (document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } } }; document.addEventListener("keydown", keydownHandler, true); (window as { __FOCUS_TRAP_CLEANUP__?: () => void }).__FOCUS_TRAP_CLEANUP__ = () => { document.removeEventListener("focusin", focusInHandler, true); document.removeEventListener("keydown", keydownHandler, true); }; const firstInput = document.getElementById("trap-input-1"); firstInput?.focus(); }, FOCUS_TRAP_CONTAINER_ID); }; const removeFocusTrap = async (page: import("@playwright/test").Page) => { await page.evaluate((containerId) => { ( window as { __FOCUS_TRAP_CLEANUP__?: () => void } ).__FOCUS_TRAP_CLEANUP__?.(); document.getElementById(containerId)?.remove(); }, FOCUS_TRAP_CONTAINER_ID); }; test.describe("Focus Trap Resistance", () => { test.afterEach(async ({ reactGrab }) => { await removeFocusTrap(reactGrab.page); }); test.describe("Activation", () => { test("should activate via API while focus trap is active", async ({ reactGrab, }) => { await injectFocusTrap(reactGrab.page); await reactGrab.activate(); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); test("should deactivate with Escape while focus trap is active", async ({ reactGrab, }) => { await injectFocusTrap(reactGrab.page); await reactGrab.activate(); await reactGrab.deactivate(); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(false); }); }); test.describe("Element Selection", () => { test("should hover and select elements behind focus trap backdrop", async ({ reactGrab, }) => { await reactGrab.activate(); await injectFocusTrap(reactGrab.page); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should select elements inside the focus-trapped modal", async ({ reactGrab, }) => { await injectFocusTrap(reactGrab.page); await reactGrab.activate(); await reactGrab.hoverElement("#trap-button"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should update selection when hovering different elements", async ({ reactGrab, }) => { await injectFocusTrap(reactGrab.page); await reactGrab.activate(); await reactGrab.hoverElement("#trap-input-1"); await reactGrab.waitForSelectionBox(); const bounds1 = await reactGrab.getSelectionBoxBounds(); await reactGrab.hoverElement("#trap-button"); await reactGrab.waitForSelectionBox(); const bounds2 = await reactGrab.getSelectionBoxBounds(); if (bounds1 && bounds2) { const didSelectionChange = bounds1.y !== bounds2.y || bounds1.height !== bounds2.height; expect(didSelectionChange).toBe(true); } }); }); test.describe("Copy", () => { test("should copy element while focus trap is active", async ({ reactGrab, }) => { await injectFocusTrap(reactGrab.page); await reactGrab.activate(); await reactGrab.hoverElement("#trap-button"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("#trap-button"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 2000 }) .toBeTruthy(); }); test("should copy element outside modal while focus trap is active", async ({ reactGrab, }) => { await reactGrab.activate(); await injectFocusTrap(reactGrab.page); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("h1"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 2000 }) .toBeTruthy(); }); }); test.describe("Prompt Mode", () => { test("should enter prompt mode while focus trap is active", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await injectFocusTrap(reactGrab.page); await reactGrab.enterPromptMode("li:first-child"); const isPromptMode = await reactGrab.isPromptModeActive(); expect(isPromptMode).toBe(true); }); test("textarea should receive typed input despite focus trap", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await injectFocusTrap(reactGrab.page); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Hello from inside focus trap"); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toBe("Hello from inside focus trap"); }); test("should submit prompt while focus trap is active", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 100 }); await injectFocusTrap(reactGrab.page); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Test prompt"); await reactGrab.submitInput(); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); test("Escape should dismiss prompt mode despite focus trap", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await injectFocusTrap(reactGrab.page); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.pressEscape(); await reactGrab.pressEscape(); await expect .poll(() => reactGrab.isOverlayVisible(), { timeout: 5000 }) .toBe(false); }); }); test.describe("Context Menu", () => { test("should open context menu while focus trap is active", async ({ reactGrab, }) => { await injectFocusTrap(reactGrab.page); await reactGrab.activate(); await reactGrab.hoverElement("#trap-button"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("#trap-button"); const isVisible = await reactGrab.isContextMenuVisible(); expect(isVisible).toBe(true); }); }); test.describe("Keyboard Navigation", () => { test("arrow key navigation should work while focus trap is active", async ({ reactGrab, }) => { await injectFocusTrap(reactGrab.page); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.waitForSelectionBox(); const isActive = await reactGrab.isOverlayVisible(); const isSelectionVisible = await reactGrab.isSelectionBoxVisible(); expect(isActive).toBe(true); expect(isSelectionVisible).toBe(true); }); test("Escape should deactivate from selection while focus trap is active", async ({ reactGrab, }) => { await injectFocusTrap(reactGrab.page); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.deactivate(); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(false); }); }); test.describe("Focus Trap Lifecycle", () => { test("should continue working after focus trap is removed", async ({ reactGrab, }) => { await injectFocusTrap(reactGrab.page); await reactGrab.activate(); await reactGrab.hoverElement("#trap-button"); await reactGrab.waitForSelectionBox(); await removeFocusTrap(reactGrab.page); await reactGrab.page.waitForTimeout(100); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should work when focus trap appears after activation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await injectFocusTrap(reactGrab.page); await reactGrab.page.waitForTimeout(100); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); }); }); ================================================ FILE: packages/react-grab/e2e/freeze-animations.spec.ts ================================================ import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures.js"; const ATTRIBUTE_NAME = "data-react-grab"; const simulateGsapPresence = (page: Page): Promise => page.evaluate(() => { (window as unknown as Record).gsapVersions = ["3.12.0"]; }); const navigateAndWaitForReactGrab = async (page: Page): Promise => { await page.goto("/", { waitUntil: "domcontentloaded" }); await page.waitForFunction( () => (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__ !== undefined, { timeout: 10000 }, ); }; const activateViaApi = (page: Page): Promise => page.evaluate(() => { ( window as unknown as { __REACT_GRAB__: { activate: () => void } } ).__REACT_GRAB__.activate(); }); const deactivateViaApi = (page: Page): Promise => page.evaluate(() => { ( window as unknown as { __REACT_GRAB__: { deactivate: () => void } } ).__REACT_GRAB__.deactivate(); }); test.describe("Freeze Animations", () => { test.describe("Page Animation Freezing", () => { test("should pause page animations when activated", async ({ reactGrab, }) => { const getPageAnimationStates = async () => { return reactGrab.page.evaluate((attrName) => { return document .getAnimations() .reduce((states, animation) => { if (animation.effect instanceof KeyframeEffect) { const target = animation.effect.target; if (target instanceof Element) { const rootNode = target.getRootNode(); if ( rootNode instanceof ShadowRoot && rootNode.host.hasAttribute(attrName) ) { return states; } } } states.push(animation.playState); return states; }, []); }, ATTRIBUTE_NAME); }; const statesBefore = await getPageAnimationStates(); expect(statesBefore.length).toBeGreaterThan(0); expect(statesBefore.every((state) => state === "running")).toBe(true); await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); const statesDuring = await getPageAnimationStates(); expect(statesDuring.every((state) => state === "paused")).toBe(true); }); test("should not leave page animations in paused state after deactivation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(100); const pausedPageAnimationCount = await reactGrab.page.evaluate( (attrName) => { return document.getAnimations().filter((animation) => { if (animation.effect instanceof KeyframeEffect) { const target = animation.effect.target; if (target instanceof Element) { const rootNode = target.getRootNode(); if ( rootNode instanceof ShadowRoot && rootNode.host.hasAttribute(attrName) ) { return false; } } } return animation.playState === "paused"; }).length; }, ATTRIBUTE_NAME, ); expect(pausedPageAnimationCount).toBe(0); }); test("should not leave global freeze style element in document after deactivation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); const hasFreezeStyleDuring = await reactGrab.page.evaluate(() => { return ( document.querySelector("[data-react-grab-global-freeze]") !== null ); }); expect(hasFreezeStyleDuring).toBe(true); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(100); const hasFreezeStyleAfter = await reactGrab.page.evaluate(() => { return ( document.querySelector("[data-react-grab-global-freeze]") !== null ); }); expect(hasFreezeStyleAfter).toBe(false); }); }); test.describe("React Grab UI Preservation", () => { test("should not finish react-grab shadow DOM animations on deactivation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.waitForTimeout(200); const shadowAnimationCountBefore = await reactGrab.page.evaluate( (attrName) => { return document.getAnimations().filter((animation) => { if (animation.effect instanceof KeyframeEffect) { const target = animation.effect.target; if (target instanceof Element) { const rootNode = target.getRootNode(); return ( rootNode instanceof ShadowRoot && rootNode.host.hasAttribute(attrName) ); } } return false; }).length; }, ATTRIBUTE_NAME, ); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(100); const shadowAnimationCountAfter = await reactGrab.page.evaluate( (attrName) => { return document.getAnimations().filter((animation) => { if (animation.effect instanceof KeyframeEffect) { const target = animation.effect.target; if (target instanceof Element) { const rootNode = target.getRootNode(); return ( rootNode instanceof ShadowRoot && rootNode.host.hasAttribute(attrName) ); } } return false; }).length; }, ATTRIBUTE_NAME, ); if (shadowAnimationCountBefore > 0) { expect(shadowAnimationCountAfter).toBe(shadowAnimationCountBefore); } }); test("toolbar should remain visible after activation cycle", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.activate(); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(200); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); }); test("toolbar should remain functional after activation cycle", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(200); await reactGrab.clickToolbarToggle(); expect(await reactGrab.isOverlayVisible()).toBe(true); await reactGrab.clickToolbarToggle(); expect(await reactGrab.isOverlayVisible()).toBe(false); }); test("selection label should be visible during hover after prior activation cycle", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(200); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionLabel(); const labelInfo = await reactGrab.getSelectionLabelInfo(); expect(labelInfo.isVisible).toBe(true); }); }); test.describe("Freeze/Unfreeze Cycles", () => { test("should handle rapid activation cycles without breaking animations", async ({ reactGrab, }) => { for (let iteration = 0; iteration < 5; iteration++) { await reactGrab.activate(); await reactGrab.page.waitForTimeout(50); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(50); } const hasFreezeStyle = await reactGrab.page.evaluate(() => { return ( document.querySelector("[data-react-grab-global-freeze]") !== null ); }); expect(hasFreezeStyle).toBe(false); const toolbarVisible = await reactGrab.isToolbarVisible(); expect(toolbarVisible).toBe(true); }); test("should correctly freeze animations after reactivation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(200); await reactGrab.page.evaluate(() => { const element = document.querySelector( "[data-testid='animated-section']", ); if (element) { const child = document.createElement("div"); child.className = "animate-ping w-4 h-4 bg-yellow-500 rounded-full"; child.setAttribute("data-testid", "injected-animation"); element.appendChild(child); } }); await reactGrab.page.waitForTimeout(100); await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); const pausedAnimationCount = await reactGrab.page.evaluate((attrName) => { return document.getAnimations().filter((animation) => { if (animation.effect instanceof KeyframeEffect) { const target = animation.effect.target; if (target instanceof Element) { const rootNode = target.getRootNode(); if ( rootNode instanceof ShadowRoot && rootNode.host.hasAttribute(attrName) ) { return false; } } } return animation.playState === "paused"; }).length; }, ATTRIBUTE_NAME); expect(pausedAnimationCount).toBeGreaterThan(0); await reactGrab.deactivate(); }); test("should not leave stale freeze styles after toolbar hover cycle", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.waitForTimeout(200); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(200); const hasFreezeStyle = await reactGrab.page.evaluate(() => { return ( document.querySelector("[data-react-grab-global-freeze]") !== null ); }); expect(hasFreezeStyle).toBe(false); }); }); test.describe("Toolbar Hover Freeze", () => { test("should clean up freeze styles after toolbar hover cycle", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); const toolbarInfo = await reactGrab.getToolbarInfo(); if (toolbarInfo.position) { await reactGrab.page.mouse.move( toolbarInfo.position.x + 10, toolbarInfo.position.y + 10, ); await reactGrab.page.waitForTimeout(200); } await reactGrab.page.mouse.move(0, 0); await reactGrab.page.waitForTimeout(200); const hasFreezeStyle = await reactGrab.page.evaluate(() => { return ( document.querySelector("[data-react-grab-global-freeze]") !== null ); }); expect(hasFreezeStyle).toBe(false); }); }); test.describe("rAF Interception", () => { test("should wrap window.requestAnimationFrame and cancelAnimationFrame", async ({ reactGrab, }) => { const isWrapped = await reactGrab.page.evaluate(() => { const rafSource = window.requestAnimationFrame.toString(); const cafSource = window.cancelAnimationFrame.toString(); return ( !rafSource.includes("[native code]") && !cafSource.includes("[native code]") ); }); expect(isWrapped).toBe(true); }); test("should execute non-animation rAF callbacks during freeze", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); const didCallbackExecute = await reactGrab.page.evaluate(() => { return new Promise((resolve) => { window.requestAnimationFrame(() => resolve(true)); setTimeout(() => resolve(false), 1000); }); }); expect(didCallbackExecute).toBe(true); }); test("should hold animation library callbacks during freeze", async ({ reactGrab, }) => { await simulateGsapPresence(reactGrab.page); await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); const wasCallbackHeld = await reactGrab.page.evaluate(() => { return new Promise((resolve) => { // HACK: function named _tick simulates GSAP's internal tick, // detected via stack trace inspection in the rAF wrapper const _tick = () => { let didExecute = false; window.requestAnimationFrame(() => { didExecute = true; }); setTimeout(() => resolve(!didExecute), 200); }; _tick(); }); }); expect(wasCallbackHeld).toBe(true); }); test("should release held callbacks after unfreeze", async ({ reactGrab, }) => { await simulateGsapPresence(reactGrab.page); await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); await reactGrab.page.evaluate(() => { (window as unknown as Record).__GSAP_TEST_FLAG__ = false; // HACK: function named _tick simulates GSAP's internal tick, // detected via stack trace inspection in the rAF wrapper const _tick = () => { window.requestAnimationFrame(() => { (window as unknown as Record).__GSAP_TEST_FLAG__ = true; }); }; _tick(); }); await reactGrab.page.waitForTimeout(100); const wasHeldDuringFreeze = await reactGrab.page.evaluate( () => !(window as unknown as Record).__GSAP_TEST_FLAG__, ); expect(wasHeldDuringFreeze).toBe(true); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(200); const wasReleasedAfterUnfreeze = await reactGrab.page.evaluate( () => (window as unknown as Record).__GSAP_TEST_FLAG__, ); expect(wasReleasedAfterUnfreeze).toBe(true); }); test("should cancel held callbacks via cancelAnimationFrame", async ({ reactGrab, }) => { await simulateGsapPresence(reactGrab.page); await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); const wasCancelledWhileHeld = await reactGrab.page.evaluate(() => { return new Promise((resolve) => { let frameIdentifier: number; // HACK: function named _tick simulates GSAP's internal tick, // detected via stack trace inspection in the rAF wrapper const _tick = () => { frameIdentifier = window.requestAnimationFrame(() => { resolve(false); }); }; _tick(); window.cancelAnimationFrame(frameIdentifier!); setTimeout(() => resolve(true), 200); }); }); expect(wasCancelledWhileHeld).toBe(true); }); test("should cancel held callbacks across evaluate calls via returned id", async ({ reactGrab, }) => { await simulateGsapPresence(reactGrab.page); await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); const heldId = await reactGrab.page.evaluate(() => { (window as unknown as Record).__RACE_CANCEL_FLAG__ = false; let capturedId: number; // HACK: function named _tick simulates GSAP's internal tick, // detected via stack trace inspection in the rAF wrapper const _tick = () => { capturedId = window.requestAnimationFrame(() => { ( window as unknown as Record ).__RACE_CANCEL_FLAG__ = true; }); }; _tick(); return capturedId!; }); await reactGrab.page.waitForTimeout(100); await reactGrab.page.evaluate((identifier: number) => { window.cancelAnimationFrame(identifier); }, heldId); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(200); const didCallbackRun = await reactGrab.page.evaluate( () => (window as unknown as Record).__RACE_CANCEL_FLAG__, ); expect(didCallbackRun).toBe(false); }); test("should cancel replayed callbacks via fake id after unfreeze", async ({ page, }) => { await navigateAndWaitForReactGrab(page); await simulateGsapPresence(page); await activateViaApi(page); await page.waitForTimeout(100); const heldId = await page.evaluate(() => { ( window as unknown as Record ).__POST_UNFREEZE_CANCEL_FLAG__ = false; let capturedId: number; // HACK: function named _tick simulates GSAP's internal tick, // detected via stack trace inspection in the rAF wrapper const _tick = () => { capturedId = window.requestAnimationFrame(() => { ( window as unknown as Record ).__POST_UNFREEZE_CANCEL_FLAG__ = true; }); }; _tick(); return capturedId!; }); // HACK: Deactivate and cancel in the same evaluate to prevent the // replayed rAF callback from firing between the two round-trips await page.evaluate((identifier: number) => { ( window as unknown as { __REACT_GRAB__: { deactivate: () => void } } ).__REACT_GRAB__.deactivate(); window.cancelAnimationFrame(identifier); }, heldId); await page.waitForTimeout(200); const didCallbackRun = await page.evaluate( () => (window as unknown as Record) .__POST_UNFREEZE_CANCEL_FLAG__, ); expect(didCallbackRun).toBe(false); }); test("should not intercept callbacks after unfreeze", async ({ reactGrab, }) => { await simulateGsapPresence(reactGrab.page); await reactGrab.activate(); await reactGrab.page.waitForTimeout(100); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(100); const didCallbackExecuteNormally = await reactGrab.page.evaluate(() => { return new Promise((resolve) => { // HACK: function named _tick simulates GSAP's internal tick, // detected via stack trace inspection in the rAF wrapper const _tick = () => { window.requestAnimationFrame(() => resolve(true)); }; _tick(); setTimeout(() => resolve(false), 1000); }); }); expect(didCallbackExecuteNormally).toBe(true); }); }); test.describe("rAF Tick Loop Interception (ESM without window.gsap)", () => { test("should stop a _tick loop scheduled before freeze via rAF guard", async ({ page, }) => { await navigateAndWaitForReactGrab(page); await simulateGsapPresence(page); await page.evaluate(() => { (window as unknown as Record).__RAF_TICK_COUNT__ = 0; // HACK: function named _tick simulates GSAP's internal tick, // detected via stack trace inspection in the rAF wrapper const _tick = (): void => { (window as unknown as Record).__RAF_TICK_COUNT__++; window.requestAnimationFrame(_tick); }; window.requestAnimationFrame(_tick); }); await page.waitForTimeout(200); const tickCountBeforeFreeze = await page.evaluate( () => (window as unknown as Record).__RAF_TICK_COUNT__, ); expect(tickCountBeforeFreeze).toBeGreaterThan(0); await activateViaApi(page); await page.waitForTimeout(200); const tickCountAtFreeze = await page.evaluate( () => (window as unknown as Record).__RAF_TICK_COUNT__, ); await page.waitForTimeout(300); const tickCountAfterWaiting = await page.evaluate( () => (window as unknown as Record).__RAF_TICK_COUNT__, ); expect(tickCountAfterWaiting).toBe(tickCountAtFreeze); }); test("should resume _tick loop after unfreeze", async ({ page }) => { await navigateAndWaitForReactGrab(page); await simulateGsapPresence(page); await page.evaluate(() => { (window as unknown as Record).__RAF_TICK_COUNT__ = 0; // HACK: function named _tick simulates GSAP's internal tick, // detected via stack trace inspection in the rAF wrapper const _tick = (): void => { (window as unknown as Record).__RAF_TICK_COUNT__++; window.requestAnimationFrame(_tick); }; window.requestAnimationFrame(_tick); }); await page.waitForTimeout(200); await activateViaApi(page); await page.waitForTimeout(200); await deactivateViaApi(page); await page.waitForTimeout(100); const tickCountAfterUnfreeze = await page.evaluate( () => (window as unknown as Record).__RAF_TICK_COUNT__, ); await page.waitForTimeout(300); const tickCountLater = await page.evaluate( () => (window as unknown as Record).__RAF_TICK_COUNT__, ); expect(tickCountLater).toBeGreaterThan(tickCountAfterUnfreeze); }); }); }); ================================================ FILE: packages/react-grab/e2e/freeze-updates.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Freeze Updates", () => { test.describe("State Freezing During Prompt Mode", () => { test("should freeze React state updates when in prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 2000 }); const getElementCount = async () => { return reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-testid^='dynamic-element-']") .length; }); }; const initialCount = await getElementCount(); expect(initialCount).toBeGreaterThan(0); await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.page.evaluate(() => { const addButton = document.querySelector( "[data-testid='add-element-button']", ) as HTMLButtonElement; addButton?.click(); }); await reactGrab.page.waitForTimeout(100); const countDuringPromptMode = await getElementCount(); expect(countDuringPromptMode).toBe(initialCount); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(200); const countAfterExit = await getElementCount(); expect(countAfterExit).toBe(initialCount); }); test("should freeze visibility toggle during prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 2000 }); const isToggleableVisible = async () => { return reactGrab.page.evaluate(() => { return ( document.querySelector("[data-testid='toggleable-element']") !== null ); }); }; const initiallyVisible = await isToggleableVisible(); expect(initiallyVisible).toBe(true); await reactGrab.enterPromptMode("[data-testid='toggleable-element']"); await reactGrab.page.evaluate(() => { const button = document.querySelector( "[data-testid='toggle-visibility-button']", ) as HTMLButtonElement; button?.click(); }); await reactGrab.page.waitForTimeout(100); const stillVisibleDuringPromptMode = await isToggleableVisible(); expect(stillVisibleDuringPromptMode).toBe(true); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(200); const visibleAfterExit = await isToggleableVisible(); expect(visibleAfterExit).toBe(true); }); test("should allow state updates after exiting prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 100 }); const getElementCount = async () => { return reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-testid^='dynamic-element-']") .length; }); }; await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(200); const countBefore = await getElementCount(); await reactGrab.page.click("[data-testid='add-element-button']"); await reactGrab.page.waitForTimeout(100); const countAfter = await getElementCount(); expect(countAfter).toBe(countBefore + 1); }); }); test.describe("Multiple Freeze/Unfreeze Cycles", () => { test("should handle multiple prompt mode cycles correctly", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 100 }); const getElementCount = async () => { return reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-testid^='dynamic-element-']") .length; }); }; for (let i = 0; i < 2; i++) { const countBefore = await getElementCount(); await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(500); await reactGrab.page.click("[data-testid='add-element-button']"); await reactGrab.page.waitForTimeout(300); const countAfter = await getElementCount(); expect(countAfter).toBe(countBefore + 1); } }); test("should not leak frozen state after rapid activation cycles", async ({ reactGrab, }) => { for (let i = 0; i < 5; i++) { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.page.waitForTimeout(50); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(50); } const getElementCount = async () => { return reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-testid^='dynamic-element-']") .length; }); }; const countBefore = await getElementCount(); await reactGrab.page.click("[data-testid='add-element-button']"); await reactGrab.page.waitForTimeout(100); const countAfter = await getElementCount(); expect(countAfter).toBe(countBefore + 1); }); }); test.describe("Freeze State Consistency", () => { test("should maintain UI consistency during prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 2000 }); await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); const elementTextDuringFreeze = await reactGrab.page.evaluate(() => { const element = document.querySelector( "[data-testid='dynamic-element-1']", ); return element?.textContent?.trim() ?? ""; }); expect(elementTextDuringFreeze).toContain("Dynamic Element 1"); await reactGrab.pressEscape(); }); test("should unfreeze all components after exiting prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 100 }); await reactGrab.enterPromptMode("[data-testid='test-input']"); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(200); await reactGrab.page.fill("[data-testid='test-input']", "test value"); const inputValue = await reactGrab.page.evaluate(() => { const input = document.querySelector( "[data-testid='test-input']", ) as HTMLInputElement; return input?.value ?? ""; }); expect(inputValue).toBe("test value"); }); test("should resume updates after deactivation", async ({ reactGrab }) => { const getElementCount = async () => { return reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-testid^='dynamic-element-']") .length; }); }; await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='dynamic-section']"); await reactGrab.waitForSelectionBox(); await reactGrab.deactivate(); const countBefore = await getElementCount(); await reactGrab.page.click("[data-testid='add-element-button']"); await reactGrab.page.waitForTimeout(100); const countAfter = await getElementCount(); expect(countAfter).toBe(countBefore + 1); }); }); test.describe("Edge Cases", () => { test("should handle freeze when no React state is present", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 100 }); await reactGrab.enterPromptMode("[data-testid='main-title']"); const isPromptMode = await reactGrab.isPromptModeActive(); expect(isPromptMode).toBe(true); await reactGrab.pressEscape(); }); test("should handle deactivation during frozen state", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 2000 }); await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(200); const getElementCount = async () => { return reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-testid^='dynamic-element-']") .length; }); }; const countBefore = await getElementCount(); await reactGrab.page.click("[data-testid='add-element-button']"); await reactGrab.page.waitForTimeout(100); const countAfter = await getElementCount(); expect(countAfter).toBe(countBefore + 1); }); test("should properly cleanup after multiple freeze operations", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 100 }); for (let i = 0; i < 3; i++) { await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.deactivate(); // HACK: allow freeze cleanup to fully propagate before next iteration await reactGrab.page.waitForTimeout(300); } const getElementCount = async () => { return reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-testid^='dynamic-element-']") .length; }); }; const countBefore = await getElementCount(); await reactGrab.page.click("[data-testid='add-element-button']"); await reactGrab.page.waitForTimeout(100); const countAfter = await getElementCount(); expect(countAfter).toBe(countBefore + 1); }); }); test.describe("Button Click Buffering", () => { test("should buffer multiple clicks during freeze and apply on unfreeze", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 100 }); const getElementCount = async () => { return reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-testid^='dynamic-element-']") .length; }); }; const countBefore = await getElementCount(); await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); for (let clickIndex = 0; clickIndex < 3; clickIndex++) { await reactGrab.page.evaluate(() => { const addButton = document.querySelector( "[data-testid='add-element-button']", ) as HTMLButtonElement; addButton?.click(); }); await reactGrab.page.waitForTimeout(50); } const countDuringFreeze = await getElementCount(); expect(countDuringFreeze).toBe(countBefore); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(300); const countAfterUnfreeze = await getElementCount(); expect(countAfterUnfreeze).toBe(countBefore); }); test("should not accumulate state incorrectly across freeze cycles", async ({ reactGrab, }) => { await reactGrab.setupMockAgent({ delay: 100 }); const getElementCount = async () => { return reactGrab.page.evaluate(() => { return document.querySelectorAll("[data-testid^='dynamic-element-']") .length; }); }; const countBeforeFirstCycle = await getElementCount(); await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.page.evaluate(() => { const addButton = document.querySelector( "[data-testid='add-element-button']", ) as HTMLButtonElement; addButton?.click(); }); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(300); const countAfterFirstCycle = await getElementCount(); expect(countAfterFirstCycle).toBe(countBeforeFirstCycle); await reactGrab.enterPromptMode("[data-testid='dynamic-element-1']"); await reactGrab.page.evaluate(() => { const addButton = document.querySelector( "[data-testid='add-element-button']", ) as HTMLButtonElement; addButton?.click(); }); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(300); const countAfterSecondCycle = await getElementCount(); expect(countAfterSecondCycle).toBe(countBeforeFirstCycle); }); }); }); ================================================ FILE: packages/react-grab/e2e/history-items.spec.ts ================================================ import { test, expect } from "./fixtures.js"; import type { ReactGrabPageObject } from "./fixtures.js"; const copyElement = async ( reactGrab: ReactGrabPageObject, selector: string, ) => { await reactGrab.activate(); await reactGrab.hoverElement(selector); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement(selector); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); // HACK: Wait for copy feedback transition and history item addition await reactGrab.page.waitForTimeout(300); }; test.describe("History Items", () => { test.describe("Toolbar History Button", () => { test("should not be visible before any elements are copied", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); const isHistoryVisible = await reactGrab.isHistoryButtonVisible(); expect(isHistoryVisible).toBe(false); }); test("should become visible after copying an element", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(true); }); test("should show unread indicator after copy", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await expect .poll(() => reactGrab.hasUnreadHistoryIndicator(), { timeout: 2000 }) .toBe(true); }); test("should clear unread indicator when dropdown is opened", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await expect .poll(() => reactGrab.hasUnreadHistoryIndicator(), { timeout: 2000 }) .toBe(true); await reactGrab.clickHistoryButton(); await expect .poll(() => reactGrab.hasUnreadHistoryIndicator(), { timeout: 2000 }) .toBe(false); }); test("should show unread indicator again after new copy while dropdown is closed", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryButton(); await expect .poll(() => reactGrab.hasUnreadHistoryIndicator(), { timeout: 2000 }) .toBe(false); await copyElement(reactGrab, "li:last-child"); await expect .poll(() => reactGrab.hasUnreadHistoryIndicator(), { timeout: 2000 }) .toBe(true); }); }); test.describe("Dropdown Open/Close", () => { test("should open when clicking the history button", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); const isDropdownVisible = await reactGrab.isHistoryDropdownVisible(); expect(isDropdownVisible).toBe(true); }); test("should close when clicking the history button again", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(false); }); test("should close when pressing Escape", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(100); expect(await reactGrab.isHistoryDropdownVisible()).toBe(false); }); test("should close when context menu is opened", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:first-child"); await expect .poll(() => reactGrab.isHistoryDropdownVisible(), { timeout: 2000 }) .toBe(false); expect(await reactGrab.isContextMenuVisible()).toBe(true); }); }); test.describe("Dropdown Content", () => { test("should display one item after copying an element", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); const dropdownInfo = await reactGrab.getHistoryDropdownInfo(); expect(dropdownInfo.isVisible).toBe(true); expect(dropdownInfo.itemCount).toBe(1); }); test("should display multiple items after copying different elements", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.clickHistoryButton(); const dropdownInfo = await reactGrab.getHistoryDropdownInfo(); expect(dropdownInfo.itemCount).toBe(2); }); test("should hide history button after clearing all items", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryClear(); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(false); expect(await reactGrab.isHistoryDropdownVisible()).toBe(false); }); }); test.describe("Item Selection", () => { test("should copy content to clipboard when clicking a regular item", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); const originalClipboard = await reactGrab.getClipboardContent(); expect(originalClipboard).toBeTruthy(); await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryItem(0); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 3000 }) .toBeTruthy(); const newClipboard = await reactGrab.getClipboardContent(); expect(newClipboard).toBe(originalClipboard); }); test("should keep the dropdown open after selecting an item", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); await reactGrab.clickHistoryItem(0); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); }); }); test.describe("Copy All", () => { test("should copy combined content of all items to clipboard", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryCopyAll(); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toContain("[1]"); expect(clipboardContent).toContain("[2]"); }); test("should keep the dropdown open after copy all", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); await reactGrab.clickHistoryCopyAll(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); }); test("should not trigger copy all via Enter key", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); await reactGrab.clickHistoryButton(); await reactGrab.pressEnter(); await reactGrab.page.waitForTimeout(200); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBe(""); }); }); test.describe("Clear All", () => { test("should remove all history items", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.clickHistoryButton(); expect((await reactGrab.getHistoryDropdownInfo()).itemCount).toBe(2); await reactGrab.clickHistoryClear(); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(false); }); test("should hide the history button in toolbar after clearing", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryClear(); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(false); }); test("should close the dropdown after clearing", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); await reactGrab.clickHistoryClear(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(false); }); }); test.describe("Deduplication", () => { test("should deduplicate when copying the same element twice", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); const dropdownInfo = await reactGrab.getHistoryDropdownInfo(); expect(dropdownInfo.itemCount).toBe(1); }); test("should not deduplicate when copying different elements", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.clickHistoryButton(); const dropdownInfo = await reactGrab.getHistoryDropdownInfo(); expect(dropdownInfo.itemCount).toBe(2); }); }); test.describe("Hover Behavior", () => { test("should show a highlight box on the element when hovering a history item", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); const grabbedBoxesBefore = await reactGrab.getGrabbedBoxInfo(); const initialBoxCount = grabbedBoxesBefore.count; await reactGrab.hoverHistoryItem(0); await expect .poll( async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.count; }, { timeout: 2000 }, ) .toBeGreaterThan(initialBoxCount); }); test("should remove highlight box when mouse leaves a history item", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await reactGrab.hoverHistoryItem(0); await expect .poll( async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.count; }, { timeout: 2000 }, ) .toBeGreaterThan(0); await reactGrab.page.mouse.move(0, 0); await reactGrab.page.waitForTimeout(200); const grabbedBoxesAfter = await reactGrab.getGrabbedBoxInfo(); const hasHistoryHoverBox = grabbedBoxesAfter.boxes.some((box) => box.id.startsWith("history-hover-"), ); expect(hasHistoryHoverBox).toBe(false); }); }); test.describe("History Button Hover Preview", () => { test("should show highlight boxes for all history items when hovering the history button", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); const grabbedBoxesBefore = await reactGrab.getGrabbedBoxInfo(); const initialBoxCount = grabbedBoxesBefore.count; await reactGrab.hoverHistoryButton(); await expect .poll( async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.count; }, { timeout: 2000 }, ) .toBeGreaterThanOrEqual(initialBoxCount + 2); const grabbedBoxes = await reactGrab.getGrabbedBoxInfo(); const allHoverBoxes = grabbedBoxes.boxes.filter((box) => box.id.startsWith("history-all-hover-"), ); expect(allHoverBoxes.length).toBe(2); }); test("should remove all highlight boxes when mouse leaves the history button", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.hoverHistoryButton(); await expect .poll( async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.boxes.filter((box) => box.id.startsWith("history-all-hover-"), ).length; }, { timeout: 2000 }, ) .toBe(2); await reactGrab.page.mouse.move(0, 0); await reactGrab.page.waitForTimeout(200); const grabbedBoxesAfter = await reactGrab.getGrabbedBoxInfo(); const remainingHoverBoxes = grabbedBoxesAfter.boxes.filter((box) => box.id.startsWith("history-all-hover-"), ); expect(remainingHoverBoxes.length).toBe(0); }); test("should clear button hover boxes when pinning the dropdown", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.hoverHistoryButton(); await expect .poll( async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.boxes.filter((box) => box.id.startsWith("history-all-hover-"), ).length; }, { timeout: 2000 }, ) .toBe(1); await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; root .querySelector("[data-react-grab-toolbar-history]") ?.click(); }, "data-react-grab"); await reactGrab.page.waitForTimeout(200); const grabbedBoxesAfter = await reactGrab.getGrabbedBoxInfo(); const remainingHoverBoxes = grabbedBoxesAfter.boxes.filter((box) => box.id.startsWith("history-all-hover-"), ); expect(remainingHoverBoxes.length).toBe(0); }); test("should show highlight box for a single history item", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.hoverHistoryButton(); await expect .poll( async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.boxes.filter((box) => box.id.startsWith("history-all-hover-"), ).length; }, { timeout: 2000 }, ) .toBe(1); }); }); test.describe("Remove Individual Item", () => { test("should remove a single item and keep others", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.clickHistoryButton(); expect((await reactGrab.getHistoryDropdownInfo()).itemCount).toBe(2); await reactGrab.clickHistoryItemRemove(0); await reactGrab.page.waitForTimeout(200); const dropdownInfo = await reactGrab.getHistoryDropdownInfo(); expect(dropdownInfo.itemCount).toBe(1); }); test("should keep the dropdown open after removing an item", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryItemRemove(0); await reactGrab.page.waitForTimeout(200); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); }); test("should close the dropdown and hide button when removing the last item", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect((await reactGrab.getHistoryDropdownInfo()).itemCount).toBe(1); await reactGrab.clickHistoryItemRemove(0); await reactGrab.page.waitForTimeout(200); expect(await reactGrab.isHistoryDropdownVisible()).toBe(false); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(false); }); }); test.describe("Copy Individual Item", () => { test("should copy the item content to clipboard", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); const originalClipboard = await reactGrab.getClipboardContent(); await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryItemCopy(0); await reactGrab.page.waitForTimeout(200); const newClipboard = await reactGrab.getClipboardContent(); expect(newClipboard).toBe(originalClipboard); }); test("should keep the dropdown open after copying an item", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryItemCopy(0); await reactGrab.page.waitForTimeout(200); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); }); test("should keep the dropdown open after clicking a row to copy", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryItem(0); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); }); }); test.describe("Dropdown Positioning", () => { test("should position the dropdown within the viewport", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await expect .poll( async () => { const position = await reactGrab.getHistoryDropdownPosition(); return position?.left ?? -9999; }, { timeout: 3000 }, ) .toBeGreaterThanOrEqual(0); const position = await reactGrab.getHistoryDropdownPosition(); expect(position).not.toBeNull(); expect(position!.top).toBeGreaterThanOrEqual(0); }); test("should reposition when toolbar is dragged to top edge", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.dragToolbar(0, -600); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); return info.snapEdge; }, { timeout: 5000 }, ) .toBe("top"); // HACK: wait for snap animation and toolbar layout transition to fully settle await reactGrab.page.waitForTimeout(500); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 5000 }) .toBe(true); const historyButtonRect = await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const button = root.querySelector( "[data-react-grab-toolbar-history]", ); if (!button) return null; const rect = button.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }, "data-react-grab"); expect(historyButtonRect).not.toBeNull(); await reactGrab.page.mouse.click( historyButtonRect!.x + historyButtonRect!.width / 2, historyButtonRect!.y + historyButtonRect!.height / 2, ); await expect .poll(() => reactGrab.isHistoryDropdownVisible(), { timeout: 5000 }) .toBe(true); await expect .poll( async () => { const position = await reactGrab.getHistoryDropdownPosition(); return position?.top ?? -9999; }, { timeout: 5000 }, ) .toBeGreaterThanOrEqual(0); }); }); test.describe("Persistence Across Copies", () => { test("should accumulate items across multiple copy operations", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, '[data-testid="card-title"]'); await copyElement(reactGrab, '[data-testid="submit-button"]'); await reactGrab.clickHistoryButton(); const dropdownInfo = await reactGrab.getHistoryDropdownInfo(); expect(dropdownInfo.itemCount).toBe(3); }); test("should maintain history items after activation cycle", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.activate(); await reactGrab.deactivate(); await reactGrab.page.waitForTimeout(200); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickHistoryButton(); const dropdownInfo = await reactGrab.getHistoryDropdownInfo(); expect(dropdownInfo.itemCount).toBe(1); }); }); test.describe("Dismiss Behavior", () => { test("should not dismiss when clicking outside the dropdown", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); await reactGrab.page.mouse.click(10, 10); await reactGrab.page.waitForTimeout(200); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); }); test("should dismiss when pressing Escape", async ({ reactGrab }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(200); expect(await reactGrab.isHistoryDropdownVisible()).toBe(false); }); test("should dismiss when clicking the history button to toggle off", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); await reactGrab.clickHistoryButton(); expect(await reactGrab.isHistoryDropdownVisible()).toBe(false); }); }); test.describe("Hover to Open", () => { test("should open dropdown when hovering the history button", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.hoverHistoryButton(); await expect .poll(() => reactGrab.isHistoryDropdownVisible(), { timeout: 2000 }) .toBe(true); }); test("should show all preview boxes when hovering the history button", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.hoverHistoryButton(); await expect .poll( async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.boxes.filter((box) => box.id.startsWith("history-all-hover-"), ).length; }, { timeout: 2000 }, ) .toBe(2); }); test("should pin dropdown open when clicking the history button while hover-opened", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.hoverHistoryButton(); await expect .poll(() => reactGrab.isHistoryDropdownVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; root .querySelector("[data-react-grab-toolbar-history]") ?.click(); }, "data-react-grab"); await reactGrab.page.waitForTimeout(300); await reactGrab.page.mouse.move(0, 0); await reactGrab.page.waitForTimeout(500); expect(await reactGrab.isHistoryDropdownVisible()).toBe(true); }); }); test.describe("Preview Suppression After Copy", () => { test("should clear hover preview boxes after copying via row click", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryItem(0); await reactGrab.page.waitForTimeout(300); const grabbedBoxes = await reactGrab.getGrabbedBoxInfo(); const hoverBoxCount = grabbedBoxes.boxes.filter( (box) => box.id.startsWith("history-hover-") || box.id.startsWith("history-all-hover-"), ).length; expect(hoverBoxCount).toBe(0); }); test("should clear all hover preview boxes after copy all", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.clickHistoryButton(); await reactGrab.page.waitForTimeout(200); await reactGrab.clickHistoryCopyAll(); await reactGrab.page.waitForTimeout(300); const grabbedBoxes = await reactGrab.getGrabbedBoxInfo(); const allHoverBoxes = grabbedBoxes.boxes.filter( (box) => box.id.startsWith("history-all-hover-") || box.id.startsWith("history-hover-"), ); expect(allHoverBoxes.length).toBe(0); }); test("should suppress all-item previews during feedback but allow different item hover", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryItemCopy(0); await reactGrab.page.waitForTimeout(200); await reactGrab.hoverHistoryItem(1); await expect .poll( async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.boxes.filter((box) => box.id.startsWith("history-hover-"), ).length; }, { timeout: 2000 }, ) .toBeGreaterThan(0); }); }); test.describe("Selection Label Lifecycle on Copy", () => { test("should show selection label when hovering a history item", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await reactGrab.page.waitForTimeout(200); await reactGrab.hoverHistoryItem(0); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); return labels.filter( (label) => label.status === "idle" && label.createdAt === 0, ).length; }, { timeout: 5000 }, ) .toBeGreaterThan(0); }); test("should clear idle labels and show copied label after copy all", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await copyElement(reactGrab, "li:last-child"); await reactGrab.clickHistoryButton(); await reactGrab.page.waitForTimeout(200); await reactGrab.hoverCopyAllButton(); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); return labels.filter( (label) => label.status === "idle" && label.createdAt === 0, ).length; }, { timeout: 5000 }, ) .toBeGreaterThanOrEqual(2); await reactGrab.clickHistoryCopyAll(); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); const idlePreviewLabels = labels.filter( (label) => label.status === "idle" && label.createdAt === 0, ); return idlePreviewLabels.length; }, { timeout: 5000 }, ) .toBe(0); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); return labels.filter((label) => label.status === "copied").length; }, { timeout: 5000 }, ) .toBeGreaterThanOrEqual(1); }); test("should clear idle labels and show copied label after individual copy", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await reactGrab.page.waitForTimeout(200); await reactGrab.hoverHistoryItem(0); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); return labels.filter( (label) => label.status === "idle" && label.createdAt === 0, ).length; }, { timeout: 5000 }, ) .toBeGreaterThan(0); await reactGrab.clickHistoryItem(0); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); const idlePreviewLabels = labels.filter( (label) => label.status === "idle" && label.createdAt === 0, ); return idlePreviewLabels.length; }, { timeout: 5000 }, ) .toBe(0); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); return labels.filter((label) => label.status === "copied").length; }, { timeout: 5000 }, ) .toBeGreaterThanOrEqual(1); }); test("should clear idle labels and show copied label after copy button click", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await reactGrab.clickHistoryButton(); await reactGrab.page.waitForTimeout(200); await reactGrab.hoverHistoryItem(0); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); return labels.filter( (label) => label.status === "idle" && label.createdAt === 0, ).length; }, { timeout: 5000 }, ) .toBeGreaterThan(0); await reactGrab.clickHistoryItemCopy(0); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); const idlePreviewLabels = labels.filter( (label) => label.status === "idle" && label.createdAt === 0, ); return idlePreviewLabels.length; }, { timeout: 5000 }, ) .toBe(0); await expect .poll( async () => { const labels = await reactGrab.getLabelInstancesInfo(); return labels.filter((label) => label.status === "copied").length; }, { timeout: 5000 }, ) .toBeGreaterThanOrEqual(1); }); }); }); ================================================ FILE: packages/react-grab/e2e/history-reacquire.spec.ts ================================================ import { test, expect } from "./fixtures.js"; import type { ReactGrabPageObject } from "./fixtures.js"; interface ViewportRect { x: number; y: number; width: number; height: number; } const getViewportRect = async ( reactGrab: ReactGrabPageObject, selector: string, ): Promise => { return reactGrab.page.evaluate((sel) => { const element = document.querySelector(sel); if (!element) return null; const rect = element.getBoundingClientRect(); return { x: rect.left, y: rect.top, width: rect.width, height: rect.height, }; }, selector); }; const setHiddenToggleSectionMarginTopPx = async ( reactGrab: ReactGrabPageObject, marginTopPx: number, ) => { await reactGrab.page.evaluate((marginTop) => { const section = document.querySelector( '[data-testid="hidden-toggle-section"]', ); if (section instanceof HTMLElement) { section.style.marginTop = `${marginTop}px`; } }, marginTopPx); }; const toggleToggleableElement = async (reactGrab: ReactGrabPageObject) => { await reactGrab.page .locator('[data-testid="toggle-visibility-button"]') .click({ force: true }); }; const copyElementViaApi = async ( reactGrab: ReactGrabPageObject, selector: string, ) => { await reactGrab.page.evaluate(() => navigator.clipboard.writeText("")); const didCopy = await reactGrab.copyElementViaApi(selector); expect(didCopy).toBe(true); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); // HACK: allow history item to be persisted + mapped await reactGrab.page.waitForTimeout(300); }; const expectCloseTo = ( actual: number, expected: number, tolerancePx: number, ) => { expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerancePx); }; test.describe("History selector reacquire", () => { test("should reacquire a remounted element and update hover preview bounds", async ({ reactGrab, }) => { const toggleableSelector = '[data-testid="toggleable-element"]'; await reactGrab.page .locator('[data-testid="hidden-toggle-section"]') .scrollIntoViewIfNeeded(); const beforeRect = await getViewportRect(reactGrab, toggleableSelector); expect(beforeRect).not.toBeNull(); await copyElementViaApi(reactGrab, toggleableSelector); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(true); await toggleToggleableElement(reactGrab); await expect(reactGrab.page.locator(toggleableSelector)).toHaveCount(0); await setHiddenToggleSectionMarginTopPx(reactGrab, 240); await toggleToggleableElement(reactGrab); await expect(reactGrab.page.locator(toggleableSelector)).toHaveCount(1); await reactGrab.page.locator(toggleableSelector).scrollIntoViewIfNeeded(); const afterRect = await getViewportRect(reactGrab, toggleableSelector); expect(afterRect).not.toBeNull(); expect(Math.abs(afterRect!.y - beforeRect!.y)).toBeGreaterThan(40); await reactGrab.clickHistoryButton(); expect((await reactGrab.getHistoryDropdownInfo()).itemCount).toBe(1); await reactGrab.hoverHistoryItem(0); await expect .poll(async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.boxes.filter((box) => box.id.startsWith("history-hover-")) .length; }) .toBeGreaterThan(0); const grabbedBoxes = await reactGrab.getGrabbedBoxInfo(); const hoverBox = grabbedBoxes.boxes.find((box) => box.id.startsWith("history-hover-"), ); expect(hoverBox).toBeTruthy(); expectCloseTo(hoverBox!.bounds.x, afterRect!.x, 8); expectCloseTo(hoverBox!.bounds.y, afterRect!.y, 8); expectCloseTo(hoverBox!.bounds.width, afterRect!.width, 8); expectCloseTo(hoverBox!.bounds.height, afterRect!.height, 8); }); test("should show copied label feedback when selecting a reacquired history item", async ({ reactGrab, }) => { const toggleableSelector = '[data-testid="toggleable-element"]'; await reactGrab.page .locator('[data-testid="hidden-toggle-section"]') .scrollIntoViewIfNeeded(); await copyElementViaApi(reactGrab, toggleableSelector); await toggleToggleableElement(reactGrab); await expect(reactGrab.page.locator(toggleableSelector)).toHaveCount(0); await setHiddenToggleSectionMarginTopPx(reactGrab, 220); await toggleToggleableElement(reactGrab); await expect(reactGrab.page.locator(toggleableSelector)).toHaveCount(1); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryItem(0); await expect .poll(async () => { const labels = await reactGrab.getLabelInstancesInfo(); return labels.filter((label) => label.status === "copied").length; }) .toBeGreaterThan(0); }); }); ================================================ FILE: packages/react-grab/e2e/hold-activation.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Hold Activation Mode", () => { test("should not activate when pressing C without Cmd/Ctrl modifier", async ({ reactGrab, }) => { await reactGrab.page.click("body"); await reactGrab.page.keyboard.down("c"); await reactGrab.page.waitForTimeout(50); await reactGrab.page.keyboard.up("c"); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(false); }); test("should allow multiple API activations in sequence", async ({ reactGrab, }) => { await reactGrab.activate(); let isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(100); isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(false); await reactGrab.activate(); isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should allow selection after API activation", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should allow dragging after API activation", async ({ reactGrab }) => { await reactGrab.activate(); const firstItem = reactGrab.page.locator("li").first(); const firstBox = await firstItem.boundingBox(); if (!firstBox) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(firstBox.x - 10, firstBox.y - 10); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(firstBox.x + 100, firstBox.y + 100, { steps: 5, }); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); await reactGrab.page.mouse.up(); }); test("should cancel hold when pressing a non-activation key during hold", async ({ reactGrab, }) => { await reactGrab.page.click("body"); await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.down("c"); await reactGrab.page.waitForTimeout(50); await reactGrab.page.keyboard.down("a"); await reactGrab.page.keyboard.up("c"); await reactGrab.page.waitForTimeout(500); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(false); await reactGrab.page.keyboard.up("a"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); }); test("should copy heading element after API activation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='main-title']"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("[data-testid='main-title']"); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toContain("React Grab"); }); }); ================================================ FILE: packages/react-grab/e2e/input-mode.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Input Mode", () => { test.describe("Entering Input Mode", () => { test("context menu edit should enter input mode when agent is configured", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:first-child"); await reactGrab.clickContextMenuItem("Edit"); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true); }); test("single click should copy without entering input mode when no agent", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await expect.poll(() => reactGrab.getClipboardContent()).toBeTruthy(); }); test("should focus input textarea when entering input mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); const isFocused = await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const textarea = root.querySelector("textarea"); return ( document.activeElement === textarea || shadowRoot.activeElement === textarea ); }, "data-react-grab"); expect(isFocused).toBe(true); }); test("input mode should show input textarea", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("h1"); const hasTextarea = await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; return root.querySelector("textarea") !== null; }, "data-react-grab"); expect(hasTextarea).toBe(true); }); }); test.describe("Text Input and Editing", () => { test("should accept text input", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Test prompt text"); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toBe("Test prompt text"); }); test("should allow editing typed text", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Hello"); await reactGrab.page.keyboard.press("Backspace"); await reactGrab.page.keyboard.press("Backspace"); await reactGrab.typeInInput("p!"); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toBe("Help!"); }); test("should handle long text input", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); const longText = "This is a very long prompt that should be handled properly by the textarea input field and might need to scroll within the container."; await reactGrab.typeInInput(longText); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toBe(longText); }); test("should handle multiline input with shift+enter", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Line 1"); await reactGrab.page.keyboard.down("Shift"); await reactGrab.page.keyboard.press("Enter"); await reactGrab.page.keyboard.up("Shift"); await reactGrab.typeInInput("Line 2"); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toContain("Line 1"); expect(inputValue).toContain("Line 2"); }); }); test.describe("Submit and Cancel", () => { test("Enter key should submit input", async ({ reactGrab }) => { await reactGrab.setupMockAgent({ delay: 100 }); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Test prompt"); await reactGrab.submitInput(); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); test("Escape should cancel input mode", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.pressEscape(); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); test("Escape in textarea should dismiss input mode directly", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); expect(await reactGrab.isPromptModeActive()).toBe(true); await reactGrab.typeInInput("Some unsaved text"); await reactGrab.pressEscape(); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); test("confirming dismiss should close input mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Some text"); await reactGrab.pressEscape(); await reactGrab.pressEscape(); await expect.poll(() => reactGrab.isOverlayVisible()).toBe(false); }); test("empty input should cancel without confirmation", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.pressEscape(); const isPendingDismiss = await reactGrab.isPendingDismissVisible(); expect(isPendingDismiss).toBe(false); }); }); test.describe("Input Mode with Selection", () => { test("should freeze selection while in input mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.page.mouse.move(500, 500); const isPromptMode = await reactGrab.isPromptModeActive(); expect(isPromptMode).toBe(true); }); }); test.describe("Keyboard Shortcuts in Input Mode", () => { test("arrow keys should not navigate elements in input mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.pressArrowDown(); const isPromptMode = await reactGrab.isPromptModeActive(); expect(isPromptMode).toBe(true); }); test("activation shortcut should not cancel input mode when input is focused", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.press("c"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true); }); }); test.describe("Input Preservation", () => { test("input should be cleared after dismissing input mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Some text"); await reactGrab.pressEscape(); await reactGrab.enterPromptMode("li:first-child"); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toBe(""); }); }); test.describe("Edge Cases", () => { test("clicking outside should cancel input mode", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.page.mouse.click(10, 10); await reactGrab.page.mouse.click(10, 10); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); test("context menu edit maintains overlay in input mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); const isPromptActive = await reactGrab.isPromptModeActive(); expect(isPromptActive).toBe(true); const isOverlayActive = await reactGrab.isOverlayVisible(); expect(isOverlayActive).toBe(true); }); test("input mode should work after scroll", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.activate(); await reactGrab.scrollPage(100); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:first-child"); await reactGrab.clickContextMenuItem("Edit"); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true); }); }); }); ================================================ FILE: packages/react-grab/e2e/keyboard-navigation.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Keyboard Navigation", () => { test("should navigate to next element with ArrowDown", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.press("ArrowDown"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should navigate to previous element with ArrowUp", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:nth-child(3)"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.press("ArrowUp"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should navigate to parent element with ArrowLeft", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.press("ArrowLeft"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should navigate to child element with ArrowRight", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("ul"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.press("ArrowRight"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should maintain activation during keyboard navigation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.press("ArrowDown"); await reactGrab.page.keyboard.press("ArrowDown"); await reactGrab.page.keyboard.press("ArrowUp"); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should copy element after keyboard navigation with click", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.press("ArrowDown"); await reactGrab.waitForSelectionBox(); const secondItem = reactGrab.page.locator( "[data-testid='todo-list'] li:nth-child(2)", ); const box = await secondItem.boundingBox(); if (box) { await reactGrab.page.mouse.click(box.x + 10, box.y + 10); } await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); }); test("should copy keyboard-selected element when clicking after mouse movement", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); await reactGrab.waitForSelectionBox(); const initialBounds = await reactGrab.getSelectionBoxBounds(); expect(initialBounds).not.toBeNull(); await reactGrab.page.keyboard.press("ArrowUp"); await reactGrab.waitForSelectionBox(); const selectionBoundsAfterArrow = await reactGrab.getSelectionBoxBounds(); expect(selectionBoundsAfterArrow).not.toBeNull(); await reactGrab.page.mouse.move(10, 10); await reactGrab.page.waitForTimeout(50); await reactGrab.page.mouse.click( selectionBoundsAfterArrow!.x + selectionBoundsAfterArrow!.width / 2, selectionBoundsAfterArrow!.y + selectionBoundsAfterArrow!.height / 2, ); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); }); test("should freeze selection when navigating with arrow keys", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.press("ArrowDown"); await reactGrab.waitForSelectionBox(); await reactGrab.page.mouse.move(0, 0); await reactGrab.page.waitForTimeout(100); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); }); test.describe("Navigation History and Wrapping", () => { test("ArrowLeft should go back to previous element", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.pressArrowDown(); await reactGrab.pressArrowLeft(); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("multiple ArrowDown should navigate through siblings", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.pressArrowDown(); await reactGrab.pressArrowDown(); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("ArrowUp at first sibling should stay on element", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("ArrowDown at last sibling should stay on element", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:last-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("navigation should work on deeply nested elements", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='deeply-nested-text']"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowLeft(); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("keyboard navigation should update selection label", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const labelBefore = await reactGrab.getSelectionLabelInfo(); await reactGrab.pressArrowLeft(); await reactGrab.waitForSelectionBox(); const labelAfter = await reactGrab.getSelectionLabelInfo(); expect(labelBefore.isVisible).toBe(true); expect(labelAfter.isVisible).toBe(true); }); }); test.describe("ArrowUp Vertical Traversal", () => { test("ArrowUp should reach parent element from child", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); await reactGrab.waitForSelectionBox(); const initialLabel = await reactGrab.getSelectionLabelInfo(); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); const afterUpLabel = await reactGrab.getSelectionLabelInfo(); expect(initialLabel.tagName).toBe("li"); expect(afterUpLabel.tagName).not.toBe("li"); expect(afterUpLabel.isVisible).toBe(true); }); test("repeated ArrowUp should not oscillate between elements", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); await reactGrab.waitForSelectionBox(); const visitedTags: string[] = []; for (let step = 0; step < 8; step++) { await reactGrab.pressArrowUp(); await reactGrab.page.waitForTimeout(50); const labelInfo = await reactGrab.getSelectionLabelInfo(); if (!labelInfo.isVisible) break; visitedTags.push(labelInfo.tagName ?? "unknown"); } let oscillationCount = 0; for (let index = 2; index < visitedTags.length; index++) { const isRepeatingTwoStepPattern = visitedTags[index] === visitedTags[index - 2] && visitedTags[index] !== visitedTags[index - 1]; if (isRepeatingTwoStepPattern) { oscillationCount++; } } expect(oscillationCount).toBeLessThan(2); }); test("ArrowUp bounds should never shrink", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); await reactGrab.waitForSelectionBox(); let previousBounds = await reactGrab.getSelectionBoxBounds(); expect(previousBounds).not.toBeNull(); let boundsShrunk = false; for (let step = 0; step < 5; step++) { await reactGrab.pressArrowUp(); await reactGrab.page.waitForTimeout(50); const currentBounds = await reactGrab.getSelectionBoxBounds(); if (!currentBounds) break; const previousArea = previousBounds!.width * previousBounds!.height; const currentArea = currentBounds.width * currentBounds.height; if (currentArea < previousArea - 1) { boundsShrunk = true; break; } previousBounds = currentBounds; } expect(boundsShrunk).toBe(false); }); test("ArrowDown should reverse ArrowUp and maintain selection", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowUp(); await reactGrab.waitForSelectionBox(); const afterUpVisible = await reactGrab.isSelectionBoxVisible(); expect(afterUpVisible).toBe(true); await reactGrab.pressArrowDown(); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.waitForSelectionBox(); const afterDownVisible = await reactGrab.isSelectionBoxVisible(); expect(afterDownVisible).toBe(true); const afterDownBounds = await reactGrab.getSelectionBoxBounds(); expect(afterDownBounds).not.toBeNull(); expect(afterDownBounds!.width).toBeGreaterThan(0); expect(afterDownBounds!.height).toBeGreaterThan(0); }); }); ================================================ FILE: packages/react-grab/e2e/keyboard-shortcuts.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Keyboard Shortcuts", () => { test("should copy selected element when clicking", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] h1"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("[data-testid='todo-list'] h1"); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toContain("Todo List"); }); test("should deactivate when pressing Escape while hovering", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(100); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(false); }); test("should not activate when pressing C without Cmd/Ctrl modifier", async ({ reactGrab, }) => { await reactGrab.page.keyboard.down("c"); await reactGrab.page.waitForTimeout(50); await reactGrab.page.keyboard.up("c"); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(false); }); test("should copy list item when clicked", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] li:nth-child(2)"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("[data-testid='todo-list'] li:nth-child(2)"); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toContain("Walk the dog"); }); test("should keep overlay active while navigating with arrow keys", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); for (let i = 0; i < 5; i++) { await reactGrab.page.keyboard.press("ArrowDown"); await reactGrab.page.waitForTimeout(50); } const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should deactivate after successful click copy in toggle mode", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(2000); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(false); }); }); ================================================ FILE: packages/react-grab/e2e/open-file.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Open File", () => { test.describe("Keyboard Shortcut", () => { test("Cmd+O should open file when source info available", async ({ reactGrab, }) => { await reactGrab.page.evaluate(() => { (window as { __OPEN_FILE_CALLED__?: boolean }).__OPEN_FILE_CALLED__ = false; const api = ( window as { __REACT_GRAB__?: { registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; api?.registerPlugin({ name: "test-open-file", hooks: { onOpenFile: () => { ( window as { __OPEN_FILE_CALLED__?: boolean } ).__OPEN_FILE_CALLED__ = true; }, }, }); }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionSource(); await expect .poll( async () => { await reactGrab.pressKeyCombo([reactGrab.modifierKey], "o"); return reactGrab.page.evaluate( () => (window as { __OPEN_FILE_CALLED__?: boolean }) .__OPEN_FILE_CALLED__ ?? false, ); }, { timeout: 5000, intervals: [500] }, ) .toBe(true); }); test("Cmd+O should do nothing without onOpenFile callback", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.press("o"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); await reactGrab.page.waitForTimeout(200); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); test("Cmd+O without selection should be ignored", async ({ reactGrab }) => { let openFileCalled = false; await reactGrab.page.evaluate(() => { (window as { __OPEN_FILE_CALLED__?: boolean }).__OPEN_FILE_CALLED__ = false; const api = ( window as { __REACT_GRAB__?: { registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; api?.registerPlugin({ name: "test-open-file", hooks: { onOpenFile: () => { ( window as { __OPEN_FILE_CALLED__?: boolean } ).__OPEN_FILE_CALLED__ = true; }, }, }); }); await reactGrab.activate(); await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.press("o"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); await reactGrab.page.waitForTimeout(200); openFileCalled = await reactGrab.page.evaluate(() => { return ( (window as { __OPEN_FILE_CALLED__?: boolean }).__OPEN_FILE_CALLED__ ?? false ); }); expect(openFileCalled).toBe(false); }); }); test.describe("Context Menu", () => { test("Open item should appear in context menu", async ({ reactGrab }) => { await reactGrab.page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; api?.registerPlugin({ name: "test-open-file", hooks: { onOpenFile: () => {}, }, }); }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:first-child"); const menuInfo = await reactGrab.getContextMenuInfo(); expect(menuInfo.isVisible).toBe(true); expect(menuInfo.menuItems).toContain("Open"); }); test("Clicking Open in context menu should trigger onOpenFile", async ({ reactGrab, }) => { let openFileCalled = false; await reactGrab.page.evaluate(() => { (window as { __OPEN_FILE_CALLED__?: boolean }).__OPEN_FILE_CALLED__ = false; const api = ( window as { __REACT_GRAB__?: { registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; api?.registerPlugin({ name: "test-open-file", hooks: { onOpenFile: () => { ( window as { __OPEN_FILE_CALLED__?: boolean } ).__OPEN_FILE_CALLED__ = true; }, }, }); }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:first-child"); await reactGrab.page.waitForTimeout(100); await reactGrab.clickContextMenuItem("Open"); await reactGrab.page.waitForTimeout(200); openFileCalled = await reactGrab.page.evaluate(() => { return ( (window as { __OPEN_FILE_CALLED__?: boolean }).__OPEN_FILE_CALLED__ ?? false ); }); expect(openFileCalled).toBe(true); }); test("Open should not be clickable without onOpenFile callback", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:first-child"); await reactGrab.page.waitForTimeout(200); const menuInfo = await reactGrab.getContextMenuInfo(); expect(menuInfo.isVisible).toBe(true); }); }); test.describe("onOpenFile Callback", () => { test("callback should receive element info", async ({ reactGrab }) => { let receivedInfo: unknown = null; await reactGrab.page.evaluate(() => { (window as { __OPEN_FILE_INFO__?: unknown }).__OPEN_FILE_INFO__ = null; const api = ( window as { __REACT_GRAB__?: { registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; api?.registerPlugin({ name: "test-open-file", hooks: { onOpenFile: (info: unknown) => { (window as { __OPEN_FILE_INFO__?: unknown }).__OPEN_FILE_INFO__ = info; }, }, }); }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.press("o"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); await reactGrab.page.waitForTimeout(200); receivedInfo = await reactGrab.page.evaluate(() => { return (window as { __OPEN_FILE_INFO__?: unknown }).__OPEN_FILE_INFO__; }); expect(receivedInfo).toBeDefined(); }); test("callback should include source info when available", async ({ reactGrab, }) => { let receivedInfo: Record | null | undefined = null; await reactGrab.page.evaluate(() => { ( window as { __OPEN_FILE_INFO__?: Record | null } ).__OPEN_FILE_INFO__ = null; const api = ( window as { __REACT_GRAB__?: { registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; api?.registerPlugin({ name: "test-open-file", hooks: { onOpenFile: (info: Record) => { ( window as { __OPEN_FILE_INFO__?: Record | null; } ).__OPEN_FILE_INFO__ = info; }, }, }); }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.press("o"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); await reactGrab.page.waitForTimeout(200); receivedInfo = await reactGrab.page.evaluate(() => { return ( window as { __OPEN_FILE_INFO__?: Record | null } ).__OPEN_FILE_INFO__; }); expect(receivedInfo).toBeDefined(); }); }); test.describe("Tag Badge Click", () => { test("clicking tag badge should trigger open file", async ({ reactGrab, }) => { let openFileCalled = false; await reactGrab.page.evaluate(() => { (window as { __OPEN_FILE_CALLED__?: boolean }).__OPEN_FILE_CALLED__ = false; const api = ( window as { __REACT_GRAB__?: { registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; api?.registerPlugin({ name: "test-open-file", hooks: { onOpenFile: () => { ( window as { __OPEN_FILE_CALLED__?: boolean } ).__OPEN_FILE_CALLED__ = true; }, }, }); }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return; const spans = root.querySelectorAll("span"); for (const span of spans) { if ( span.textContent?.includes("li") || span.textContent?.includes("span") ) { (span as HTMLElement).click(); return; } } }, "data-react-grab"); await reactGrab.page.waitForTimeout(200); openFileCalled = await reactGrab.page.evaluate(() => { return ( (window as { __OPEN_FILE_CALLED__?: boolean }).__OPEN_FILE_CALLED__ ?? false ); }); expect(typeof openFileCalled).toBe("boolean"); }); }); test.describe("Edge Cases", () => { test("open file should work after element change", async ({ reactGrab, }) => { await reactGrab.page.evaluate(() => { (window as { __OPEN_FILE_COUNT__?: number }).__OPEN_FILE_COUNT__ = 0; const api = ( window as { __REACT_GRAB__?: { registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; api?.registerPlugin({ name: "test-open-file", hooks: { onOpenFile: () => { (window as { __OPEN_FILE_COUNT__?: number }).__OPEN_FILE_COUNT__ = ((window as { __OPEN_FILE_COUNT__?: number }) .__OPEN_FILE_COUNT__ ?? 0) + 1; }, }, }); }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionSource(); await expect .poll( async () => { await reactGrab.pressKeyCombo([reactGrab.modifierKey], "o"); return reactGrab.page.evaluate( () => (window as { __OPEN_FILE_COUNT__?: number }) .__OPEN_FILE_COUNT__ ?? 0, ); }, { timeout: 5000, intervals: [500] }, ) .toBeGreaterThanOrEqual(1); await reactGrab.hoverElement("li:nth-child(2)"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionSource(); await expect .poll( async () => { await reactGrab.pressKeyCombo([reactGrab.modifierKey], "o"); return reactGrab.page.evaluate( () => (window as { __OPEN_FILE_COUNT__?: number }) .__OPEN_FILE_COUNT__ ?? 0, ); }, { timeout: 5000, intervals: [500] }, ) .toBeGreaterThanOrEqual(2); }); test("open file should work with drag-selected elements", async ({ reactGrab, }) => { let openFileCalled = false; await reactGrab.page.evaluate(() => { (window as { __OPEN_FILE_CALLED__?: boolean }).__OPEN_FILE_CALLED__ = false; const api = ( window as { __REACT_GRAB__?: { registerPlugin: (plugin: Record) => void; }; } ).__REACT_GRAB__; api?.registerPlugin({ name: "test-open-file", hooks: { onOpenFile: () => { ( window as { __OPEN_FILE_CALLED__?: boolean } ).__OPEN_FILE_CALLED__ = true; }, }, }); }); await reactGrab.activate(); await reactGrab.dragSelect("li:first-child", "li:nth-child(2)"); await reactGrab.page.waitForTimeout(200); await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.press("o"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); await reactGrab.page.waitForTimeout(200); openFileCalled = await reactGrab.page.evaluate(() => { return ( (window as { __OPEN_FILE_CALLED__?: boolean }).__OPEN_FILE_CALLED__ ?? false ); }); expect(typeof openFileCalled).toBe("boolean"); }); }); }); ================================================ FILE: packages/react-grab/e2e/overlay-filtering.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Overlay Filtering", () => { test.describe("React-grab elements should not be selectable", () => { test("should not select react-grab host element", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const selectedElement = await reactGrab.page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { targetElement: Element | null }; }; } ).__REACT_GRAB__; const state = api?.getState(); return state?.targetElement?.hasAttribute("data-react-grab") ?? false; }); expect(selectedElement).toBe(false); }); test("should not select elements inside react-grab shadow DOM", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const isInsideShadowDom = await reactGrab.page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { targetElement: Element | null }; }; } ).__REACT_GRAB__; const state = api?.getState(); const target = state?.targetElement; if (!target) return false; const rootNode = target.getRootNode(); if (rootNode instanceof ShadowRoot) { return rootNode.host.hasAttribute("data-react-grab"); } return false; }); expect(isInsideShadowDom).toBe(false); }); test("should select page elements through react-grab overlay", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const tagName = await reactGrab.page.evaluate(() => { const api = ( window as { __REACT_GRAB__?: { getState: () => { targetElement: Element | null }; }; } ).__REACT_GRAB__; const state = api?.getState(); return state?.targetElement?.tagName?.toLowerCase() ?? null; }); expect(tagName).toBe("li"); }); }); test.describe("Selection ignores react-grab UI components", () => { test("hovering over toolbar area should still select underlying element", async ({ reactGrab, }) => { await reactGrab.activate(); const toolbarInfo = await reactGrab.getToolbarInfo(); if (toolbarInfo.position) { await reactGrab.page.mouse.move( toolbarInfo.position.x + 10, toolbarInfo.position.y + 10, ); await reactGrab.page.waitForTimeout(200); const state = await reactGrab.getState(); expect(state.isActive).toBe(true); } }); test("clicking through overlay should copy correct element", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] h1"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("[data-testid='todo-list'] h1"); await expect .poll(() => reactGrab.getClipboardContent()) .toContain("Todo List"); }); test("drag selection should work through overlay canvas", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.dragSelect("li:first-child", "li:nth-child(3)"); await reactGrab.page.waitForTimeout(500); const grabbedInfo = await reactGrab.getGrabbedBoxInfo(); expect(grabbedInfo.count).toBeGreaterThan(1); }); }); test.describe("Shadow DOM isolation", () => { test("should only filter elements inside react-grab shadow DOM", async ({ reactGrab, }) => { const shadowHostExists = await reactGrab.page.evaluate(() => { const host = document.querySelector("[data-react-grab]"); return host !== null && host.shadowRoot !== null; }); expect(shadowHostExists).toBe(true); await reactGrab.activate(); const isReactGrabHostFiltered = await reactGrab.page.evaluate(() => { const host = document.querySelector("[data-react-grab]"); if (!host) return false; const api = ( window as { __REACT_GRAB__?: { getState: () => { targetElement: Element | null }; }; } ).__REACT_GRAB__; const state = api?.getState(); return state?.targetElement !== host; }); expect(isReactGrabHostFiltered).toBe(true); }); test("should verify react-grab host has correct attribute", async ({ reactGrab, }) => { const hostHasAttribute = await reactGrab.page.evaluate(() => { const host = document.querySelector("[data-react-grab]"); return host?.hasAttribute("data-react-grab") ?? false; }); expect(hostHasAttribute).toBe(true); }); }); }); ================================================ FILE: packages/react-grab/e2e/prompt-mode.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Prompt Mode", () => { test.describe("Entering Prompt Mode", () => { test("context menu edit should enter prompt mode when agent is configured", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:first-child"); await reactGrab.clickContextMenuItem("Edit"); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true); }); test("single click should copy without entering prompt mode when no agent", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await expect.poll(() => reactGrab.getClipboardContent()).toBeTruthy(); }); test("should focus input textarea when entering prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); const isFocused = await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; const textarea = root.querySelector("textarea"); return ( document.activeElement === textarea || shadowRoot.activeElement === textarea ); }, "data-react-grab"); expect(isFocused).toBe(true); }); test("prompt mode should show input textarea", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("h1"); const hasTextarea = await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return false; return root.querySelector("textarea") !== null; }, "data-react-grab"); expect(hasTextarea).toBe(true); }); }); test.describe("Prompt Mode Control", () => { test("API toggle should exit prompt mode", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.toggle(); await expect .poll(() => reactGrab.isOverlayVisible(), { timeout: 2000 }) .toBe(false); expect(await reactGrab.isPromptModeActive()).toBe(false); }); }); test.describe("Text Input and Editing", () => { test("should accept text input", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Test prompt text"); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toBe("Test prompt text"); }); test("should allow editing typed text", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Hello"); await reactGrab.page.keyboard.press("Backspace"); await reactGrab.page.keyboard.press("Backspace"); await reactGrab.typeInInput("p!"); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toBe("Help!"); }); test("should handle long text input", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); const longText = "This is a very long prompt that should be handled properly by the textarea input field and might need to scroll within the container."; await reactGrab.typeInInput(longText); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toBe(longText); }); test("should handle multiline input with shift+enter", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Line 1"); await reactGrab.page.keyboard.down("Shift"); await reactGrab.page.keyboard.press("Enter"); await reactGrab.page.keyboard.up("Shift"); await reactGrab.typeInInput("Line 2"); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toContain("Line 1"); expect(inputValue).toContain("Line 2"); }); }); test.describe("Submit and Cancel", () => { test("Enter key should submit input", async ({ reactGrab }) => { await reactGrab.setupMockAgent({ delay: 100 }); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Test prompt"); await reactGrab.submitInput(); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); test("Escape should cancel prompt mode", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.pressEscape(); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); test("Escape in textarea should dismiss prompt mode directly", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); expect(await reactGrab.isPromptModeActive()).toBe(true); await reactGrab.typeInInput("Some unsaved text"); await reactGrab.pressEscape(); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); test("confirming dismiss should close prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Some text"); await reactGrab.pressEscape(); await reactGrab.pressEscape(); await expect.poll(() => reactGrab.isOverlayVisible()).toBe(false); }); test("empty input should cancel without confirmation", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.pressEscape(); const isPendingDismiss = await reactGrab.isPendingDismissVisible(); expect(isPendingDismiss).toBe(false); }); }); test.describe("Prompt Mode with Selection", () => { test("should freeze selection while in prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.page.mouse.move(500, 500); const isPromptMode = await reactGrab.isPromptModeActive(); expect(isPromptMode).toBe(true); }); }); test.describe("Keyboard Shortcuts in Prompt Mode", () => { test("arrow keys should not navigate elements in prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.pressArrowDown(); const isPromptMode = await reactGrab.isPromptModeActive(); expect(isPromptMode).toBe(true); }); test("activation shortcut should not cancel prompt mode when input is focused", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.press("c"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true); }); }); test.describe("Input Preservation", () => { test("input should be cleared after dismissing prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.typeInInput("Some text"); await reactGrab.pressEscape(); await reactGrab.enterPromptMode("li:first-child"); const inputValue = await reactGrab.getInputValue(); expect(inputValue).toBe(""); }); }); test.describe("Edge Cases", () => { test("clicking outside should cancel prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); await reactGrab.page.mouse.click(10, 10); await reactGrab.page.mouse.click(10, 10); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); test("context menu edit maintains overlay in prompt mode", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.enterPromptMode("li:first-child"); const isPromptActive = await reactGrab.isPromptModeActive(); expect(isPromptActive).toBe(true); const isOverlayActive = await reactGrab.isOverlayVisible(); expect(isOverlayActive).toBe(true); }); test("prompt mode should work after scroll", async ({ reactGrab }) => { await reactGrab.setupMockAgent(); await reactGrab.activate(); await reactGrab.scrollPage(100); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:first-child"); await reactGrab.clickContextMenuItem("Edit"); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true); }); }); }); ================================================ FILE: packages/react-grab/e2e/selection.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Element Selection", () => { test("should show selection box when hovering over element while active", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li"); await reactGrab.waitForSelectionBox(); const hasSelectionContent = await reactGrab.page.evaluate(() => { const host = document.querySelector("[data-react-grab]"); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector("[data-react-grab]"); return root !== null && root.innerHTML.length > 0; }); expect(hasSelectionContent).toBe(true); }); test("should copy element content to clipboard on click", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li"); await expect.poll(() => reactGrab.getClipboardContent()).toBeTruthy(); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent.length).toBeGreaterThan(0); }); test("should copy heading element to clipboard", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] h1"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("[data-testid='todo-list'] h1"); await expect .poll(() => reactGrab.getClipboardContent()) .toContain("Todo List"); }); test("should write React Grab clipboard metadata on copy", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] h1"); await reactGrab.waitForSelectionBox(); const copyPayloadPromise = reactGrab.captureNextClipboardWrites(); await reactGrab.clickElement("[data-testid='todo-list'] h1"); const copyPayload = await copyPayloadPromise; const clipboardMetadataText = copyPayload["application/x-react-grab"]; if (!clipboardMetadataText) { throw new Error("Missing React Grab clipboard metadata"); } const clipboardMetadata = JSON.parse(clipboardMetadataText); expect(clipboardMetadata.content).toContain("Todo List"); expect(clipboardMetadata.entries).toHaveLength(1); expect(clipboardMetadata.entries[0].content).toContain("Todo List"); }); test("should highlight different elements when hovering", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.hoverElement("ul"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should deactivate after successful copy in toggle mode", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li"); await reactGrab.clickElement("li"); await expect .poll(() => reactGrab.isOverlayVisible(), { timeout: 3000 }) .toBe(false); }); test("should not show selection when inactive", async ({ reactGrab }) => { const isVisibleBefore = await reactGrab.isOverlayVisible(); expect(isVisibleBefore).toBe(false); await reactGrab.hoverElement("li"); const isVisibleAfter = await reactGrab.isOverlayVisible(); expect(isVisibleAfter).toBe(false); }); test("should select nested elements correctly", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:nth-child(3)"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:nth-child(3)"); await expect.poll(() => reactGrab.getClipboardContent()).toBeTruthy(); }); test("should maintain selection target while hovering", async ({ reactGrab, }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move( box.x + box.width / 2, box.y + box.height / 2, ); await reactGrab.waitForSelectionBox(); await reactGrab.page.mouse.move( box.x + box.width / 2 + 5, box.y + box.height / 2 + 5, ); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); }); test.describe("Selection Bounds and Mutations", () => { test("selection box should update when element size changes", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const initialBounds = await reactGrab.getSelectionBoxBounds(); expect(initialBounds).not.toBeNull(); await reactGrab.page.evaluate(() => { const element = document.querySelector("li:first-child") as HTMLElement; if (element) { element.style.height = "200px"; } }); await expect .poll(async () => { const bounds = await reactGrab.getSelectionBoxBounds(); return bounds?.height ?? 0; }) .toBeGreaterThan(initialBounds?.height ?? 0); }); test("selection should handle element being hidden", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='toggleable-element']"); await reactGrab.waitForSelectionBox(); await reactGrab.hideElement("[data-testid='toggleable-element']"); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("selection should recalculate after scroll", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const boundsBefore = await reactGrab.getSelectionBoxBounds(); await reactGrab.scrollPage(50); if (boundsBefore) { await expect .poll(async () => { const bounds = await reactGrab.getSelectionBoxBounds(); return bounds?.y; }) .not.toBe(boundsBefore.y); } }); test("multiple selection boxes should display for drag selection", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.dragSelect("li:first-child", "li:nth-child(3)"); await reactGrab.page.waitForTimeout(500); await expect .poll(async () => { const info = await reactGrab.getGrabbedBoxInfo(); return info.count; }) .toBeGreaterThan(1); }); test("selection should work on deeply nested elements", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='deeply-nested-text']"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("[data-testid='deeply-nested-text']"); await expect .poll(() => reactGrab.getClipboardContent()) .toContain("deeply nested"); }); }); ================================================ FILE: packages/react-grab/e2e/ssr.spec.ts ================================================ import { test, expect } from "@playwright/test"; import { execSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const DIRECTORY = path.dirname(fileURLToPath(import.meta.url)); const PACKAGE_DIRECTORY = path.resolve(DIRECTORY, ".."); test.describe("SSR Compatibility", () => { test("importing react-grab in Node.js should not throw", () => { const result = execSync( `node -e "require('./dist/index.cjs'); console.log('OK')"`, { cwd: PACKAGE_DIRECTORY, encoding: "utf-8" }, ); expect(result.trim()).toBe("OK"); }); test("importing react-grab/core in Node.js should not throw", () => { const result = execSync( `node -e "require('./dist/core/index.cjs'); console.log('OK')"`, { cwd: PACKAGE_DIRECTORY, encoding: "utf-8" }, ); expect(result.trim()).toBe("OK"); }); test("init() should return a noop API in Node.js", () => { const result = execSync( `node -e "const m = require('./dist/index.cjs'); const api = m.getGlobalApi(); console.log(api === null ? 'NULL' : 'NOT_NULL')"`, { cwd: PACKAGE_DIRECTORY, encoding: "utf-8" }, ); expect(result.trim()).toBe("NULL"); }); test("init() called explicitly in Node.js should return noop API without crashing", () => { const result = execSync( `node -e "const { init } = require('./dist/core/index.cjs'); const api = init(); console.log(typeof api.activate === 'function' ? 'NOOP_API' : 'UNEXPECTED')"`, { cwd: PACKAGE_DIRECTORY, encoding: "utf-8" }, ); expect(result.trim()).toBe("NOOP_API"); }); test("ESM import of react-grab in Node.js should not throw", () => { const esmEntryUrl = pathToFileURL( path.resolve(PACKAGE_DIRECTORY, "dist/index.js"), ).href; const result = execSync( `node -e "import('${esmEntryUrl}').then(() => console.log('OK')).catch(e => { console.error(e); process.exit(1); })"`, { cwd: PACKAGE_DIRECTORY, encoding: "utf-8" }, ); expect(result.trim()).toBe("OK"); }); }); ================================================ FILE: packages/react-grab/e2e/theme-customization.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Theme Customization", () => { test.describe("Hue Rotation", () => { test("should apply hue rotation filter", async ({ reactGrab }) => { await reactGrab.updateOptions({ theme: { hue: 180 } }); await reactGrab.activate(); const hasFilter = await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return false; const root = shadowRoot.querySelector(`[${attrName}]`) as HTMLElement; return root?.style.filter?.includes("hue-rotate") ?? false; }, "data-react-grab"); expect(hasFilter).toBe(true); }); test("should apply correct hue rotation value", async ({ reactGrab }) => { await reactGrab.updateOptions({ theme: { hue: 90 } }); await reactGrab.activate(); const filterValue = await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`) as HTMLElement; return root?.style.filter; }, "data-react-grab"); expect(filterValue).toContain("hue-rotate(90deg)"); }); test("should not apply filter when hue is 0", async ({ reactGrab }) => { await reactGrab.updateOptions({ theme: { hue: 0 } }); await reactGrab.activate(); const filterValue = await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return ""; const root = shadowRoot.querySelector(`[${attrName}]`) as HTMLElement; return root?.style.filter ?? ""; }, "data-react-grab"); expect(filterValue).toBe(""); }); }); test.describe("Selection Box", () => { test("should show selection box by default", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionBoxVisible(); expect(isVisible).toBe(true); }); test("should hide selection box when disabled", async ({ reactGrab }) => { await reactGrab.updateOptions({ theme: { selectionBox: { enabled: false } }, }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const bounds = await reactGrab.getSelectionBoxBounds(); expect(bounds).toBeNull(); }); }); test.describe("Drag Box", () => { test("should show drag box by default", async ({ reactGrab }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x - 20, box.y - 20); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 150, box.y + 150, { steps: 10 }); const dragBounds = await reactGrab.getDragBoxBounds(); await reactGrab.page.mouse.up(); expect(dragBounds).toBeDefined(); }); test("should hide drag box when disabled", async ({ reactGrab }) => { await reactGrab.updateOptions({ theme: { dragBox: { enabled: false } } }); await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x - 20, box.y - 20); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 150, box.y + 150, { steps: 10 }); const dragBounds = await reactGrab.getDragBoxBounds(); await reactGrab.page.mouse.up(); expect(dragBounds).toBeNull(); }); }); test.describe("Grabbed Boxes", () => { test("should show grabbed boxes by default", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(200); const info = await reactGrab.getGrabbedBoxInfo(); expect(info.count).toBeGreaterThan(0); }); test("should hide grabbed boxes when disabled", async ({ reactGrab }) => { await reactGrab.updateOptions({ theme: { grabbedBoxes: { enabled: false } }, }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(200); const isVisible = await reactGrab.isGrabbedBoxVisible(); expect(isVisible).toBe(false); }); }); test.describe("Element Label", () => { test("should show element label by default", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isSelectionLabelVisible(); expect(isVisible).toBe(true); }); test("should hide element label when disabled", async ({ reactGrab }) => { await reactGrab.updateOptions({ theme: { elementLabel: { enabled: false } }, }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const labelInfo = await reactGrab.getSelectionLabelInfo(); expect(labelInfo.isVisible).toBe(false); }); }); test.describe("Toolbar", () => { test("should show toolbar by default", async ({ reactGrab }) => { await reactGrab.page.waitForTimeout(600); const isVisible = await reactGrab.isToolbarVisible(); expect(isVisible).toBe(true); }); test("should hide toolbar when disabled", async ({ reactGrab }) => { await reactGrab.updateOptions({ theme: { toolbar: { enabled: false } } }); await reactGrab.page.waitForTimeout(600); const isVisible = await reactGrab.isToolbarVisible(); expect(isVisible).toBe(false); }); }); test.describe("Global Enable/Disable", () => { test("should disable entire overlay when enabled is false", async ({ reactGrab, }) => { await reactGrab.updateOptions({ theme: { enabled: false } }); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const isSelectionBoxVisible = await reactGrab.isSelectionBoxVisible(); expect(isSelectionBoxVisible).toBe(false); }); }); test.describe("Theme Persistence", () => { test("theme should persist across activation cycles", async ({ reactGrab, }) => { await reactGrab.updateOptions({ theme: { hue: 120 } }); await reactGrab.activate(); await reactGrab.deactivate(); await reactGrab.activate(); const hasFilter = await reactGrab.page.evaluate(() => { const host = document.querySelector("[data-react-grab]"); const shadowRoot = host?.shadowRoot; const root = shadowRoot?.querySelector( "[data-react-grab]", ) as HTMLElement; return root?.style.filter?.includes("hue-rotate(120deg)") ?? false; }); expect(hasFilter).toBe(true); }); }); }); ================================================ FILE: packages/react-grab/e2e/toggle-position-stability.spec.ts ================================================ import { test, expect } from "./fixtures.js"; import type { ReactGrabPageObject } from "./fixtures.js"; const POSITION_TOLERANCE_PX = 3; const TOGGLE_ANIMATION_SETTLE_MS = 300; const getToggleButtonCenter = async (reactGrab: ReactGrabPageObject) => { return reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const button = root.querySelector( "[data-react-grab-toolbar-enabled]", ); if (!button) return null; const rect = button.getBoundingClientRect(); return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; }, "data-react-grab"); }; const seedToolbarEdge = async ( page: import("@playwright/test").Page, edge: "top" | "bottom" | "left" | "right", enabled = true, ) => { await page.evaluate( ({ edge: savedEdge, enabled: savedEnabled }) => { localStorage.setItem( "react-grab-toolbar-state", JSON.stringify({ edge: savedEdge, ratio: 0.5, collapsed: false, enabled: savedEnabled, }), ); }, { edge, enabled }, ); await page.reload(); await page.waitForLoadState("domcontentloaded"); }; const copyElement = async ( reactGrab: ReactGrabPageObject, selector: string, ) => { await reactGrab.activate(); await reactGrab.hoverElement(selector); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement(selector); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); // HACK: Wait for copy feedback transition and history item addition await reactGrab.page.waitForTimeout(300); }; const expectPositionStable = ( beforePosition: { x: number; y: number }, afterPosition: { x: number; y: number }, ) => { expect(Math.abs(afterPosition.x - beforePosition.x)).toBeLessThan( POSITION_TOLERANCE_PX, ); expect(Math.abs(afterPosition.y - beforePosition.y)).toBeLessThan( POSITION_TOLERANCE_PX, ); }; const waitForToolbarReady = async (reactGrab: ReactGrabPageObject) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); // HACK: Wait for toolbar fade-in animation to complete await reactGrab.page.waitForTimeout(600); }; test.describe("Toggle Position Stability", () => { test.beforeEach(async ({ reactGrab }) => { await reactGrab.page.evaluate(() => { localStorage.removeItem("react-grab-toolbar-state"); }); await reactGrab.page.reload(); await reactGrab.page.waitForLoadState("domcontentloaded"); await waitForToolbarReady(reactGrab); }); test.describe("Horizontal Layout", () => { test("toggle should stay in place when disabling on bottom edge", async ({ reactGrab, }) => { const beforeToggle = await getToggleButtonCenter(reactGrab); expect(beforeToggle).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterToggle = await getToggleButtonCenter(reactGrab); expect(afterToggle).not.toBeNull(); expectPositionStable(beforeToggle!, afterToggle!); }); test("toggle should stay in place when re-enabling on bottom edge", async ({ reactGrab, }) => { await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const beforeReEnable = await getToggleButtonCenter(reactGrab); expect(beforeReEnable).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterReEnable = await getToggleButtonCenter(reactGrab); expect(afterReEnable).not.toBeNull(); expectPositionStable(beforeReEnable!, afterReEnable!); }); test("toggle should return to same position after full cycle on bottom edge", async ({ reactGrab, }) => { const initialPosition = await getToggleButtonCenter(reactGrab); expect(initialPosition).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterCycle = await getToggleButtonCenter(reactGrab); expect(afterCycle).not.toBeNull(); expectPositionStable(initialPosition!, afterCycle!); }); test("toggle should stay in place when toggling on top edge", async ({ reactGrab, }) => { await seedToolbarEdge(reactGrab.page, "top"); await waitForToolbarReady(reactGrab); const beforeToggle = await getToggleButtonCenter(reactGrab); expect(beforeToggle).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterToggle = await getToggleButtonCenter(reactGrab); expect(afterToggle).not.toBeNull(); expectPositionStable(beforeToggle!, afterToggle!); }); }); test.describe("Vertical Layout", () => { test("toggle should stay in place when toggling on right edge", async ({ reactGrab, }) => { await seedToolbarEdge(reactGrab.page, "right"); await waitForToolbarReady(reactGrab); const beforeToggle = await getToggleButtonCenter(reactGrab); expect(beforeToggle).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterToggle = await getToggleButtonCenter(reactGrab); expect(afterToggle).not.toBeNull(); expectPositionStable(beforeToggle!, afterToggle!); }); test("toggle should stay in place when toggling on left edge", async ({ reactGrab, }) => { await seedToolbarEdge(reactGrab.page, "left"); await waitForToolbarReady(reactGrab); const beforeToggle = await getToggleButtonCenter(reactGrab); expect(beforeToggle).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterToggle = await getToggleButtonCenter(reactGrab); expect(afterToggle).not.toBeNull(); expectPositionStable(beforeToggle!, afterToggle!); }); }); test.describe("First Enable", () => { test("first enable on bottom edge should not cause position jump", async ({ reactGrab, }) => { await seedToolbarEdge(reactGrab.page, "bottom", false); await waitForToolbarReady(reactGrab); const beforeFirstEnable = await getToggleButtonCenter(reactGrab); expect(beforeFirstEnable).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterFirstEnable = await getToggleButtonCenter(reactGrab); expect(afterFirstEnable).not.toBeNull(); expectPositionStable(beforeFirstEnable!, afterFirstEnable!); }); test("first enable on top edge should not cause position jump", async ({ reactGrab, }) => { await seedToolbarEdge(reactGrab.page, "top", false); await waitForToolbarReady(reactGrab); const beforeFirstEnable = await getToggleButtonCenter(reactGrab); expect(beforeFirstEnable).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterFirstEnable = await getToggleButtonCenter(reactGrab); expect(afterFirstEnable).not.toBeNull(); expectPositionStable(beforeFirstEnable!, afterFirstEnable!); }); }); test.describe("Position Drift Prevention", () => { test("should not drift after history button appears then disappears", async ({ reactGrab, }) => { await copyElement(reactGrab, "li:first-child"); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(true); const withHistoryPosition = await getToggleButtonCenter(reactGrab); expect(withHistoryPosition).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterCycleWithHistory = await getToggleButtonCenter(reactGrab); expect(afterCycleWithHistory).not.toBeNull(); expectPositionStable(withHistoryPosition!, afterCycleWithHistory!); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryClear(); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(false); // HACK: Wait for history button hide animation await reactGrab.page.waitForTimeout(200); const withoutHistoryPosition = await getToggleButtonCenter(reactGrab); expect(withoutHistoryPosition).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterCycleWithoutHistory = await getToggleButtonCenter(reactGrab); expect(afterCycleWithoutHistory).not.toBeNull(); expectPositionStable(withoutHistoryPosition!, afterCycleWithoutHistory!); }); test("should not accumulate drift over multiple toggle cycles", async ({ reactGrab, }) => { const initialPosition = await getToggleButtonCenter(reactGrab); expect(initialPosition).not.toBeNull(); for (let cycleIndex = 0; cycleIndex < 5; cycleIndex++) { await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); } const finalPosition = await getToggleButtonCenter(reactGrab); expect(finalPosition).not.toBeNull(); expectPositionStable(initialPosition!, finalPosition!); }); test("should not drift on vertical edge after history changes", async ({ reactGrab, }) => { await seedToolbarEdge(reactGrab.page, "right"); await waitForToolbarReady(reactGrab); await copyElement(reactGrab, "li:first-child"); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(true); const beforeCyclePosition = await getToggleButtonCenter(reactGrab); expect(beforeCyclePosition).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterCyclePosition = await getToggleButtonCenter(reactGrab); expect(afterCyclePosition).not.toBeNull(); expectPositionStable(beforeCyclePosition!, afterCyclePosition!); await reactGrab.clickHistoryButton(); await reactGrab.clickHistoryClear(); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(false); // HACK: Wait for history button hide animation await reactGrab.page.waitForTimeout(200); const afterClearPosition = await getToggleButtonCenter(reactGrab); expect(afterClearPosition).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterClearCyclePosition = await getToggleButtonCenter(reactGrab); expect(afterClearCyclePosition).not.toBeNull(); expectPositionStable(afterClearPosition!, afterClearCyclePosition!); }); }); test.describe("Rapid Toggle", () => { test("rapid toggles should maintain toolbar visibility and state", async ({ reactGrab, }) => { for (let toggleIndex = 0; toggleIndex < 6; toggleIndex++) { await reactGrab.clickToolbarEnabled(); // HACK: Brief pause between rapid toggles await reactGrab.page.waitForTimeout(50); } // HACK: Wait for all toggle animations to fully settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS * 2); const toolbarInfo = await reactGrab.getToolbarInfo(); expect(toolbarInfo.isVisible).toBe(true); expect(toolbarInfo.position).not.toBeNull(); const togglePosition = await getToggleButtonCenter(reactGrab); expect(togglePosition).not.toBeNull(); }); test("position should stabilize after rapid toggles settle", async ({ reactGrab, }) => { for (let toggleIndex = 0; toggleIndex < 6; toggleIndex++) { await reactGrab.clickToolbarEnabled(); // HACK: Brief pause between rapid toggles await reactGrab.page.waitForTimeout(50); } // HACK: Wait for all toggle animations to fully settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS * 2); const settledPosition = await getToggleButtonCenter(reactGrab); expect(settledPosition).not.toBeNull(); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to settle await reactGrab.page.waitForTimeout(TOGGLE_ANIMATION_SETTLE_MS); const afterNormalCycle = await getToggleButtonCenter(reactGrab); expect(afterNormalCycle).not.toBeNull(); expectPositionStable(settledPosition!, afterNormalCycle!); }); }); }); ================================================ FILE: packages/react-grab/e2e/toolbar-menu.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Toolbar Menu", () => { test.describe("Visibility", () => { test("menu button should be visible when toolbar actions are registered", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await expect .poll(() => reactGrab.isToolbarMenuButtonVisible(), { timeout: 2000 }) .toBe(true); }); test("menu dropdown should not be visible by default", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); const isMenuVisible = await reactGrab.isToolbarMenuVisible(); expect(isMenuVisible).toBe(false); }); test("menu button should be hidden when toolbar is disabled", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarEnabled(); await reactGrab.page.waitForTimeout(200); const isMenuButtonVisible = await reactGrab.isToolbarMenuButtonVisible(); expect(isMenuButtonVisible).toBe(false); }); }); test.describe("Open and Close", () => { test("clicking menu button should open the menu", async ({ reactGrab }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarMenuButton(); const isMenuVisible = await reactGrab.isToolbarMenuVisible(); expect(isMenuVisible).toBe(true); }); test("clicking menu button again should close the menu", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarMenuButton(); await reactGrab.clickToolbarMenuButton(); const isMenuVisible = await reactGrab.isToolbarMenuVisible(); expect(isMenuVisible).toBe(false); }); test("pressing Escape should close the menu", async ({ reactGrab }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarMenuButton(); await expect .poll(() => reactGrab.isToolbarMenuVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.pressEscape(); await reactGrab.page.waitForTimeout(200); const isMenuVisible = await reactGrab.isToolbarMenuVisible(); expect(isMenuVisible).toBe(false); }); }); test.describe("Menu Items", () => { test("menu should display registered toolbar actions", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarMenuButton(); const menuInfo = await reactGrab.getToolbarMenuInfo(); expect(menuInfo.isVisible).toBe(true); expect(menuInfo.itemCount).toBeGreaterThan(0); expect(menuInfo.itemLabels.length).toBeGreaterThan(0); }); }); test.describe("Interaction with Other Dropdowns", () => { test("opening context menu should dismiss toolbar menu", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarMenuButton(); await expect .poll(() => reactGrab.isToolbarMenuVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li:first-child"); await expect .poll(() => reactGrab.isToolbarMenuVisible(), { timeout: 2000 }) .toBe(false); }); test("opening toolbar menu should dismiss history dropdown", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(300); await expect .poll(() => reactGrab.isHistoryButtonVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickHistoryButton(); await expect .poll(() => reactGrab.isHistoryDropdownVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarMenuButton(); await expect .poll(() => reactGrab.isHistoryDropdownVisible(), { timeout: 2000 }) .toBe(false); await expect .poll(() => reactGrab.isToolbarMenuVisible(), { timeout: 2000 }) .toBe(true); }); }); }); ================================================ FILE: packages/react-grab/e2e/toolbar-selection-hover.spec.ts ================================================ import { test, expect } from "./fixtures.js"; const ATTRIBUTE_NAME = "data-react-grab"; const hoverToolbar = async (page: import("@playwright/test").Page) => { const toolbarRect = await page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return null; const root = shadowRoot.querySelector(`[${attrName}]`); if (!root) return null; const toolbar = root.querySelector( "[data-react-grab-toolbar]", ); if (!toolbar) return null; const rect = toolbar.getBoundingClientRect(); return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }, ATTRIBUTE_NAME); if (!toolbarRect) throw new Error("Toolbar not found"); await page.mouse.move( toolbarRect.x + toolbarRect.width / 2, toolbarRect.y + toolbarRect.height / 2, ); await page.waitForTimeout(150); }; const hoverAwayFromToolbar = async (page: import("@playwright/test").Page) => { await page.mouse.move(10, 10); await page.waitForTimeout(150); }; test.describe("Toolbar Selection Hover", () => { test.describe("Selection Mode", () => { test("should hide selection box when hovering toolbar", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li"); await reactGrab.waitForSelectionBox(); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(true); await hoverToolbar(reactGrab.page); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(false); }); test("should hide selection label when hovering toolbar", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li"); await reactGrab.waitForSelectionBox(); await expect .poll(() => reactGrab.isSelectionLabelVisible(), { timeout: 2000 }) .toBe(true); await hoverToolbar(reactGrab.page); await expect .poll(() => reactGrab.isSelectionLabelVisible(), { timeout: 2000 }) .toBe(false); }); test("should restore selection after moving mouse back from toolbar", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li"); await reactGrab.waitForSelectionBox(); await hoverToolbar(reactGrab.page); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(false); await reactGrab.hoverElement("li"); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(true); }); }); test.describe("Frozen Mode", () => { test("should keep selection box visible when hovering toolbar after right-click freeze", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li"); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(true); await hoverToolbar(reactGrab.page); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(true); }); test("should keep selection box visible after context menu dismiss and toolbar hover", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li"); await expect .poll(() => reactGrab.isContextMenuVisible(), { timeout: 2000 }) .toBe(true); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(true); await hoverToolbar(reactGrab.page); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(true); }); test("selection box should not flicker when moving between frozen element and toolbar", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li"); await reactGrab.waitForSelectionBox(); await reactGrab.rightClickElement("li"); for (let hoverIndex = 0; hoverIndex < 3; hoverIndex++) { await hoverToolbar(reactGrab.page); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(true); await hoverAwayFromToolbar(reactGrab.page); await expect .poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }) .toBe(true); } }); }); }); ================================================ FILE: packages/react-grab/e2e/toolbar.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Toolbar", () => { test.describe("Visibility", () => { test("toolbar should be visible after initial load", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); }); test("toolbar should fade in after delay", async ({ reactGrab }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); }); test("toolbar should be visible on mobile viewport after reload", async ({ reactGrab, }) => { await reactGrab.setViewportSize(375, 667); await reactGrab.page.reload(); await reactGrab.page.waitForLoadState("domcontentloaded"); await reactGrab.page.waitForFunction( () => (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__ !== undefined, { timeout: 10000 }, ); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); await reactGrab.setViewportSize(1280, 720); }); test("toolbar should remain visible through viewport resize cycles", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.setViewportSize(375, 667); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.setViewportSize(1280, 720); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); }); }); test.describe("Toggle Activation", () => { test("clicking toolbar toggle should activate overlay", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarToggle(); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); test("clicking toolbar toggle again should deactivate overlay", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarToggle(); await reactGrab.clickToolbarToggle(); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(false); }); test("toolbar toggle should reflect current activation state", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.activate(); const toolbarInfo = await reactGrab.getToolbarInfo(); expect(toolbarInfo.isVisible).toBe(true); }); }); test.describe("Collapse/Expand", () => { test("clicking collapse button should collapse toolbar", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarCollapse(); await expect .poll(() => reactGrab.isToolbarCollapsed(), { timeout: 2000 }) .toBe(true); }); test("clicking collapsed toolbar should expand it", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarCollapse(); await expect .poll(() => reactGrab.isToolbarCollapsed(), { timeout: 2000 }) .toBe(true); await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); const toolbar = root?.querySelector( "[data-react-grab-toolbar]", ); const innerDiv = toolbar?.querySelector("div"); innerDiv?.click(); }, "data-react-grab"); await expect .poll(() => reactGrab.isToolbarCollapsed(), { timeout: 2000 }) .toBe(false); }); test("collapsed toolbar should not allow activation toggle", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarCollapse(); await expect .poll(() => reactGrab.isToolbarCollapsed(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarToggle(); const isActive = await reactGrab.isOverlayVisible(); const isCollapsed = await reactGrab.isToolbarCollapsed(); expect(isCollapsed || !isActive).toBe(true); }); }); test.describe("Dragging", () => { test.beforeEach(async ({ reactGrab }) => { await reactGrab.page.evaluate(() => { localStorage.removeItem("react-grab-toolbar-state"); }); await reactGrab.page.reload(); await reactGrab.page.waitForLoadState("domcontentloaded"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); // HACK: Wait for toolbar fade-in animation to complete await reactGrab.page.waitForTimeout(600); }); test("should be draggable", async ({ reactGrab }) => { const initialInfo = await reactGrab.getToolbarInfo(); const initialPosition = initialInfo.position; expect(initialPosition).not.toBeNull(); await reactGrab.dragToolbar(100, 0); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); if (!info.position || !initialPosition) return 0; return Math.abs(info.position.x - initialPosition.x); }, { timeout: 3000 }, ) .toBeGreaterThan(0); }); test("should snap to edges after drag", async ({ reactGrab }) => { await reactGrab.dragToolbar(500, 0); const info = await reactGrab.getToolbarInfo(); expect(info.snapEdge).toBeDefined(); }); test("should snap to top edge", async ({ reactGrab }) => { await reactGrab.dragToolbar(0, -500); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); return info.snapEdge; }, { timeout: 3000 }, ) .toBe("top"); }); test("should snap to left edge", async ({ reactGrab }) => { await reactGrab.dragToolbar(-1000, -500); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); return info.snapEdge; }, { timeout: 3000 }, ) .toMatch(/^(left|top)$/); }); test("should snap to right edge", async ({ reactGrab }) => { await reactGrab.dragToolbar(1500, -500); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); return info.snapEdge; }, { timeout: 3000 }, ) .toMatch(/^(right|top)$/); }); test("should not drag when collapsed", async ({ reactGrab }) => { await reactGrab.clickToolbarCollapse(); await expect .poll(() => reactGrab.isToolbarCollapsed(), { timeout: 2000 }) .toBe(true); const initialInfo = await reactGrab.getToolbarInfo(); const initialPosition = initialInfo.position; await reactGrab.dragToolbar(100, 100); const finalInfo = await reactGrab.getToolbarInfo(); const finalPosition = finalInfo.position; if (initialPosition && finalPosition) { expect(Math.abs(finalPosition.x - initialPosition.x)).toBeLessThan(20); expect(Math.abs(finalPosition.y - initialPosition.y)).toBeLessThan(20); } }); test("should be draggable from select button", async ({ reactGrab }) => { const initialInfo = await reactGrab.getToolbarInfo(); const initialPosition = initialInfo.position; expect(initialPosition).not.toBeNull(); await reactGrab.dragToolbarFromButton( "[data-react-grab-toolbar-toggle]", 100, 0, ); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); if (!info.position || !initialPosition) return 0; return Math.abs(info.position.x - initialPosition.x); }, { timeout: 3000 }, ) .toBeGreaterThan(0); }); test("should not close page dropdown when clicking select button", async ({ reactGrab, }) => { await reactGrab.openDropdown(); expect(await reactGrab.isDropdownOpen()).toBe(true); await reactGrab.clickToolbarToggle(); expect(await reactGrab.isDropdownOpen()).toBe(true); }); test("should not close page dropdown when dragging from select button", async ({ reactGrab, }) => { await reactGrab.openDropdown(); expect(await reactGrab.isDropdownOpen()).toBe(true); await reactGrab.dragToolbarFromButton( "[data-react-grab-toolbar-toggle]", 50, 0, ); expect(await reactGrab.isDropdownOpen()).toBe(true); }); }); test.describe("State Persistence", () => { test("toolbar position should persist across page reloads", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.dragToolbar(200, -200); // HACK: Wait for snap animation await reactGrab.page.waitForTimeout(200); const positionBeforeReload = await reactGrab.getToolbarInfo(); await reactGrab.page.reload(); await reactGrab.page.waitForLoadState("domcontentloaded"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); const positionAfterReload = await reactGrab.getToolbarInfo(); if (positionBeforeReload.snapEdge && positionAfterReload.snapEdge) { expect(positionAfterReload.snapEdge).toBe( positionBeforeReload.snapEdge, ); } }); test("collapsed state should persist across page reloads", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.clickToolbarCollapse(); await expect .poll(() => reactGrab.isToolbarCollapsed(), { timeout: 2000 }) .toBe(true); await reactGrab.page.reload(); await reactGrab.page.waitForLoadState("domcontentloaded"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await expect .poll(() => reactGrab.isToolbarCollapsed(), { timeout: 2000 }) .toBe(true); }); }); test.describe("Chevron Rotation", () => { test.beforeEach(async ({ reactGrab }) => { await reactGrab.page.evaluate(() => { localStorage.removeItem("react-grab-toolbar-state"); }); await reactGrab.page.reload(); await reactGrab.page.waitForLoadState("domcontentloaded"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); // HACK: Wait for toolbar fade-in animation to complete await reactGrab.page.waitForTimeout(600); }); test("chevron should rotate based on snap edge", async ({ reactGrab }) => { await reactGrab.dragToolbar(0, -500); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); return info.snapEdge; }, { timeout: 3000 }, ) .toBe("top"); // HACK: Need extra delay for snap animation before next drag await reactGrab.page.waitForTimeout(300); await reactGrab.dragToolbar(0, 800); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); return info.snapEdge; }, { timeout: 3000 }, ) .toBe("bottom"); }); }); test.describe("Viewport Resize Handling", () => { test("toolbar should recalculate position on viewport resize", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.setViewportSize(1920, 1080); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); return info.isVisible; }, { timeout: 2000 }, ) .toBe(true); await reactGrab.setViewportSize(1280, 720); }); test("toolbar should remain visible after rapid resize", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); for (let i = 0; i < 3; i++) { await reactGrab.setViewportSize(1000 + i * 100, 700 + i * 50); } await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.setViewportSize(1280, 720); }); }); test.describe("Edge Cases", () => { test("toolbar should handle very small viewport", async ({ reactGrab }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.setViewportSize(320, 480); const isVisible = await reactGrab.isToolbarVisible(); expect(typeof isVisible).toBe("boolean"); await reactGrab.setViewportSize(1280, 720); }); test("toolbar should handle rapid collapse/expand", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); for (let i = 0; i < 5; i++) { await reactGrab.clickToolbarCollapse(); } const info = await reactGrab.getToolbarInfo(); expect(info.isVisible).toBe(true); }); test("toolbar should maintain position ratio on resize", async ({ reactGrab, }) => { await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 2000 }) .toBe(true); await reactGrab.dragToolbar(-200, 0); await reactGrab.setViewportSize(800, 600); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); return info.isVisible; }, { timeout: 2000 }, ) .toBe(true); await reactGrab.setViewportSize(1280, 720); }); }); test.describe("Vertical Layout", () => { const seedVerticalState = async ( page: import("@playwright/test").Page, edge: "left" | "right", ) => { await page.evaluate( ({ edge: savedEdge }) => { localStorage.setItem( "react-grab-toolbar-state", JSON.stringify({ edge: savedEdge, ratio: 0.5, collapsed: false, enabled: true, }), ); }, { edge }, ); await page.reload(); await page.waitForLoadState("domcontentloaded"); }; test.beforeEach(async ({ reactGrab }) => { await reactGrab.page.evaluate(() => { localStorage.removeItem("react-grab-toolbar-state"); }); await reactGrab.page.reload(); await reactGrab.page.waitForLoadState("domcontentloaded"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); // HACK: Wait for toolbar fade-in animation to complete await reactGrab.page.waitForTimeout(600); }); test("should render vertically when snapped to right edge", async ({ reactGrab, }) => { await seedVerticalState(reactGrab.page, "right"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); const info = await reactGrab.getToolbarInfo(); expect(info.snapEdge).toBe("right"); expect(info.isVertical).toBe(true); expect(info.dimensions).not.toBeNull(); expect(info.dimensions!.height).toBeGreaterThan(info.dimensions!.width); }); test("should render vertically when snapped to left edge", async ({ reactGrab, }) => { await seedVerticalState(reactGrab.page, "left"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); const info = await reactGrab.getToolbarInfo(); expect(info.snapEdge).toBe("left"); expect(info.isVertical).toBe(true); expect(info.dimensions).not.toBeNull(); expect(info.dimensions!.height).toBeGreaterThan(info.dimensions!.width); }); test("should render horizontally when snapped to top or bottom", async ({ reactGrab, }) => { const info = await reactGrab.getToolbarInfo(); expect(info.isVertical).toBe(false); expect(info.dimensions).not.toBeNull(); expect(info.dimensions!.width).toBeGreaterThan(info.dimensions!.height); }); test("should allow toggle activation in vertical mode", async ({ reactGrab, }) => { await seedVerticalState(reactGrab.page, "right"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); await reactGrab.clickToolbarToggle(); const isActive = await reactGrab.isOverlayVisible(); expect(isActive).toBe(true); }); test("should collapse and expand in vertical mode", async ({ reactGrab, }) => { await seedVerticalState(reactGrab.page, "right"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); await reactGrab.clickToolbarCollapse(); await expect .poll(() => reactGrab.isToolbarCollapsed(), { timeout: 2000 }) .toBe(true); await reactGrab.page.evaluate((attrName) => { const host = document.querySelector(`[${attrName}]`); const shadowRoot = host?.shadowRoot; if (!shadowRoot) return; const root = shadowRoot.querySelector(`[${attrName}]`); const toolbar = root?.querySelector( "[data-react-grab-toolbar]", ); const innerDiv = toolbar?.querySelector("div"); innerDiv?.click(); }, "data-react-grab"); await expect .poll(() => reactGrab.isToolbarCollapsed(), { timeout: 2000 }) .toBe(false); }); test("should toggle enabled state in vertical mode", async ({ reactGrab, }) => { await seedVerticalState(reactGrab.page, "right"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to complete await reactGrab.page.waitForTimeout(200); await reactGrab.clickToolbarEnabled(); // HACK: Wait for toggle animation to complete await reactGrab.page.waitForTimeout(200); const info = await reactGrab.getToolbarInfo(); expect(info.isVisible).toBe(true); expect(info.snapEdge).toBe("right"); }); test("vertical edge state should persist across page reloads", async ({ reactGrab, }) => { await seedVerticalState(reactGrab.page, "right"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); await reactGrab.page.reload(); await reactGrab.page.waitForLoadState("domcontentloaded"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); const infoAfterReload = await reactGrab.getToolbarInfo(); expect(infoAfterReload.snapEdge).toBe("right"); expect(infoAfterReload.isVertical).toBe(true); }); test("vertical toolbar should be snapped to edge after reload", async ({ reactGrab, }) => { const viewportSize = await reactGrab.getViewportSize(); await seedVerticalState(reactGrab.page, "right"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); await reactGrab.page.reload(); await reactGrab.page.waitForLoadState("domcontentloaded"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); const info = await reactGrab.getToolbarInfo(); expect(info.position).not.toBeNull(); expect(info.dimensions).not.toBeNull(); const rightEdgePosition = info.position!.x + info.dimensions!.width; expect(rightEdgePosition).toBeGreaterThan(viewportSize.width - 30); }); test("should transition from vertical to horizontal when dragged to bottom", async ({ reactGrab, }) => { await seedVerticalState(reactGrab.page, "right"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); // HACK: Wait for toolbar fade-in animation to complete await reactGrab.page.waitForTimeout(600); const verticalInfo = await reactGrab.getToolbarInfo(); expect(verticalInfo.isVertical).toBe(true); await reactGrab.dragToolbar(-500, 500); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); return info.snapEdge; }, { timeout: 3000 }, ) .toBe("bottom"); const horizontalInfo = await reactGrab.getToolbarInfo(); expect(horizontalInfo.isVertical).toBe(false); }); test("should be draggable from vertical position", async ({ reactGrab, }) => { await seedVerticalState(reactGrab.page, "right"); await expect .poll(() => reactGrab.isToolbarVisible(), { timeout: 3000 }) .toBe(true); // HACK: Wait for toolbar fade-in animation to complete await reactGrab.page.waitForTimeout(600); const initialInfo = await reactGrab.getToolbarInfo(); expect(initialInfo.position).not.toBeNull(); await reactGrab.dragToolbar(0, 100); await expect .poll( async () => { const info = await reactGrab.getToolbarInfo(); if (!info.position || !initialInfo.position) return false; return ( Math.abs(info.position.x - initialInfo.position.x) > 0 || Math.abs(info.position.y - initialInfo.position.y) > 0 ); }, { timeout: 3000 }, ) .toBe(true); const movedInfo = await reactGrab.getToolbarInfo(); expect(movedInfo.isVisible).toBe(true); expect(movedInfo.snapEdge).not.toBeNull(); }); }); }); ================================================ FILE: packages/react-grab/e2e/touch-mode.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Touch Mode", () => { test.describe("Touch Events", () => { test("touch tap should work for element selection", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.touchTap("li:first-child"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); }); test("touch should set touch mode flag", async ({ reactGrab }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.touchscreen.tap( box.x + box.width / 2, box.y + box.height / 2, ); await reactGrab.page.waitForTimeout(100); const state = await reactGrab.getState(); expect(state).toBeDefined(); }); test("touch drag should create drag selection", async ({ reactGrab }) => { await reactGrab.activate(); const firstItem = reactGrab.page.locator("li").first(); const lastItem = reactGrab.page.locator("li").nth(3); const startBox = await firstItem.boundingBox(); const endBox = await lastItem.boundingBox(); if (!startBox || !endBox) throw new Error("Could not get bounding boxes"); await reactGrab.touchDrag( startBox.x - 10, startBox.y - 10, endBox.x + endBox.width + 10, endBox.y + endBox.height + 10, ); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); }); }); test.describe("Touch Mode Behavior", () => { test("touch events should update pointer position", async ({ reactGrab, }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.touchscreen.tap( box.x + box.width / 2, box.y + box.height / 2, ); await reactGrab.page.waitForTimeout(100); const state = await reactGrab.getState(); expect(state).toBeDefined(); }); }); test.describe("Touch Selection", () => { test("touch should select element", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] h1"); await reactGrab.waitForSelectionBox(); const element = reactGrab.page.locator("[data-testid='todo-list'] h1"); const box = await element.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.touchscreen.tap( box.x + box.width / 2, box.y + box.height / 2, ); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toContain("Todo List"); }); test("touch on different elements should work", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.touchTap("li:nth-child(2)"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); await expect .poll(() => reactGrab.isOverlayVisible(), { timeout: 5000 }) .toBe(false); await reactGrab.activate(); await reactGrab.touchTap("li:nth-child(4)"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); }); }); test.describe("Touch Drag Selection", () => { test("touch drag should select multiple elements", async ({ reactGrab, }) => { await reactGrab.activate(); const firstItem = reactGrab.page.locator("li").first(); const secondItem = reactGrab.page.locator("li").nth(1); const startBox = await firstItem.boundingBox(); const endBox = await secondItem.boundingBox(); if (!startBox || !endBox) throw new Error("Could not get bounding boxes"); await reactGrab.touchDrag( startBox.x - 5, startBox.y - 5, endBox.x + endBox.width + 5, endBox.y + endBox.height + 5, ); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); }); test("short touch drag should be treated as tap", async ({ reactGrab }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.touchDrag( box.x + box.width / 2, box.y + box.height / 2, box.x + box.width / 2 + 2, box.y + box.height / 2 + 2, ); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); }); }); test.describe("Touch and Mouse Switching", () => { test("should handle switch from mouse to touch", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.touchTap("li:nth-child(2)"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); }); test("should handle switch from touch to mouse", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.touchTap("li:first-child"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); await expect .poll(() => reactGrab.isOverlayVisible(), { timeout: 5000 }) .toBe(false); await reactGrab.activate(); await reactGrab.hoverElement("li:nth-child(3)"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:nth-child(3)"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); }); }); test.describe("Touch Input Mode", () => { test("double tap should enter input mode with agent", async ({ reactGrab, }) => { await reactGrab.setupMockAgent(); await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.touchscreen.tap( box.x + box.width / 2, box.y + box.height / 2, ); await reactGrab.page.waitForTimeout(100); await reactGrab.page.touchscreen.tap( box.x + box.width / 2, box.y + box.height / 2, ); await reactGrab.page.waitForTimeout(200); const state = await reactGrab.getState(); expect(state).toBeDefined(); }); }); test.describe("Touch with Scroll", () => { test("should handle touch after scroll", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.scrollPage(200); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (box) { await reactGrab.page.touchscreen.tap( box.x + box.width / 2, box.y + box.height / 2, ); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toBeTruthy(); } }); }); test.describe("Touch Edge Cases", () => { test("should handle rapid touch events", async ({ reactGrab }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); for (let i = 0; i < 5; i++) { await reactGrab.page.touchscreen.tap( box.x + box.width / 2 + i * 10, box.y + box.height / 2, ); await reactGrab.page.waitForTimeout(50); } const state = await reactGrab.getState(); expect(state).toBeDefined(); }); test("should handle touch on overlay elements", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.page.waitForTimeout(600); const toolbarInfo = await reactGrab.getToolbarInfo(); if (toolbarInfo.position) { await reactGrab.page.touchscreen.tap( toolbarInfo.position.x + 20, toolbarInfo.position.y + 10, ); await reactGrab.page.waitForTimeout(200); } const state = await reactGrab.getState(); expect(state).toBeDefined(); }); }); }); ================================================ FILE: packages/react-grab/e2e/viewport.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Viewport and Scroll Handling", () => { test("should maintain selection after scrolling page", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.evaluate(() => { window.scrollBy(0, 50); }); await reactGrab.page.waitForTimeout(200); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should re-detect element under cursor after scroll without mouse movement", async ({ reactGrab, }) => { await reactGrab.activate(); const firstItem = reactGrab.page .locator("[data-testid='todo-list'] li") .first(); const firstItemBox = await firstItem.boundingBox(); expect(firstItemBox).not.toBeNull(); await reactGrab.page.mouse.move( firstItemBox!.x + firstItemBox!.width / 2, firstItemBox!.y + firstItemBox!.height / 2, ); await reactGrab.page.waitForTimeout(150); await reactGrab.waitForSelectionBox(); const initialLabel = await reactGrab.getSelectionLabelInfo(); expect(initialLabel.isVisible).toBe(true); await reactGrab.page.evaluate(() => { window.scrollBy(0, 100); }); await reactGrab.page.waitForTimeout(200); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should update selection to new element after scroll changes element under cursor", async ({ reactGrab, }) => { await reactGrab.activate(); const heading = reactGrab.page.locator("[data-testid='main-title']"); const headingBox = await heading.boundingBox(); expect(headingBox).not.toBeNull(); const cursorX = headingBox!.x + headingBox!.width / 2; const cursorY = headingBox!.y + headingBox!.height / 2; await reactGrab.page.mouse.move(cursorX, cursorY); await reactGrab.page.waitForTimeout(150); await reactGrab.waitForSelectionBox(); const initialBounds = await reactGrab.getSelectionBoxBounds(); expect(initialBounds).not.toBeNull(); await reactGrab.page.evaluate(() => { window.scrollBy(0, 200); }); await reactGrab.page.waitForTimeout(200); const newBounds = await reactGrab.getSelectionBoxBounds(); if (newBounds !== null && initialBounds !== null) { const boundsChanged = newBounds.y !== initialBounds.y || newBounds.height !== initialBounds.height; expect(boundsChanged).toBe(true); } }); test("should re-detect element after viewport resize without mouse movement", async ({ reactGrab, }) => { await reactGrab.activate(); const heading = reactGrab.page.locator("[data-testid='main-title']"); const headingBox = await heading.boundingBox(); expect(headingBox).not.toBeNull(); await reactGrab.page.mouse.move( headingBox!.x + headingBox!.width / 2, headingBox!.y + headingBox!.height / 2, ); await reactGrab.page.waitForTimeout(150); await reactGrab.waitForSelectionBox(); const initialBounds = await reactGrab.getSelectionBoxBounds(); expect(initialBounds).not.toBeNull(); await reactGrab.setViewportSize(800, 400); await reactGrab.page.waitForTimeout(200); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); await reactGrab.setViewportSize(1280, 720); }); test("should not re-detect element during drag operation on scroll", async ({ reactGrab, }) => { await reactGrab.activate(); const todoList = reactGrab.page.locator("[data-testid='todo-list'] ul"); const listBox = await todoList.boundingBox(); expect(listBox).not.toBeNull(); const startX = listBox!.x - 10; const startY = listBox!.y; const endX = listBox!.x + listBox!.width + 10; const endY = listBox!.y + listBox!.height; await reactGrab.page.mouse.move(startX, startY); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(endX, endY, { steps: 5 }); const state = await reactGrab.getState(); expect(state.isDragging).toBe(true); await reactGrab.page.evaluate(() => { window.scrollBy(0, 50); }); await reactGrab.page.waitForTimeout(100); const stateAfterScroll = await reactGrab.getState(); expect(stateAfterScroll.isDragging).toBe(true); await reactGrab.page.mouse.up(); }); test("should not re-detect element when selection is frozen via arrow navigation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.page.waitForTimeout(100); const labelBeforeScroll = await reactGrab.getSelectionLabelInfo(); await reactGrab.page.evaluate(() => { window.scrollBy(0, 30); }); await reactGrab.page.waitForTimeout(200); const labelAfterScroll = await reactGrab.getSelectionLabelInfo(); expect(labelAfterScroll.tagName).toBe(labelBeforeScroll.tagName); }); test("should update selection position after viewport resize", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.setViewportSize({ width: 800, height: 600 }); await reactGrab.page.waitForTimeout(200); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); await reactGrab.page.setViewportSize({ width: 1280, height: 720 }); }); test("should handle mouse movement after scroll", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.scrollPage(100); await reactGrab.hoverElement("li:nth-child(5)"); await reactGrab.waitForSelectionBox(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should allow drag selection after scrolling", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.scrollPage(50); await reactGrab.dragSelect("li:first-child", "li:nth-child(3)"); await reactGrab.page.waitForTimeout(500); const clipboardContent = await reactGrab.getClipboardContent(); expect(clipboardContent).toBeTruthy(); }); test("should preserve frozen selection during scroll via arrow navigation", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.scrollPage(100); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should handle keyboard navigation after scroll", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.scrollPage(50); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.pressArrowDown(); await reactGrab.pressArrowDown(); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); }); test("should recalculate bounds after visual viewport change", async ({ reactGrab, }) => { await reactGrab.activate(); const heading = reactGrab.page.locator("[data-testid='main-title']"); const headingBox = await heading.boundingBox(); expect(headingBox).not.toBeNull(); await reactGrab.page.mouse.move( headingBox!.x + headingBox!.width / 2, headingBox!.y + headingBox!.height / 2, ); await reactGrab.page.waitForTimeout(150); await reactGrab.waitForSelectionBox(); const initialBounds = await reactGrab.getSelectionBoxBounds(); expect(initialBounds).not.toBeNull(); await reactGrab.page.evaluate(() => { window.visualViewport?.dispatchEvent(new Event("resize")); window.visualViewport?.dispatchEvent(new Event("scroll")); }); await reactGrab.page.waitForTimeout(200); const isVisible = await reactGrab.isOverlayVisible(); expect(isVisible).toBe(true); const boundsAfter = await reactGrab.getSelectionBoxBounds(); expect(boundsAfter).not.toBeNull(); }); test("should copy element after resize using click", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='todo-list'] h1"); await reactGrab.waitForSelectionBox(); await reactGrab.page.setViewportSize({ width: 600, height: 400 }); await reactGrab.page.waitForTimeout(200); await reactGrab.hoverElement("[data-testid='todo-list'] h1"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("[data-testid='todo-list'] h1"); await expect .poll(() => reactGrab.getClipboardContent(), { timeout: 5000 }) .toContain("Todo List"); await reactGrab.page.setViewportSize({ width: 1280, height: 720 }); }); }); ================================================ FILE: packages/react-grab/e2e/visual-feedback.spec.ts ================================================ import { test, expect } from "./fixtures.js"; test.describe("Visual Feedback", () => { test.describe("Selection Box", () => { test("selection box should match element bounds", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const elementBounds = await reactGrab.getElementBounds("li:first-child"); const selectionBounds = await reactGrab.getSelectionBoxBounds(); if (elementBounds && selectionBounds) { expect(Math.abs(selectionBounds.x - elementBounds.x)).toBeLessThan(5); expect(Math.abs(selectionBounds.y - elementBounds.y)).toBeLessThan(5); expect( Math.abs(selectionBounds.width - elementBounds.width), ).toBeLessThan(10); expect( Math.abs(selectionBounds.height - elementBounds.height), ).toBeLessThan(10); } }); test("selection box should update when hovering different elements", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.page.waitForTimeout(100); const bounds1 = await reactGrab.getSelectionBoxBounds(); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); await reactGrab.page.waitForTimeout(100); const bounds2 = await reactGrab.getSelectionBoxBounds(); if (bounds1 && bounds2) { expect( bounds1.width !== bounds2.width || bounds1.height !== bounds2.height, ).toBe(true); } }); test("selection box should track scrolling element", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); const boundsBefore = await reactGrab.getSelectionBoxBounds(); await reactGrab.scrollPage(50); await reactGrab.page.waitForTimeout(200); const boundsAfter = await reactGrab.getSelectionBoxBounds(); if (boundsBefore && boundsAfter) { expect(boundsBefore.y - boundsAfter.y).toBeGreaterThan(0); } }); }); test.describe("Drag Box", () => { test("drag box should appear during drag", async ({ reactGrab }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x - 20, box.y - 20); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 150, box.y + 150, { steps: 10 }); const dragBounds = await reactGrab.getDragBoxBounds(); expect(dragBounds).toBeDefined(); await reactGrab.page.mouse.up(); }); test("drag box should grow with drag distance", async ({ reactGrab }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x - 20, box.y - 20); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 50, box.y + 50, { steps: 5 }); const smallDragBounds = await reactGrab.getDragBoxBounds(); await reactGrab.page.mouse.move(box.x + 200, box.y + 200, { steps: 5 }); const largeDragBounds = await reactGrab.getDragBoxBounds(); if (smallDragBounds && largeDragBounds) { expect(largeDragBounds.width).toBeGreaterThan(smallDragBounds.width); expect(largeDragBounds.height).toBeGreaterThan(smallDragBounds.height); } await reactGrab.page.mouse.up(); }); test("drag box should disappear after drag ends", async ({ reactGrab }) => { await reactGrab.activate(); const listItem = reactGrab.page.locator("li").first(); const box = await listItem.boundingBox(); if (!box) throw new Error("Could not get bounding box"); await reactGrab.page.mouse.move(box.x - 20, box.y - 20); await reactGrab.page.mouse.down(); await reactGrab.page.mouse.move(box.x + 150, box.y + 150, { steps: 10 }); await reactGrab.page.mouse.up(); await reactGrab.page.waitForTimeout(100); const dragBounds = await reactGrab.getDragBoxBounds(); expect(dragBounds).toBeNull(); }); }); test.describe("Grabbed Box", () => { test("grabbed box should appear after element click", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(200); const grabbedInfo = await reactGrab.getGrabbedBoxInfo(); expect(grabbedInfo.count).toBeGreaterThan(0); }); test("grabbed box should fade out after delay", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await reactGrab.page.waitForTimeout(2000); const grabbedInfo = await reactGrab.getGrabbedBoxInfo(); expect(grabbedInfo.count).toBe(0); }); }); test.describe("Selection Label", () => { test("label should show tag name", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionLabel(); const labelInfo = await reactGrab.getSelectionLabelInfo(); expect(labelInfo.tagName).toBe("h1"); }); test("label should show element count for multi-select", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.dragSelect("li:first-child", "li:nth-child(3)"); await reactGrab.page.waitForTimeout(200); const state = await reactGrab.getState(); expect(state).toBeDefined(); }); test("label should position below element by default", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); const elementBounds = await reactGrab.getElementBounds("h1"); const labelInfo = await reactGrab.getSelectionLabelInfo(); expect(labelInfo.isVisible).toBe(true); expect(elementBounds).toBeDefined(); }); test("label should be clamped to viewport", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='edge-bottom-left']"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionLabel(); const labelInfo = await reactGrab.getSelectionLabelInfo(); expect(labelInfo.isVisible).toBe(true); }); test("label and arrow should stay within bounds at left edge", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='edge-top-left']"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionLabel(); await expect(async () => { const bounds = await reactGrab.getSelectionLabelBounds(); expect(bounds).not.toBeNull(); expect(bounds?.arrow).not.toBeNull(); if (bounds?.arrow) { expect(bounds.label.x).toBeGreaterThanOrEqual(0); expect(bounds.label.x + bounds.label.width).toBeLessThanOrEqual( bounds.viewport.width, ); expect(bounds.arrow.x).toBeGreaterThanOrEqual(bounds.label.x); expect(bounds.arrow.x + bounds.arrow.width).toBeLessThanOrEqual( bounds.label.x + bounds.label.width, ); } }).toPass({ timeout: 2000 }); }); test("label and arrow should stay within bounds at right edge", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='edge-top-right']"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionLabel(); await expect(async () => { const bounds = await reactGrab.getSelectionLabelBounds(); expect(bounds).not.toBeNull(); expect(bounds?.arrow).not.toBeNull(); if (bounds?.arrow) { expect(bounds.label.x).toBeGreaterThanOrEqual(0); expect(bounds.label.x + bounds.label.width).toBeLessThanOrEqual( bounds.viewport.width, ); expect(bounds.arrow.x).toBeGreaterThanOrEqual(bounds.label.x); expect(bounds.arrow.x + bounds.arrow.width).toBeLessThanOrEqual( bounds.label.x + bounds.label.width, ); } }).toPass({ timeout: 2000 }); }); test("label and arrow should stay within bounds at bottom-left edge", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("[data-testid='edge-bottom-left']"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionLabel(); await expect(async () => { const bounds = await reactGrab.getSelectionLabelBounds(); expect(bounds).not.toBeNull(); expect(bounds?.arrow).not.toBeNull(); if (bounds?.arrow) { expect(bounds.label.x).toBeGreaterThanOrEqual(0); expect(bounds.label.x + bounds.label.width).toBeLessThanOrEqual( bounds.viewport.width, ); expect(bounds.arrow.x).toBeGreaterThanOrEqual(bounds.label.x); expect(bounds.arrow.x + bounds.arrow.width).toBeLessThanOrEqual( bounds.label.x + bounds.label.width, ); } }).toPass({ timeout: 2000 }); }); }); test.describe("Status Transitions", () => { test("should show copying status during copy", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); // During/after copy, a status label should appear (e.g., "Copying..." or "Copied") await expect .poll(() => reactGrab.getLabelStatusText(), { timeout: 2000 }) .toBeTruthy(); }); test("should transition to copied status after copy", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.clickElement("li:first-child"); await expect .poll(() => reactGrab.getLabelStatusText(), { timeout: 2000 }) .toBe("Copied"); }); }); test.describe("Arrow Direction", () => { test("arrow should point down when label is below element", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("h1"); await reactGrab.waitForSelectionBox(); const labelInfo = await reactGrab.getSelectionLabelInfo(); expect(labelInfo.isVisible).toBe(true); }); test("arrow should adjust when near viewport bottom", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.scrollPage(500); await reactGrab.hoverElement("[data-testid='footer']"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionLabel(); const labelInfo = await reactGrab.getSelectionLabelInfo(); expect(labelInfo.isVisible).toBe(true); }); }); test.describe("Multiple Visual Elements", () => { test("selection box and label should be synchronized", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.waitForSelectionLabel(); const selectionVisible = await reactGrab.isSelectionBoxVisible(); const labelVisible = await reactGrab.isSelectionLabelVisible(); expect(selectionVisible).toBe(true); expect(labelVisible).toBe(true); }); test("all visual elements should update on viewport change", async ({ reactGrab, }) => { await reactGrab.activate(); await reactGrab.hoverElement("li:first-child"); await reactGrab.waitForSelectionBox(); await reactGrab.setViewportSize(1024, 768); await reactGrab.page.waitForTimeout(200); const selectionVisible = await reactGrab.isSelectionBoxVisible(); expect(selectionVisible).toBe(true); await reactGrab.setViewportSize(1280, 720); }); }); }); ================================================ FILE: packages/react-grab/package.json ================================================ { "name": "react-grab", "version": "0.1.28", "description": "Select context for coding agents directly from your website", "keywords": [ "agent", "context", "grab", "react", "react-grab" ], "homepage": "https://react-grab.com", "bugs": { "url": "https://github.com/aidenybai/react-grab/issues" }, "license": "MIT", "author": { "name": "Aiden Bai", "email": "aiden@million.dev" }, "repository": { "type": "git", "url": "git+https://github.com/aidenybai/react-grab.git" }, "bin": { "react-grab": "./bin/cli.js" }, "files": [ "bin", "dist", "scripts/postinstall.cjs", "package.json", "README.md", "LICENSE" ], "type": "module", "main": "dist/index.js", "module": "dist/index.js", "browser": "dist/index.global.js", "types": "dist/index.d.ts", "exports": { "./package.json": "./package.json", ".": { "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } }, "./core": { "import": { "types": "./dist/core/index.d.ts", "default": "./dist/core/index.js" }, "require": { "types": "./dist/core/index.d.cts", "default": "./dist/core/index.cjs" } }, "./primitives": { "import": { "types": "./dist/primitives.d.ts", "default": "./dist/primitives.js" }, "require": { "types": "./dist/primitives.d.cts", "default": "./dist/primitives.cjs" } }, "./src/*": "./src/*", "./styles.css": "./dist/styles.css", "./dist/styles.css": "./dist/styles.css", "./dist/*": "./dist/*.js", "./dist/*.js": "./dist/*.js", "./dist/*.cjs": "./dist/*.cjs", "./dist/*.mjs": "./dist/*.mjs" }, "publishConfig": { "access": "public" }, "scripts": { "css:build": "tailwindcss -i ./src/styles.css -o ./dist/styles.css -m && node scripts/css-rem-to-px.mjs", "css:watch": "tailwindcss -i ./src/styles.css -o ./dist/styles.css -w", "prebuild": "mkdir -p dist && tailwindcss -i ./src/styles.css -o ./dist/styles.css -m && node scripts/css-rem-to-px.mjs", "build": "NODE_ENV=production tsup", "postinstall": "node ./scripts/postinstall.cjs", "dev": "concurrently \"pnpm:css:watch\" \"tsup --watch --ignore-watch dist\"", "lint": "oxlint", "lint:fix": "oxlint --fix", "test": "playwright test", "typecheck": "tsc --noEmit", "publint": "publint", "prepublishOnly": "pnpm build", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" }, "dependencies": { "@medv/finder": "^4.0.2", "@react-grab/cli": "workspace:*", "bippy": "^0.5.32", "element-source": "^0.0.3", "solid-js": "^1.9.10" }, "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@playwright/test": "^1.40.0", "@tailwindcss/cli": "^4.1.17", "@types/node": "^20.19.23", "@types/react": "^19.2.11", "babel-preset-solid": "^1.9.10", "clsx": "^2.1.1", "concurrently": "^9.1.2", "esbuild-plugin-babel": "^0.2.3", "oxlint": "^1.42.0", "publint": "^0.2.12", "tailwindcss": "^4.1.0", "tsup": "^8.2.4", "tsx": "^4.21.0" }, "peerDependencies": { "react": ">=17.0.0" }, "peerDependenciesMeta": { "react": { "optional": true } } } ================================================ FILE: packages/react-grab/playwright.config.ts ================================================ import { defineConfig, devices } from "@playwright/test"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 1, workers: process.env.CI ? 4 : undefined, timeout: 60_000, reporter: "html", use: { baseURL: "http://localhost:5175", trace: "on-first-retry", permissions: ["clipboard-read", "clipboard-write"], }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, testIgnore: /touch-mode\.spec\.ts/, }, { name: "chromium-touch", use: { ...devices["Desktop Chrome"], hasTouch: true, }, testMatch: /touch-mode\.spec\.ts/, }, ], webServer: { command: "pnpm --filter react-grab build && pnpm dev", url: "http://localhost:5175", reuseExistingServer: !process.env.CI, cwd: path.resolve(__dirname, "../e2e-playground"), timeout: 30000, }, }); ================================================ FILE: packages/react-grab/scripts/css-rem-to-px.mjs ================================================ /** * Converts all `rem` units in the built Tailwind CSS to `px`. * * The toolbar renders inside a shadow DOM for style isolation, but `rem` is * always relative to the document root (``) font-size — not the shadow * host. Pages like YouTube set `html { font-size: 10px }`, which shrinks every * rem-based value (spacing, radii, text sizes) and breaks the toolbar layout. * * Running this after Tailwind makes all values absolute, so the toolbar renders * identically regardless of the host page's root font-size. */ import { readFileSync, writeFileSync } from "node:fs"; const BROWSER_DEFAULT_FONT_SIZE_PX = 16; const CSS_OUTPUT_PATH = "./dist/styles.css"; const cssContent = readFileSync(CSS_OUTPUT_PATH, "utf8"); const transformedCss = cssContent.replace( /(\d*\.?\d+)rem\b/g, (_, remValue) => `${parseFloat(remValue) * BROWSER_DEFAULT_FONT_SIZE_PX}px`, ); writeFileSync(CSS_OUTPUT_PATH, transformedCss); ================================================ FILE: packages/react-grab/scripts/postinstall.cjs ================================================ const AUTOMATION_ENVIRONMENT_VARIABLE_NAMES = [ "CURSOR_AGENT", "CI", "CLAUDECODE", "CODEX_CI", "OPENCODE", "AMP_HOME", "AMI", ]; const REACT_GRAB_INIT_COMMAND = "npx -y grab@latest init"; const INSTALL_HINT_MESSAGE = `[react-grab] Package installed via automation. This step only installs the package. Run \`${REACT_GRAB_INIT_COMMAND}\` to complete setup.`; const isAlreadyRunningInit = Boolean(process.env.REACT_GRAB_INIT); const isRunningInAutomatedEnvironment = AUTOMATION_ENVIRONMENT_VARIABLE_NAMES.some((environmentVariableName) => Boolean(process.env[environmentVariableName]), ); if (isRunningInAutomatedEnvironment && !isAlreadyRunningInit) { console.log(INSTALL_HINT_MESSAGE); } ================================================ FILE: packages/react-grab/src/components/clear-history-prompt.tsx ================================================ import { Show, onMount, onCleanup } from "solid-js"; import type { Component } from "solid-js"; import type { DropdownAnchor } from "../types.js"; import { DROPDOWN_EDGE_TRANSFORM_ORIGIN, Z_INDEX_LABEL } from "../constants.js"; import { cn } from "../utils/cn.js"; import { DiscardPrompt } from "./selection-label/discard-prompt.js"; import { suppressMenuEvent } from "../utils/suppress-menu-event.js"; import { createAnchoredDropdown } from "../utils/create-anchored-dropdown.js"; import { registerOverlayDismiss } from "../utils/register-overlay-dismiss.js"; interface ClearHistoryPromptProps { position: DropdownAnchor | null; onConfirm: () => void; onCancel: () => void; } export const ClearHistoryPrompt: Component = ( props, ) => { let containerRef: HTMLDivElement | undefined; const dropdown = createAnchoredDropdown( () => containerRef, () => props.position, ); onMount(() => { dropdown.measure(); const unregisterOverlayDismiss = registerOverlayDismiss({ isOpen: () => Boolean(props.position), onDismiss: props.onCancel, onConfirm: props.onConfirm, shouldIgnoreInputEvents: true, }); onCleanup(() => { dropdown.clearAnimationHandles(); unregisterOverlayDismiss(); }); }); return (
    ); }; ================================================ FILE: packages/react-grab/src/components/context-menu.tsx ================================================ import { Show, For, onMount, onCleanup, createSignal, createEffect, createMemo, } from "solid-js"; import type { Component } from "solid-js"; import type { Position, OverlayBounds, ContextMenuAction, ContextMenuActionContext, } from "../types.js"; import { ARROW_HEIGHT_PX, DROPDOWN_OFFSCREEN_POSITION, LABEL_GAP_PX, Z_INDEX_LABEL, } from "../constants.js"; import { cn } from "../utils/cn.js"; import { Arrow } from "./selection-label/arrow.js"; import { TagBadge } from "./selection-label/tag-badge.js"; import { BottomSection } from "./selection-label/bottom-section.js"; import { formatShortcut } from "../utils/format-shortcut.js"; import { getTagDisplay } from "../utils/get-tag-display.js"; import { resolveActionEnabled } from "../utils/resolve-action-enabled.js"; import { nativeRequestAnimationFrame } from "../utils/native-raf.js"; import { createMenuHighlight } from "../utils/create-menu-highlight.js"; import { suppressMenuEvent } from "../utils/suppress-menu-event.js"; import { registerOverlayDismiss } from "../utils/register-overlay-dismiss.js"; interface ContextMenuProps { position: Position | null; selectionBounds: OverlayBounds | null; tagName?: string; componentName?: string; hasFilePath: boolean; actions?: ContextMenuAction[]; actionContext?: ContextMenuActionContext; onDismiss: () => void; onHide: () => void; } interface MenuItem { label: string; action: () => void; enabled: boolean; shortcut?: string; } export const ContextMenu: Component = (props) => { let containerRef: HTMLDivElement | undefined; const { containerRef: highlightContainerRef, highlightRef, updateHighlight, clearHighlight, } = createMenuHighlight(); const [measuredWidth, setMeasuredWidth] = createSignal(0); const [measuredHeight, setMeasuredHeight] = createSignal(0); const isVisible = () => props.position !== null; const tagDisplayResult = createMemo(() => getTagDisplay({ tagName: props.tagName, componentName: props.componentName, }), ); const measureContainer = () => { if (containerRef) { const rect = containerRef.getBoundingClientRect(); setMeasuredWidth(rect.width); setMeasuredHeight(rect.height); } }; createEffect(() => { if (isVisible()) { nativeRequestAnimationFrame(measureContainer); } }); const computedPosition = createMemo(() => { const bounds = props.selectionBounds; const clickPosition = props.position; const labelWidth = measuredWidth(); const labelHeight = measuredHeight(); if (labelWidth === 0 || labelHeight === 0 || !bounds || !clickPosition) { return { left: DROPDOWN_OFFSCREEN_POSITION.left, top: DROPDOWN_OFFSCREEN_POSITION.top, arrowLeft: 0, arrowPosition: "bottom" as const, }; } const cursorX = clickPosition.x ?? bounds.x + bounds.width / 2; const positionLeft = Math.max( LABEL_GAP_PX, Math.min( cursorX - labelWidth / 2, window.innerWidth - labelWidth - LABEL_GAP_PX, ), ); const arrowLeft = Math.max( ARROW_HEIGHT_PX, Math.min(cursorX - positionLeft, labelWidth - ARROW_HEIGHT_PX), ); const positionBelow = bounds.y + bounds.height + ARROW_HEIGHT_PX + LABEL_GAP_PX; const positionAbove = bounds.y - labelHeight - ARROW_HEIGHT_PX - LABEL_GAP_PX; const wouldOverflowBottom = positionBelow + labelHeight > window.innerHeight; const hasSpaceAbove = positionAbove >= 0; const shouldFlipAbove = wouldOverflowBottom && hasSpaceAbove; let positionTop = shouldFlipAbove ? positionAbove : positionBelow; let arrowPosition: "top" | "bottom" = shouldFlipAbove ? "top" : "bottom"; if (wouldOverflowBottom && !hasSpaceAbove) { const cursorY = clickPosition.y ?? bounds.y + bounds.height / 2; positionTop = Math.max( LABEL_GAP_PX, Math.min( cursorY + LABEL_GAP_PX, window.innerHeight - labelHeight - LABEL_GAP_PX, ), ); arrowPosition = "top"; } return { left: positionLeft, top: positionTop, arrowLeft, arrowPosition }; }); const menuItems = createMemo(() => { const pluginActions = props.actions ?? []; const context = props.actionContext; return pluginActions.map((action) => ({ label: action.label, action: () => { if (context) { action.onAction(context); } }, enabled: resolveActionEnabled(action, context), shortcut: action.shortcut, })); }); const handleAction = (item: MenuItem, event: Event) => { event.stopPropagation(); if (item.enabled) { item.action(); props.onHide(); } }; onMount(() => { measureContainer(); const handleKeyDown = (event: KeyboardEvent) => { if (!isVisible()) return; const isEnter = event.key === "Enter"; const hasModifierKey = event.metaKey || event.ctrlKey; const keyLower = event.key.toLowerCase(); const pluginActions = props.actions ?? []; const context = props.actionContext; const runActionIfAllowed = (action: ContextMenuAction) => { if (!context) return false; if (!resolveActionEnabled(action, context)) return false; event.preventDefault(); event.stopPropagation(); action.onAction(context); props.onHide(); return true; }; if (isEnter) { const enterAction = pluginActions.find( (action) => action.shortcut === "Enter", ); if (enterAction) { runActionIfAllowed(enterAction); } return; } if (!hasModifierKey) return; if (event.repeat) return; const modifierAction = pluginActions.find( (action) => action.shortcut && action.shortcut !== "Enter" && keyLower === action.shortcut.toLowerCase(), ); if (modifierAction) { runActionIfAllowed(modifierAction); } }; const unregisterOverlayDismiss = registerOverlayDismiss({ isOpen: isVisible, onDismiss: props.onDismiss, shouldIgnoreRightClick: true, }); window.addEventListener("keydown", handleKeyDown, { capture: true }); onCleanup(() => { unregisterOverlayDismiss(); window.removeEventListener("keydown", handleKeyDown, { capture: true }); }); }); return (
    { event.stopPropagation(); if (props.hasFilePath && props.actionContext) { const openAction = props.actions?.find( (action) => action.id === "open", ); openAction?.onAction(props.actionContext); } }} shrink forceShowIcon={props.hasFilePath} />
    {(item) => ( )}
    ); }; ================================================ FILE: packages/react-grab/src/components/history-dropdown.tsx ================================================ import { Show, For, onMount, onCleanup, createSignal, createEffect, on, } from "solid-js"; import type { Component } from "solid-js"; import type { HistoryItem, DropdownAnchor } from "../types.js"; import { DROPDOWN_EDGE_TRANSFORM_ORIGIN, DROPDOWN_ICON_SIZE_PX, DROPDOWN_MAX_WIDTH_PX, DROPDOWN_MIN_WIDTH_PX, DROPDOWN_VIEWPORT_PADDING_PX, FEEDBACK_DURATION_MS, SAFE_POLYGON_BUFFER_PX, Z_INDEX_LABEL, } from "../constants.js"; import { createSafePolygonTracker } from "../utils/safe-polygon.js"; import { cn } from "../utils/cn.js"; import { IconTrash } from "./icons/icon-trash.jsx"; import { IconCopy } from "./icons/icon-copy.jsx"; import { IconCheck } from "./icons/icon-check.jsx"; import { Tooltip } from "./tooltip.jsx"; import { createMenuHighlight } from "../utils/create-menu-highlight.js"; import { suppressMenuEvent } from "../utils/suppress-menu-event.js"; import { createAnchoredDropdown } from "../utils/create-anchored-dropdown.js"; import { formatRelativeTime } from "../utils/format-relative-time.js"; const ITEM_ACTION_CLASS = "flex items-center justify-center cursor-pointer text-black/25 transition-colors press-scale"; interface HistoryDropdownProps { position: DropdownAnchor | null; items: HistoryItem[]; disconnectedItemIds?: Set; onSelectItem?: (item: HistoryItem) => void; onRemoveItem?: (item: HistoryItem) => void; onCopyItem?: (item: HistoryItem) => void; onItemHover?: (historyItemId: string | null) => void; onCopyAll?: () => void; onCopyAllHover?: (isHovered: boolean) => void; onClearAll?: () => void; onDismiss?: () => void; onDropdownHover?: (isHovered: boolean) => void; } const getHistoryItemDisplayName = (item: HistoryItem): string => { if (item.elementsCount && item.elementsCount > 1) { return `${item.elementsCount} elements`; } return item.componentName ?? item.tagName; }; export const HistoryDropdown: Component = (props) => { let containerRef: HTMLDivElement | undefined; const { containerRef: highlightContainerRef, highlightRef, updateHighlight, clearHighlight, } = createMenuHighlight(); const safePolygonTracker = createSafePolygonTracker(); const getToolbarTargetRects = () => { if (!containerRef) return null; const rootNode = containerRef.getRootNode() as Document | ShadowRoot; const toolbar = rootNode.querySelector( "[data-react-grab-toolbar]", ); if (!toolbar) return null; const rect = toolbar.getBoundingClientRect(); return [ { x: rect.x - SAFE_POLYGON_BUFFER_PX, y: rect.y - SAFE_POLYGON_BUFFER_PX, width: rect.width + SAFE_POLYGON_BUFFER_PX * 2, height: rect.height + SAFE_POLYGON_BUFFER_PX * 2, }, ]; }; const dropdown = createAnchoredDropdown( () => containerRef, () => props.position, ); const [activeHeaderTooltip, setActiveHeaderTooltip] = createSignal< "clear" | "copy" | null >(null); const [isCopyAllConfirmed, setIsCopyAllConfirmed] = createSignal(false); const [confirmedCopyItemId, setConfirmedCopyItemId] = createSignal< string | null >(null); let copyAllFeedbackTimeout: ReturnType | undefined; let copyItemFeedbackTimeout: ReturnType | undefined; // HACK: mouseenter doesn't fire when an element appears under the cursor, so we check :hover after the enter animation commits createEffect( on( () => dropdown.isAnimatedIn(), (animatedIn) => { if (animatedIn && containerRef?.matches(":hover")) { props.onDropdownHover?.(true); } }, { defer: true }, ), ); const clampedMaxWidth = () => Math.min( DROPDOWN_MAX_WIDTH_PX, window.innerWidth - dropdown.displayPosition().left - DROPDOWN_VIEWPORT_PADDING_PX, ); const clampedMaxHeight = () => window.innerHeight - dropdown.displayPosition().top - DROPDOWN_VIEWPORT_PADDING_PX; const panelMinWidth = () => Math.max(DROPDOWN_MIN_WIDTH_PX, props.position?.toolbarWidth ?? 0); onMount(() => { dropdown.measure(); const handleKeyDown = (event: KeyboardEvent) => { if (!props.position) return; if (event.code === "Escape") { event.preventDefault(); event.stopPropagation(); props.onDismiss?.(); } }; window.addEventListener("keydown", handleKeyDown, { capture: true }); onCleanup(() => { clearTimeout(copyAllFeedbackTimeout); clearTimeout(copyItemFeedbackTimeout); dropdown.clearAnimationHandles(); window.removeEventListener("keydown", handleKeyDown, { capture: true }); safePolygonTracker.stop(); }); }); return (
    { safePolygonTracker.stop(); props.onDropdownHover?.(true); }} onMouseLeave={(event: MouseEvent) => { const targetRects = getToolbarTargetRects(); if (targetRects) { safePolygonTracker.start( { x: event.clientX, y: event.clientY }, targetRects, () => props.onDropdownHover?.(false), ); return; } props.onDropdownHover?.(false); }} >
    History 0}>
    Clear all
    Copy all
    {(item) => (
    event.stopPropagation()} onClick={(event) => { event.stopPropagation(); props.onSelectItem?.(item); setConfirmedCopyItemId(item.id); clearTimeout(copyItemFeedbackTimeout); copyItemFeedbackTimeout = setTimeout(() => { setConfirmedCopyItemId(null); }, FEEDBACK_DURATION_MS); }} onKeyDown={(event) => { if ( event.code === "Space" && event.currentTarget === event.target ) { event.preventDefault(); event.stopPropagation(); props.onSelectItem?.(item); } }} onMouseEnter={(event) => { if (!props.disconnectedItemIds?.has(item.id)) { props.onItemHover?.(item.id); } updateHighlight(event.currentTarget); }} onMouseLeave={() => { props.onItemHover?.(null); clearHighlight(); }} onFocus={(event) => updateHighlight(event.currentTarget)} onBlur={clearHighlight} > {getHistoryItemDisplayName(item)} {item.commentText} {formatRelativeTime(item.timestamp)}
    )}
    ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-check.tsx ================================================ import type { Component } from "solid-js"; interface IconCheckProps { size?: number; class?: string; } export const IconCheck: Component = (props) => { const size = () => props.size ?? 21; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-chevron.tsx ================================================ import type { Component } from "solid-js"; interface IconChevronProps { size?: number; class?: string; } export const IconChevron: Component = (props) => { const size = () => props.size ?? 12; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-clock.tsx ================================================ import type { Component } from "solid-js"; interface IconClockProps { size?: number; class?: string; } export const IconClock: Component = (props) => { const size = () => props.size ?? 14; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-copy.tsx ================================================ import type { Component } from "solid-js"; interface IconCopyProps { size?: number; class?: string; } export const IconCopy: Component = (props) => { const size = () => props.size ?? 14; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-ellipsis.tsx ================================================ import type { Component } from "solid-js"; interface IconEllipsisProps { size?: number; class?: string; } export const IconEllipsis: Component = (props) => { const size = () => props.size ?? 12; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-loader.tsx ================================================ import type { Component } from "solid-js"; interface IconLoaderProps { size?: number; class?: string; } export const IconLoader: Component = (props) => { const size = () => props.size ?? 16; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-open.tsx ================================================ import type { Component } from "solid-js"; interface IconOpenProps { size?: number; class?: string; } export const IconOpen: Component = (props) => { const size = () => props.size ?? 12; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-reply.tsx ================================================ import type { Component } from "solid-js"; interface IconReplyProps { size?: number; class?: string; } export const IconReply: Component = (props) => { const size = () => props.size ?? 12; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-retry.tsx ================================================ import type { Component } from "solid-js"; interface IconRetryProps { size?: number; class?: string; } export const IconRetry: Component = (props) => { const size = () => props.size ?? 12; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-return.tsx ================================================ import type { Component } from "solid-js"; interface IconReturnProps { size?: number; class?: string; } export const IconReturn: Component = (props) => { const size = () => props.size ?? 12; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-select.tsx ================================================ import type { Component } from "solid-js"; interface IconSelectProps { size?: number; class?: string; } export const IconSelect: Component = (props) => { const size = () => props.size ?? 14; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-submit.tsx ================================================ import type { Component } from "solid-js"; interface IconSubmitProps { size?: number; class?: string; } export const IconSubmit: Component = (props) => { const size = () => props.size ?? 12; return ( ); }; ================================================ FILE: packages/react-grab/src/components/icons/icon-trash.tsx ================================================ import type { Component } from "solid-js"; interface IconTrashProps { size?: number; class?: string; } export const IconTrash: Component = (props) => { const size = () => props.size ?? 14; return ( ); }; ================================================ FILE: packages/react-grab/src/components/kbd.tsx ================================================ import type { Component, JSX } from "solid-js"; export const Kbd: Component<{ children: JSX.Element }> = (props) => ( {props.children} ); ================================================ FILE: packages/react-grab/src/components/overlay-canvas.tsx ================================================ import { createEffect, onCleanup, onMount, on } from "solid-js"; import type { Component } from "solid-js"; import type { OverlayBounds, SelectionLabelInstance, AgentSession, } from "../types.js"; import { lerp } from "../utils/lerp.js"; import { SELECTION_LERP_FACTOR, FEEDBACK_DURATION_MS, DRAG_LERP_FACTOR, LERP_CONVERGENCE_THRESHOLD_PX, FADE_OUT_BUFFER_MS, MIN_DEVICE_PIXEL_RATIO, Z_INDEX_OVERLAY_CANVAS, OVERLAY_BORDER_COLOR_DRAG, OVERLAY_FILL_COLOR_DRAG, OPACITY_CONVERGENCE_THRESHOLD, OVERLAY_BORDER_COLOR_DEFAULT, OVERLAY_FILL_COLOR_DEFAULT, } from "../constants.js"; import { nativeCancelAnimationFrame, nativeRequestAnimationFrame, } from "../utils/native-raf.js"; import { supportsDisplayP3 } from "../utils/supports-display-p3.js"; const LAYER_STYLES = { drag: { borderColor: OVERLAY_BORDER_COLOR_DRAG, fillColor: OVERLAY_FILL_COLOR_DRAG, lerpFactor: DRAG_LERP_FACTOR, }, selection: { borderColor: OVERLAY_BORDER_COLOR_DEFAULT, fillColor: OVERLAY_FILL_COLOR_DEFAULT, lerpFactor: SELECTION_LERP_FACTOR, }, grabbed: { borderColor: OVERLAY_BORDER_COLOR_DEFAULT, fillColor: OVERLAY_FILL_COLOR_DEFAULT, lerpFactor: SELECTION_LERP_FACTOR, }, processing: { borderColor: OVERLAY_BORDER_COLOR_DEFAULT, fillColor: OVERLAY_FILL_COLOR_DEFAULT, lerpFactor: SELECTION_LERP_FACTOR, }, } as const; type LayerName = "drag" | "selection" | "grabbed" | "processing"; interface OffscreenLayer { canvas: OffscreenCanvas | null; context: OffscreenCanvasRenderingContext2D | null; } interface AnimatedBounds { id: string; current: { x: number; y: number; width: number; height: number }; target: { x: number; y: number; width: number; height: number }; borderRadius: number; opacity: number; targetOpacity: number; createdAt?: number; isInitialized: boolean; } export interface OverlayCanvasProps { selectionVisible?: boolean; selectionBounds?: OverlayBounds; selectionBoundsMultiple?: OverlayBounds[]; selectionIsFading?: boolean; selectionShouldSnap?: boolean; dragVisible?: boolean; dragBounds?: OverlayBounds; grabbedBoxes?: Array<{ id: string; bounds: OverlayBounds; createdAt: number; }>; agentSessions?: Map; labelInstances?: SelectionLabelInstance[]; } export const OverlayCanvas: Component = (props) => { let canvasRef: HTMLCanvasElement | undefined; let mainContext: CanvasRenderingContext2D | null = null; let canvasWidth = 0; let canvasHeight = 0; let devicePixelRatio = 1; let animationFrameId: number | null = null; const layers: Record = { drag: { canvas: null, context: null }, selection: { canvas: null, context: null }, grabbed: { canvas: null, context: null }, processing: { canvas: null, context: null }, }; let selectionAnimations: AnimatedBounds[] = []; let dragAnimation: AnimatedBounds | null = null; let grabbedAnimations: AnimatedBounds[] = []; let processingAnimations: AnimatedBounds[] = []; const canvasColorSpace: PredefinedColorSpace = supportsDisplayP3() ? "display-p3" : "srgb"; const createOffscreenLayer = ( layerWidth: number, layerHeight: number, scaleFactor: number, ): OffscreenLayer => { const canvas = new OffscreenCanvas( layerWidth * scaleFactor, layerHeight * scaleFactor, ); const context = canvas.getContext("2d", { colorSpace: canvasColorSpace }); if (context) { context.scale(scaleFactor, scaleFactor); } return { canvas, context }; }; const initializeCanvas = () => { if (!canvasRef) return; devicePixelRatio = Math.max( window.devicePixelRatio || 1, MIN_DEVICE_PIXEL_RATIO, ); canvasWidth = window.innerWidth; canvasHeight = window.innerHeight; canvasRef.width = canvasWidth * devicePixelRatio; canvasRef.height = canvasHeight * devicePixelRatio; canvasRef.style.width = `${canvasWidth}px`; canvasRef.style.height = `${canvasHeight}px`; mainContext = canvasRef.getContext("2d", { colorSpace: canvasColorSpace }); if (mainContext) { mainContext.scale(devicePixelRatio, devicePixelRatio); } for (const layerName of Object.keys(layers) as LayerName[]) { layers[layerName] = createOffscreenLayer( canvasWidth, canvasHeight, devicePixelRatio, ); } }; const parseBorderRadiusValue = (borderRadius: string): number => { if (!borderRadius) return 0; const match = borderRadius.match(/^(\d+(?:\.\d+)?)/); return match ? parseFloat(match[1]) : 0; }; const createAnimatedBounds = ( id: string, bounds: OverlayBounds, options?: { createdAt?: number; opacity?: number; targetOpacity?: number }, ): AnimatedBounds => ({ id, current: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, }, target: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, }, borderRadius: parseBorderRadiusValue(bounds.borderRadius), opacity: options?.opacity ?? 1, targetOpacity: options?.targetOpacity ?? options?.opacity ?? 1, createdAt: options?.createdAt, isInitialized: true, }); const updateAnimationTarget = ( animation: AnimatedBounds, bounds: OverlayBounds, targetOpacity?: number, ) => { animation.target = { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, }; animation.borderRadius = parseBorderRadiusValue(bounds.borderRadius); if (targetOpacity !== undefined) { animation.targetOpacity = targetOpacity; } }; const resolveBoundsArray = ( instance: SelectionLabelInstance, ): OverlayBounds[] => instance.boundsMultiple ?? [instance.bounds]; const drawRoundedRectangle = ( context: OffscreenCanvasRenderingContext2D, rectX: number, rectY: number, rectWidth: number, rectHeight: number, cornerRadius: number, fillColor: string, strokeColor: string, opacity: number = 1, ) => { if (rectWidth <= 0 || rectHeight <= 0) return; const maxCornerRadius = Math.min(rectWidth / 2, rectHeight / 2); const clampedCornerRadius = Math.min(cornerRadius, maxCornerRadius); context.globalAlpha = opacity; context.beginPath(); if (clampedCornerRadius > 0) { context.roundRect( rectX, rectY, rectWidth, rectHeight, clampedCornerRadius, ); } else { context.rect(rectX, rectY, rectWidth, rectHeight); } context.fillStyle = fillColor; context.fill(); context.strokeStyle = strokeColor; context.lineWidth = 1; context.stroke(); context.globalAlpha = 1; }; const renderDragLayer = () => { const layer = layers.drag; if (!layer.context) return; const context = layer.context; context.clearRect(0, 0, canvasWidth, canvasHeight); if (!props.dragVisible || !dragAnimation) return; const style = LAYER_STYLES.drag; drawRoundedRectangle( context, dragAnimation.current.x, dragAnimation.current.y, dragAnimation.current.width, dragAnimation.current.height, dragAnimation.borderRadius, style.fillColor, style.borderColor, ); }; const renderSelectionLayer = () => { const layer = layers.selection; if (!layer.context) return; const context = layer.context; context.clearRect(0, 0, canvasWidth, canvasHeight); if (!props.selectionVisible) return; const style = LAYER_STYLES.selection; for (const animation of selectionAnimations) { const effectiveOpacity = props.selectionIsFading ? 0 : animation.opacity; drawRoundedRectangle( context, animation.current.x, animation.current.y, animation.current.width, animation.current.height, animation.borderRadius, style.fillColor, style.borderColor, effectiveOpacity, ); } }; const renderBoundsLayer = ( layerName: keyof typeof LAYER_STYLES, animations: AnimatedBounds[], ) => { const layer = layers[layerName]; if (!layer.context) return; const context = layer.context; context.clearRect(0, 0, canvasWidth, canvasHeight); const style = LAYER_STYLES[layerName]; for (const animation of animations) { drawRoundedRectangle( context, animation.current.x, animation.current.y, animation.current.width, animation.current.height, animation.borderRadius, style.fillColor, style.borderColor, animation.opacity, ); } }; const compositeAllLayers = () => { if (!mainContext || !canvasRef) return; mainContext.setTransform(1, 0, 0, 1, 0, 0); mainContext.clearRect(0, 0, canvasRef.width, canvasRef.height); mainContext.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); renderDragLayer(); renderSelectionLayer(); renderBoundsLayer("grabbed", grabbedAnimations); renderBoundsLayer("processing", processingAnimations); const layerRenderOrder: LayerName[] = [ "drag", "selection", "grabbed", "processing", ]; for (const layerName of layerRenderOrder) { const layer = layers[layerName]; if (layer.canvas) { mainContext.drawImage(layer.canvas, 0, 0, canvasWidth, canvasHeight); } } }; const interpolateBounds = ( animation: AnimatedBounds, lerpFactor: number, options?: { interpolateOpacity?: boolean }, ): boolean => { const lerpedX = lerp(animation.current.x, animation.target.x, lerpFactor); const lerpedY = lerp(animation.current.y, animation.target.y, lerpFactor); const lerpedWidth = lerp( animation.current.width, animation.target.width, lerpFactor, ); const lerpedHeight = lerp( animation.current.height, animation.target.height, lerpFactor, ); const hasBoundsConverged = Math.abs(lerpedX - animation.target.x) < LERP_CONVERGENCE_THRESHOLD_PX && Math.abs(lerpedY - animation.target.y) < LERP_CONVERGENCE_THRESHOLD_PX && Math.abs(lerpedWidth - animation.target.width) < LERP_CONVERGENCE_THRESHOLD_PX && Math.abs(lerpedHeight - animation.target.height) < LERP_CONVERGENCE_THRESHOLD_PX; animation.current.x = hasBoundsConverged ? animation.target.x : lerpedX; animation.current.y = hasBoundsConverged ? animation.target.y : lerpedY; animation.current.width = hasBoundsConverged ? animation.target.width : lerpedWidth; animation.current.height = hasBoundsConverged ? animation.target.height : lerpedHeight; let hasOpacityConverged = true; if (options?.interpolateOpacity) { const lerpedOpacity = lerp( animation.opacity, animation.targetOpacity, lerpFactor, ); const opacityThreshold = OPACITY_CONVERGENCE_THRESHOLD; hasOpacityConverged = Math.abs(lerpedOpacity - animation.targetOpacity) < opacityThreshold; animation.opacity = hasOpacityConverged ? animation.targetOpacity : lerpedOpacity; } return !hasBoundsConverged || !hasOpacityConverged; }; const runAnimationFrame = () => { let shouldContinueAnimating = false; if (dragAnimation?.isInitialized) { if (interpolateBounds(dragAnimation, LAYER_STYLES.drag.lerpFactor)) { shouldContinueAnimating = true; } } for (const animation of selectionAnimations) { if (animation.isInitialized) { if (interpolateBounds(animation, LAYER_STYLES.selection.lerpFactor)) { shouldContinueAnimating = true; } } } const currentTimestamp = Date.now(); grabbedAnimations = grabbedAnimations.filter((animation) => { const isLabelAnimation = animation.id.startsWith("label-"); if (animation.isInitialized) { const isStillAnimating = interpolateBounds( animation, LAYER_STYLES.grabbed.lerpFactor, { interpolateOpacity: isLabelAnimation }, ); if (isStillAnimating) { shouldContinueAnimating = true; } } if (animation.createdAt) { const elapsed = currentTimestamp - animation.createdAt; const fadeOutDeadline = FEEDBACK_DURATION_MS + FADE_OUT_BUFFER_MS; if (elapsed >= fadeOutDeadline) { return false; } if (elapsed > FEEDBACK_DURATION_MS) { const fadeProgress = (elapsed - FEEDBACK_DURATION_MS) / FADE_OUT_BUFFER_MS; animation.opacity = 1 - fadeProgress; shouldContinueAnimating = true; } return true; } if (isLabelAnimation) { const hasOpacityConverged = Math.abs(animation.opacity - animation.targetOpacity) < OPACITY_CONVERGENCE_THRESHOLD; if (hasOpacityConverged && animation.targetOpacity === 0) { return false; } return true; } return animation.opacity > 0; }); for (const animation of processingAnimations) { if (animation.isInitialized) { if (interpolateBounds(animation, LAYER_STYLES.processing.lerpFactor)) { shouldContinueAnimating = true; } } } compositeAllLayers(); if (shouldContinueAnimating) { animationFrameId = nativeRequestAnimationFrame(runAnimationFrame); } else { animationFrameId = null; } }; const scheduleAnimationFrame = () => { if (animationFrameId !== null) return; animationFrameId = nativeRequestAnimationFrame(runAnimationFrame); }; const handleWindowResize = () => { initializeCanvas(); scheduleAnimationFrame(); }; createEffect( on( () => [ props.selectionVisible, props.selectionBounds, props.selectionBoundsMultiple, props.selectionIsFading, props.selectionShouldSnap, ] as const, ([isVisible, singleBounds, multipleBounds, , shouldSnap]) => { if ( !isVisible || (!singleBounds && (!multipleBounds || multipleBounds.length === 0)) ) { selectionAnimations = []; scheduleAnimationFrame(); return; } const boundsToRender = multipleBounds && multipleBounds.length > 0 ? multipleBounds : singleBounds ? [singleBounds] : []; selectionAnimations = boundsToRender.map((bounds, index) => { const animationId = `selection-${index}`; const existingAnimation = selectionAnimations.find( (animation) => animation.id === animationId, ); if (existingAnimation) { updateAnimationTarget(existingAnimation, bounds); if (shouldSnap) { existingAnimation.current = { ...existingAnimation.target }; } return existingAnimation; } return createAnimatedBounds(animationId, bounds); }); scheduleAnimationFrame(); }, ), ); createEffect( on( () => [props.dragVisible, props.dragBounds] as const, ([isVisible, bounds]) => { if (!isVisible || !bounds) { dragAnimation = null; scheduleAnimationFrame(); return; } if (dragAnimation) { updateAnimationTarget(dragAnimation, bounds); } else { dragAnimation = createAnimatedBounds("drag", bounds); } scheduleAnimationFrame(); }, ), ); createEffect( on( () => props.grabbedBoxes, (grabbedBoxes) => { const boxesToProcess = grabbedBoxes ?? []; const activeBoxIds = new Set(boxesToProcess.map((box) => box.id)); const existingAnimationIds = new Set( grabbedAnimations.map((animation) => animation.id), ); for (const box of boxesToProcess) { if (!existingAnimationIds.has(box.id)) { grabbedAnimations.push( createAnimatedBounds(box.id, box.bounds, { createdAt: box.createdAt, }), ); } } for (const animation of grabbedAnimations) { const matchingBox = boxesToProcess.find( (box) => box.id === animation.id, ); if (matchingBox) { updateAnimationTarget(animation, matchingBox.bounds); } } grabbedAnimations = grabbedAnimations.filter((animation) => { if (animation.id.startsWith("label-")) { return true; } return activeBoxIds.has(animation.id); }); scheduleAnimationFrame(); }, ), ); createEffect( on( () => props.agentSessions, (agentSessions) => { if (!agentSessions || agentSessions.size === 0) { processingAnimations = []; scheduleAnimationFrame(); return; } const updatedAnimations: AnimatedBounds[] = []; for (const [sessionId, session] of agentSessions) { for (let index = 0; index < session.selectionBounds.length; index++) { const bounds = session.selectionBounds[index]; const animationId = `processing-${sessionId}-${index}`; const existingAnimation = processingAnimations.find( (animation) => animation.id === animationId, ); if (existingAnimation) { updateAnimationTarget(existingAnimation, bounds); updatedAnimations.push(existingAnimation); } else { updatedAnimations.push(createAnimatedBounds(animationId, bounds)); } } } processingAnimations = updatedAnimations; scheduleAnimationFrame(); }, ), ); createEffect( on( () => props.labelInstances, (labelInstances) => { const instancesToProcess = labelInstances ?? []; for (const instance of instancesToProcess) { const boundsToRender = resolveBoundsArray(instance); const targetOpacity = instance.status === "fading" ? 0 : 1; for (let index = 0; index < boundsToRender.length; index++) { const bounds = boundsToRender[index]; const animationId = `label-${instance.id}-${index}`; const existingAnimation = grabbedAnimations.find( (animation) => animation.id === animationId, ); if (existingAnimation) { updateAnimationTarget(existingAnimation, bounds, targetOpacity); } else { grabbedAnimations.push( createAnimatedBounds(animationId, bounds, { opacity: 1, targetOpacity, }), ); } } } const activeLabelIds = new Set(); for (const instance of instancesToProcess) { const boundsToRender = resolveBoundsArray(instance); for (let index = 0; index < boundsToRender.length; index++) { activeLabelIds.add(`label-${instance.id}-${index}`); } } grabbedAnimations = grabbedAnimations.filter((animation) => { if (animation.id.startsWith("label-")) { return activeLabelIds.has(animation.id); } return true; }); scheduleAnimationFrame(); }, ), ); onMount(() => { initializeCanvas(); scheduleAnimationFrame(); window.addEventListener("resize", handleWindowResize); let currentDprMediaQuery: MediaQueryList | null = null; const handleDevicePixelRatioChange = () => { const newDevicePixelRatio = Math.max( window.devicePixelRatio || 1, MIN_DEVICE_PIXEL_RATIO, ); if (newDevicePixelRatio !== devicePixelRatio) { handleWindowResize(); setupDprMediaQuery(); } }; const setupDprMediaQuery = () => { if (currentDprMediaQuery) { currentDprMediaQuery.removeEventListener( "change", handleDevicePixelRatioChange, ); } currentDprMediaQuery = window.matchMedia( `(resolution: ${window.devicePixelRatio}dppx)`, ); currentDprMediaQuery.addEventListener( "change", handleDevicePixelRatioChange, ); }; setupDprMediaQuery(); onCleanup(() => { window.removeEventListener("resize", handleWindowResize); if (currentDprMediaQuery) { currentDprMediaQuery.removeEventListener( "change", handleDevicePixelRatioChange, ); } if (animationFrameId !== null) { nativeCancelAnimationFrame(animationFrameId); } }); }); return ( ); }; ================================================ FILE: packages/react-grab/src/components/renderer.tsx ================================================ import { Show, Index } from "solid-js"; import type { Component } from "solid-js"; import type { AgentSession, ReactGrabRendererProps } from "../types.js"; import { FROZEN_GLOW_COLOR, FROZEN_GLOW_EDGE_PX, Z_INDEX_OVERLAY_CANVAS, } from "../constants.js"; import { openFile } from "../utils/open-file.js"; import { isElementConnected } from "../utils/is-element-connected.js"; import { OverlayCanvas } from "./overlay-canvas.js"; import { SelectionLabel } from "./selection-label/index.js"; import { Toolbar } from "./toolbar/index.js"; import { ToolbarMenu } from "./toolbar/toolbar-menu.js"; import { ContextMenu } from "./context-menu.js"; import { HistoryDropdown } from "./history-dropdown.js"; import { ClearHistoryPrompt } from "./clear-history-prompt.js"; export const ReactGrabRenderer: Component = (props) => { const getSessionStatus = ( session: AgentSession, ): "copying" | "copied" | "fading" => { if (session.isFading) { return "fading"; } if (session.isStreaming) { return "copying"; } return "copied"; }; return ( <>
    {(session) => ( 0}> props.onRequestAbortSession?.(session().id)} onDismiss={ session().isStreaming ? undefined : () => props.onDismissSession?.(session().id) } onUndo={ session().isStreaming ? undefined : () => props.onUndoSession?.(session().id) } onFollowUpSubmit={ session().isStreaming ? undefined : (prompt) => props.onFollowUpSubmitSession?.(session().id, prompt) } error={session().error} onAcknowledgeError={() => props.onAcknowledgeSessionError?.(session().id) } onRetry={() => props.onRetrySession?.(session().id)} isPendingAbort={ session().isStreaming && props.pendingAbortSessionId === session().id } onConfirmAbort={() => props.onAbortSession?.(session().id, true)} onCancelAbort={() => props.onAbortSession?.(session().id, false)} /> )} { if (props.selectionFilePath) { openFile(props.selectionFilePath, props.selectionLineNumber); } }} isContextMenuOpen={props.contextMenuPosition !== null} /> {(instance) => ( { const currentInstance = instance(); const hasCompletedStatus = currentInstance.status === "copied" || currentInstance.status === "fading"; if ( !hasCompletedStatus || !isElementConnected(currentInstance.element) ) { return undefined; } return () => props.onShowContextMenuInstance?.(currentInstance.id); })()} onHoverChange={(isHovered) => props.onLabelInstanceHoverChange?.(instance().id, isHovered) } /> )} {})} onHide={props.onContextMenuHide ?? (() => {})} /> {})} /> {})} onCancel={props.onClearHistoryCancel ?? (() => {})} /> ); }; ================================================ FILE: packages/react-grab/src/components/selection-label/arrow-navigation-menu.tsx ================================================ import { Show, For, createEffect } from "solid-js"; import type { Component } from "solid-js"; import type { ArrowNavigationItem } from "../../types.js"; import { createMenuHighlight } from "../../utils/create-menu-highlight.js"; import { BottomSection } from "./bottom-section.js"; interface ArrowNavigationMenuProps { items: ArrowNavigationItem[]; activeIndex: number; onSelect: (index: number) => void; } export const ArrowNavigationMenu: Component = ( props, ) => { const { containerRef: highlightContainerRef, highlightRef, updateHighlight, clearHighlight, } = createMenuHighlight(); let menuItemsRef: HTMLDivElement | undefined; let didPointerMove = false; const getMenuItemByIndex = ( itemIndex: number, ): HTMLButtonElement | undefined => { if (!menuItemsRef) return undefined; const activeMenuButton = menuItemsRef.querySelector( `[data-react-grab-arrow-nav-index="${itemIndex}"]`, ); return activeMenuButton ?? undefined; }; createEffect(() => { void props.items; didPointerMove = false; }); createEffect(() => { const activeMenuItem = getMenuItemByIndex(props.activeIndex); if (activeMenuItem) { updateHighlight(activeMenuItem); } }); return (
    { menuItemsRef = element; highlightContainerRef(element); }} class="relative flex flex-col w-[calc(100%+16px)] -mx-2 -my-1.5" onPointerMove={() => { didPointerMove = true; }} >
    {(item, itemIndex) => ( )}
    ); }; ================================================ FILE: packages/react-grab/src/components/selection-label/arrow.tsx ================================================ import type { Component } from "solid-js"; import type { ArrowProps } from "../../types.js"; import { getArrowSize } from "../../utils/get-arrow-size.js"; export const Arrow: Component = (props) => { const arrowColor = () => props.color ?? "white"; const isBottom = () => props.position === "bottom"; const arrowSize = () => getArrowSize(props.labelWidth ?? 0); return (