diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82eb8e2..27ccd3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,15 @@ jobs: - name: Install Dependencies run: npm ci + - name: Install Playwright Chromium + 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 38b87fa..9ee9e65 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", @@ -1881,6 +1882,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "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": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -5970,6 +5987,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "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.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "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": { + "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/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", @@ -8485,6 +8549,15 @@ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "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", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -11260,6 +11333,31 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true }, + "playwright": { + "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.1" + }, + "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.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "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", diff --git a/package.json b/package.json index 0809040..bf9e702 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format": "prettier --check .", "format:fix": "prettier --write .", "test": "vitest run", + "test:browser": "playwright test", "types": "tsc --noEmit", "dev": "wrangler dev", "dev:web": "wrangler dev --var WEBSITE_BASE:http://localhost:8787" @@ -51,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", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7f34f1c --- /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 new file mode 100644 index 0000000..cbee157 --- /dev/null +++ b/src/routes/api.browser.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '../utils/spec/playwright.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); + + 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 new file mode 100644 index 0000000..6fa2d11 --- /dev/null +++ b/src/routes/library.browser.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '../utils/spec/playwright.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); + + await expect( + page.getByRole('heading', { + name: /^backbone\.js /, + }), + ).toBeVisible(); + + 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 script = page.getByRole('link', { + name: 'backbone-min.js', + exact: true, + }); + const sourceMap = page.getByRole('link', { + name: 'backbone-min.map', + exact: true, + }); + await expect(script).toBeVisible(); + await expect(sourceMap).toBeVisible(); + + await filter.selectOption({ label: 'Source Maps' }); + + await expect(filter).toHaveValue('Source Maps'); + await expect(sourceMap).toBeVisible(); + await expect(script).toHaveCount(0); + }); +}); diff --git a/src/utils/spec/playwright.ts b/src/utils/spec/playwright.ts new file mode 100644 index 0000000..f456008 --- /dev/null +++ b/src/utils/spec/playwright.ts @@ -0,0 +1,85 @@ +import { test as base, expect } from '@playwright/test'; +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 { + server: TestHarness; +} + +export const test = base.extend({ + server: [ + 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(server); + } finally { + await server.close(); + } + }, + { scope: 'worker', timeout: 120_000 }, + ], + 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 }; 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: [