From f59750a4d96e6b581c958e9e08356532470c5875 Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:39:49 +0900 Subject: [PATCH 1/6] test: add browser coverage for human routes --- .github/workflows/ci.yml | 6 + package-lock.json | 248 +++++++++++++++++++++++++++++++ package.json | 3 + src/routes/human.browser.spec.ts | 70 +++++++++ vite.client.config.ts | 11 +- vitest.browser.config.ts | 119 +++++++++++++++ vitest.browser.setup.ts | 40 +++++ vitest.config.ts | 3 +- 8 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 src/routes/human.browser.spec.ts create mode 100644 vitest.browser.config.ts create mode 100644 vitest.browser.setup.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 827e2102..81bf48b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,15 @@ jobs: - name: Install Dependencies run: npm ci + - name: Install Browser + run: npx playwright install --with-deps chromium --only-shell + - name: Run Tests run: npm test + - name: Run Browser Tests + run: npm run test:browser + types: runs-on: ubuntu-latest diff --git a/package-lock.json b/package-lock.json index 6da875fa..70c8f496 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,11 +37,13 @@ "@types/semver": "^7.7.1", "@types/spdx-license-ids": "^3.0.0", "@types/swagger-ui-react": "^5.18.0", + "@vitest/browser-playwright": "^4.1.2", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsdoc": "^63.0.10", "husky": "^9.1.7", "lint-staged": "^17.0.8", + "playwright": "^1.62.0", "prettier": "^3.9.1", "typescript": "^6.0.3", "typescript-eslint": "^8.62.0", @@ -405,6 +407,13 @@ "node": ">=6.9.0" } }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -1897,6 +1906,13 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -3388,6 +3404,53 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/browser": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.2.tgz", + "integrity": "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.2" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.2.tgz", + "integrity": "sha512-N0Z2HzMLvMR6k/tWPTS6Q/DaRscrkax/f2f9DIbNQr+Cd1l4W4wTf/I6S983PAMr0tNqqoTL+xNkLh9M5vbkLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.2", + "@vitest/mocker": "4.1.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.2" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", @@ -5853,6 +5916,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6177,6 +6250,63 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6806,6 +6936,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -7158,6 +7303,16 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tree-sitter": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", @@ -8000,6 +8155,12 @@ "@babel/helper-validator-identifier": "^7.28.5" } }, + "@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true + }, "@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -8810,6 +8971,12 @@ "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true }, + "@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true + }, "@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -9951,6 +10118,33 @@ } } }, + "@vitest/browser": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.2.tgz", + "integrity": "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ==", + "dev": true, + "requires": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.0", + "ws": "^8.19.0" + } + }, + "@vitest/browser-playwright": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.2.tgz", + "integrity": "sha512-N0Z2HzMLvMR6k/tWPTS6Q/DaRscrkax/f2f9DIbNQr+Cd1l4W4wTf/I6S983PAMr0tNqqoTL+xNkLh9M5vbkLg==", + "dev": true, + "requires": { + "@vitest/browser": "4.1.2", + "@vitest/mocker": "4.1.2", + "tinyrainbow": "^3.1.0" + } + }, "@vitest/expect": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", @@ -11484,6 +11678,12 @@ "brace-expansion": "^2.0.2" } }, + "mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11703,6 +11903,37 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true }, + "playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.62.0" + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + } + } + }, + "playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true + }, + "pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true + }, "possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -12096,6 +12327,17 @@ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true }, + "sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "requires": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + } + }, "slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", @@ -12358,6 +12600,12 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, + "totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true + }, "tree-sitter": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", diff --git a/package.json b/package.json index e69d82b6..412f5a40 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format": "prettier --check .", "format:fix": "prettier --write .", "test": "vitest run", + "test:browser": "vitest run --config vitest.browser.config.ts", "types": "tsc --noEmit", "dev": "wrangler dev" }, @@ -59,11 +60,13 @@ "@types/semver": "^7.7.1", "@types/spdx-license-ids": "^3.0.0", "@types/swagger-ui-react": "^5.18.0", + "@vitest/browser-playwright": "^4.1.2", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsdoc": "^63.0.10", "husky": "^9.1.7", "lint-staged": "^17.0.8", + "playwright": "^1.62.0", "prettier": "^3.9.1", "typescript": "^6.0.3", "typescript-eslint": "^8.62.0", diff --git a/src/routes/human.browser.spec.ts b/src/routes/human.browser.spec.ts new file mode 100644 index 00000000..ab5ddae0 --- /dev/null +++ b/src/routes/human.browser.spec.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; + +const openHumanRoute = (path: string) => { + const title = `Human output: ${path}`; + const frame = document.createElement('iframe'); + frame.title = title; + frame.src = `/__worker${path}`; + document.body.appendChild(frame); + + return page.frameLocator(page.getByTitle(title)); +}; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('human-readable routes', () => { + it('renders the API documentation with accessible navigation', async () => { + const frame = openHumanRoute('/api?output=human'); + + await expect.element(frame.getByRole('navigation')).toBeInTheDocument(); + await expect + .element(frame.getByRole('link', { name: 'cdnjs' }).first()) + .toBeInTheDocument(); + await expect + .element(frame.getByText('Loading OpenAPI specification...')) + .not.toBeInTheDocument(); + await expect + .element(frame.getByRole('heading', { name: 'Libraries' })) + .toBeInTheDocument(); + }); + + it('renders an immutable library version and hydrates its file filter', async () => { + const frame = openHumanRoute( + '/libraries/backbone.js/1.1.0?output=human', + ); + + await expect + .element( + frame.getByRole('heading', { + name: 'backbone.js', + exact: true, + }), + ) + .toBeInTheDocument(); + + const version = frame.getByRole('combobox', { name: 'Version:' }); + const filter = frame.getByRole('combobox', { name: 'Filter:' }); + await expect.element(version).toHaveValue('1.1.0'); + await expect.element(filter).toHaveDisplayValue('All assets'); + + const script = frame.getByRole('link', { + name: 'backbone-min.js', + exact: true, + }); + const sourceMap = frame.getByRole('link', { + name: 'backbone-min.map', + exact: true, + }); + await expect.element(script).toBeInTheDocument(); + await expect.element(sourceMap).toBeInTheDocument(); + + await filter.selectOptions('Source Maps'); + + await expect.element(filter).toHaveDisplayValue('Source Maps'); + await expect.element(sourceMap).toBeInTheDocument(); + await expect.element(script).not.toBeInTheDocument(); + }); +}); diff --git a/vite.client.config.ts b/vite.client.config.ts index d0a6c35c..a98243f1 100644 --- a/vite.client.config.ts +++ b/vite.client.config.ts @@ -1,6 +1,6 @@ import { existsSync, globSync, mkdirSync, rmSync } from 'node:fs'; import { basename, extname, resolve } from 'node:path'; -import { defineConfig } from 'vite'; +import { defineConfig, normalizePath } from 'vite'; const outputDirectory = resolve('dist-client'); const virtualEntryPrefix = 'virtual:island-entry:'; @@ -29,7 +29,7 @@ const islandEntryByName = new Map( islandEntries.map((entry) => [entry.name, entry]), ); const islandEntryByPath = new Map( - islandEntries.map((entry) => [entry.path, entry]), + islandEntries.map((entry) => [normalizePath(entry.path), entry]), ); const parseCreateIslandDeclaration = (source: string) => { @@ -77,7 +77,7 @@ export default defineConfig({ }, // Strip the SSR wrapper from island modules in the client build. transform(code, id) { - const entry = islandEntryByPath.get(id); + const entry = islandEntryByPath.get(normalizePath(id)); if (!entry) { return null; } @@ -153,13 +153,12 @@ export default defineConfig({ assetFileNames: 'islands/assets/[name]-[hash][extname]', // Share the core React hydration code across all islands as a separate chunk. manualChunks(id) { - const normalizedId = id.replaceAll('\\', '/'); + const normalizedId = normalizePath(id); if ( normalizedId.includes('/node_modules/react/') || normalizedId.includes('/node_modules/react-dom/') || - normalizedId === - hydrationRuntimePath.replaceAll('\\', '/') + normalizedId === normalizePath(hydrationRuntimePath) ) { return 'hydration-runtime'; } diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts new file mode 100644 index 00000000..a2d3cb50 --- /dev/null +++ b/vitest.browser.config.ts @@ -0,0 +1,119 @@ +import { playwright } from '@vitest/browser-playwright'; +import { createServer } from 'node:net'; +import { defineConfig } from 'vitest/config'; + +const host = '127.0.0.1'; + +const findAvailablePorts = async (count: number) => { + const ports = new Set(); + + while (ports.size < count) { + const port = await new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once('error', reject); + server.listen(0, host, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject( + new Error('Unable to allocate a browser test port.'), + ); + return; + } + + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(address.port); + }); + }); + }); + + ports.add(port); + } + + return [...ports]; +}; + +const readConfiguredPorts = (keys: string[]) => { + const ports = keys.map((key) => { + const port = Number(process.env[key]); + return Number.isInteger(port) && port > 0 && port <= 65_535 + ? port + : undefined; + }); + + return ports.every((port): port is number => port !== undefined) + ? ports + : null; +}; + +const externalApiUrl = process.env.VITEST_EXTERNAL_API_URL?.replace(/\/+$/, ''); +const portEnvironmentKeys = externalApiUrl + ? ['VITEST_BROWSER_API_PORT'] + : [ + 'VITEST_BROWSER_API_PORT', + 'VITEST_BROWSER_WORKER_PORT', + 'VITEST_BROWSER_INSPECTOR_PORT', + ]; +const ports = + readConfiguredPorts(portEnvironmentKeys) ?? + (await findAvailablePorts(portEnvironmentKeys.length)); +portEnvironmentKeys.forEach((key, index) => { + process.env[key] = String(ports[index]); +}); + +const [browserPort, workerPort, inspectorPort] = ports; + +if (!browserPort) { + throw new Error('Unable to allocate the browser server port.'); +} + +if (!externalApiUrl && (!workerPort || !inspectorPort)) { + throw new Error('Unable to allocate the local Worker ports.'); +} + +const workerTarget = externalApiUrl ?? `http://${host}:${String(workerPort)}`; + +export default defineConfig({ + server: { + host, + proxy: { + '/__worker': { + target: workerTarget, + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/__worker/, ''), + }, + '/islands': { + target: workerTarget, + changeOrigin: true, + }, + }, + }, + test: { + expect: { + poll: { + timeout: 10_000, + }, + }, + include: ['src/**/*.browser.spec.ts'], + globalSetup: './vitest.browser.setup.ts', + testTimeout: 30_000, + browser: { + api: { + host, + port: browserPort, + strictPort: true, + }, + enabled: true, + headless: true, + screenshotFailures: false, + provider: playwright({ actionTimeout: 10_000 }), + instances: [{ browser: 'chromium' }], + }, + }, +}); diff --git a/vitest.browser.setup.ts b/vitest.browser.setup.ts new file mode 100644 index 00000000..5498c7da --- /dev/null +++ b/vitest.browser.setup.ts @@ -0,0 +1,40 @@ +import { unstable_dev } from 'wrangler'; + +const host = '127.0.0.1'; + +/** + * Start a local Worker for the browser test project. + */ +export default async function setup() { + if (process.env.VITEST_EXTERNAL_API_URL) return; + + const workerPort = Number(process.env.VITEST_BROWSER_WORKER_PORT); + const inspectorPort = Number(process.env.VITEST_BROWSER_INSPECTOR_PORT); + if (!workerPort || !inspectorPort) { + throw new Error('Browser test Worker ports were not configured.'); + } + + const worker = await unstable_dev('src/index.ts', { + config: './wrangler.toml', + ip: host, + port: workerPort, + inspectorPort, + local: true, + logLevel: 'error', + experimental: { + disableExperimentalWarning: true, + showInteractiveDevSession: false, + watch: false, + }, + }); + + const response = await worker.fetch('/api'); + if (!response.ok) { + await worker.stop(); + throw new Error( + `Local Worker health check failed with status ${String(response.status)}.`, + ); + } + + return () => worker.stop(); +} diff --git a/vitest.config.ts b/vitest.config.ts index baef3612..78a3fb6a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,9 @@ import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; export default defineConfig({ test: { + exclude: [...configDefaults.exclude, '**/*.browser.spec.ts'], globalSetup: './vitest.setup.ts', silent: 'passed-only', reporters: [ From 29565e1f055d7e57231cc3b5a1f725bd3e701873 Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:15:27 +0900 Subject: [PATCH 2/6] Use the Worker test harness for browser coverage --- src/routes/api.browser.spec.ts | 20 +++++ src/routes/human.browser.spec.ts | 70 ----------------- src/routes/library.browser.spec.ts | 41 ++++++++++ src/utils/spec/browser.ts | 25 ++++++ vitest.browser.config.ts | 120 ++++++++++++----------------- vitest.browser.setup.ts | 42 +++++----- 6 files changed, 152 insertions(+), 166 deletions(-) create mode 100644 src/routes/api.browser.spec.ts delete mode 100644 src/routes/human.browser.spec.ts create mode 100644 src/routes/library.browser.spec.ts create mode 100644 src/utils/spec/browser.ts diff --git a/src/routes/api.browser.spec.ts b/src/routes/api.browser.spec.ts new file mode 100644 index 00000000..79ea2cca --- /dev/null +++ b/src/routes/api.browser.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import openHumanRoute from '../utils/spec/browser.ts'; + +describe('/api human output', () => { + it('renders accessible navigation and documentation', async () => { + const frame = await openHumanRoute('/api?output=human'); + + await expect.element(frame.getByRole('navigation')).toBeInTheDocument(); + await expect + .element(frame.getByRole('link', { name: 'cdnjs' }).first()) + .toBeInTheDocument(); + await expect + .element(frame.getByText('Loading OpenAPI specification...')) + .not.toBeInTheDocument(); + await expect + .element(frame.getByRole('heading', { name: 'Libraries' })) + .toBeInTheDocument(); + }); +}); diff --git a/src/routes/human.browser.spec.ts b/src/routes/human.browser.spec.ts deleted file mode 100644 index ab5ddae0..00000000 --- a/src/routes/human.browser.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { page } from 'vitest/browser'; - -const openHumanRoute = (path: string) => { - const title = `Human output: ${path}`; - const frame = document.createElement('iframe'); - frame.title = title; - frame.src = `/__worker${path}`; - document.body.appendChild(frame); - - return page.frameLocator(page.getByTitle(title)); -}; - -afterEach(() => { - document.body.replaceChildren(); -}); - -describe('human-readable routes', () => { - it('renders the API documentation with accessible navigation', async () => { - const frame = openHumanRoute('/api?output=human'); - - await expect.element(frame.getByRole('navigation')).toBeInTheDocument(); - await expect - .element(frame.getByRole('link', { name: 'cdnjs' }).first()) - .toBeInTheDocument(); - await expect - .element(frame.getByText('Loading OpenAPI specification...')) - .not.toBeInTheDocument(); - await expect - .element(frame.getByRole('heading', { name: 'Libraries' })) - .toBeInTheDocument(); - }); - - it('renders an immutable library version and hydrates its file filter', async () => { - const frame = openHumanRoute( - '/libraries/backbone.js/1.1.0?output=human', - ); - - await expect - .element( - frame.getByRole('heading', { - name: 'backbone.js', - exact: true, - }), - ) - .toBeInTheDocument(); - - const version = frame.getByRole('combobox', { name: 'Version:' }); - const filter = frame.getByRole('combobox', { name: 'Filter:' }); - await expect.element(version).toHaveValue('1.1.0'); - await expect.element(filter).toHaveDisplayValue('All assets'); - - const script = frame.getByRole('link', { - name: 'backbone-min.js', - exact: true, - }); - const sourceMap = frame.getByRole('link', { - name: 'backbone-min.map', - exact: true, - }); - await expect.element(script).toBeInTheDocument(); - await expect.element(sourceMap).toBeInTheDocument(); - - await filter.selectOptions('Source Maps'); - - await expect.element(filter).toHaveDisplayValue('Source Maps'); - await expect.element(sourceMap).toBeInTheDocument(); - await expect.element(script).not.toBeInTheDocument(); - }); -}); diff --git a/src/routes/library.browser.spec.ts b/src/routes/library.browser.spec.ts new file mode 100644 index 00000000..24c3ecde --- /dev/null +++ b/src/routes/library.browser.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import openHumanRoute from '../utils/spec/browser.ts'; + +describe('/libraries/:library/:version human output', () => { + it('renders an immutable version and hydrates its file filter', async () => { + const frame = await openHumanRoute( + '/libraries/backbone.js/1.1.0?output=human', + ); + + await expect + .element( + frame.getByRole('heading', { + name: /^backbone\.js /, + }), + ) + .toBeInTheDocument(); + + const version = frame.getByRole('combobox', { name: 'Version:' }); + const filter = frame.getByRole('combobox', { name: 'Filter:' }); + await expect.element(version).toHaveValue('1.1.0'); + await expect.element(filter).toHaveDisplayValue('All assets'); + + const script = frame.getByRole('link', { + name: 'backbone-min.js', + exact: true, + }); + const sourceMap = frame.getByRole('link', { + name: 'backbone-min.map', + exact: true, + }); + await expect.element(script).toBeInTheDocument(); + await expect.element(sourceMap).toBeInTheDocument(); + + await filter.selectOptions('Source Maps'); + + await expect.element(filter).toHaveDisplayValue('Source Maps'); + await expect.element(sourceMap).toBeInTheDocument(); + await expect.element(script).not.toBeInTheDocument(); + }); +}); diff --git a/src/utils/spec/browser.ts b/src/utils/spec/browser.ts new file mode 100644 index 00000000..2266f0d8 --- /dev/null +++ b/src/utils/spec/browser.ts @@ -0,0 +1,25 @@ +import { page } from 'vitest/browser'; + +/** + * Open a human-readable Worker route in a browser-test frame. + * + * @param path Worker route, including the human-output query parameter. + */ +export default async (path: string) => { + const title = `Human output: ${path}`; + const frame = document.createElement('iframe'); + frame.title = title; + frame.src = `/__worker${path}`; + const loaded = new Promise((resolve, reject) => { + frame.addEventListener('load', () => resolve(), { once: true }); + frame.addEventListener( + 'error', + () => reject(new Error(`Unable to load ${path}.`)), + { once: true }, + ); + }); + document.body.replaceChildren(frame); + await loaded; + + return page.frameLocator(page.getByTitle(title)); +}; diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index a2d3cb50..d31efa8e 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -3,94 +3,70 @@ import { createServer } from 'node:net'; import { defineConfig } from 'vitest/config'; const host = '127.0.0.1'; +const fallbackWorkerTarget = + process.env.VITEST_EXTERNAL_API_URL?.replace(/\/+$/, '') ?? + `http://${host}`; +const resolveWorkerTarget = () => + process.env.VITEST_BROWSER_WORKER_URL ?? fallbackWorkerTarget; +const proxyToWorker: typeof fetch = async (input, init) => { + const sourceUrl = new URL(input instanceof Request ? input.url : input); + const targetUrl = new URL( + `${sourceUrl.pathname}${sourceUrl.search}`, + resolveWorkerTarget(), + ); + const headers = new Headers(init?.headers); + // Node fetch decodes compressed bodies while preserving their encoding header. + // Request identity encoding so the browser receives bytes matching the headers. + headers.set('accept-encoding', 'identity'); -const findAvailablePorts = async (count: number) => { - const ports = new Set(); - - while (ports.size < count) { - const port = await new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.once('error', reject); - server.listen(0, host, () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - reject( - new Error('Unable to allocate a browser test port.'), - ); - return; - } - - server.close((error) => { - if (error) { - reject(error); - return; - } - - resolve(address.port); - }); - }); - }); - - ports.add(port); - } - - return [...ports]; + return fetch(targetUrl, { ...init, headers }); }; -const readConfiguredPorts = (keys: string[]) => { - const ports = keys.map((key) => { - const port = Number(process.env[key]); - return Number.isInteger(port) && port > 0 && port <= 65_535 - ? port - : undefined; - }); - - return ports.every((port): port is number => port !== undefined) - ? ports - : null; -}; - -const externalApiUrl = process.env.VITEST_EXTERNAL_API_URL?.replace(/\/+$/, ''); -const portEnvironmentKeys = externalApiUrl - ? ['VITEST_BROWSER_API_PORT'] - : [ - 'VITEST_BROWSER_API_PORT', - 'VITEST_BROWSER_WORKER_PORT', - 'VITEST_BROWSER_INSPECTOR_PORT', - ]; -const ports = - readConfiguredPorts(portEnvironmentKeys) ?? - (await findAvailablePorts(portEnvironmentKeys.length)); -portEnvironmentKeys.forEach((key, index) => { - process.env[key] = String(ports[index]); -}); - -const [browserPort, workerPort, inspectorPort] = ports; - -if (!browserPort) { - throw new Error('Unable to allocate the browser server port.'); -} +const configuredPort = Number(process.env.VITEST_BROWSER_API_PORT); +const browserPort = + Number.isInteger(configuredPort) && + configuredPort > 0 && + configuredPort <= 65_535 + ? configuredPort + : await new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once('error', reject); + server.listen(0, host, () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject( + new Error('Unable to allocate a browser test port.'), + ); + return; + } -if (!externalApiUrl && (!workerPort || !inspectorPort)) { - throw new Error('Unable to allocate the local Worker ports.'); -} + server.close((error) => { + if (error) { + reject(error); + return; + } -const workerTarget = externalApiUrl ?? `http://${host}:${String(workerPort)}`; + resolve(address.port); + }); + }); + }); export default defineConfig({ server: { host, proxy: { '/__worker': { - target: workerTarget, + target: fallbackWorkerTarget, changeOrigin: true, + fetch: proxyToWorker, rewrite: (path: string) => path.replace(/^\/__worker/, ''), }, '/islands': { - target: workerTarget, + target: fallbackWorkerTarget, changeOrigin: true, + fetch: proxyToWorker, }, }, }, diff --git a/vitest.browser.setup.ts b/vitest.browser.setup.ts index 5498c7da..7afe97c0 100644 --- a/vitest.browser.setup.ts +++ b/vitest.browser.setup.ts @@ -1,40 +1,34 @@ -import { unstable_dev } from 'wrangler'; - -const host = '127.0.0.1'; +import { createTestHarness } from 'wrangler'; /** * Start a local Worker for the browser test project. */ export default async function setup() { - if (process.env.VITEST_EXTERNAL_API_URL) return; - - const workerPort = Number(process.env.VITEST_BROWSER_WORKER_PORT); - const inspectorPort = Number(process.env.VITEST_BROWSER_INSPECTOR_PORT); - if (!workerPort || !inspectorPort) { - throw new Error('Browser test Worker ports were not configured.'); + const externalApiUrl = process.env.VITEST_EXTERNAL_API_URL?.replace( + /\/+$/, + '', + ); + if (externalApiUrl) { + process.env.VITEST_BROWSER_WORKER_URL = externalApiUrl; + return; } - const worker = await unstable_dev('src/index.ts', { - config: './wrangler.toml', - ip: host, - port: workerPort, - inspectorPort, - local: true, - logLevel: 'error', - experimental: { - disableExperimentalWarning: true, - showInteractiveDevSession: false, - watch: false, - }, + const server = createTestHarness({ + workers: [{ configPath: './wrangler.toml' }], }); + const { url } = await server.listen(); - const response = await worker.fetch('/api'); + const response = await server.fetch('/health'); if (!response.ok) { - await worker.stop(); + await server.close(); throw new Error( `Local Worker health check failed with status ${String(response.status)}.`, ); } - return () => worker.stop(); + process.env.VITEST_BROWSER_WORKER_URL = url.origin; + return async () => { + delete process.env.VITEST_BROWSER_WORKER_URL; + await server.close(); + }; } From 21cb4f207f901ce1dc4be6e13ac416133b632c46 Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:50:50 +0900 Subject: [PATCH 3/6] Update browser coverage for website routing --- src/routes/api.browser.spec.ts | 6 ++-- src/routes/library.browser.spec.ts | 8 ++--- src/utils/spec/browser.ts | 6 ++-- vitest.browser.setup.ts | 58 +++++++++++++++++++++++------- 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/routes/api.browser.spec.ts b/src/routes/api.browser.spec.ts index 79ea2cca..0421a216 100644 --- a/src/routes/api.browser.spec.ts +++ b/src/routes/api.browser.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest'; -import openHumanRoute from '../utils/spec/browser.ts'; +import openWebsiteRoute from '../utils/spec/browser.ts'; -describe('/api human output', () => { +describe('/api website output', () => { it('renders accessible navigation and documentation', async () => { - const frame = await openHumanRoute('/api?output=human'); + const frame = await openWebsiteRoute('/api'); await expect.element(frame.getByRole('navigation')).toBeInTheDocument(); await expect diff --git a/src/routes/library.browser.spec.ts b/src/routes/library.browser.spec.ts index 24c3ecde..ceda1c27 100644 --- a/src/routes/library.browser.spec.ts +++ b/src/routes/library.browser.spec.ts @@ -1,12 +1,10 @@ import { describe, expect, it } from 'vitest'; -import openHumanRoute from '../utils/spec/browser.ts'; +import openWebsiteRoute from '../utils/spec/browser.ts'; -describe('/libraries/:library/:version human output', () => { +describe('/libraries/:library/:version website output', () => { it('renders an immutable version and hydrates its file filter', async () => { - const frame = await openHumanRoute( - '/libraries/backbone.js/1.1.0?output=human', - ); + const frame = await openWebsiteRoute('/libraries/backbone.js/1.1.0'); await expect .element( diff --git a/src/utils/spec/browser.ts b/src/utils/spec/browser.ts index 2266f0d8..5a5ea764 100644 --- a/src/utils/spec/browser.ts +++ b/src/utils/spec/browser.ts @@ -1,12 +1,12 @@ import { page } from 'vitest/browser'; /** - * Open a human-readable Worker route in a browser-test frame. + * Open a website Worker route in a browser-test frame. * - * @param path Worker route, including the human-output query parameter. + * @param path Website Worker route. */ export default async (path: string) => { - const title = `Human output: ${path}`; + const title = `Website output: ${path}`; const frame = document.createElement('iframe'); frame.title = title; frame.src = `/__worker${path}`; diff --git a/vitest.browser.setup.ts b/vitest.browser.setup.ts index 7afe97c0..6175338a 100644 --- a/vitest.browser.setup.ts +++ b/vitest.browser.setup.ts @@ -1,5 +1,8 @@ import { createTestHarness } from 'wrangler'; +const configPath = './wrangler.jsonc'; +const websiteBase = 'http://127.0.0.1:'; + /** * Start a local Worker for the browser test project. */ @@ -14,21 +17,50 @@ export default async function setup() { } const server = createTestHarness({ - workers: [{ configPath: './wrangler.toml' }], + workers: [ + { + configPath, + vars: { WEBSITE_BASE: websiteBase }, + }, + ], }); - const { url } = await server.listen(); + try { + const { url } = await server.listen(); + if (!url.origin.startsWith(websiteBase)) { + throw new Error( + `Local Worker origin ${url.origin} does not match website base ${websiteBase}.`, + ); + } - const response = await server.fetch('/health'); - if (!response.ok) { - await server.close(); - throw new Error( - `Local Worker health check failed with status ${String(response.status)}.`, - ); - } + const workerEnv = await server + .getWorker<{ WEBSITE_BASE: string }>() + .getEnv(); + if (workerEnv.WEBSITE_BASE !== websiteBase) { + throw new Error( + `Local Worker WEBSITE_BASE is ${workerEnv.WEBSITE_BASE}, expected ${websiteBase}.`, + ); + } - process.env.VITEST_BROWSER_WORKER_URL = url.origin; - return async () => { - delete process.env.VITEST_BROWSER_WORKER_URL; + const response = await fetch(new URL('/health', url)); + const body = await response.text(); + if (!response.ok || body !== 'OK') { + throw new Error( + [ + `Local website health check failed with status ${String(response.status)}`, + `and body ${JSON.stringify(body)}.`, + `Harness origin: ${url.origin}.`, + `Response URL: ${response.url || ''}.`, + ].join(' '), + ); + } + + process.env.VITEST_BROWSER_WORKER_URL = url.origin; + return async () => { + delete process.env.VITEST_BROWSER_WORKER_URL; + await server.close(); + }; + } catch (error) { await server.close(); - }; + throw error; + } } From 11956f83738a28a8ee5869b1b8235d337357618c Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:09:45 +0900 Subject: [PATCH 4/6] Harden browser test port allocation --- vitest.browser.config.ts | 4 +++- vitest.browser.setup.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index d31efa8e..7c763bd6 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -83,7 +83,9 @@ export default defineConfig({ api: { host, port: browserPort, - strictPort: true, + // The probe releases the port before Vite binds it. Let Vite + // advance if another process wins that race. + strictPort: false, }, enabled: true, headless: true, diff --git a/vitest.browser.setup.ts b/vitest.browser.setup.ts index 6175338a..462a776b 100644 --- a/vitest.browser.setup.ts +++ b/vitest.browser.setup.ts @@ -1,6 +1,8 @@ import { createTestHarness } from 'wrangler'; const configPath = './wrangler.jsonc'; +// isWebsite() uses startsWith(), so the trailing colon matches every +// dynamically allocated loopback port without matching another host. const websiteBase = 'http://127.0.0.1:'; /** From 9e632da3f0fd083652c2165c15a55b23841191a1 Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:02:43 +0900 Subject: [PATCH 5/6] Replace browser test proxy with Playwright harness --- .github/workflows/ci.yml | 2 +- package-lock.json | 224 +++++------------------------ package.json | 5 +- playwright.config.ts | 16 +++ src/routes/api.browser.spec.ts | 33 ++--- src/routes/library.browser.spec.ts | 49 ++++--- src/utils/spec/browser.ts | 25 ---- src/utils/spec/playwright.ts | 68 +++++++++ vitest.browser.config.ts | 97 ------------- vitest.browser.setup.ts | 68 --------- 10 files changed, 165 insertions(+), 422 deletions(-) create mode 100644 playwright.config.ts delete mode 100644 src/utils/spec/browser.ts create mode 100644 src/utils/spec/playwright.ts delete mode 100644 vitest.browser.config.ts delete mode 100644 vitest.browser.setup.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5528652..27ccd3fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Install Dependencies run: npm ci - - name: Install Browser + - name: Install Playwright Chromium run: npx playwright install --with-deps chromium --only-shell - name: Run Tests diff --git a/package-lock.json b/package-lock.json index e213a536..9ee9e651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.20.3", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/is-deflate": "^1.0.0", "@types/is-gzip": "^2.0.2", @@ -37,13 +38,11 @@ "@types/semver": "^7.7.1", "@types/spdx-license-ids": "^3.0.0", "@types/swagger-ui-react": "^5.18.0", - "@vitest/browser-playwright": "^4.1.2", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsdoc": "^63.3.2", "husky": "^9.1.7", "lint-staged": "^17.2.0", - "playwright": "^1.62.0", "prettier": "^3.9.6", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", @@ -454,13 +453,6 @@ "node": ">=6.9.0" } }, - "node_modules/@blazediff/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", - "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", - "dev": true, - "license": "MIT" - }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -1890,12 +1882,21 @@ "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } }, "node_modules/@poppinss/colors": { "version": "4.1.6", @@ -3421,53 +3422,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@vitest/browser": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.2.tgz", - "integrity": "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.2", - "@vitest/utils": "4.1.2", - "magic-string": "^0.30.21", - "pngjs": "^7.0.0", - "sirv": "^3.0.2", - "tinyrainbow": "^3.1.0", - "ws": "^8.19.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.1.2" - } - }, - "node_modules/@vitest/browser-playwright": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.2.tgz", - "integrity": "sha512-N0Z2HzMLvMR6k/tWPTS6Q/DaRscrkax/f2f9DIbNQr+Cd1l4W4wTf/I6S983PAMr0tNqqoTL+xNkLh9M5vbkLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/browser": "4.1.2", - "@vitest/mocker": "4.1.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "playwright": "*", - "vitest": "4.1.2" - }, - "peerDependenciesMeta": { - "playwright": { - "optional": false - } - } - }, "node_modules/@vitest/expect": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", @@ -5726,16 +5680,6 @@ "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==" }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6044,13 +5988,13 @@ } }, "node_modules/playwright": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", - "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" @@ -6063,9 +6007,9 @@ } }, "node_modules/playwright-core": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", - "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6090,16 +6034,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.19.0" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6700,21 +6634,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -7020,16 +6939,6 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/tree-sitter": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", @@ -7896,12 +7805,6 @@ "@babel/helper-validator-identifier": "^7.28.5" } }, - "@blazediff/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", - "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", - "dev": true - }, "@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -8646,11 +8549,14 @@ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true }, - "@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true + "@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "requires": { + "playwright": "1.62.1" + } }, "@poppinss/colors": { "version": "4.1.6", @@ -9814,33 +9720,6 @@ } } }, - "@vitest/browser": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.2.tgz", - "integrity": "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ==", - "dev": true, - "requires": { - "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.2", - "@vitest/utils": "4.1.2", - "magic-string": "^0.30.21", - "pngjs": "^7.0.0", - "sirv": "^3.0.2", - "tinyrainbow": "^3.1.0", - "ws": "^8.19.0" - } - }, - "@vitest/browser-playwright": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.2.tgz", - "integrity": "sha512-N0Z2HzMLvMR6k/tWPTS6Q/DaRscrkax/f2f9DIbNQr+Cd1l4W4wTf/I6S983PAMr0tNqqoTL+xNkLh9M5vbkLg==", - "dev": true, - "requires": { - "@vitest/browser": "4.1.2", - "@vitest/mocker": "4.1.2", - "tinyrainbow": "^3.1.0" - } - }, "@vitest/expect": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", @@ -11244,12 +11123,6 @@ "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==" }, - "mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true - }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11461,13 +11334,13 @@ "dev": true }, "playwright": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", - "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "requires": { "fsevents": "2.3.2", - "playwright-core": "1.62.0" + "playwright-core": "1.62.1" }, "dependencies": { "fsevents": { @@ -11480,15 +11353,9 @@ } }, "playwright-core": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", - "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", - "dev": true - }, - "pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true }, "possible-typed-array-names": { @@ -11867,17 +11734,6 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, - "sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "requires": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - } - }, "source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -12111,12 +11967,6 @@ "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" }, - "totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true - }, "tree-sitter": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", diff --git a/package.json b/package.json index 01030daf..bf9e7021 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "format": "prettier --check .", "format:fix": "prettier --write .", "test": "vitest run", - "test:browser": "vitest run --config vitest.browser.config.ts", + "test:browser": "playwright test", "types": "tsc --noEmit", "dev": "wrangler dev", "dev:web": "wrangler dev --var WEBSITE_BASE:http://localhost:8787" @@ -52,6 +52,7 @@ "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.20.3", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/is-deflate": "^1.0.0", "@types/is-gzip": "^2.0.2", @@ -61,13 +62,11 @@ "@types/semver": "^7.7.1", "@types/spdx-license-ids": "^3.0.0", "@types/swagger-ui-react": "^5.18.0", - "@vitest/browser-playwright": "^4.1.2", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-jsdoc": "^63.3.2", "husky": "^9.1.7", "lint-staged": "^17.2.0", - "playwright": "^1.62.0", "prettier": "^3.9.6", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..7f34f1cc --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './src', + testMatch: '**/*.browser.spec.ts', + outputDir: '.wrangler/playwright-results', + workers: 1, + timeout: 30_000, + expect: { + timeout: 10_000, + }, + use: { + browserName: 'chromium', + headless: true, + }, +}); diff --git a/src/routes/api.browser.spec.ts b/src/routes/api.browser.spec.ts index 0421a216..cbee157a 100644 --- a/src/routes/api.browser.spec.ts +++ b/src/routes/api.browser.spec.ts @@ -1,20 +1,21 @@ -import { describe, expect, it } from 'vitest'; +import { expect, test } from '../utils/spec/playwright.ts'; -import openWebsiteRoute from '../utils/spec/browser.ts'; +test.describe('/api website output', () => { + test('renders accessible navigation and documentation', async ({ + page, + }) => { + const response = await page.goto('/api'); + expect(response?.ok()).toBe(true); -describe('/api website output', () => { - it('renders accessible navigation and documentation', async () => { - const frame = await openWebsiteRoute('/api'); - - await expect.element(frame.getByRole('navigation')).toBeInTheDocument(); - await expect - .element(frame.getByRole('link', { name: 'cdnjs' }).first()) - .toBeInTheDocument(); - await expect - .element(frame.getByText('Loading OpenAPI specification...')) - .not.toBeInTheDocument(); - await expect - .element(frame.getByRole('heading', { name: 'Libraries' })) - .toBeInTheDocument(); + await expect(page.getByRole('navigation')).toBeVisible(); + await expect( + page.getByRole('link', { name: 'cdnjs' }).first(), + ).toBeVisible(); + await expect( + page.getByText('Loading OpenAPI specification...'), + ).toHaveCount(0); + await expect( + page.getByRole('heading', { name: 'Libraries' }), + ).toBeVisible(); }); }); diff --git a/src/routes/library.browser.spec.ts b/src/routes/library.browser.spec.ts index ceda1c27..6fa2d116 100644 --- a/src/routes/library.browser.spec.ts +++ b/src/routes/library.browser.spec.ts @@ -1,39 +1,38 @@ -import { describe, expect, it } from 'vitest'; +import { expect, test } from '../utils/spec/playwright.ts'; -import openWebsiteRoute from '../utils/spec/browser.ts'; +test.describe('/libraries/:library/:version website output', () => { + test('renders an immutable version and hydrates its file filter', async ({ + page, + }) => { + const response = await page.goto('/libraries/backbone.js/1.1.0'); + expect(response?.ok()).toBe(true); -describe('/libraries/:library/:version website output', () => { - it('renders an immutable version and hydrates its file filter', async () => { - const frame = await openWebsiteRoute('/libraries/backbone.js/1.1.0'); + await expect( + page.getByRole('heading', { + name: /^backbone\.js /, + }), + ).toBeVisible(); - await expect - .element( - frame.getByRole('heading', { - name: /^backbone\.js /, - }), - ) - .toBeInTheDocument(); + const version = page.getByRole('combobox', { name: 'Version:' }); + const filter = page.getByRole('combobox', { name: 'Filter:' }); + await expect(version).toHaveValue('1.1.0'); + await expect(filter).toHaveValue(''); - const version = frame.getByRole('combobox', { name: 'Version:' }); - const filter = frame.getByRole('combobox', { name: 'Filter:' }); - await expect.element(version).toHaveValue('1.1.0'); - await expect.element(filter).toHaveDisplayValue('All assets'); - - const script = frame.getByRole('link', { + const script = page.getByRole('link', { name: 'backbone-min.js', exact: true, }); - const sourceMap = frame.getByRole('link', { + const sourceMap = page.getByRole('link', { name: 'backbone-min.map', exact: true, }); - await expect.element(script).toBeInTheDocument(); - await expect.element(sourceMap).toBeInTheDocument(); + await expect(script).toBeVisible(); + await expect(sourceMap).toBeVisible(); - await filter.selectOptions('Source Maps'); + await filter.selectOption({ label: 'Source Maps' }); - await expect.element(filter).toHaveDisplayValue('Source Maps'); - await expect.element(sourceMap).toBeInTheDocument(); - await expect.element(script).not.toBeInTheDocument(); + await expect(filter).toHaveValue('Source Maps'); + await expect(sourceMap).toBeVisible(); + await expect(script).toHaveCount(0); }); }); diff --git a/src/utils/spec/browser.ts b/src/utils/spec/browser.ts deleted file mode 100644 index 5a5ea764..00000000 --- a/src/utils/spec/browser.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { page } from 'vitest/browser'; - -/** - * Open a website Worker route in a browser-test frame. - * - * @param path Website Worker route. - */ -export default async (path: string) => { - const title = `Website output: ${path}`; - const frame = document.createElement('iframe'); - frame.title = title; - frame.src = `/__worker${path}`; - const loaded = new Promise((resolve, reject) => { - frame.addEventListener('load', () => resolve(), { once: true }); - frame.addEventListener( - 'error', - () => reject(new Error(`Unable to load ${path}.`)), - { once: true }, - ); - }); - document.body.replaceChildren(frame); - await loaded; - - return page.frameLocator(page.getByTitle(title)); -}; diff --git a/src/utils/spec/playwright.ts b/src/utils/spec/playwright.ts new file mode 100644 index 00000000..d3e2adab --- /dev/null +++ b/src/utils/spec/playwright.ts @@ -0,0 +1,68 @@ +import { test as base, expect } from '@playwright/test'; +import { createTestHarness } from 'wrangler'; + +const configPath = './wrangler.jsonc'; +// isWebsite() uses startsWith(), so the trailing colon matches every +// dynamically allocated loopback port without matching another host. +const websiteBase = 'http://127.0.0.1:'; + +interface WorkerFixtures { + workerUrl: string; +} + +export const test = base.extend, WorkerFixtures>({ + workerUrl: [ + async ({ browserName }, use) => { + if (browserName !== 'chromium') { + throw new Error( + `Browser tests require Chromium, received ${browserName}.`, + ); + } + + const server = createTestHarness({ + workers: [ + { + configPath, + vars: { WEBSITE_BASE: websiteBase }, + }, + ], + }); + + try { + const { url } = await server.listen(); + if (!url.origin.startsWith(websiteBase)) { + throw new Error( + `Local Worker origin ${url.origin} does not match website base ${websiteBase}.`, + ); + } + + const workerEnv = await server + .getWorker<{ WEBSITE_BASE: string }>() + .getEnv(); + if (workerEnv.WEBSITE_BASE !== websiteBase) { + throw new Error( + `Local Worker WEBSITE_BASE is ${workerEnv.WEBSITE_BASE}, expected ${websiteBase}.`, + ); + } + + const response = await fetch(new URL('/health', url)); + const body = await response.text(); + if (!response.ok || body !== 'OK') { + throw new Error( + `Local website health check failed with status ${String(response.status)} and body ${JSON.stringify(body)}.`, + ); + } + + await use(url.href); + } finally { + await server.close(); + } + }, + { scope: 'worker', timeout: 120_000 }, + ], + baseURL: async ({ workerUrl }, use) => { + await use(workerUrl); + }, +}); + +export { expect }; diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts deleted file mode 100644 index 7c763bd6..00000000 --- a/vitest.browser.config.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { playwright } from '@vitest/browser-playwright'; -import { createServer } from 'node:net'; -import { defineConfig } from 'vitest/config'; - -const host = '127.0.0.1'; -const fallbackWorkerTarget = - process.env.VITEST_EXTERNAL_API_URL?.replace(/\/+$/, '') ?? - `http://${host}`; -const resolveWorkerTarget = () => - process.env.VITEST_BROWSER_WORKER_URL ?? fallbackWorkerTarget; -const proxyToWorker: typeof fetch = async (input, init) => { - const sourceUrl = new URL(input instanceof Request ? input.url : input); - const targetUrl = new URL( - `${sourceUrl.pathname}${sourceUrl.search}`, - resolveWorkerTarget(), - ); - const headers = new Headers(init?.headers); - // Node fetch decodes compressed bodies while preserving their encoding header. - // Request identity encoding so the browser receives bytes matching the headers. - headers.set('accept-encoding', 'identity'); - - return fetch(targetUrl, { ...init, headers }); -}; - -const configuredPort = Number(process.env.VITEST_BROWSER_API_PORT); -const browserPort = - Number.isInteger(configuredPort) && - configuredPort > 0 && - configuredPort <= 65_535 - ? configuredPort - : await new Promise((resolve, reject) => { - const server = createServer(); - server.unref(); - server.once('error', reject); - server.listen(0, host, () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - reject( - new Error('Unable to allocate a browser test port.'), - ); - return; - } - - server.close((error) => { - if (error) { - reject(error); - return; - } - - resolve(address.port); - }); - }); - }); - -export default defineConfig({ - server: { - host, - proxy: { - '/__worker': { - target: fallbackWorkerTarget, - changeOrigin: true, - fetch: proxyToWorker, - rewrite: (path: string) => path.replace(/^\/__worker/, ''), - }, - '/islands': { - target: fallbackWorkerTarget, - changeOrigin: true, - fetch: proxyToWorker, - }, - }, - }, - test: { - expect: { - poll: { - timeout: 10_000, - }, - }, - include: ['src/**/*.browser.spec.ts'], - globalSetup: './vitest.browser.setup.ts', - testTimeout: 30_000, - browser: { - api: { - host, - port: browserPort, - // The probe releases the port before Vite binds it. Let Vite - // advance if another process wins that race. - strictPort: false, - }, - enabled: true, - headless: true, - screenshotFailures: false, - provider: playwright({ actionTimeout: 10_000 }), - instances: [{ browser: 'chromium' }], - }, - }, -}); diff --git a/vitest.browser.setup.ts b/vitest.browser.setup.ts deleted file mode 100644 index 462a776b..00000000 --- a/vitest.browser.setup.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { createTestHarness } from 'wrangler'; - -const configPath = './wrangler.jsonc'; -// isWebsite() uses startsWith(), so the trailing colon matches every -// dynamically allocated loopback port without matching another host. -const websiteBase = 'http://127.0.0.1:'; - -/** - * Start a local Worker for the browser test project. - */ -export default async function setup() { - const externalApiUrl = process.env.VITEST_EXTERNAL_API_URL?.replace( - /\/+$/, - '', - ); - if (externalApiUrl) { - process.env.VITEST_BROWSER_WORKER_URL = externalApiUrl; - return; - } - - const server = createTestHarness({ - workers: [ - { - configPath, - vars: { WEBSITE_BASE: websiteBase }, - }, - ], - }); - try { - const { url } = await server.listen(); - if (!url.origin.startsWith(websiteBase)) { - throw new Error( - `Local Worker origin ${url.origin} does not match website base ${websiteBase}.`, - ); - } - - const workerEnv = await server - .getWorker<{ WEBSITE_BASE: string }>() - .getEnv(); - if (workerEnv.WEBSITE_BASE !== websiteBase) { - throw new Error( - `Local Worker WEBSITE_BASE is ${workerEnv.WEBSITE_BASE}, expected ${websiteBase}.`, - ); - } - - const response = await fetch(new URL('/health', url)); - const body = await response.text(); - if (!response.ok || body !== 'OK') { - throw new Error( - [ - `Local website health check failed with status ${String(response.status)}`, - `and body ${JSON.stringify(body)}.`, - `Harness origin: ${url.origin}.`, - `Response URL: ${response.url || ''}.`, - ].join(' '), - ); - } - - process.env.VITEST_BROWSER_WORKER_URL = url.origin; - return async () => { - delete process.env.VITEST_BROWSER_WORKER_URL; - await server.close(); - }; - } catch (error) { - await server.close(); - throw error; - } -} From fc48ad645b3ce54e290d4a1bef47804522754632 Mon Sep 17 00:00:00 2001 From: snowyukitty <270071858+snowyukitty@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:25:35 +0900 Subject: [PATCH 6/6] test: align Playwright harness fixtures --- src/utils/spec/playwright.ts | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/utils/spec/playwright.ts b/src/utils/spec/playwright.ts index d3e2adab..f4560084 100644 --- a/src/utils/spec/playwright.ts +++ b/src/utils/spec/playwright.ts @@ -1,17 +1,21 @@ import { test as base, expect } from '@playwright/test'; -import { createTestHarness } from 'wrangler'; +import { type TestHarness, createTestHarness } from 'wrangler'; const configPath = './wrangler.jsonc'; // isWebsite() uses startsWith(), so the trailing colon matches every // dynamically allocated loopback port without matching another host. const websiteBase = 'http://127.0.0.1:'; +interface TestFixtures { + reset: undefined; +} + interface WorkerFixtures { - workerUrl: string; + server: TestHarness; } -export const test = base.extend, WorkerFixtures>({ - workerUrl: [ +export const test = base.extend({ + server: [ async ({ browserName }, use) => { if (browserName !== 'chromium') { throw new Error( @@ -53,16 +57,29 @@ export const test = base.extend, WorkerFixtures>({ ); } - await use(url.href); + await use(server); } finally { await server.close(); } }, { scope: 'worker', timeout: 120_000 }, ], - baseURL: async ({ workerUrl }, use) => { - await use(workerUrl); + baseURL: async ({ server }, use) => { + const { url } = await server.listen(); + await use(url.href); }, + reset: [ + async ({ server }, use, testInfo) => { + await use(undefined); + + if (testInfo.status !== testInfo.expectedStatus) { + server.debug(); + } + + await server.reset(); + }, + { auto: true }, + ], }); export { expect };