From c9619618eac049a453137f9cb326a6247f62249f Mon Sep 17 00:00:00 2001 From: Brian Muenzenmeyer Date: Tue, 22 Sep 2026 21:51:46 -0500 Subject: [PATCH 1/6] fix: keep the scheme in `origin` so local builds use http #143 moved the scheme into `baseURL` as a hard-coded `https://`, so local builds now point their assets at https://localhost:3000, which `serve` doesn't answer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AkQ47hh78xyt4wWG9GVL5t --- doc-kit.config.mjs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/doc-kit.config.mjs b/doc-kit.config.mjs index 3e4f3b3..34dab35 100644 --- a/doc-kit.config.mjs +++ b/doc-kit.config.mjs @@ -1,11 +1,14 @@ import { join } from 'node:path'; +// The full origin (scheme included) lives here, and only here, so `baseURL` +// below never has to guess the protocol. Local builds are served over plain +// HTTP (see CONTRIBUTING.md), previews and production over HTTPS. const origin = process.env.VERCEL_ENV === 'preview' - ? process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` : process.env.VERCEL_ENV === 'production' - ? 'nodejs.org' - : 'localhost:3000'; + ? 'https://nodejs.org' + : 'http://localhost:3000'; /** @type {import('@doc-kit/core/utils/configuration/types.d.ts').Configuration} */ export default { @@ -15,7 +18,7 @@ export default { global: { output: 'out/learn', input: ['pages/**/*.md'], - baseURL: `https://${origin}/learn`, + baseURL: `${origin}/learn`, // The preset documents the runtime itself, so it points these at // nodejs/node. Learn is its own repository, and it has no use for the From 72d6846514b4304b35ead7c9bdc6286216721e63 Mon Sep 17 00:00:00 2001 From: Brian Muenzenmeyer Date: Tue, 22 Sep 2026 21:51:47 -0500 Subject: [PATCH 2/6] fix: hydrate the theme toggle Since the doc-kit 2 migration (#138) the navbar rendered the ui-components ThemeToggle directly. Only islands hydrate, so the button never opened. Use doc-kit's ThemeToggle island instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AkQ47hh78xyt4wWG9GVL5t --- components/Navigation/index.jsx | 45 +++++++++++++++------------------ 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/components/Navigation/index.jsx b/components/Navigation/index.jsx index e6abacd..e68673b 100644 --- a/components/Navigation/index.jsx +++ b/components/Navigation/index.jsx @@ -1,37 +1,32 @@ -import ThemeToggle from '@node-core/ui-components/Common/ThemeToggle'; import NavBar from '@node-core/ui-components/Containers/NavBar'; import styles from '@node-core/ui-components/Containers/NavBar/index.module.css'; import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub'; import SearchBox from '@doc-kit/generator-react/html/ui/components/SearchBox/index.jsx'; -import { useTheme } from '@doc-kit/generator-react/html/ui/hooks/useTheme.mjs'; +// doc-kit's ThemeToggle is registered as an island, so it hydrates on the +// client. Rendering `@node-core/ui-components`' toggle directly here leaves a +// static button that never opens. +import ThemeToggle from '@doc-kit/generator-react/html/ui/components/ThemeToggle.jsx'; import { navigation } from '../../site.json' with { type: 'json' }; import Logo from '#theme/Logo'; /** * NavBar component that displays the headings, search, etc. */ -export default ({ metadata }) => { - const [themePreference, setThemePreference] = useTheme(); - - return ( - ( + + + + - - - - - - - ); -}; + + + +); From b76c629adb45e6ba3d5f6f17ce85253a59d35d11 Mon Sep 17 00:00:00 2001 From: Brian Muenzenmeyer Date: Tue, 22 Sep 2026 21:51:48 -0500 Subject: [PATCH 3/6] test: add Playwright smoke tests against Vercel previews Ports nodejs.org's playwright.yml and adds tests for asset URLs, the sitemap, navigation, search and the theme toggle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AkQ47hh78xyt4wWG9GVL5t --- .github/workflows/playwright.yml | 81 +++ .gitignore | 6 + CONTRIBUTING.md | 18 +- eslint.config.mjs | 9 +- package-lock.json | 975 +++++++++++++++++++++++++++---- package.json | 4 + playwright.config.mjs | 37 ++ tests/e2e/assets.spec.mjs | 50 ++ tests/e2e/helpers.mjs | 62 ++ tests/e2e/interactions.spec.mjs | 97 +++ tests/e2e/sitemap.spec.mjs | 52 ++ 11 files changed, 1289 insertions(+), 102 deletions(-) create mode 100644 .github/workflows/playwright.yml create mode 100644 playwright.config.mjs create mode 100644 tests/e2e/assets.spec.mjs create mode 100644 tests/e2e/helpers.mjs create mode 100644 tests/e2e/interactions.spec.mjs create mode 100644 tests/e2e/sitemap.spec.mjs diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..94b7af7 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,81 @@ +# Security Notes +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. +# REVIEWERS, please always double-check security practices before merging a PR that contains workflow changes!! +# AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags. + +name: Playwright Tests + +# Runs end-to-end tests against the Vercel preview of each pull request, so the +# site is exercised exactly as Vercel builds it (VERCEL_ENV=preview), not just +# as it builds locally. Ported from nodejs/nodejs.org's playwright.yml. +# +# Unlike nodejs.org, Dependabot PRs are NOT skipped here: dependency bumps +# (e.g. doc-kit, #138) are one of the likeliest ways for Learn to break. + +on: + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + +jobs: + playwright: + name: Playwright Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Capture Vercel Preview + id: deployment + uses: patrickedqvist/wait-for-vercel-preview@d7982701e6fcd3ae073bff929e408e004404d38d # v1.3.3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + max_timeout: 300 # timeout after 5 minutes + check_interval: 10 # check every 10 seconds + + - name: Get Playwright version + id: playwright-version + run: echo "version=$(node_modules/.bin/playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright Browsers + run: node_modules/.bin/playwright install --with-deps chromium + + - name: Run Playwright tests + run: node --run test:e2e + env: + PLAYWRIGHT_BASE_URL: ${{ steps.deployment.outputs.url }} + + - name: Upload Playwright test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report + path: playwright-report/ diff --git a/.gitignore b/.gitignore index 9209ef5..48cc4f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ node_modules out + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf3e5b5..5f2e0a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,12 +54,28 @@ npm run build The output is written to `out/`. Start a local server from a separate terminal: ```bash -npx serve out +npm run serve ``` Open to preview the site. Run `npm run build` again after making changes to update the generated output. +## Running the end-to-end tests + +The [Playwright](https://playwright.dev) tests in `tests/e2e/` check that the +built site loads its assets, and that navigation, search and the theme toggle +work. They run against the Vercel preview of every pull request. To run them +locally against your own build: + +```bash +npm run build +npx playwright install chromium # first time only +npm run test:e2e +``` + +The tests start `npm run serve` for you, or reuse a server already running on +port 3000. Set `PLAYWRIGHT_BASE_URL` to test a deployed copy instead. + ## Code of Conduct This project follows the diff --git a/eslint.config.mjs b/eslint.config.mjs index 352822a..a904c52 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,7 @@ import { defineConfig } from 'eslint/config'; import * as mdx from 'eslint-plugin-mdx'; export default defineConfig( - { ignores: ['out'] }, + { ignores: ['out', 'playwright-report', 'test-results'] }, eslint.configs.recommended, tseslint.configs.recommended, mdx.flatCodeBlocks, @@ -14,6 +14,13 @@ export default defineConfig( globals: globals.nodeBuiltin, }, }, + { + // Callbacks passed to `page.evaluate()` run in the browser + files: ['tests/e2e/**/*.mjs'], + languageOptions: { + globals: { ...globals.nodeBuiltin, ...globals.browser }, + }, + }, { files: ['**/*.{md,mdx}/*.cjs'], rules: { diff --git a/package-lock.json b/package-lock.json index 7e16cbd..777d0a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@eslint/js": "^10.0.1", "@node-core/doc-kit": "2.0.2", "@node-core/remark-lint": "^1.3.0", + "@playwright/test": "1.63.0", "eslint": "^10.9.1", "eslint-plugin-mdx": "^3.8.1", "globals": "^17.11.0", @@ -22,6 +23,7 @@ "lint-staged": "^17.5.1", "prettier": "^3.9.6", "remark-frontmatter": "^5.0.0", + "serve": "14.2.6", "typescript-eslint": "^8.70.0" }, "engines": { @@ -671,9 +673,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -691,9 +690,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -711,9 +707,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -731,9 +724,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -751,9 +741,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -771,9 +758,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -791,9 +775,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1571,9 +1552,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1591,9 +1569,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1611,9 +1586,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1631,9 +1603,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1651,9 +1620,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1671,9 +1637,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1691,9 +1654,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1711,9 +1671,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1844,6 +1801,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", @@ -2750,9 +2723,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2770,9 +2740,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2790,9 +2757,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2810,9 +2774,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2830,9 +2791,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2850,9 +2808,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3629,6 +3584,13 @@ } } }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -3679,6 +3641,61 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -3705,6 +3722,34 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3761,6 +3806,42 @@ "dev": true, "license": "ISC" }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -3781,6 +3862,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3791,6 +3882,19 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -3801,6 +3905,81 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3863,6 +4042,37 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/collapse-white-space": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", @@ -3914,6 +4124,62 @@ "node": ">=22.12.0" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -3930,6 +4196,16 @@ "typedarray": "^0.0.6" } }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cosmiconfig": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", @@ -4074,6 +4350,16 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4496,6 +4782,37 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4523,6 +4840,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fault": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", @@ -4656,6 +4990,19 @@ "node": ">=6" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -4744,6 +5091,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -4984,6 +5341,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -5112,6 +5479,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-empty": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-empty/-/is-empty-1.2.0.tgz", @@ -5158,22 +5541,61 @@ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", "license": "MIT", "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5425,9 +5847,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5449,9 +5868,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5473,9 +5889,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5497,9 +5910,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6063,6 +6473,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -6804,6 +7221,49 @@ ], "license": "MIT" }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -6820,6 +7280,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -6862,6 +7332,16 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/nopt": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", @@ -6948,6 +7428,19 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -6961,6 +7454,32 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -7156,6 +7675,13 @@ "node": ">=8" } }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -7183,6 +7709,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7216,6 +7749,35 @@ "@napi-rs/nice": "^1.0.4" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -7381,6 +7943,39 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -7617,6 +8212,30 @@ "dev": true, "license": "MIT" }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rehype-raw": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", @@ -8535,6 +9154,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -8640,6 +9269,113 @@ "node": ">=10" } }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-handler/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-handler/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/serve/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/serve/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8900,6 +9636,26 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -9405,6 +10161,17 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -9486,6 +10253,16 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -9786,9 +10563,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9806,9 +10580,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9826,9 +10597,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9846,9 +10614,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9866,9 +10631,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -9886,9 +10648,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -10017,6 +10776,22 @@ "node": ">= 8" } }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index dcbc8b0..6711acd 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "lint:fix": "eslint --fix .", "format": "prettier --write .", "format:check": "prettier --check .", + "serve": "serve out --listen 3000", + "test:e2e": "playwright test", "prepare": "husky" }, "devDependencies": { @@ -13,6 +15,7 @@ "@eslint/js": "^10.0.1", "@node-core/doc-kit": "2.0.2", "@node-core/remark-lint": "^1.3.0", + "@playwright/test": "1.63.0", "eslint": "^10.9.1", "eslint-plugin-mdx": "^3.8.1", "globals": "^17.11.0", @@ -20,6 +23,7 @@ "lint-staged": "^17.5.1", "prettier": "^3.9.6", "remark-frontmatter": "^5.0.0", + "serve": "14.2.6", "typescript-eslint": "^8.70.0" }, "dependencies": { diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..3e3f97b --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test'; + +const isCI = !!process.env.CI; + +// When PLAYWRIGHT_BASE_URL is set (CI sets it to the Vercel preview), the +// tests run against that deployment. Otherwise they build nothing themselves +// and serve the existing `out/` directory: run `npm run build` first. +const externalBaseURL = process.env.PLAYWRIGHT_BASE_URL; +const localBaseURL = 'http://localhost:3000'; + +// https://playwright.dev/docs/test-configuration +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 1 : undefined, + reporter: isCI ? [['html'], ['github']] : [['html']], + use: { + baseURL: externalBaseURL || localBaseURL, + trace: 'on-first-retry', + }, + webServer: externalBaseURL + ? undefined + : { + command: 'npm run serve', + url: `${localBaseURL}/learn`, + reuseExistingServer: !isCI, + }, + // Start with one engine; add Firefox/WebKit once the suite is stable. + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/tests/e2e/assets.spec.mjs b/tests/e2e/assets.spec.mjs new file mode 100644 index 0000000..7664b60 --- /dev/null +++ b/tests/e2e/assets.spec.mjs @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test'; + +import { representativePages, trackPageProblems } from './helpers.mjs'; + +// Guards against the class of bug from nodejs/learn#133, where a malformed +// `baseURL` produced asset URLs like `https://https//nodejs.org/learn/...` +// and every page shipped without its CSS, JS and fonts. +test.describe('Page assets', () => { + for (const path of representativePages) { + test(`${path} loads its assets from the site`, async ({ + page, + baseURL, + }) => { + const { origin } = new URL(baseURL); + const problems = trackPageProblems(page, origin); + + const response = await page.goto(path); + expect(response?.status()).toBe(200); + await page.waitForLoadState('networkidle'); + + // Every stylesheet, script and preload must point at this site's own + // `/learn/assets/` folder. A mangled origin fails here even when the + // browser can't tell us the request went nowhere. + const assetURLs = await page + .locator( + 'link[rel="stylesheet"], link[rel="modulepreload"], link[rel="preload"], script[src]' + ) + .evaluateAll(elements => + elements.map(el => el.href || el.src).filter(Boolean) + ); + + expect(assetURLs.length).toBeGreaterThan(0); + + for (const url of assetURLs) { + const { origin: assetOrigin, pathname } = new URL(url); + expect.soft(assetOrigin, url).toBe(origin); + expect.soft(pathname, url).toMatch(/^\/learn\/assets\//); + } + + // If the stylesheet didn't apply, the body falls back to the browser's + // default serif font. + const fontFamily = await page.evaluate( + () => getComputedStyle(document.body).fontFamily + ); + expect(fontFamily).toContain('Open Sans'); + + expect(problems).toEqual([]); + }); + } +}); diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs new file mode 100644 index 0000000..46e7694 --- /dev/null +++ b/tests/e2e/helpers.mjs @@ -0,0 +1,62 @@ +/** + * A handful of pages that cover the shapes Learn renders: the landing page, + * a regular article, an article with authors, and a deeply linked section. + */ +export const representativePages = [ + '/learn', + '/learn/getting-started/introduction-to-nodejs', + '/learn/getting-started/fetch', + '/learn/asynchronous-work/event-loop-timers-and-nexttick', +]; + +/** + * Starts recording anything that goes wrong while a page loads. + * + * Only failures for URLs under `/learn/` on the page's own origin count. + * The site links to (and prefetches) other nodejs.org routes like `/about`, + * which don't exist on a Learn-only preview, and third-party requests + * (avatars, analytics) are out of our hands. + * + * @param {import('@playwright/test').Page} page + * @param {string} origin + */ +export const trackPageProblems = (page, origin) => { + /** @type {string[]} */ + const problems = []; + + const isOurs = url => { + const { origin: o, pathname } = new URL(url); + return o === origin && pathname.startsWith('/learn/'); + }; + + page.on('pageerror', error => problems.push(`Uncaught: ${error.message}`)); + + page.on('requestfailed', request => { + if (isOurs(request.url())) { + problems.push( + `Failed: ${request.url()} (${request.failure()?.errorText})` + ); + } + }); + + page.on('response', response => { + if (response.status() >= 400 && isOurs(response.url())) { + problems.push(`HTTP ${response.status()}: ${response.url()}`); + } + }); + + return problems; +}; + +/** + * Waits for a doc-kit island to finish hydrating. Islands load on idle, so + * clicking one straight after navigation can hit the static server markup. + * + * @param {import('@playwright/test').Page} page + * @param {string} name The island's `data-island-name`, e.g. `ThemeToggle`. + */ +export const waitForIsland = (page, name) => + page + .locator(`is-land[data-island-name="${name}"][ready]`) + .first() + .waitFor({ state: 'attached' }); diff --git a/tests/e2e/interactions.spec.mjs b/tests/e2e/interactions.spec.mjs new file mode 100644 index 0000000..76b0165 --- /dev/null +++ b/tests/e2e/interactions.spec.mjs @@ -0,0 +1,97 @@ +import { test, expect } from '@playwright/test'; + +import { waitForIsland } from './helpers.mjs'; + +const ARTICLE = '/learn/getting-started/introduction-to-nodejs'; + +/** @param {import('@playwright/test').Page} page */ +const getTheme = page => + page.evaluate(() => document.documentElement.dataset.theme); + +/** + * @param {import('@playwright/test').Page} page + * @param {'System' | 'Light' | 'Dark'} label + */ +const selectTheme = async (page, label) => { + await waitForIsland(page, 'ThemeToggle'); + await page.getByRole('button', { name: 'Select theme' }).click(); + await page.getByRole('menuitem', { name: label }).click(); +}; + +test.describe('Navigation', () => { + test('renders an article inside the site layout', async ({ page }) => { + await page.goto(ARTICLE); + + await expect(page.getByRole('navigation').first()).toBeVisible(); + await expect(page.getByRole('main')).toBeVisible(); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Edit this page' }) + ).toHaveAttribute( + 'href', + /github\.com\/nodejs\/learn\/edit\/main\/pages\// + ); + }); + + test('sidebar links lead to other articles', async ({ page }) => { + await page.goto(ARTICLE); + + const sidebar = page.getByRole('complementary'); + const link = sidebar.getByRole('link', { + name: 'Fetching data with Node.js', + }); + const href = await link.getAttribute('href'); + + await link.click(); + + await expect(page).toHaveURL(new RegExp(`${href}$`)); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + }); + + test('unknown pages respond with 404', async ({ page }) => { + // Only the status is checked: which 404 page renders depends on the host + // (nodejs.org proxies /learn, previews and `serve` use their own). + const response = await page.goto('/learn/this-page-does-not-exist'); + expect(response?.status()).toBe(404); + }); +}); + +test.describe('Theme', () => { + test('switches between dark and light', async ({ page }) => { + await page.goto(ARTICLE); + + await selectTheme(page, 'Dark'); + expect(await getTheme(page)).toBe('dark'); + + await selectTheme(page, 'Light'); + expect(await getTheme(page)).toBe('light'); + }); + + test('keeps the chosen theme across pages', async ({ page }) => { + await page.goto(ARTICLE); + await selectTheme(page, 'Dark'); + + await page.goto('/learn/getting-started/fetch'); + expect(await getTheme(page)).toBe('dark'); + }); +}); + +test.describe('Search', () => { + test('finds articles and opens a result', async ({ page }) => { + await page.goto(ARTICLE); + await waitForIsland(page, 'SearchBox'); + + await page.getByRole('button', { name: /Start typing/ }).click(); + + // The Orama modal nests one dialog inside another, so pick the inner one. + const results = page.getByRole('dialog').last(); + await results.getByPlaceholder('Start typing...').fill('streams'); + + const firstHit = results.getByRole('link', { name: /streams/i }).first(); + await expect(firstHit).toBeVisible(); + + await firstHit.click(); + await expect(page).toHaveURL(/\/learn\/.+/); + await expect(page).not.toHaveURL(new RegExp(`${ARTICLE}$`)); + }); +}); diff --git a/tests/e2e/sitemap.spec.mjs b/tests/e2e/sitemap.spec.mjs new file mode 100644 index 0000000..f7b0938 --- /dev/null +++ b/tests/e2e/sitemap.spec.mjs @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; + +/** + * @param {import('@playwright/test').APIRequestContext} request + * @returns {Promise} Every `` in the sitemap. + */ +const getSitemapURLs = async request => { + const response = await request.get('/learn/sitemap.xml'); + expect(response.status()).toBe(200); + + const xml = await response.text(); + return [...xml.matchAll(/([^<]+)<\/loc>/g)].map(([, loc]) => loc); +}; + +test.describe('Sitemap', () => { + test('lists well-formed Learn URLs', async ({ request }) => { + const urls = await getSitemapURLs(request); + expect(urls.length).toBeGreaterThan(50); + + for (const url of urls) { + const { protocol, hostname, pathname } = new URL(url); + + expect.soft(protocol, url).toMatch(/^https?:$/); + // `https://https//nodejs.org/...` parses with a hostname of `https` + expect.soft(hostname, url).not.toMatch(/^https?$/); + expect.soft(pathname, url).toMatch(/^\/learn(\/|$)/); + } + }); + + test('every listed page responds with 200', async ({ request }) => { + const urls = await getSitemapURLs(request); + + // Request each path against the server under test, whatever origin the + // sitemap was built with. Redirects are followed: section indexes are + // listed as `/section/index`, which the host redirects to `/section`. + const results = await Promise.all( + urls.map(async url => { + const { pathname } = new URL(url); + const response = await request.get(pathname); + return { pathname, status: response.status() }; + }) + ); + + expect(results.filter(({ status }) => status !== 200)).toEqual([]); + }); + + test('ships the search index', async ({ request }) => { + const response = await request.get('/learn/orama-db.json'); + expect(response.status()).toBe(200); + expect(await response.json()).toHaveProperty('docs'); + }); +}); From 768830f83ac69870dfb2b4a3a3dd5038228830e9 Mon Sep 17 00:00:00 2001 From: bmuenzenmeyer Date: Tue, 22 Sep 2026 22:04:36 -0500 Subject: [PATCH 4/6] fixes playwright error --- tests/e2e/interactions.spec.mjs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/e2e/interactions.spec.mjs b/tests/e2e/interactions.spec.mjs index 76b0165..1b56f07 100644 --- a/tests/e2e/interactions.spec.mjs +++ b/tests/e2e/interactions.spec.mjs @@ -4,9 +4,15 @@ import { waitForIsland } from './helpers.mjs'; const ARTICLE = '/learn/getting-started/introduction-to-nodejs'; -/** @param {import('@playwright/test').Page} page */ -const getTheme = page => - page.evaluate(() => document.documentElement.dataset.theme); +/** + * The theme is applied in an effect after the menu item is clicked, so use a + * retrying assertion rather than reading `data-theme` once. + * + * @param {import('@playwright/test').Page} page + * @param {'light' | 'dark'} theme + */ +const expectTheme = (page, theme) => + expect(page.locator('html')).toHaveAttribute('data-theme', theme); /** * @param {import('@playwright/test').Page} page @@ -61,10 +67,10 @@ test.describe('Theme', () => { await page.goto(ARTICLE); await selectTheme(page, 'Dark'); - expect(await getTheme(page)).toBe('dark'); + await expectTheme(page, 'dark'); await selectTheme(page, 'Light'); - expect(await getTheme(page)).toBe('light'); + await expectTheme(page, 'light'); }); test('keeps the chosen theme across pages', async ({ page }) => { @@ -72,7 +78,7 @@ test.describe('Theme', () => { await selectTheme(page, 'Dark'); await page.goto('/learn/getting-started/fetch'); - expect(await getTheme(page)).toBe('dark'); + await expectTheme(page, 'dark'); }); }); From d34484538b29f8565614bf4c8d8809ff0ea14ac5 Mon Sep 17 00:00:00 2001 From: Brian Muenzenmeyer Date: Tue, 22 Sep 2026 22:26:57 -0500 Subject: [PATCH 5/6] fix: hydrate the sidebar so mobile navigation works On small screens the sidebar collapses into a dropdown, which needs JavaScript to open. Since the doc-kit 2 migration (#138) only islands hydrate, so the dropdown rendered but did nothing. Register the sidebar as an island, like Authors, and cover small screens in the e2e tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AkQ47hh78xyt4wWG9GVL5t --- components/Layout/index.jsx | 2 +- components/Sidebar/index.jsx | 15 +++++++++++--- doc-kit.config.mjs | 3 ++- tests/e2e/interactions.spec.mjs | 35 +++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/components/Layout/index.jsx b/components/Layout/index.jsx index 6d9d566..08f553e 100644 --- a/components/Layout/index.jsx +++ b/components/Layout/index.jsx @@ -27,7 +27,7 @@ export default ({ metadata, headings, readingTime, children }) => (
- +
diff --git a/components/Sidebar/index.jsx b/components/Sidebar/index.jsx index 0d88837..3eb0c6a 100644 --- a/components/Sidebar/index.jsx +++ b/components/Sidebar/index.jsx @@ -1,4 +1,5 @@ import SideBar from '@node-core/ui-components/Containers/Sidebar'; +import withIsland from '@doc-kit/generator-react/html/ui/islands/withIsland.jsx'; import { sidebar } from '../../site.json' with { type: 'json' }; /** @param {string} url */ @@ -7,14 +8,22 @@ const redirect = url => (window.location.href = url); const PrefetchLink = props => ; /** - * Sidebar component for MDX documentation with page navigation + * Sidebar component for MDX documentation with page navigation. + * + * On small screens the sidebar collapses into a dropdown, which only opens + * once hydrated. doc-kit 2.x hydrates islands and nothing else, so the sidebar + * is registered as one (see `components` in doc-kit.config.mjs). + * + * @param {{ pathname: string }} props */ -export default ({ metadata }) => ( +const Sidebar = ({ pathname }) => ( ); + +export default withIsland(Sidebar, { name: 'Sidebar', on: { idle: true } }); diff --git a/doc-kit.config.mjs b/doc-kit.config.mjs index 34dab35..f705389 100644 --- a/doc-kit.config.mjs +++ b/doc-kit.config.mjs @@ -42,9 +42,10 @@ export default { templatePath: join(import.meta.dirname, 'template.html'), generateAllPage: false, - // Registers the component as an island, so it hydrates client-side + // Registers the components as islands, so they hydrate client-side components: { Authors: join(import.meta.dirname, 'components/Authors/index.jsx'), + Sidebar: join(import.meta.dirname, 'components/Sidebar/index.jsx'), }, // Imports diff --git a/tests/e2e/interactions.spec.mjs b/tests/e2e/interactions.spec.mjs index 1b56f07..5bc1ca2 100644 --- a/tests/e2e/interactions.spec.mjs +++ b/tests/e2e/interactions.spec.mjs @@ -101,3 +101,38 @@ test.describe('Search', () => { await expect(page).not.toHaveURL(new RegExp(`${ARTICLE}$`)); }); }); + +test.describe('Small screens', () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test('the navigation dropdown leads to other articles', async ({ page }) => { + await page.goto('/learn/getting-started/fetch'); + + // The sidebar collapses into a dropdown that only works once hydrated. + await waitForIsland(page, 'Sidebar'); + await page.getByRole('combobox', { name: 'Navigation' }).click(); + + // Radix ignores a selection made in the first moments after the list + // opens, so wait for it to settle and choose with the keyboard. + const option = page.getByRole('option', { + name: 'Introduction to Node.js', + }); + await expect(option).toBeVisible(); + await option.focus(); + await page.keyboard.press('Enter'); + + await expect(page).toHaveURL(new RegExp(`${ARTICLE}$`)); + }); + + test('the menu button reveals the site links', async ({ page }) => { + await page.goto(ARTICLE); + + const blogLink = page.getByRole('navigation').getByRole('link', { + name: 'Blog', + }); + await expect(blogLink).toBeHidden(); + + await page.getByRole('button', { name: 'Toggle navigation menu' }).click(); + await expect(blogLink).toBeVisible(); + }); +}); From 881199374a43a927ff6683706f8ab09a4b9744d0 Mon Sep 17 00:00:00 2001 From: Matt Cowley Date: Wed, 23 Sep 2026 12:10:42 +0100 Subject: [PATCH 6/6] Fix repository settings link Signed-off-by: Matt Cowley --- .github/workflows/playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 94b7af7..a7727e8 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,5 +1,5 @@ # Security Notes -# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/nodejs.org/settings/actions) +# Only selected Actions are allowed within this repository. Please refer to (https://github.com/nodejs/learn/settings/actions) # for the full list of available actions. If you want to add a new one, please reach out a maintainer with Admin permissions. # REVIEWERS, please always double-check security practices before merging a PR that contains workflow changes!! # AUTHORS, please only use actions with explicit SHA references, and avoid using `@master` or `@main` references or `@version` tags.