diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82eb8e2..e552865 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 bb400bf..150f4f3 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.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", @@ -452,6 +454,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", @@ -1891,6 +1900,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", @@ -3415,6 +3431,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", @@ -5676,6 +5739,16 @@ "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", @@ -5983,6 +6056,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", @@ -6583,6 +6713,21 @@ "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", @@ -6888,6 +7033,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", @@ -7754,6 +7909,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", @@ -8506,6 +8667,12 @@ "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 + }, "@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -9668,6 +9835,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", @@ -11071,6 +11265,12 @@ "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", @@ -11281,6 +11481,37 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "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", @@ -11657,6 +11888,17 @@ "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", @@ -11890,6 +12132,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 f04a521..ed71319 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", "dev:web": "wrangler dev --var WEBSITE_BASE:http://localhost:8787" @@ -60,11 +61,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.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/src/routes/api.browser.spec.ts b/src/routes/api.browser.spec.ts new file mode 100644 index 0000000..0421a21 --- /dev/null +++ b/src/routes/api.browser.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import openWebsiteRoute from '../utils/spec/browser.ts'; + +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(); + }); +}); diff --git a/src/routes/library.browser.spec.ts b/src/routes/library.browser.spec.ts new file mode 100644 index 0000000..ceda1c2 --- /dev/null +++ b/src/routes/library.browser.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import openWebsiteRoute from '../utils/spec/browser.ts'; + +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 + .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 0000000..5a5ea76 --- /dev/null +++ b/src/utils/spec/browser.ts @@ -0,0 +1,25 @@ +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/vite.client.config.ts b/vite.client.config.ts index d0a6c35..a98243f 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 0000000..7c763bd --- /dev/null +++ b/vitest.browser.config.ts @@ -0,0 +1,97 @@ +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 new file mode 100644 index 0000000..462a776 --- /dev/null +++ b/vitest.browser.setup.ts @@ -0,0 +1,68 @@ +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; + } +} diff --git a/vitest.config.ts b/vitest.config.ts index fb41950..6840b13 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: [