Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hide-in-automation.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions e2e/apps/react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
Expand Down
24 changes: 24 additions & 0 deletions e2e/apps/react-vite/tests/hide-in-automation.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <html> 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()
})
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/devtools/src/context/devtools-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return state
Expand Down
8 changes: 8 additions & 0 deletions packages/devtools/src/context/devtools-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -204,6 +211,7 @@ export const initialState: DevtoolsStore = {
inspectHotkey: ['Shift', 'Alt', 'CtrlOrMeta'],
requireUrlFlag: false,
urlFlag: 'tanstack-devtools',
hideInAutomation: false,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
openAsModal: false,
theme:
typeof window !== 'undefined' &&
Expand Down
91 changes: 49 additions & 42 deletions packages/devtools/src/devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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()
})
}
})
Expand All @@ -184,48 +189,50 @@ export default function DevTools() {

return (
<ThemeContextProvider theme={theme()}>
<Portal mount={(pip().pipWindow ?? window).document.body}>
<div ref={setRootEl} data-testid={TANSTACK_DEVTOOLS}>
<Show
when={
pip().pipWindow !== null
? true
: settings().requireUrlFlag
? window.location.search.includes(settings().urlFlag)
: true
}
>
<Trigger isOpen={isOpen} setIsOpen={toggleOpen} />
<MainPanel
isResizing={isResizing}
isOpen={isOpen}
isCollapsed={isCollapsed}
hasSubheader={hasSubheader}
<Show when={!hiddenInAutomation()}>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Portal mount={(pip().pipWindow ?? window).document.body}>
<div ref={setRootEl} data-testid={TANSTACK_DEVTOOLS}>
<Show
when={
pip().pipWindow !== null
? true
: settings().requireUrlFlag
? window.location.search.includes(settings().urlFlag)
: true
}
>
<ContentPanel
ref={(ref) => (panelRef = ref)}
handleDragStart={(event) => handleDragStart(panelRef, event)}
handleHeightChange={updateHeight}
<Trigger isOpen={isOpen} setIsOpen={toggleOpen} />
<MainPanel
isResizing={isResizing}
isOpen={isOpen}
isCollapsed={isCollapsed}
hasSubheader={hasSubheader}
>
<WorkbenchHeader
showMarketplace={showMarketplace}
setShowMarketplace={setShowMarketplace}
toggleOpen={toggleOpen}
/>
{/* Stays mounted while folded so it can slide shut. */}
<Show when={showsPluginsStrip()}>
<PluginsStrip isOpen={isOpen} />
</Show>
<TabContent
isOpen={isOpen()}
showMarketplace={showMarketplace()}
/>
</ContentPanel>
</MainPanel>
</Show>
<SourceInspector />
</div>
</Portal>
<ContentPanel
ref={(ref) => (panelRef = ref)}
handleDragStart={(event) => handleDragStart(panelRef, event)}
handleHeightChange={updateHeight}
>
<WorkbenchHeader
showMarketplace={showMarketplace}
setShowMarketplace={setShowMarketplace}
toggleOpen={toggleOpen}
/>
{/* Stays mounted while folded so it can slide shut. */}
<Show when={showsPluginsStrip()}>
<PluginsStrip isOpen={isOpen} />
</Show>
<TabContent
isOpen={isOpen()}
showMarketplace={showMarketplace()}
/>
</ContentPanel>
</MainPanel>
</Show>
<SourceInspector />
</div>
</Portal>
</Show>
</ThemeContextProvider>
)
}
78 changes: 77 additions & 1 deletion packages/devtools/tests/workbench.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
Loading