diff --git a/.changeset/hide-in-automation.md b/.changeset/hide-in-automation.md new file mode 100644 index 000000000..1baa8a3bd --- /dev/null +++ b/.changeset/hide-in-automation.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': minor +--- + +Add the `hideInAutomation` config option. When it is `true`, the devtools do not render in a browser that automation drives (Playwright, Cypress, Selenium), detected by `navigator.webdriver`. This keeps the devtools out of end-to-end test selectors. The default is `false`. diff --git a/docs/configuration.md b/docs/configuration.md index a8fe98fff..6240d6162 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,6 +71,12 @@ type KeyboardKey = ModifierKey | (string & {}); { requireUrlFlag: boolean } ``` +- `hideInAutomation` - Does not render the devtools when a browser is driven by automation (Playwright, Cypress, Selenium). This keeps the devtools out of your end-to-end test selectors. The check uses `navigator.webdriver`. The default is `false`. + +```ts +{ hideInAutomation: boolean } +``` + - `triggerImage` - The image used for the dev tools trigger ```ts diff --git a/e2e/apps/react-vite/src/main.tsx b/e2e/apps/react-vite/src/main.tsx index e30abcda6..d7a3c0a84 100644 --- a/e2e/apps/react-vite/src/main.tsx +++ b/e2e/apps/react-vite/src/main.tsx @@ -16,6 +16,9 @@ function App() { config={{ theme: 'dark', requireUrlFlag: new URLSearchParams(location.search).has('gated'), + hideInAutomation: new URLSearchParams(location.search).has( + 'automation-hidden', + ), }} plugins={[ { diff --git a/e2e/apps/react-vite/tests/hide-in-automation.spec.ts b/e2e/apps/react-vite/tests/hide-in-automation.spec.ts new file mode 100644 index 000000000..b8091d587 --- /dev/null +++ b/e2e/apps/react-vite/tests/hide-in-automation.spec.ts @@ -0,0 +1,24 @@ +import { test, expect } from '@playwright/test' +import { DevtoolsPage } from '@tanstack/devtools-e2e' + +// Playwright sets navigator.webdriver to true. +test.describe('hideInAutomation', () => { + test('set → devtools not rendered', async ({ page }) => { + const dt = new DevtoolsPage(page) + await dt.goto('/?automation-hidden') + // Devtools set the theme on when they mount, also when hidden, so + // the check below runs after the mount and not before it. + await expect(page.locator('html')).toHaveAttribute( + 'data-tanstack-devtools-theme', + 'dark', + ) + await expect(page.getByTestId('tanstack_devtools')).toHaveCount(0) + await expect(dt.trigger()).toHaveCount(0) + }) + + test('not set → trigger visible', async ({ page }) => { + const dt = new DevtoolsPage(page) + await dt.goto('/') + await expect(dt.trigger()).toBeVisible() + }) +}) diff --git a/packages/devtools/src/context/devtools-store.ts b/packages/devtools/src/context/devtools-store.ts index 3aefe9966..abe16070a 100644 --- a/packages/devtools/src/context/devtools-store.ts +++ b/packages/devtools/src/context/devtools-store.ts @@ -110,6 +110,13 @@ export type DevtoolsStore = { * @default "tanstack-devtools" */ urlFlag: string + /** + * Whether to not render the dev tools in a browser driven by automation + * (Playwright, Cypress, Selenium), detected by `navigator.webdriver`. + * Keeps the dev tools out of end-to-end test selectors. + * @default false + */ + hideInAutomation: boolean /** * The theme of the dev tools * @default "dark" @@ -196,6 +203,7 @@ export const initialState: DevtoolsStore = { inspectHotkey: ['Shift', 'Alt', 'CtrlOrMeta'], requireUrlFlag: false, urlFlag: 'tanstack-devtools', + hideInAutomation: false, theme: typeof window !== 'undefined' && typeof window.matchMedia !== 'undefined' && diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 54f01c6f7..1d0da2f11 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -77,6 +77,9 @@ export default function DevTools() { const { isCollapsed } = createCollapsed() const [showMarketplace, setShowMarketplace] = createSignal(false) const themeOwner = Symbol('tanstack-devtools-theme') + // Playwright, Cypress, and Selenium set navigator.webdriver. + const hiddenInAutomation = () => + settings().hideInAutomation && navigator.webdriver const updateHeight = (nextHeight: number) => { setHeight(nextHeight) @@ -168,7 +171,8 @@ export default function DevTools() { } for (const permutation of getHotkeyPermutations(settings().openHotkey)) { createShortcut(permutation, () => { - if (!isEditableTarget(document.activeElement)) toggleOpen() + if (!hiddenInAutomation() && !isEditableTarget(document.activeElement)) + toggleOpen() }) } }) @@ -182,48 +186,50 @@ export default function DevTools() { return ( - -
- - - + +
+ - (panelRef = ref)} - handleDragStart={(event) => handleDragStart(panelRef, event)} - handleHeightChange={updateHeight} + + - - {/* Stays mounted while folded so it can slide shut. */} - - - - - - - - -
-
+ (panelRef = ref)} + handleDragStart={(event) => handleDragStart(panelRef, event)} + handleHeightChange={updateHeight} + > + + {/* Stays mounted while folded so it can slide shut. */} + + + + + +
+
+ +
+
+
) } diff --git a/packages/devtools/tests/workbench.test.tsx b/packages/devtools/tests/workbench.test.tsx index 915cf1b79..f7a8334db 100644 --- a/packages/devtools/tests/workbench.test.tsx +++ b/packages/devtools/tests/workbench.test.tsx @@ -203,6 +203,56 @@ afterEach(() => { }) describe('workbench', { timeout: 30_000 }, () => { + describe('hideInAutomation', () => { + const devtoolsRoot = () => + document.querySelector('[data-testid="tanstack_devtools"]') + + beforeEach(() => { + // Playwright, Cypress, and Selenium set navigator.webdriver to true. + Object.defineProperty(navigator, 'webdriver', { + configurable: true, + value: true, + }) + }) + + afterEach(() => { + delete (navigator as { webdriver?: boolean }).webdriver + }) + + it('does not render in an automated browser when hideInAutomation is set', () => { + mountWorkbench([plugin('one')], { hideInAutomation: true }) + + expect(devtoolsRoot()).toBeNull() + expect(events).toEqual([]) + }) + + it('ignores the open hotkey while hidden in an automated browser', () => { + mountWorkbench([plugin('one')], { + hideInAutomation: true, + defaultOpen: false, + }) + + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: '~', + ctrlKey: true, + bubbles: true, + }), + ) + + const stored = JSON.parse( + localStorage.getItem(TANSTACK_DEVTOOLS_STATE) ?? '{}', + ) + expect(stored.persistOpen).not.toBe(true) + }) + + it('still renders in an automated browser by default', () => { + mountWorkbench([plugin('one')]) + + expect(devtoolsRoot()?.childElementCount).toBeGreaterThan(0) + }) + }) + it('keeps the secondary Plugins bar fixed-height without hover timers', () => { vi.useFakeTimers() expect({