diff --git a/.github/workflows/docs-screenshots.yml b/.github/workflows/docs-screenshots.yml new file mode 100644 index 0000000000..ad15360720 --- /dev/null +++ b/.github/workflows/docs-screenshots.yml @@ -0,0 +1,56 @@ +name: docs-screenshots + +# Regenerates the screenshots in `docs/images/` (`pnpm docs:screenshots`) and +# opens a pull request when they changed. Run it from the Actions tab after a +# UI change; one runner keeps the rendering consistent across runs. +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + screenshots: + if: github.repository == 'nuxt/devtools' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: lts/* + cache: pnpm + - run: npm install -g corepack@latest + - run: corepack enable + - run: pnpm install + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm docs:screenshots + - name: Open a pull request + env: + GH_TOKEN: ${{ github.token }} + BASE: ${{ github.ref_name }} + BRANCH: docs/screenshots-${{ github.run_id }} + run: | + if [ -z "$(git status --porcelain -- docs/images)" ]; then + echo "Screenshots are up to date." + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git switch -c "$BRANCH" + git add docs/images + git commit -m 'docs: update screenshots' + git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + gh pr create --base "$BASE" --head "$BRANCH" \ + --title 'docs: update screenshots' \ + --body "Regenerated by \`pnpm docs:screenshots\` in the [docs-screenshots workflow](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})." diff --git a/docs/1.features.md b/docs/1.features.md index 90c9942480..e069ac6eda 100644 --- a/docs/1.features.md +++ b/docs/1.features.md @@ -53,10 +53,14 @@ Appears while the current page holds an error, and shows it with its stack trace Shows the state and data payloads of your page: keyed state from `useState()` and the data fetched with `useAsyncData()` and `useFetch()`, which you can re-fetch. +![Payload tab](./images/payload.webp) + ### Runtime Configs Shows your App Config and runtime config. App Config and public runtime config can be edited live; private runtime config is read-only, as it is never exposed to the client. +![Runtime Configs tab](./images/runtime-configs.webp) + ### Plugins Shows the plugins of your app, with their mode (client or server) and initialization time. @@ -65,6 +69,8 @@ Shows the plugins of your app, with their mode (client or server) and initializa Records when auto-imported composables run and when the route changes, to help you understand the order in which things happen. It is opt-in: until it is enabled, the tab offers to enable it for you, which updates your `nuxt.config` and restarts Nuxt. +![Timeline tab](./images/timeline.webp) + ```ts [nuxt.config.ts] export default defineNuxtConfig({ devtools: { @@ -95,14 +101,20 @@ The Open Graph panel, powered by [`@devframes/plugin-og`](https://npmx.dev/packa Shows all the [Nitro](https://nitro.build) routes of your project, with a playground to send requests to your endpoints and inspect their responses. +![Server Routes tab](./images/server-routes.webp) + ### Server Tasks Appears when Nitro's `experimental.tasks` is enabled and your project defines [tasks](https://nitro.build/guide/tasks). Pass task payloads, run them, and see their results. +![Server Tasks tab](./images/server-tasks.webp) + ### Storage Shows the [Nitro storage](https://nitro.build/guide/storage) mounts of your project. You can create, edit, and delete their items. +![Storage tab](./images/storage.webp) + ## Modules Tabs contributed by the Nuxt modules you install (for example `@nuxt/fonts`, `@nuxt/scripts` or `nuxt-og-image`) appear in this section. See the [Module Authors Guide](/modules/devtools/module-authors) to add your own. diff --git a/docs/8.contributing.md b/docs/8.contributing.md index bfcac39bb6..1b276c0958 100644 --- a/docs/8.contributing.md +++ b/docs/8.contributing.md @@ -78,4 +78,14 @@ On the module side, the source TypeScript files are used directly, so you don't The documentation lives in the [`docs/`](https://github.com/nuxt/devtools/tree/main/docs) directory and is published on [nuxt.com/modules/devtools](/modules/devtools). Keep images next to the pages in `docs/images/` and link them with a relative path, such as `![Overview tab](./images/overview.webp)`. +The DevTools screenshots are generated from the [`docs` playground](https://github.com/nuxt/devtools/tree/main/playgrounds/docs), a small shop with pages, components, server routes, a task and a storage mount. After a UI change, regenerate them with: + +```sh +pnpm build +pnpm exec playwright install chromium +pnpm docs:screenshots +``` + +Each screenshot is a step in [`tests/docs-screenshots/screenshots.spec.ts`](https://github.com/nuxt/devtools/blob/main/tests/docs-screenshots/screenshots.spec.ts): add one there to document a new tab. The `docs-screenshots` workflow, run from the Actions tab, does the same on CI and opens a pull request with the updated images. + To preview your changes, run the [nuxt.com](https://github.com/nuxt/nuxt.com) dev server with `NUXT_DEVTOOLS_PATH` set to your local clone of this repo. Pull requests changing `docs/` can also be previewed on nuxt.com once a maintainer adds the `preview:enabled` label. diff --git a/docs/images/components.webp b/docs/images/components.webp index 19eeb4f559..7b11edb883 100644 Binary files a/docs/images/components.webp and b/docs/images/components.webp differ diff --git a/docs/images/hooks.webp b/docs/images/hooks.webp index 31201e7a2c..d5c64e0705 100644 Binary files a/docs/images/hooks.webp and b/docs/images/hooks.webp differ diff --git a/docs/images/imports.webp b/docs/images/imports.webp index b4ddfe6e66..f4c4d68dc6 100644 Binary files a/docs/images/imports.webp and b/docs/images/imports.webp differ diff --git a/docs/images/modules.webp b/docs/images/modules.webp index ad5948a1de..9653f69024 100644 Binary files a/docs/images/modules.webp and b/docs/images/modules.webp differ diff --git a/docs/images/overview.webp b/docs/images/overview.webp index 595569cccd..46c78ab91a 100644 Binary files a/docs/images/overview.webp and b/docs/images/overview.webp differ diff --git a/docs/images/pages.webp b/docs/images/pages.webp index 5ab10887bb..1f032ca844 100644 Binary files a/docs/images/pages.webp and b/docs/images/pages.webp differ diff --git a/docs/images/payload.webp b/docs/images/payload.webp new file mode 100644 index 0000000000..a4d3ef0dd5 Binary files /dev/null and b/docs/images/payload.webp differ diff --git a/docs/images/runtime-configs.webp b/docs/images/runtime-configs.webp new file mode 100644 index 0000000000..b9f80a37f7 Binary files /dev/null and b/docs/images/runtime-configs.webp differ diff --git a/docs/images/server-routes.webp b/docs/images/server-routes.webp new file mode 100644 index 0000000000..a0ee854fe4 Binary files /dev/null and b/docs/images/server-routes.webp differ diff --git a/docs/images/server-tasks.webp b/docs/images/server-tasks.webp new file mode 100644 index 0000000000..e88fdf45ff Binary files /dev/null and b/docs/images/server-tasks.webp differ diff --git a/docs/images/storage.webp b/docs/images/storage.webp new file mode 100644 index 0000000000..867ec4ca05 Binary files /dev/null and b/docs/images/storage.webp differ diff --git a/docs/images/timeline.webp b/docs/images/timeline.webp new file mode 100644 index 0000000000..c3c2a2afc9 Binary files /dev/null and b/docs/images/timeline.webp differ diff --git a/knip.jsonc b/knip.jsonc index 2aabd1947d..ced8214ddc 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -9,7 +9,7 @@ "local.ts", "scripts/*.{js,mjs,ts}", "taze.config.ts", - "tests/e2e/**/*.ts" + "tests/{e2e,docs-screenshots}/**/*.ts" ], "project": [ "*.{js,mjs,ts}", diff --git a/package.json b/package.json index c3c430fc0d..f6b2041f04 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:e2e:prebuild": "pnpm -C playgrounds/empty exec nuxt build && pnpm -C playgrounds/spa exec nuxt build", "test:e2e:ui": "playwright test --config tests/e2e/playwright.config.ts --ui", "test:e2e:ecosystem": "playwright test --config playgrounds-ecosystem/tests/playwright.config.ts", + "docs:screenshots": "playwright test --config tests/docs-screenshots/playwright.config.ts", "typecheck": "vue-tsc --noEmit", "postinstall": "simple-git-hooks && skills experimental_sync -y -r", "dev:prepare": "pnpm -r --filter=\"./packages/*\" run dev:prepare" diff --git a/playgrounds/docs/app/app.config.ts b/playgrounds/docs/app/app.config.ts new file mode 100644 index 0000000000..644f7ba444 --- /dev/null +++ b/playgrounds/docs/app/app.config.ts @@ -0,0 +1,6 @@ +export default defineAppConfig({ + theme: { + primary: 'green', + }, + announcement: 'Free shipping on orders over €50', +}) diff --git a/playgrounds/docs/app/app.vue b/playgrounds/docs/app/app.vue new file mode 100644 index 0000000000..f8eacfa737 --- /dev/null +++ b/playgrounds/docs/app/app.vue @@ -0,0 +1,5 @@ + diff --git a/playgrounds/docs/app/assets/main.css b/playgrounds/docs/app/assets/main.css new file mode 100644 index 0000000000..5e5d40761f --- /dev/null +++ b/playgrounds/docs/app/assets/main.css @@ -0,0 +1,9 @@ +html { + background: #0b0b0b; + color: #e5e5e5; + font-family: ui-sans-serif, system-ui, sans-serif; +} + +body { + margin: 0; +} diff --git a/playgrounds/docs/app/components/AppHeader.vue b/playgrounds/docs/app/components/AppHeader.vue new file mode 100644 index 0000000000..e60f3021ff --- /dev/null +++ b/playgrounds/docs/app/components/AppHeader.vue @@ -0,0 +1,22 @@ + + + diff --git a/playgrounds/docs/app/components/CartButton.vue b/playgrounds/docs/app/components/CartButton.vue new file mode 100644 index 0000000000..ac6c40d50b --- /dev/null +++ b/playgrounds/docs/app/components/CartButton.vue @@ -0,0 +1,7 @@ + + + diff --git a/playgrounds/docs/app/components/PriceTag.vue b/playgrounds/docs/app/components/PriceTag.vue new file mode 100644 index 0000000000..d93770a931 --- /dev/null +++ b/playgrounds/docs/app/components/PriceTag.vue @@ -0,0 +1,9 @@ + + + diff --git a/playgrounds/docs/app/components/ProductCard.vue b/playgrounds/docs/app/components/ProductCard.vue new file mode 100644 index 0000000000..eb799130b1 --- /dev/null +++ b/playgrounds/docs/app/components/ProductCard.vue @@ -0,0 +1,24 @@ + + + diff --git a/playgrounds/docs/app/composables/useCart.ts b/playgrounds/docs/app/composables/useCart.ts new file mode 100644 index 0000000000..74601914c6 --- /dev/null +++ b/playgrounds/docs/app/composables/useCart.ts @@ -0,0 +1,9 @@ +export function useCart() { + const items = useState('cart', () => []) + + return { + items, + count: computed(() => items.value.length), + add: (id: number) => items.value.push(id), + } +} diff --git a/playgrounds/docs/app/composables/useProducts.ts b/playgrounds/docs/app/composables/useProducts.ts new file mode 100644 index 0000000000..6ec5dc680e --- /dev/null +++ b/playgrounds/docs/app/composables/useProducts.ts @@ -0,0 +1,3 @@ +export function useProducts() { + return useFetch('/api/products', { key: 'products' }) +} diff --git a/playgrounds/docs/app/layouts/default.vue b/playgrounds/docs/app/layouts/default.vue new file mode 100644 index 0000000000..789834025b --- /dev/null +++ b/playgrounds/docs/app/layouts/default.vue @@ -0,0 +1,8 @@ + diff --git a/playgrounds/docs/app/middleware/analytics.global.ts b/playgrounds/docs/app/middleware/analytics.global.ts new file mode 100644 index 0000000000..0282ddf624 --- /dev/null +++ b/playgrounds/docs/app/middleware/analytics.global.ts @@ -0,0 +1,3 @@ +export default defineNuxtRouteMiddleware(() => { + useState('page-views', () => 0).value++ +}) diff --git a/playgrounds/docs/app/middleware/auth.ts b/playgrounds/docs/app/middleware/auth.ts new file mode 100644 index 0000000000..6c9dba14dd --- /dev/null +++ b/playgrounds/docs/app/middleware/auth.ts @@ -0,0 +1,3 @@ +export default defineNuxtRouteMiddleware(() => { + // Every visitor may browse the shop; a real app would check a session here. +}) diff --git a/playgrounds/docs/app/pages/about.vue b/playgrounds/docs/app/pages/about.vue new file mode 100644 index 0000000000..79c57b0a83 --- /dev/null +++ b/playgrounds/docs/app/pages/about.vue @@ -0,0 +1,10 @@ + diff --git a/playgrounds/docs/app/pages/index.vue b/playgrounds/docs/app/pages/index.vue new file mode 100644 index 0000000000..56095052da --- /dev/null +++ b/playgrounds/docs/app/pages/index.vue @@ -0,0 +1,20 @@ + + + diff --git a/playgrounds/docs/app/pages/products/[id].vue b/playgrounds/docs/app/pages/products/[id].vue new file mode 100644 index 0000000000..f9450a3c5d --- /dev/null +++ b/playgrounds/docs/app/pages/products/[id].vue @@ -0,0 +1,20 @@ + + + diff --git a/playgrounds/docs/app/pages/products/index.vue b/playgrounds/docs/app/pages/products/index.vue new file mode 100644 index 0000000000..41555994dd --- /dev/null +++ b/playgrounds/docs/app/pages/products/index.vue @@ -0,0 +1,14 @@ + + + diff --git a/playgrounds/docs/app/plugins/currency.ts b/playgrounds/docs/app/plugins/currency.ts new file mode 100644 index 0000000000..c1d0eab34f --- /dev/null +++ b/playgrounds/docs/app/plugins/currency.ts @@ -0,0 +1,7 @@ +export default defineNuxtPlugin(() => { + return { + provide: { + locale: 'en', + }, + } +}) diff --git a/playgrounds/docs/nuxt.config.ts b/playgrounds/docs/nuxt.config.ts new file mode 100644 index 0000000000..47bfac14f7 --- /dev/null +++ b/playgrounds/docs/nuxt.config.ts @@ -0,0 +1,45 @@ +// The app the docs screenshots are taken from (`pnpm docs:screenshots`): +// a small shop, so every DevTools tab has something realistic to show. +const devtoolsModule = process.env.NUXT_DEVTOOLS_LOCAL ? '../../local' : '@nuxt/devtools' + +export default defineNuxtConfig({ + modules: [ + devtoolsModule, + '@unocss/nuxt', + '@vueuse/nuxt', + ], + + devtools: { + timeline: { + enabled: true, + }, + }, + + app: { + head: { + title: 'Nuxt Shop', + htmlAttrs: { lang: 'en', class: 'dark' }, + }, + }, + + css: ['~/assets/main.css'], + + runtimeConfig: { + stripeSecretKey: '', + public: { + siteName: 'Nuxt Shop', + currency: 'EUR', + }, + }, + + nitro: { + experimental: { + tasks: true, + }, + storage: { + shop: { driver: 'fs', base: './.data/shop' }, + }, + }, + + compatibilityDate: '2026-10-01', +}) diff --git a/playgrounds/docs/package.json b/playgrounds/docs/package.json new file mode 100644 index 0000000000..1d124ab957 --- /dev/null +++ b/playgrounds/docs/package.json @@ -0,0 +1,13 @@ +{ + "version": "4.0.0", + "private": true, + "scripts": { + "play:dev": "nuxt dev" + }, + "devDependencies": { + "@nuxt/devtools": "workspace:^", + "@unocss/nuxt": "catalog:buildtools", + "@vueuse/nuxt": "catalog:buildtools", + "nuxt": "catalog:buildtools" + } +} diff --git a/playgrounds/docs/server/api/cart.post.ts b/playgrounds/docs/server/api/cart.post.ts new file mode 100644 index 0000000000..a3a1a98f0c --- /dev/null +++ b/playgrounds/docs/server/api/cart.post.ts @@ -0,0 +1,13 @@ +import { defineHandler, readBody } from 'nitro/h3' +import { useStorage } from 'nitro/storage' + +export default defineHandler(async (event) => { + const { id } = await readBody<{ id: number }>(event) ?? {} + const storage = useStorage('shop') + const cart = [ + ...await storage.getItem('cart') ?? [], + id, + ] + await storage.setItem('cart', cart) + return cart +}) diff --git a/playgrounds/docs/server/api/products/[id].get.ts b/playgrounds/docs/server/api/products/[id].get.ts new file mode 100644 index 0000000000..f8a34aedff --- /dev/null +++ b/playgrounds/docs/server/api/products/[id].get.ts @@ -0,0 +1,13 @@ +import { defineHandler, getRouterParam, HTTPError } from 'nitro/h3' +import { products } from '../../utils/products' + +export default defineHandler(async (event) => { + // A warehouse lookup, slow enough for the Timeline tab to show the request. + await new Promise(resolve => setTimeout(resolve, 600)) + + const id = Number(getRouterParam(event, 'id')) + const product = products.find(product => product.id === id) + if (!product) + throw new HTTPError({ status: 404, statusText: 'Product not found' }) + return product +}) diff --git a/playgrounds/docs/server/api/products/index.get.ts b/playgrounds/docs/server/api/products/index.get.ts new file mode 100644 index 0000000000..af4a3a6afd --- /dev/null +++ b/playgrounds/docs/server/api/products/index.get.ts @@ -0,0 +1,4 @@ +import { defineHandler } from 'nitro/h3' +import { products } from '../../utils/products' + +export default defineHandler(() => products) diff --git a/playgrounds/docs/server/tasks/products/sync.ts b/playgrounds/docs/server/tasks/products/sync.ts new file mode 100644 index 0000000000..e34225a575 --- /dev/null +++ b/playgrounds/docs/server/tasks/products/sync.ts @@ -0,0 +1,14 @@ +import { useStorage } from 'nitro/storage' +import { defineTask } from 'nitro/task' +import { products } from '../../utils/products' + +export default defineTask({ + meta: { + name: 'products:sync', + description: 'Sync the product catalog from the warehouse', + }, + async run() { + await useStorage('shop').setItem('products', products) + return { result: `${products.length} products synced` } + }, +}) diff --git a/playgrounds/docs/server/utils/products.ts b/playgrounds/docs/server/utils/products.ts new file mode 100644 index 0000000000..daae742171 --- /dev/null +++ b/playgrounds/docs/server/utils/products.ts @@ -0,0 +1,45 @@ +export interface Product { + id: number + name: string + description: string + price: number +} + +export const products: Product[] = [ + { + id: 1, + name: 'Nuxt T-Shirt', + description: 'Organic cotton, with the Nuxt logo.', + price: 25, + }, + { + id: 2, + name: 'Vue Mug', + description: 'For your morning coffee.', + price: 14, + }, + { + id: 3, + name: 'Sticker Pack', + description: 'Twelve stickers for your laptop.', + price: 6, + }, + { + id: 4, + name: 'Nitro Hoodie', + description: 'Warm and fast.', + price: 49, + }, + { + id: 5, + name: 'Vite Cap', + description: 'Lightning on your head.', + price: 19, + }, + { + id: 6, + name: 'DevTools Notebook', + description: 'Write down what you found.', + price: 9, + }, +] diff --git a/playgrounds/docs/tsconfig.json b/playgrounds/docs/tsconfig.json new file mode 100644 index 0000000000..a746f2a70c --- /dev/null +++ b/playgrounds/docs/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/playgrounds/docs/uno.config.ts b/playgrounds/docs/uno.config.ts new file mode 100644 index 0000000000..9e397feb66 --- /dev/null +++ b/playgrounds/docs/uno.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, presetAttributify, presetWind3 } from 'unocss' + +export default defineConfig({ + presets: [ + presetWind3(), + presetAttributify(), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7323a3a5df..ee327ca1d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1010,6 +1010,21 @@ importers: specifier: catalog:buildtools version: 3.6.1(typescript@6.0.3)(vue-sfc-transformer@0.3.1(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/compiler-core@3.5.44)(@vue/language-core@3.3.13)(esbuild@0.28.2)(rolldown@1.2.15)(typescript@6.0.3)(vue@3.5.44(typescript@6.0.3)))(vue-tsc@3.3.13(typescript@6.0.3))(vue@3.5.44(typescript@6.0.3)) + playgrounds/docs: + devDependencies: + '@nuxt/devtools': + specifier: workspace:* + version: link:../../packages/devtools + '@unocss/nuxt': + specifier: catalog:buildtools + version: 66.12.0(@devframes/agentic@1.2.6)(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(@oxc-project/types@0.155.0)(cac@7.1.0)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.5)(mlly@1.8.2)(ocache@0.3.0)(oxc-parser@0.153.0)(rolldown@1.2.15)(rollup@4.65.1)(srvx@1.0.6)(unplugin@3.5.0(esbuild@0.28.2)(rolldown@1.2.15)(rollup@4.65.1)(vite@8.3.5)(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.31)(svgo@4.1.0)))(vite@8.3.5)(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.31)(svgo@4.1.0)) + '@vueuse/nuxt': + specifier: catalog:buildtools + version: 15.0.0(6201695474da9df305876c91d98139a3) + nuxt: + specifier: npm:nuxt-nightly@5.0.0-2610052343-36eafab + version: nuxt-nightly@5.0.0-2610052343-36eafab(@oxc-project/types@0.155.0)(@parcel/watcher@2.6.0)(@types/node@26.6.5)(@vitejs/devtools-kit@0.8.0(@devframes/agentic@1.2.6)(cac@7.1.0)(crossws@0.4.12(srvx@0.11.22))(ocache@0.3.0)(srvx@0.11.22)(vite@8.3.5))(@vitejs/devtools@0.8.0)(@vue/compiler-sfc@3.5.44)(acorn@8.18.0)(autoprefixer@10.6.1(postcss@8.5.31))(confbox@0.2.4)(cssnano@7.1.9(postcss@8.5.31))(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.13.0(jiti@2.7.0)(supports-color@10.2.2))(giget@3.3.1)(jiti@2.7.0)(lightningcss@1.33.0)(mlly@1.8.2)(nuxt-nightly@5.0.0-2610052343-36eafab)(optionator@0.9.4)(oxc-parser@0.153.0)(rolldown@1.2.15)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.15)(rollup@4.65.1))(rollup@4.65.1)(terser@5.51.2)(tsx@4.23.15)(typescript@6.0.3)(vite@8.3.5)(vue-tsc@3.3.13(typescript@6.0.3))(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.31)(svgo@4.1.0))(yaml@2.9.1) + playgrounds/empty: devDependencies: '@types/node': @@ -1984,8 +1999,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nuxt/cli-nightly@4.0.1-2610242016-93315b9': - resolution: {integrity: sha512-YPPrOjgcKAcqx/KiCtI3iirM/xkXE7of0YPQYzUqm4T/Oqr48F4mkdRxW5lChgcuyEPmVo4bV/R4V900n4ICmg==} + '@nuxt/cli-nightly@4.0.1-2610271322-c9a6b02': + resolution: {integrity: sha512-JegF1SevQkK+xeiDuMvVDDLieevUyqruQTbaTcJyhozAVZRP6T39fmMnm0SoBP91b36Ed7jXEjU9WsyNRubh5w==} engines: {node: ^22.21.0 || ^24.11.0 || >=26.0.0} hasBin: true peerDependencies: @@ -6823,6 +6838,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@1.0.8: + resolution: {integrity: sha512-ngFS/J6St4PJfHRHGjr78xkPPtbyXtdMOLNE4x/XZunEK3OhV7aGUWMHf9WTUEy6wm1U0W4okkd82ZFFWRVQTw==} + engines: {node: '>=20.16.0'} + hasBin: true + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -8741,7 +8761,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 - '@nuxt/cli-nightly@4.0.1-2610242016-93315b9(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(jiti@2.7.0)(oxc-parser@0.153.0)(rolldown@1.2.15)(vite@8.3.5)': + '@nuxt/cli-nightly@4.0.1-2610271322-c9a6b02(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(jiti@2.7.0)(oxc-parser@0.153.0)(rolldown@1.2.15)(vite@8.3.5)': dependencies: '@clack/prompts': 1.8.1 args-tokenizer: 0.3.0 @@ -8760,7 +8780,7 @@ snapshots: pkg-types: 2.4.0 rc9: 3.1.0 scule: 1.3.0 - srvx: 1.0.6 + srvx: 1.0.8 std-env: 4.3.0 tinyclip: 1.0.3 tinyexec: 1.3.1 @@ -13644,7 +13664,7 @@ snapshots: dependencies: '@dxup/nuxt': 0.5.10(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(jiti@2.7.0)(mlly@1.8.2)(oxc-parser@0.153.0)(rolldown@1.2.15)(rollup@4.65.1)(vite@8.3.5)(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.31)(svgo@4.1.0)) '@jridgewell/trace-mapping': 0.3.31 - '@nuxt/cli': '@nuxt/cli-nightly@4.0.1-2610242016-93315b9(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(jiti@2.7.0)(oxc-parser@0.153.0)(rolldown@1.2.15)(vite@8.3.5)' + '@nuxt/cli': '@nuxt/cli-nightly@4.0.1-2610271322-c9a6b02(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(jiti@2.7.0)(oxc-parser@0.153.0)(rolldown@1.2.15)(vite@8.3.5)' '@nuxt/devtools': link:packages/devtools '@nuxt/kit': '@nuxt/kit-nightly@5.0.0-2610052343-36eafab(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.5)(mlly@1.8.2)(oxc-parser@0.153.0)(rolldown@1.2.15)(unplugin@3.5.0(esbuild@0.28.2)(rolldown@1.2.15)(rollup@4.65.1)(vite@8.3.5)(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.31)(svgo@4.1.0)))' '@nuxt/nitro-server': '@nuxt/nitro-server-nightly@5.0.0-2610052343-36eafab(3a904335c5ea38c2ce689d6d3bd5d21f)' @@ -13766,7 +13786,7 @@ snapshots: dependencies: '@dxup/nuxt': 0.5.10(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(confbox@0.2.4)(dotenv@17.4.2)(esbuild@0.28.2)(giget@3.3.1)(jiti@2.7.0)(mlly@1.8.2)(oxc-parser@0.153.0)(rolldown@1.2.15)(rollup@4.65.1)(vite@8.3.5)(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.31)(svgo@4.1.0)) '@jridgewell/trace-mapping': 0.3.31 - '@nuxt/cli': '@nuxt/cli-nightly@4.0.1-2610242016-93315b9(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(jiti@2.7.0)(oxc-parser@0.153.0)(rolldown@1.2.15)(vite@8.3.5)' + '@nuxt/cli': '@nuxt/cli-nightly@4.0.1-2610271322-c9a6b02(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(jiti@2.7.0)(oxc-parser@0.153.0)(rolldown@1.2.15)(vite@8.3.5)' '@nuxt/devtools': link:packages/devtools '@nuxt/kit': '@nuxt/kit-nightly@5.0.0-2610052343-36eafab(@nuxt/schema-nightly@5.0.0-2610052343-36eafab)(confbox@0.2.4)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.5)(mlly@1.8.2)(oxc-parser@0.153.0)(rolldown@1.2.15)(unplugin@3.5.0(esbuild@0.28.2)(rolldown@1.2.15)(rollup@4.65.1)(vite@8.3.5)(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.31))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.31)(svgo@4.1.0)))' '@nuxt/nitro-server': '@nuxt/nitro-server-nightly@5.0.0-2610052343-36eafab(1d13569f58cf3d72e24ed25927215047)' @@ -14709,6 +14729,8 @@ snapshots: srvx@1.0.6: {} + srvx@1.0.8: {} + stable-hash-x@0.2.0: {} standard-as-callback@2.1.0: {} diff --git a/tests/docs-screenshots/playwright.config.ts b/tests/docs-screenshots/playwright.config.ts new file mode 100644 index 0000000000..615e36c298 --- /dev/null +++ b/tests/docs-screenshots/playwright.config.ts @@ -0,0 +1,49 @@ +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { defineConfig, devices } from '@playwright/test' + +const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)) +const PORT = 13200 + +/** + * Regenerates the screenshots in `docs/images/` from `playgrounds/docs`: `pnpm docs:screenshots`. + * Not part of the e2e suite: it writes files instead of asserting. + */ +export default defineConfig({ + testDir: '.', + fullyParallel: false, + workers: 1, + reporter: 'list', + // A cold dev server can fail the first Iconify fetches; a retry starts from a warm one. + retries: 1, + timeout: 120_000, + use: { + ...devices['Desktop Chrome'], + baseURL: `http://localhost:${PORT}`, + // The panel opens at 80% of the viewport: 1024×640, captured at 2x. + viewport: { width: 1280, height: 800 }, + deviceScaleFactor: 2, + colorScheme: 'dark', + actionTimeout: 15_000, + navigationTimeout: 30_000, + }, + projects: [ + { + name: 'docs', + // Read by the e2e fixtures. + metadata: { playground: 'docs', mode: 'dev' }, + }, + ], + webServer: { + command: `pnpm -C playgrounds/docs exec nuxt dev --port ${PORT}`, + cwd: REPO_ROOT, + url: `http://localhost:${PORT}/`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + // Ephemeral server: skip the browser authorization prompt. + VITE_DEVTOOLS_DISABLE_CLIENT_AUTH: 'true', + HOST: '0.0.0.0', + }, + }, +}) diff --git a/tests/docs-screenshots/screenshots.spec.ts b/tests/docs-screenshots/screenshots.spec.ts new file mode 100644 index 0000000000..0bf20602d0 --- /dev/null +++ b/tests/docs-screenshots/screenshots.spec.ts @@ -0,0 +1,192 @@ +import type { FrameLocator, Page } from '@playwright/test' +import { Buffer } from 'node:buffer' +import { writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { expect, test } from '../e2e/fixtures/devtools' + +const IMAGES_DIR = fileURLToPath(new URL('../../docs/images/', import.meta.url)) + +/** Width of the WebP files written to `docs/images/`. */ +const IMAGE_WIDTH = 1600 + +interface Shot { + /** File name in `docs/images/`, without the extension. */ + name: string + /** DevTools tab route. */ + tab: string + /** Puts the tab in the state the docs show, then waits for it. */ + prepare: (frame: FrameLocator, page: Page) => Promise +} + +function shows(text: string) { + return async (frame: FrameLocator) => { + await frame.getByText(text, { exact: true }).first().waitFor() + } +} + +const SHOTS: Shot[] = [ + { + name: 'overview', + tab: '/modules/overview', + prepare: shows('Nuxt DevTools'), + }, + { + name: 'pages', + tab: '/modules/pages', + prepare: shows('All Routes'), + }, + { + name: 'components', + tab: '/modules/components', + prepare: shows('User components'), + }, + { + name: 'imports', + tab: '/modules/imports', + prepare: shows('User composables'), + }, + { + name: 'modules', + tab: '/modules/modules', + prepare: shows('Installed Modules'), + }, + { + name: 'payload', + tab: '/modules/payload', + async prepare(frame) { + for (const key of ['cart', 'products']) + await frame.getByRole('button', { name: key, exact: true }).click() + await frame.getByText('Nuxt T-Shirt').waitFor() + }, + }, + { + name: 'runtime-configs', + tab: '/modules/runtime-configs', + prepare: shows('Public Runtime Config'), + }, + { + name: 'timeline', + tab: '/modules/timeline', + async prepare(frame, page) { + await frame.getByRole('button', { name: 'Start Tracking' }).click() + for (const path of [ + '/products/1', + '/about', + '/products/4', + '/products', + ]) { + await page.evaluate(path => (globalThis as any).__NUXT_DEVTOOLS_HOST__.app.navigate(path), path) + await page.waitForURL(`**${path}`) + await page.waitForTimeout(500) + } + // The tab only picks up the recorded events when it renders again. + for (const tab of ['/modules/overview', '/modules/timeline']) + await page.evaluate(tab => (globalThis as any).__NUXT_DEVTOOLS_HOST__.devtools.navigate(tab), tab) + await expect(frame.locator('body')).toContainText('/products/4') + // Zoom in (Alt + wheel) so the function names fit their segments. + await frame.locator('[of-x-scroll][n-panel-grids]').dispatchEvent('wheel', { deltaY: 300, altKey: true }) + }, + }, + { + name: 'server-routes', + tab: '/modules/server-routes', + async prepare(frame) { + await frame.getByRole('button', { name: 'GET /api/products', exact: true }).click() + await frame.getByRole('button', { name: 'Send request' }).click() + await frame.getByText('Response', { exact: true }).waitFor() + }, + }, + { + name: 'server-tasks', + tab: '/modules/server-tasks', + async prepare(frame) { + await frame.getByRole('button', { name: 'products:sync' }).click() + await frame.getByRole('button', { name: 'Run task' }).click() + await frame.getByText('Result', { exact: true }).waitFor() + }, + }, + { + name: 'storage', + tab: '/modules/storage', + async prepare(frame) { + await frame.getByText('shop', { exact: true }).click() + // Written by the task run in `server-tasks`. + await frame.getByRole('button', { name: 'products', exact: true }).click() + await frame.getByText('Nuxt T-Shirt').first().waitFor() + }, + }, + { + name: 'hooks', + tab: '/modules/hooks', + prepare: shows('Server Hooks'), + }, +] + +/** The Vite DevTools panel and its dock bar, from the shadow root they are mounted in. */ +async function panelClip(page: Page) { + return page.evaluate(() => { + const root = document.querySelector('devframes-dock-embedded')!.shadowRoot! + const rects = [ + root.querySelector('#devframes-views-container')!.closest('.bg-dock-glass')!, + root.querySelector('#devframes-dock')!, + ].map(el => el.getBoundingClientRect()) + const x = Math.floor(Math.min(...rects.map(r => r.left))) + const y = Math.floor(Math.min(...rects.map(r => r.top))) + return { + x, + y, + width: Math.ceil(Math.max(...rects.map(r => r.right))) - x, + height: Math.ceil(Math.max(...rects.map(r => r.bottom))) - y, + } + }) +} + +/** Chromium encodes the WebP, so the script needs no image dependency. */ +async function toWebp(page: Page, png: Buffer): Promise { + const data = await page.evaluate(async ({ png, width }) => { + const image = new Image() + image.src = `data:image/png;base64,${png}` + await image.decode() + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = Math.round(image.height * width / image.width) + const context = canvas.getContext('2d')! + context.imageSmoothingQuality = 'high' + context.drawImage(image, 0, 0, canvas.width, canvas.height) + return canvas.toDataURL('image/webp', 0.85).split(',')[1]! + }, { + png: png.toString('base64'), + width: IMAGE_WIDTH, + }) + + return Buffer.from(data, 'base64') +} + +test('docs screenshots', async ({ page, openDevTools, navigateTab, devtoolsFrame }) => { + // Something in the cart, for the Payload tab; retried until the page has hydrated. + await page.goto('/products') + await expect(async () => { + await page.getByRole('button', { name: 'Add to cart' }).first().click() + await expect(page.getByText('Cart (0)')).toBeHidden({ timeout: 1000 }) + }).toPass() + + await openDevTools() + // Only the panel is captured; hide the app so it does not show around the dock bar. + await page.addStyleTag({ content: '#__nuxt { visibility: hidden }' }) + + for (const shot of SHOTS) { + await test.step(shot.name, async () => { + await navigateTab(shot.tab) + await shot.prepare(devtoolsFrame(), page) + // Tab icons are fetched from the Iconify API; a failed fetch shows a warning icon instead. + await page.waitForLoadState('networkidle') + await expect(page.locator('devframes-dock-embedded [class*="warning-duotone"]')).toHaveCount(0) + // Away from the panel: no hover state in the capture. + await page.mouse.move(1279, 799) + await page.waitForTimeout(500) + + const png = await page.screenshot({ clip: await panelClip(page), animations: 'disabled', caret: 'hide' }) + await writeFile(`${IMAGES_DIR}${shot.name}.webp`, await toWebp(page, png)) + }) + } +})