diff --git a/.changeset/hide-in-automation.md b/.changeset/hide-in-automation.md new file mode 100644 index 00000000..1baa8a3b --- /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 d9f9a52e..041a1d31 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 } +``` + - `openAsModal` - Moves the open panel on top of a modal dialog of your app. A dialog that you open with `dialog.showModal()` makes the rest of the page inert, the devtools included. While the panel is on top, your dialog is inert. Close the panel to use your dialog again. The trigger is inert while your dialog is open, so open the panel with the open hotkey. The default is `false`. ```ts diff --git a/e2e/apps/react-vite/src/main.tsx b/e2e/apps/react-vite/src/main.tsx index 1e5bae60..4d006093 100644 --- a/e2e/apps/react-vite/src/main.tsx +++ b/e2e/apps/react-vite/src/main.tsx @@ -27,6 +27,9 @@ function App() { config={{ theme: 'dark', requireUrlFlag: new URLSearchParams(location.search).has('gated'), + hideInAutomation: new URLSearchParams(location.search).has( + 'automation-hidden', + ), openAsModal: new URLSearchParams(location.search).has( 'open-as-modal', ), 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 00000000..b8091d58 --- /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/package.json b/package.json index a0be938b..9336392f 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "size-limit": [ { "path": "packages/devtools/dist/index.js", - "limit": "66 KB" + "limit": "67 KB" }, { "path": "packages/event-bus-client/dist/esm/plugin.js", diff --git a/packages/devtools/src/context/devtools-context.tsx b/packages/devtools/src/context/devtools-context.tsx index 2de2c92f..f89c4b7b 100644 --- a/packages/devtools/src/context/devtools-context.tsx +++ b/packages/devtools/src/context/devtools-context.tsx @@ -204,6 +204,9 @@ export const getExistingStateFromStorage = ( ...initialState.settings, ...config, ...settings, + // Not in the settings tab, so the config wins over a stored value. + hideInAutomation: + config?.hideInAutomation ?? initialState.settings.hideInAutomation, }, } return state diff --git a/packages/devtools/src/context/devtools-store.ts b/packages/devtools/src/context/devtools-store.ts index 2d5864d2..7055c724 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 /** * Whether the open panel moves on top of a modal dialog of the app * (`dialog.showModal()`), which otherwise makes the dev tools inert. @@ -204,6 +211,7 @@ export const initialState: DevtoolsStore = { inspectHotkey: ['Shift', 'Alt', 'CtrlOrMeta'], requireUrlFlag: false, urlFlag: 'tanstack-devtools', + hideInAutomation: false, openAsModal: false, theme: typeof window !== 'undefined' && diff --git a/packages/devtools/src/devtools.tsx b/packages/devtools/src/devtools.tsx index 8aac0c0b..a838922a 100644 --- a/packages/devtools/src/devtools.tsx +++ b/packages/devtools/src/devtools.tsx @@ -78,6 +78,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) @@ -145,7 +148,8 @@ export default function DevTools() { createEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && isOpen()) toggleOpen() + if (event.key === 'Escape' && isOpen() && !hiddenInAutomation()) + toggleOpen() } window.addEventListener('keydown', onKeyDown) onCleanup(() => window.removeEventListener('keydown', onKeyDown)) @@ -170,7 +174,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() }) } }) @@ -184,48 +189,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 915cf1b7..578cfe5a 100644 --- a/packages/devtools/tests/workbench.test.tsx +++ b/packages/devtools/tests/workbench.test.tsx @@ -6,7 +6,10 @@ import { DevtoolsProvider } from '../src/context/devtools-context' import { PiPProvider } from '../src/context/pip-context' import { PluginSectionComponent } from '../src/tabs/marketplace/plugin-section' import { flattenTabs } from '../src/utils/layout-tree' -import { TANSTACK_DEVTOOLS_STATE } from '../src/utils/storage' +import { + TANSTACK_DEVTOOLS_SETTINGS, + TANSTACK_DEVTOOLS_STATE, +} from '../src/utils/storage' import { DEVTOOLS_FONT_STYLE_ID, DEVTOOLS_FORCED_COLORS_STYLE_ID, @@ -203,6 +206,79 @@ 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('uses the config over a stored hideInAutomation value', () => { + localStorage.setItem( + TANSTACK_DEVTOOLS_SETTINGS, + JSON.stringify({ hideInAutomation: false }), + ) + mountWorkbench([plugin('one')], { hideInAutomation: true }) + + expect(devtoolsRoot()).toBeNull() + }) + + it('ignores Escape while hidden in an automated browser', () => { + localStorage.setItem( + TANSTACK_DEVTOOLS_STATE, + JSON.stringify({ persistOpen: true }), + ) + mountWorkbench([plugin('one')], { hideInAutomation: true }) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + + const stored = JSON.parse(localStorage.getItem(TANSTACK_DEVTOOLS_STATE)!) + expect(stored.persistOpen).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({