diff --git a/packages/devtools/package.json b/packages/devtools/package.json index ef1f86c462..09aa592c29 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -42,7 +42,8 @@ "dev:playground": "pnpm build && nuxi dev playground", "dev:prepare": "pnpm run stub && nuxi prepare client", "prepare": "tsx scripts/prepare.ts", - "prepack": "pnpm build" + "prepack": "pnpm build", + "test": "vitest run" }, "peerDependencies": { "vite": ">=6.0" @@ -58,13 +59,16 @@ "consola": "catalog:prod", "destr": "catalog:prod", "error-stack-parser-es": "catalog:frontend", + "escape-string-regexp": "catalog:prod", "fast-npm-meta": "catalog:prod", "get-port-please": "catalog:prod", "hookable": "catalog:frontend", "image-meta": "catalog:prod", "launch-editor": "catalog:prod", "local-pkg": "catalog:prod", + "magic-string": "catalog:prod", "magicast": "catalog:prod", + "mlly": "catalog:prod", "ohash": "catalog:frontend", "pathe": "catalog:frontend", "perfect-debounce": "catalog:frontend", @@ -74,6 +78,7 @@ "structured-clone-es": "catalog:frontend", "tinyexec": "catalog:prod", "tinyglobby": "catalog:prod", + "unplugin": "catalog:prod", "unstorage": "catalog:prod", "vite-plugin-inspect": "catalog:prod", "vite-plugin-vue-tracer": "catalog:prod", @@ -91,6 +96,7 @@ "@iconify-json/ri": "catalog:icons", "@iconify-json/simple-icons": "catalog:icons", "@iconify-json/tabler": "catalog:icons", + "@nuxt/schema": "catalog:types", "@nuxt/test-utils": "catalog:cli", "@parcel/watcher": "catalog:buildtools", "@types/markdown-it-link-attributes": "catalog:types", @@ -131,6 +137,7 @@ "vanilla-jsoneditor": "catalog:frontend", "vis-data": "catalog:frontend", "vis-network": "catalog:frontend", + "vitest": "catalog:cli", "vue-tsc": "catalog:cli", "vue-virtual-scroller": "catalog:frontend" } diff --git a/packages/devtools/src/integrations/timeline-wrap.ts b/packages/devtools/src/integrations/timeline-wrap.ts new file mode 100644 index 0000000000..67c93f798d --- /dev/null +++ b/packages/devtools/src/integrations/timeline-wrap.ts @@ -0,0 +1,82 @@ +import type { SourceMap } from 'magic-string' +import MagicString from 'magic-string' +import { findStaticImports, parseStaticImport } from 'mlly' + +export const HELPER_NAME = '__nuxtTimelineWrap' +export const HELPER_NAME_RE = /__nuxtTimelineWrap/ +const RENAME_PREFIX = '_$__' + +export const TRANSFORM_ID_INCLUDE: RegExp[] = [ + /^[^?]*\.(?:m?[jt]sx?|vue)(?:$|\?)/, +] +export const TRANSFORM_ID_EXCLUDE: RegExp[] = [ + /^\0/, + /[\\/]node_modules[\\/]/, + /[?&]macro=true/, + /[?&]type=(?:style|template|custom)\b/, +] + +export function wrapTimelineImports( + code: string, + isWrappable: (source: string, name: string) => boolean, + helperPath: string, +): { code: string, map: SourceMap } | undefined { + if (!code.includes('import') || code.includes(HELPER_NAME)) + return + + const staticImports = findStaticImports(code) + if (!staticImports.length) + return + + const s = new MagicString(code) + const wrappers: string[] = [] + + for (const imp of staticImports) { + const { specifier, defaultImport, namespacedImport, namedImports } = parseStaticImport(imp) + let wrapped = false + + // returns the local binding for an imported name, renamed when it gets wrapped + const bind = (name: string, local: string): string => { + if (!isWrappable(specifier, name)) + return local + wrapped = true + // a default import has no meaningful exported name, record it under its local name + wrappers.push(`const ${local} = ${HELPER_NAME}(${JSON.stringify(name === 'default' ? local : name)}, ${RENAME_PREFIX}${local});`) + return RENAME_PREFIX + local + } + + const clause: string[] = [] + if (defaultImport) + clause.push(bind('default', defaultImport)) + if (namespacedImport) + clause.push(`* as ${namespacedImport}`) + const named = Object.entries(namedImports ?? {}).map(([name, local]) => { + const bound = bind(name, local) + return name === bound ? name : `${name} as ${bound}` + }) + + if (named.length) + clause.push(`{ ${named.join(', ')} }`) + + if (!wrapped) + continue + + // rebuild only the clause between `import` and `from`, keeping the specifier untouched + const clauseStart = imp.start + imp.code.indexOf(imp.imports) + s.overwrite(clauseStart, clauseStart + imp.imports.length, `${clause.join(', ')} `) + } + + if (!wrappers.length) + return + + s.prepend([ + `import { ${HELPER_NAME} } from ${JSON.stringify(helperPath)};`, + ...wrappers, + '', + ].join('\n')) + + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }), + } +} diff --git a/packages/devtools/src/integrations/timeline.ts b/packages/devtools/src/integrations/timeline.ts index fe07f00aa3..a4eaab4e21 100644 --- a/packages/devtools/src/integrations/timeline.ts +++ b/packages/devtools/src/integrations/timeline.ts @@ -1,10 +1,16 @@ +import type { NuxtHooks, NuxtOptions } from '@nuxt/schema' import type { Import } from 'unimport' import type { NuxtDevtoolsServerContext } from '../types' +import { addBuildPlugin } from '@nuxt/kit' +import escapeRE from 'escape-string-regexp' import { resolve } from 'pathe' -import semver from 'semver' +import { createUnplugin } from 'unplugin' import { runtimeDir } from '../dirs' +import { HELPER_NAME_RE, TRANSFORM_ID_EXCLUDE, TRANSFORM_ID_INCLUDE, wrapTimelineImports } from './timeline-wrap' const DEFINE_UPPER_RE = /^define[A-Z]/ +const RUNTIME_DIR_RE = new RegExp(`^${escapeRE(runtimeDir)}`) +const IMPORT_RE = /\bimport\b/ export function setup({ nuxt, options }: NuxtDevtoolsServerContext) { const helperPath = resolve(runtimeDir, 'function-metrics-helpers') @@ -34,50 +40,100 @@ export function setup({ nuxt, options }: NuxtDevtoolsServerContext) { return true } - nuxt.hook('imports:context', (unimport) => { - const ctx = unimport.getInternalContext() + let unimport: Parameters[0] | undefined + nuxt.hook('imports:context', (ctx) => { + unimport = ctx + }) + + // wrappable auto-imports keyed by `${source}\0${name}`, cached until the import list changes + const wrappableCache = new WeakMap>>() + const resolvedFrom = new Map>() - if (!ctx.version || !semver.gte(ctx.version, '3.1.0')) - throw new Error(`[Nuxt DevTools] The timeline feature requires \`unimport\` >= v3.1.0, but got \`${ctx.version || '(unknown)'}\`. Please upgrade using \`nuxi upgrade --force\`.`) + async function getWrappable(): Promise> { + if (!unimport) + return new Map() + const imports = await unimport.getImports() + let cached = wrappableCache.get(imports) + if (!cached) { + cached = buildWrappable(imports) + wrappableCache.set(imports, cached) + } + return cached + } - ctx.addons.push( - { - injectImportsResolved(imports, _code, id) { - if (id?.includes('?macro=true')) - return - return imports.map((i) => { - if (!filter(i)) - return i + async function buildWrappable(imports: Import[]): Promise> { + const map = new Map() + // never wrap keyed-function factory macros (`createUseFetch`, etc.) + // the option exists since Nuxt 4.4 and is not yet part of the schema types we build against (+ we need it as optional for backwards compat) + const { keyedComposableFactories } = nuxt.options.optimization as NuxtOptions['optimization'] & { + keyedComposableFactories?: { name: string }[] + } + const factoryNames = new Set(keyedComposableFactories?.map(f => f.name) ?? []) + const ctx = unimport?.getInternalContext() - const name = i.as || i.name + // group by source so that each unique `from` is only resolved once + const bySource = new Map() + for (const i of imports) { + if (!filter(i) || factoryNames.has(i.name)) + continue + map.set(`${i.from}\0${i.name}`, i) + const group = bySource.get(i.from) + if (group) + group.push(i) + else + bySource.set(i.from, [i]) + } - return { - ...i, - meta: { - wrapperOriginalAs: name, - }, - as: `_$__${name}`, - } - }) - }, - injectImportsStringified(str, imports, s, id) { - if (id?.includes('?macro=true')) - return - const code = s.toString() - const injected = imports.filter(i => i.meta?.wrapperOriginalAs) - if (injected.length) { - const result = [ - str, - code.includes('__nuxtTimelineWrap') - ? '' - : `import { __nuxtTimelineWrap } from ${JSON.stringify(helperPath)}`, - ...injected.map(i => `const ${i.meta!.wrapperOriginalAs} = __nuxtTimelineWrap(${JSON.stringify(i.name)}, ${i.as})`), - '', - ].join(';') - return result - } + if (!ctx?.resolveId) + return map + + // unimport injects imports with the resolved specifier, which may differ from `i.from` + await Promise.all(Array.from(bySource, async ([from, items]) => { + let resolving = resolvedFrom.get(from) + if (!resolving) { + resolving = Promise.resolve(ctx.resolveId(from)).then(r => r || undefined, () => undefined) + resolvedFrom.set(from, resolving) + } + const resolved = await resolving + if (resolved && resolved !== from) { + for (const i of items) + map.set(`${resolved}\0${i.name}`, i) + } + })) + return map + } + + // the wrapping must happen AFTER Nuxt's key injection and keyed function factory macro rewriting + + // Nuxt registers these plugins in the `build:before` hook during core module setup, so we register ours + // from `modules:done` to be queued after them + nuxt.hook('modules:done', () => { + nuxt.hook('build:before', () => { + addBuildPlugin(createUnplugin(() => ({ + name: 'nuxt:devtools:timeline-function-wrap', + enforce: 'post', + transform: { + filter: { + id: { + include: TRANSFORM_ID_INCLUDE, + exclude: [ + ...TRANSFORM_ID_EXCLUDE, + RUNTIME_DIR_RE, + ], + }, + code: { + include: IMPORT_RE, + exclude: HELPER_NAME_RE, + }, + }, + async handler(code) { + const wrappable = await getWrappable() + if (!wrappable.size) + return + return wrapTimelineImports(code, (source, name) => wrappable.has(`${source}\0${name}`), helperPath) + }, }, - }, - ) + }))) + }) }) } diff --git a/packages/devtools/test/timeline-wrap.test.ts b/packages/devtools/test/timeline-wrap.test.ts new file mode 100644 index 0000000000..00e0d80369 --- /dev/null +++ b/packages/devtools/test/timeline-wrap.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { TRANSFORM_ID_EXCLUDE, TRANSFORM_ID_INCLUDE, wrapTimelineImports } from '../src/integrations/timeline-wrap' + +function shouldTransform(id: string): boolean { + return TRANSFORM_ID_INCLUDE.some(re => re.test(id)) && !TRANSFORM_ID_EXCLUDE.some(re => re.test(id)) +} + +const HELPER_PATH = '/runtime/function-metrics-helpers' + +function wrap(code: string, wrappable: [source: string, name: string][]) { + return wrapTimelineImports( + code, + (source, name) => wrappable.some(([s, n]) => s === source && n === name), + HELPER_PATH, + )?.code +} + +describe('wrapTimelineImports', () => { + it('wraps a bare named import and keeps call sites untouched', () => { + const result = wrap( + [ + `import { useState } from '#app/composables/state';`, + `const counter = useState(() => 0, '$key123' /* nuxt-injected */);`, + ].join('\n'), + [['#app/composables/state', 'useState']], + ) + expect(result).toMatchInlineSnapshot(` + "import { __nuxtTimelineWrap } from "/runtime/function-metrics-helpers"; + const useState = __nuxtTimelineWrap("useState", _$__useState); + import { useState as _$__useState } from '#app/composables/state'; + const counter = useState(() => 0, '$key123' /* nuxt-injected */);" + `) + }) + + it('wraps an aliased import under its local name', () => { + const result = wrap( + `import { useState as myState } from '#app/composables/state';`, + [['#app/composables/state', 'useState']], + ) + expect(result).toMatchInlineSnapshot(` + "import { __nuxtTimelineWrap } from "/runtime/function-metrics-helpers"; + const myState = __nuxtTimelineWrap("useState", _$__myState); + import { useState as _$__myState } from '#app/composables/state';" + `) + }) + + it('only wraps matched specifiers within an import statement', () => { + const result = wrap( + `import { ref, useState, useStateFoo } from '#app/composables/state';`, + [['#app/composables/state', 'useState']], + ) + expect(result).toMatchInlineSnapshot(` + "import { __nuxtTimelineWrap } from "/runtime/function-metrics-helpers"; + const useState = __nuxtTimelineWrap("useState", _$__useState); + import { ref, useState as _$__useState, useStateFoo } from '#app/composables/state';" + `) + }) + + it('wraps imports from multiple sources with a single helper import', () => { + const result = wrap( + [ + `import { useState } from '#app/composables/state';`, + `import { useHead } from '@unhead/vue';`, + ].join('\n'), + [ + ['#app/composables/state', 'useState'], + ['@unhead/vue', 'useHead'], + ], + ) + expect(result).toMatchInlineSnapshot(` + "import { __nuxtTimelineWrap } from "/runtime/function-metrics-helpers"; + const useState = __nuxtTimelineWrap("useState", _$__useState); + const useHead = __nuxtTimelineWrap("useHead", _$__useHead); + import { useState as _$__useState } from '#app/composables/state'; + import { useHead as _$__useHead } from '@unhead/vue';" + `) + }) + + it('keeps default imports untouched while wrapping named ones', () => { + const result = wrap( + `import myDefault, { useState } from '#app/composables/state';`, + [['#app/composables/state', 'useState']], + ) + expect(result).toMatchInlineSnapshot(` + "import { __nuxtTimelineWrap } from "/runtime/function-metrics-helpers"; + const useState = __nuxtTimelineWrap("useState", _$__useState); + import myDefault, { useState as _$__useState } from '#app/composables/state';" + `) + }) + + it('wraps a default import under its local name', () => { + const result = wrap( + `import myComposable from '~/composables/myComposable';`, + [['~/composables/myComposable', 'default']], + ) + expect(result).toMatchInlineSnapshot(` + "import { __nuxtTimelineWrap } from "/runtime/function-metrics-helpers"; + const myComposable = __nuxtTimelineWrap("myComposable", _$__myComposable); + import _$__myComposable from '~/composables/myComposable';" + `) + }) + + it('ignores imports from other sources', () => { + expect(wrap( + `import { useState } from '@vueuse/core';`, + [['#app/composables/state', 'useState']], + )).toBeUndefined() + }) + + it('ignores re-exports', () => { + expect(wrap( + `export { useState } from '#app/composables/state';`, + [['#app/composables/state', 'useState']], + )).toBeUndefined() + }) + + it('ignores code without imports', () => { + expect(wrap('const useState = () => {}', [['#app/composables/state', 'useState']])).toBeUndefined() + }) + + it('is idempotent', () => { + const code = [ + `import { useState } from '#app/composables/state';`, + `useState();`, + ].join('\n') + const wrappable: [string, string][] = [['#app/composables/state', 'useState']] + const once = wrap(code, wrappable) + expect(once).toBeDefined() + expect(wrap(once!, wrappable)).toBeUndefined() + }) +}) + +describe('shouldTransform', () => { + it.each([ + ['/app/pages/index.vue', true], + ['/app/pages/index.vue?vue&type=script&setup=true&lang.ts', true], + ['/app/composables/foo.ts', true], + ['/app/utils/bar.mjs', true], + ['/app/pages/index.vue?vue&type=style&index=0&lang.css', false], + ['/app/composables/foo.ts?macro=true', false], + ['/node_modules/some-lib/dist/index.mjs', false], + ['\0virtual:my-module', false], + ['/app/assets/style.css', false], + // a code extension in the query string must not match a non-code pathname + ['/app/assets/icon.svg?import&fallback=x.js', false], + ])('%s -> %s', (id, expected) => { + expect(shouldTransform(id)).toBe(expected) + }) +}) diff --git a/packages/devtools/vitest.config.ts b/packages/devtools/vitest.config.ts new file mode 100644 index 0000000000..3e4279747d --- /dev/null +++ b/packages/devtools/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}) diff --git a/playgrounds/tab-timeline/components/GlobalNav.vue b/playgrounds/tab-timeline/components/GlobalNav.vue index e40e8d8d9c..d8698133d2 100644 --- a/playgrounds/tab-timeline/components/GlobalNav.vue +++ b/playgrounds/tab-timeline/components/GlobalNav.vue @@ -26,6 +26,9 @@ /error + + /keyed + diff --git a/playgrounds/tab-timeline/nuxt.config.ts b/playgrounds/tab-timeline/nuxt.config.ts index bc88d51a27..b3d871c670 100644 --- a/playgrounds/tab-timeline/nuxt.config.ts +++ b/playgrounds/tab-timeline/nuxt.config.ts @@ -11,7 +11,6 @@ export default defineNuxtConfig({ clearScreen: false, }, devtools: { - enabled: false, experimental: { timeline: true, }, diff --git a/playgrounds/tab-timeline/pages/keyed.vue b/playgrounds/tab-timeline/pages/keyed.vue new file mode 100644 index 0000000000..9d2014346e --- /dev/null +++ b/playgrounds/tab-timeline/pages/keyed.vue @@ -0,0 +1,20 @@ + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6ae2b6233..080aaf2863 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -300,6 +300,9 @@ catalogs: destr: specifier: ^2.0.5 version: 2.0.5 + escape-string-regexp: + specifier: ^5.0.0 + version: 5.0.0 fast-npm-meta: specifier: ^2.1.0 version: 2.1.0 @@ -315,9 +318,15 @@ catalogs: local-pkg: specifier: ^1.2.1 version: 1.2.1 + magic-string: + specifier: ^0.30.21 + version: 0.30.21 magicast: specifier: ^0.5.3 version: 0.5.3 + mlly: + specifier: ^1.8.1 + version: 1.8.2 nostics: specifier: ^1.1.4 version: 1.1.4 @@ -333,6 +342,9 @@ catalogs: tinyglobby: specifier: ^0.2.17 version: 0.2.17 + unplugin: + specifier: ^2.3.11 + version: 2.3.11 unstorage: specifier: ^1.17.5 version: 1.17.5 @@ -542,6 +554,9 @@ importers: error-stack-parser-es: specifier: catalog:frontend version: 2.0.1 + escape-string-regexp: + specifier: catalog:prod + version: 5.0.0 fast-npm-meta: specifier: catalog:prod version: 2.1.0 @@ -560,9 +575,15 @@ importers: local-pkg: specifier: catalog:prod version: 1.2.1 + magic-string: + specifier: catalog:prod + version: 0.30.21 magicast: specifier: catalog:prod version: 0.5.3 + mlly: + specifier: catalog:prod + version: 1.8.2 ohash: specifier: catalog:frontend version: 2.0.11 @@ -590,6 +611,9 @@ importers: tinyglobby: specifier: catalog:prod version: 0.2.17 + unplugin: + specifier: catalog:prod + version: 2.3.11 unstorage: specifier: catalog:prod version: 1.17.5(db0@0.3.4)(ioredis@5.11.1) @@ -639,6 +663,9 @@ importers: '@iconify-json/tabler': specifier: catalog:icons version: 1.2.35 + '@nuxt/schema': + specifier: catalog:types + version: 4.4.8 '@nuxt/test-utils': specifier: catalog:cli version: 4.0.3(@playwright/test@1.61.1)(@vitest/ui@4.1.10)(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.1)(magicast@0.5.3)(playwright-core@1.61.1)(rolldown@1.1.5)(rollup@4.62.2)(typescript@6.0.3)(vite@8.0.16)(vitest@4.1.10)(webpack@5.108.4(esbuild@0.28.1)(lightningcss@1.32.0)(postcss@8.5.18)) @@ -756,6 +783,9 @@ importers: vis-network: specifier: catalog:frontend version: 10.1.0(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0)(keycharm@0.4.0)(uuid@14.0.1)(vis-data@8.0.4(uuid@14.0.1)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0)) + vitest: + specifier: catalog:cli + version: 4.1.10(@types/node@26.1.1)(@vitest/ui@4.1.10)(vite@8.0.16) vue-tsc: specifier: ^3.3.7 version: 3.3.7(typescript@6.0.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7b88ab50af..d2f4f957bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -164,18 +164,22 @@ catalogs: consola: ^3.4.2 defu: ^6.1.7 destr: ^2.0.5 + escape-string-regexp: ^5.0.0 fast-npm-meta: ^2.1.0 get-port-please: ^3.2.0 image-meta: ^0.2.2 launch-editor: ^2.14.1 local-pkg: ^1.2.1 + magic-string: ^0.30.21 magicast: ^0.5.3 + mlly: ^1.8.1 nostics: ^1.1.4 pkg-types: ^2.3.1 semver: ^7.8.5 sirv: ^3.0.2 tinyexec: ^1.2.4 tinyglobby: ^0.2.17 + unplugin: ^2.3.11 unstorage: ^1.17.5 vite-plugin-inspect: ^12.0.2 vite-plugin-vue-tracer: ^1.4.0 diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index f68c4ea289..604776887a 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -5,12 +5,14 @@ import { matchesProjectFilter } from './shared/glob' const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)) -const PLAYGROUNDS = ['empty', 'spa', 'tab-pinia', 'tab-seo'] as const +const PLAYGROUNDS = ['empty', 'spa', 'tab-pinia', 'tab-seo', 'tab-timeline'] as const const MODES = ['dev', 'built'] as const type Mode = typeof MODES[number] type Playground = typeof PLAYGROUNDS[number] +const DEV_ONLY_PLAYGROUNDS: readonly Playground[] = ['tab-timeline'] + interface Spec { name: string playground: Playground @@ -19,12 +21,13 @@ interface Spec { } const allSpecs: Spec[] = PLAYGROUNDS.flatMap((playground, idx) => - MODES.map((mode): Spec => ({ - name: `${playground}:${mode}`, - playground, - mode, - port: 13000 + idx * 10 + (mode === 'dev' ? 0 : 1), - })), + MODES.filter(mode => mode === 'dev' || !DEV_ONLY_PLAYGROUNDS.includes(playground)) + .map((mode): Spec => ({ + name: `${playground}:${mode}`, + playground, + mode, + port: 13000 + idx * 10 + (mode === 'dev' ? 0 : 1), + })), ) // PW_PROJECT supports glob-style filtering (e.g. `*:dev`, `empty:*`, `empty:dev`). diff --git a/tests/e2e/specs/playground-tab-timeline.spec.ts b/tests/e2e/specs/playground-tab-timeline.spec.ts new file mode 100644 index 0000000000..698522da73 --- /dev/null +++ b/tests/e2e/specs/playground-tab-timeline.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '../fixtures/devtools' + +test.skip( + ({ playground }) => playground !== 'tab-timeline', + 'tab-timeline playground only', +) + +test('does not break key injection of keyed composables', async ({ page }) => { + await page.goto('/keyed') + await expect(page.getByTestId('counter')).toHaveText('counter: 1') + await expect(page.getByTestId('data')).toHaveText('async data') +}) + +test('applies both key injection and timeline wrapping to the module', async ({ page }) => { + await page.goto('/keyed') + const res = await page.request.get('/_nuxt/pages/keyed.vue') + expect(res.ok()).toBe(true) + const code = await res.text() + expect(code).toContain('__nuxtTimelineWrap("useState", _$__useState)') + expect(code).toContain('__nuxtTimelineWrap("callOnce", _$__callOnce)') + expect(code).toContain('/* nuxt-injected */') +})