Skip to content

Commit 8badc89

Browse files
authored
test(devtools): make e2e behaviour-driven and leaner (#1045)
1 parent 06de1bb commit 8badc89

13 files changed

Lines changed: 175 additions & 316 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"test:e2e:all": "pnpm test:e2e:prebuild && playwright test --config tests/e2e/playwright.config.ts",
2121
"test:e2e:dev": "PW_PROJECT='*:dev' playwright test --config tests/e2e/playwright.config.ts",
2222
"test:e2e:built": "pnpm test:e2e:prebuild && PW_PROJECT='*:built' playwright test --config tests/e2e/playwright.config.ts",
23-
"test:e2e:prebuild": "pnpm -C playgrounds/empty exec nuxt build && pnpm -C playgrounds/spa exec nuxt build && pnpm -C playgrounds/tab-pinia exec nuxt build && pnpm -C playgrounds/tab-seo exec nuxt build",
23+
"test:e2e:prebuild": "pnpm -C playgrounds/empty exec nuxt build && pnpm -C playgrounds/spa exec nuxt build",
2424
"test:e2e:ui": "playwright test --config tests/e2e/playwright.config.ts --ui",
2525
"docs": "pnpm -C docs install && nuxi dev docs",
2626
"docs:build": "pnpm -C docs install && CI=true nuxi generate docs",

‎packages/devtools/src/runtime/plugins/view/client.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,19 +72,19 @@ export async function setupDevToolsClient({
7272
close() {
7373
const ctx = getViteDevToolsContext()
7474
if (ctx)
75-
ctx.panel.store.value.open = false
75+
ctx.panel.store.open = false
7676
},
7777
open() {
7878
const ctx = getViteDevToolsContext()
7979
if (ctx) {
80-
ctx.panel.store.value.open = true
80+
ctx.panel.store.open = true
8181
ctx.docks.switchEntry(NUXT_DOCK_GROUP_ID)
8282
}
8383
},
8484
async navigate(path: string) {
8585
const ctx = getViteDevToolsContext()
8686
if (ctx) {
87-
ctx.panel.store.value.open = true
87+
ctx.panel.store.open = true
8888
ctx.docks.switchEntry(NUXT_DOCK_GROUP_ID)
8989
}
9090
await client.hooks.callHook('host:action:navigate', path)

‎tests/e2e/fixtures/devtools.ts‎

Lines changed: 45 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,49 @@
1-
import type { FrameLocator } from '@playwright/test'
1+
import type { FrameLocator, Page } from '@playwright/test'
22
import { test as base, expect } from '@playwright/test'
33

4-
// Vite DevTools renders the Nuxt DevTools dock entry as an iframe nested inside its
5-
// own shadow DOM. The iframe has no stable id, so we target by src.
4+
// The Nuxt DevTools client renders inside an iframe that Vite DevTools mounts in
5+
// its own shadow DOM. The iframe has no stable id, so we target it by src.
66
const IFRAME_SELECTOR = 'iframe[src*="__nuxt_devtools__/client"]'
77

88
interface DevToolsFixtures {
99
playground: string
1010
mode: 'dev' | 'built'
11+
/** Open the DevTools panel and wait for the client app to hydrate. */
1112
openDevTools: () => Promise<void>
13+
/** Navigate the open DevTools client to a tab route (e.g. `/modules/modules`). */
1214
navigateTab: (path: string) => Promise<void>
15+
/** Locator scoped to the DevTools client iframe. */
1316
devtoolsFrame: () => FrameLocator
1417
}
1518

19+
// e2e servers run with `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH=true`, which trusts the
20+
// *server* peer (so RPC is allowed) but never flips the *client-side* trust flag.
21+
// Until it does, Vite DevTools never subscribes to the dock list, so no dock —
22+
// and therefore no Nuxt group — ever appears. Nudge the flag here. This is purely
23+
// test-environment plumbing; it is not something the tests assert on.
24+
async function ensureDockReady(page: Page): Promise<void> {
25+
await page.waitForFunction(
26+
() => Boolean((globalThis as any).__NUXT_DEVTOOLS_HOST__?.devtools),
27+
null,
28+
{ timeout: 30_000 },
29+
)
30+
await page.waitForFunction(
31+
() => Boolean((globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__?.rpc),
32+
null,
33+
{ timeout: 30_000 },
34+
)
35+
await page.evaluate(() => {
36+
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
37+
if (ctx?.rpc && !ctx.rpc.isTrusted)
38+
ctx.rpc.events?.emit?.('rpc:is-trusted:updated', true)
39+
})
40+
await page.waitForFunction(
41+
() => Boolean((globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__?.docks?.entries?.length),
42+
null,
43+
{ timeout: 30_000 },
44+
)
45+
}
46+
1647
export const test = base.extend<DevToolsFixtures>({
1748
// eslint-disable-next-line no-empty-pattern
1849
playground: async ({}, use, info) => {
@@ -25,58 +56,23 @@ export const test = base.extend<DevToolsFixtures>({
2556

2657
openDevTools: async ({ page }, use) => {
2758
await use(async () => {
28-
// Wait for the Vite DevTools client context to be injected.
29-
await page.waitForFunction(
30-
() => Boolean((globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__?.rpc),
31-
null,
32-
{ timeout: 30_000 },
33-
)
34-
// Devframe 0.7 only fetches the `devframe:docks` shared state once the
35-
// client is marked trusted (via the `rpc:is-trusted:updated` event).
36-
// `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH` trusts the *server* peer — so RPC
37-
// calls are allowed — but never flips the *client-side* trust flag, so the
38-
// dock list would otherwise stay empty forever. Nudge it here so the docks
39-
// subscription kicks off. (Built/preview mode uses the static RPC backend,
40-
// which is already trusted, so this is a harmless no-op there.)
41-
await page.evaluate(() => {
42-
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
43-
if (ctx?.rpc && !ctx.rpc.isTrusted)
44-
ctx.rpc.events?.emit?.('rpc:is-trusted:updated', true)
45-
})
46-
await page.waitForFunction(
47-
() => Boolean((globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__?.docks?.entries?.length),
48-
null,
49-
{ timeout: 30_000 },
50-
)
51-
// Drive Vite DevTools directly: open the panel, then switch the active
52-
// dock entry to nuxt:devtools (idempotent; safe to call when already open).
53-
await page.evaluate(async () => {
54-
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
55-
ctx.panel.store.open = true
56-
await ctx.docks.switchEntry('nuxt:devtools')
57-
})
58-
// Iframe creation is async after switchEntry resolves. Poll the dock entry
59-
// state until its DOM iframe is attached.
60-
await page.waitForFunction(() => {
61-
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
62-
const state = ctx?.docks?.getStateById?.('nuxt:devtools')
63-
return Boolean(state?.domElements?.iframe?.isConnected)
64-
}, null, { timeout: 30_000 })
65-
// Then wait for the inner app to hydrate. Generous timeout: playgrounds that
66-
// use `../../local` spawn a separate Nuxt dev subprocess for the devtools
67-
// client, and its first Vite compile is slow on cold start.
68-
await page.frameLocator(IFRAME_SELECTOR).locator('#nuxt-devtools-app').waitFor({ state: 'attached', timeout: 90_000 })
59+
await ensureDockReady(page)
60+
// Drive the *public* host API the same way the keyboard shortcut / host app
61+
// would — no reaching into Vite DevTools' internal panel/dock state.
62+
await page.evaluate(() => (globalThis as any).__NUXT_DEVTOOLS_HOST__.devtools.open())
63+
// Wait for the client app inside the iframe to hydrate.
64+
await page.frameLocator(IFRAME_SELECTOR)
65+
.locator('#nuxt-devtools-app')
66+
.waitFor({ state: 'attached', timeout: 60_000 })
6967
})
7068
},
7169

7270
navigateTab: async ({ page }, use) => {
7371
await use(async (path: string) => {
74-
// Drive navigation through the documented host hook.
75-
// (`__NUXT_DEVTOOLS_HOST__.devtools.navigate` itself currently writes to
76-
// `ctx.panel.store.value.open`, which assumes a Vue ref but the kit exposes
77-
// a plain object — using the hook avoids that landmine.)
72+
// Navigate via the documented host API (`devtools.navigate`) rather than
73+
// poking the frame-nav hook or router directly.
7874
await page.evaluate(
79-
p => (window as any).__NUXT_DEVTOOLS_HOST__.hooks.callHook('host:action:navigate', p),
75+
p => (globalThis as any).__NUXT_DEVTOOLS_HOST__.devtools.navigate(p),
8076
path,
8177
)
8278
})

‎tests/e2e/playwright.config.ts‎

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ const MODES = ['dev', 'built'] as const
1111
type Mode = typeof MODES[number]
1212
type Playground = typeof PLAYGROUNDS[number]
1313

14+
// Dev mode exercises the DevTools UI on every playground. Built (production
15+
// preview) mode only checks the app still renders — Nuxt DevTools no-ops
16+
// outside dev — so we run it on just the two distinct rendering targets:
17+
// `empty` (SSR) and `spa` (ssr:false). Building `tab-pinia`/`tab-seo` too, only
18+
// to load a page, is wasted time (and `tab-seo`'s prod build has a known
19+
// auto-import bug). `tab-pinia`/`tab-seo` are therefore dev-only playgrounds.
20+
const BUILT_PLAYGROUNDS = new Set<Playground>(['empty', 'spa'])
21+
1422
interface Spec {
1523
name: string
1624
playground: Playground
@@ -19,17 +27,19 @@ interface Spec {
1927
}
2028

2129
const allSpecs: Spec[] = PLAYGROUNDS.flatMap((playground, idx) =>
22-
MODES.map((mode): Spec => ({
23-
name: `${playground}:${mode}`,
24-
playground,
25-
mode,
26-
port: 13000 + idx * 10 + (mode === 'dev' ? 0 : 1),
27-
})),
30+
MODES
31+
.filter(mode => mode === 'dev' || BUILT_PLAYGROUNDS.has(playground))
32+
.map((mode): Spec => ({
33+
name: `${playground}:${mode}`,
34+
playground,
35+
mode,
36+
port: 13000 + idx * 10 + (mode === 'dev' ? 0 : 1),
37+
})),
2838
)
2939

3040
// PW_PROJECT supports glob-style filtering (e.g. `*:dev`, `empty:*`, `empty:dev`).
31-
// Used by the npm scripts to avoid booting all 6 servers when only one mode is needed.
32-
// Falls back to all specs when unset.
41+
// Used by the npm scripts to avoid booting every server when only one mode is
42+
// needed. Falls back to all specs when unset.
3343
const filter = process.env.PW_PROJECT
3444
const specs = allSpecs.filter(s => matchesProjectFilter(s.name, filter))
3545

@@ -38,9 +48,9 @@ export default defineConfig({
3848
fullyParallel: false,
3949
workers: 1,
4050
forbidOnly: !!process.env.CI,
41-
// First iframe test for `../../local` playgrounds (tab-pinia, tab-seo) can flake
42-
// on cold subprocess boot; one retry covers it.
43-
retries: 1,
51+
// The client SPA's first Vite compile is slow on a cold server; one retry
52+
// absorbs that startup flake without masking real, reproducible failures.
53+
retries: process.env.CI ? 1 : 0,
4454
reporter: process.env.CI
4555
? [['list'], ['github'], ['html', { open: 'never', outputFolder: 'playwright-report' }]]
4656
: 'list',
@@ -59,10 +69,11 @@ export default defineConfig({
5969
},
6070
metadata: { playground: s.playground, mode: s.mode },
6171
})),
72+
// Production builds are done once, up front, by `pnpm test:e2e:prebuild`
73+
// (see package.json). The servers below only spawn `dev`/`preview`, which
74+
// boot in seconds.
6275
webServer: specs.map((s) => {
6376
const target = `playgrounds/${s.playground}`
64-
// Builds happen in globalSetup; webServer here only spawns dev or preview,
65-
// both of which boot in seconds.
6677
const command = s.mode === 'dev'
6778
? `pnpm -C ${target} exec nuxt dev --port ${s.port}`
6879
: `pnpm -C ${target} exec nuxt preview --port ${s.port}`
@@ -79,12 +90,12 @@ export default defineConfig({
7990
stdout: 'pipe' as const,
8091
stderr: 'pipe' as const,
8192
env: {
82-
// Vite DevTools requires per-client trust by default. For e2e we're spawning
83-
// ephemeral servers, so disable auth — any browser may connect.
93+
// Vite DevTools requires per-client trust by default. For e2e we're
94+
// spawning ephemeral servers, so disable auth — any browser may connect.
8495
VITE_DEVTOOLS_DISABLE_CLIENT_AUTH: 'true',
85-
// Bind the main app server to all interfaces. Default `nuxt dev`/`preview`
96+
// Bind the app server to all interfaces. Default `nuxt dev`/`preview`
8697
// on macOS binds only to IPv6, so 127.0.0.1 requests get refused. We use
87-
// `localhost` baseURL above to match the Vite DevTools websocket bind.
98+
// a `localhost` baseURL above to match the Vite DevTools websocket bind.
8899
HOST: '0.0.0.0',
89100
...(s.playground === 'empty'
90101
? { NUXT_DEVTOOLS_CODE_SERVER_BIN: 'nuxt-devtools-e2e-missing-code-server' }

‎tests/e2e/specs/code-server.spec.ts‎

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { expect, test } from '../fixtures/devtools'
44
const CODE_SERVER_DOCK_ID = 'devframes_plugin_code-server'
55
const CODE_SERVER_STATUS_RPC = 'devframes:plugin:code-server:status'
66

7-
test('mounts Code Server in the Nuxt group and reports a missing binary without starting it', async ({ page, openDevTools, mode, playground }) => {
7+
test('reports a missing editor binary without starting a process', async ({ page, openDevTools, mode, playground }) => {
8+
// `empty`'s config points Code Server at a deliberately missing binary
9+
// (`NUXT_DEVTOOLS_CODE_SERVER_BIN`), so detection is deterministic everywhere.
810
test.skip(mode !== 'dev' || playground !== 'empty', 'missing-binary fixture runs only in empty:dev')
911

1012
await page.goto('/')
@@ -15,46 +17,23 @@ test('mounts Code Server in the Nuxt group and reports a missing binary without
1517
return ctx?.docks?.entries?.some((entry: any) => entry.id === id)
1618
}, CODE_SERVER_DOCK_ID, { timeout: 30_000 })
1719

18-
const entry = await page.evaluate((id) => {
19-
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
20-
return ctx.docks.entries.find((candidate: any) => candidate.id === id)
21-
}, CODE_SERVER_DOCK_ID)
22-
expect(entry).toMatchObject({
23-
id: CODE_SERVER_DOCK_ID,
24-
type: 'iframe',
25-
groupId: 'nuxt',
26-
category: 'modules',
27-
})
28-
29-
const status = await page.evaluate(async (rpcName) => {
30-
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
31-
return ctx.rpc.call(rpcName)
32-
}, CODE_SERVER_STATUS_RPC)
33-
expect(status).toMatchObject({
34-
detection: {
35-
checked: true,
36-
installed: false,
37-
bin: 'nuxt-devtools-e2e-missing-code-server',
38-
backend: 'code-server',
39-
mode: 'local',
40-
},
41-
server: { status: 'stopped' },
42-
})
43-
20+
// Open the Code Server member the way a user would.
4421
await page.evaluate(async (id) => {
4522
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
4623
await ctx.docks.switchEntry(id)
4724
}, CODE_SERVER_DOCK_ID)
25+
26+
// The user sees the "no editor" empty state (the binary can't be found).
4827
const codeServerFrame = page.frameLocator(`iframe[src*="${CODE_SERVER_DOCK_ID}"]`)
4928
await expect(codeServerFrame.getByRole('heading', { name: 'No editor found' }))
5029
.toBeVisible({ timeout: 30_000 })
5130

52-
// Opening the member performs detection only; it must not start a process.
53-
const statusAfterOpen = await page.evaluate(async (rpcName) => {
31+
// Opening the member performs detection only — it must not launch a process.
32+
const status = await page.evaluate(async (rpcName) => {
5433
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
5534
return ctx.rpc.call(rpcName)
5635
}, CODE_SERVER_STATUS_RPC)
57-
expect((statusAfterOpen as any).server.status).toBe('stopped')
36+
expect((status as any).server.status).toBe('stopped')
5837
})
5938

6039
test('launches an installed Code Server with authenticated iframe handoff', async ({ page, openDevTools, mode, playground }) => {
@@ -70,15 +49,6 @@ test('launches an installed Code Server with authenticated iframe handoff', asyn
7049
return ctx?.docks?.entries?.some((entry: any) => entry.id === id)
7150
}, CODE_SERVER_DOCK_ID, { timeout: 30_000 })
7251

73-
const initialStatus = await page.evaluate(async (rpcName) => {
74-
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
75-
return ctx.rpc.call(rpcName)
76-
}, CODE_SERVER_STATUS_RPC)
77-
expect(initialStatus).toMatchObject({
78-
detection: { checked: true, installed: true, backend: 'code-server' },
79-
server: { status: 'stopped' },
80-
})
81-
8252
await page.evaluate(async (id) => {
8353
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
8454
await ctx.docks.switchEntry(id)

‎tests/e2e/specs/data-inspector.spec.ts‎

Lines changed: 18 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,33 @@
11
import { expect, test } from '../fixtures/devtools'
22

3-
// Vite DevTools UI (and its dock registry) is dev-mode only.
4-
test.skip(({ mode }) => mode !== 'dev', 'devtools UI is dev-mode only')
3+
// Plugin integration behaviour is playground-independent — run once, on `empty`.
4+
test.skip(({ playground, mode }) => playground !== 'empty' || mode !== 'dev', 'Data Inspector: empty playground, dev mode')
55

6-
// The Data Inspector devframe's default id — also its RPC namespace.
7-
const DATA_INSPECTOR_ID = 'devframes:plugin:data-inspector'
86
const NUXT_SOURCE_ID = 'nuxt:application'
97
const EXAMPLE_SOURCE_ID = 'devframes:plugin:data-inspector:example'
108

11-
test('mounts the Data Inspector in the Nuxt group exposing only the Nuxt source', async ({ page, openDevTools }) => {
12-
await page.goto('/')
13-
await openDevTools()
14-
15-
// The Data Inspector dock member registers a moment after the group; poll for it.
16-
await page.waitForFunction((id) => {
17-
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
18-
return ctx?.docks?.entries?.some((entry: any) => entry.id === id)
19-
}, DATA_INSPECTOR_ID, { timeout: 30_000 })
20-
21-
// Mounted as a member of the `nuxt` group, in the `advanced` in-group
22-
// category (the definition defaults to `~builtin`; the per-mount override
23-
// must win).
24-
const entry = await page.evaluate((id) => {
25-
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
26-
return ctx.docks.entries.find((e: any) => e.id === id)
27-
}, DATA_INSPECTOR_ID)
28-
expect(entry).toMatchObject({
29-
id: DATA_INSPECTOR_ID,
30-
type: 'iframe',
31-
groupId: 'nuxt',
32-
category: 'advanced',
33-
})
34-
35-
// The source picker contains the Nuxt source and NOT the built-in example.
36-
await expect.poll(async () => {
37-
return page.evaluate(async () => {
38-
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
39-
const sources = await ctx.rpc.call('devframes:plugin:data-inspector:sources')
40-
return (sources as { id: string }[]).map(s => s.id)
41-
})
42-
}, { timeout: 30_000 }).toContain(NUXT_SOURCE_ID)
43-
44-
const sourceIds = await page.evaluate(async () => {
9+
async function inspectorSources(page: any): Promise<string[]> {
10+
return page.evaluate(async () => {
4511
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
4612
const sources = await ctx.rpc.call('devframes:plugin:data-inspector:sources')
4713
return (sources as { id: string }[]).map(s => s.id)
4814
})
15+
}
16+
17+
// What the user actually gets from the integration: the Data Inspector Nuxt
18+
// DevTools mounts inspects the *live Nuxt app*, and hides the plugin's own demo
19+
// source. We assert that observable contract rather than the dock entry's
20+
// internal registry shape.
21+
test('exposes the live Nuxt application as a data source and hides the demo source', async ({ page, openDevTools }) => {
22+
await page.goto('/')
23+
await openDevTools()
24+
25+
await expect.poll(() => inspectorSources(page), { timeout: 30_000 }).toContain(NUXT_SOURCE_ID)
26+
27+
const sourceIds = await inspectorSources(page)
4928
expect(sourceIds).not.toContain(EXAMPLE_SOURCE_ID)
5029

51-
// Querying at least `nuxt` and `vite` against the live source succeeds.
30+
// Querying the live source returns data (the integration is wired end to end).
5231
const results = await page.evaluate(async (sourceId) => {
5332
const ctx = (globalThis as any).__VITE_DEVTOOLS_CLIENT_CONTEXT__
5433
const nuxt = await ctx.rpc.call('devframes:plugin:data-inspector:query', sourceId, 'nuxt')

0 commit comments

Comments
 (0)