diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c40ac3..6c32ecb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,5 +45,10 @@ jobs: - name: Playground prepare run: npm run dev:prepare + - name: Install Playwright browsers + run: npx playwright-core install --with-deps chromium + - name: Test - run: npm run test + # xvfb provides a display so the COS extension can run in a headed + # Chrome for Testing instance; extensions do not load headless. + run: xvfb-run --auto-servernum npm run test diff --git a/.gitignore b/.gitignore index 7a3eb22..9f726d4 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ coverage Network Trash Folder Temporary Items .apdisk +test/.cos-extension diff --git a/package.json b/package.json index 69c888f..46e7660 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "changelogen": "^0.6.2", "eslint": "^10.4.1", "nuxt": "^4.4.8", + "playwright-core": "^1.61.1", "typescript": "~6.0.3", "vitest": "^4.1.8", "vue-tsc": "^3.3.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 146e5fb..a80f754 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: version: 4.4.8 '@nuxt/test-utils': specifier: ^4.0.3 - version: 4.0.3(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) + version: 4.0.3(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(playwright-core@1.61.1)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) '@types/node': specifier: latest version: 26.0.0 @@ -45,6 +45,9 @@ importers: nuxt: specifier: ^4.4.8 version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@26.0.0)(@vue/compiler-sfc@3.5.38)(cac@6.7.14)(db0@0.3.4)(eslint@10.5.0(jiti@2.7.0))(ioredis@5.11.1)(magicast@0.5.3)(optionator@0.9.4)(rolldown@1.1.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.1.0)(rollup@4.62.0))(rollup@4.62.0)(srvx@0.11.16)(terser@5.48.0)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vue-tsc@3.3.5(typescript@6.0.3))(yaml@2.9.0) + playwright-core: + specifier: ^1.61.1 + version: 1.61.1 typescript: specifier: ~6.0.3 version: 6.0.3 @@ -3735,6 +3738,11 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -5932,7 +5940,7 @@ snapshots: rc9: 3.0.1 std-env: 4.1.0 - '@nuxt/test-utils@4.0.3(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))': + '@nuxt/test-utils@4.0.3(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(playwright-core@1.61.1)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)))': dependencies: '@clack/prompts': 1.2.0 '@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) @@ -5961,9 +5969,10 @@ snapshots: tinyexec: 1.2.4 ufo: 1.6.4 unplugin: 3.0.0 - vitest-environment-nuxt: 2.0.0(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) + vitest-environment-nuxt: 2.0.0(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(playwright-core@1.61.1)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) vue: 3.5.38(typescript@6.0.3) optionalDependencies: + playwright-core: 1.61.1 vitest: 4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) transitivePeerDependencies: - crossws @@ -8777,6 +8786,8 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.61.1: {} + pluralize@8.0.0: {} postcss-calc@10.1.1(postcss@8.5.15): @@ -9831,9 +9842,9 @@ snapshots: terser: 5.48.0 yaml: 2.9.0 - vitest-environment-nuxt@2.0.0(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))): + vitest-environment-nuxt@2.0.0(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(playwright-core@1.61.1)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))): dependencies: - '@nuxt/test-utils': 4.0.3(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) + '@nuxt/test-utils': 4.0.3(crossws@0.4.6(srvx@0.11.16))(magicast@0.5.3)(playwright-core@1.61.1)(typescript@6.0.3)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))(vitest@4.1.8(@types/node@26.0.0)(vite@7.3.5(@types/node@26.0.0)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))) transitivePeerDependencies: - '@cucumber/cucumber' - '@jest/globals' diff --git a/src/module.ts b/src/module.ts index ac7237a..a3f5cd3 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,9 +1,21 @@ import { createHash } from 'node:crypto' import { defineNuxtModule, addServerPlugin, addVitePlugin, createResolver } from '@nuxt/kit' import { rolldown } from 'rolldown' -import { runCosLoader } from './runtime/loader' import type { CosManifest } from './runtime/loader' +const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__' + +/** + * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving + * `__COS_MANIFEST__` as a literal token for the caller to substitute. + */ +async function bundleLoader(entry: string): Promise { + const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true }) + const { output } = await builder.generate({ format: 'iife', minify: true }) + await builder.close() + return output[0].code +} + export interface ModuleOptions { /** * Packages to extract into standalone Cross-Origin Storage chunks. @@ -41,6 +53,8 @@ export default defineNuxtModule({ const resolver = createResolver(import.meta.url) const packages = options.packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p) + const loaderEntry = resolver.resolve('./runtime/loader.entry') + let loaderTemplate: Promise | undefined let scriptContent = '' nuxt.options.nitro.virtual ||= {} @@ -159,7 +173,8 @@ export default defineNuxtModule({ } const manifest: CosManifest = { base: '/_nuxt/', entry, chunks: managed } - scriptContent = `(${runCosLoader.toString()})(${JSON.stringify(manifest)})` + loaderTemplate ??= bundleLoader(loaderEntry) + scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest)) }, }), { client: true, server: false }) }, diff --git a/src/runtime/loader.entry.ts b/src/runtime/loader.entry.ts new file mode 100644 index 0000000..c1f6f5a --- /dev/null +++ b/src/runtime/loader.entry.ts @@ -0,0 +1,6 @@ +import { runCosLoader } from './loader' +import type { CosManifest } from './loader' + +declare const __COS_MANIFEST__: CosManifest + +runCosLoader(__COS_MANIFEST__) diff --git a/test/browser.test.ts b/test/browser.test.ts new file mode 100644 index 0000000..c015326 --- /dev/null +++ b/test/browser.test.ts @@ -0,0 +1,125 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { setup, url } from '@nuxt/test-utils/e2e' +import type { Page } from 'playwright-core' +import { assertExtensionRunnable, launchPlainBrowser, launchWithExtension, skipExtensionTest } from './utils/browser' + +const cosChunkPattern = /\/_nuxt\/[a-f0-9]{64}\.js$/ + +// The COS extension's content scripts match `http://localhost:*` but not the +// `127.0.0.1` host that @nuxt/test-utils binds to; both are loopback. +function localhost(target: string): string { + return target.replace('127.0.0.1', 'localhost') +} + +async function hydrate(page: Page): Promise<{ errors: string[], failed: string[] }> { + const errors: string[] = [] + const failed: string[] = [] + page.on('console', msg => msg.type() === 'error' && errors.push(msg.text())) + page.on('requestfailed', req => failed.push(req.url())) + + await page.goto(url('/'), { waitUntil: 'networkidle' }) + + const importMap = await page.locator('script[type="importmap"]').textContent() + expect(importMap).toMatch(/cos1:[a-f0-9]{64}/) + + // Hydration only completes if the whole cos chunk graph resolved and ran. + await page.locator('button').click() + await page.locator('p', { hasText: 'count: 1' }).waitFor({ timeout: 5000 }) + expect(await page.locator('p').textContent()).toBe('count: 1') + + return { errors, failed } +} + +describe('browser', async () => { + await setup({ + rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), + build: true, + }) + + describe('without the cos extension (network fallback)', () => { + it('hydrates the app and loads every managed chunk over the network', async () => { + const browser = await launchPlainBrowser() + try { + const page = await browser.newPage() + const cosChunks = new Set() + page.on('response', (res) => { + const path = new URL(res.url()).pathname + if (cosChunkPattern.test(path)) { + expect(res.status(), `${path} returned ${res.status()}`).toBe(200) + cosChunks.add(path) + } + }) + + const { errors, failed } = await hydrate(page) + + expect(await page.evaluate(() => 'crossOriginStorage' in navigator)).toBe(false) + // vue + runtime-dom + runtime-core + reactivity + shared + expect(cosChunks.size).toBe(5) + expect(failed, `failed requests: ${failed.join(', ')}`).toEqual([]) + expect(errors, `console errors: ${errors.join(', ')}`).toEqual([]) + } + finally { + await browser.close() + } + }) + }) + + // Skip only when explicitly opted out; otherwise a missing extension or + // browser is a setup failure and throws in beforeAll. + describe.skipIf(skipExtensionTest())('with the cos extension', () => { + let userDataDir: string + + beforeAll(async () => { + assertExtensionRunnable() + userDataDir = await mkdtemp(join(tmpdir(), 'cos-ext-')) + }) + + afterAll(async () => { + await rm(userDataDir, { recursive: true, force: true }) + }) + + it('stores chunks in cos on first load, then serves them from cos without the network', async () => { + const context = await launchWithExtension(userDataDir) + try { + const page = await context.newPage() + const cosErrors: string[] = [] + page.on('console', (msg) => { + if (msg.type() === 'error' && msg.text().includes('[cos]')) cosErrors.push(msg.text()) + }) + + // First load: cold cache, the extension injects the API and the loader + // stores every managed chunk in COS. + await page.goto(localhost(url('/')), { waitUntil: 'networkidle' }) + expect(await page.evaluate(() => 'crossOriginStorage' in navigator)).toBe(true) + + // The store is async; give it a beat to settle. A declared hash that + // disagrees with the served bytes surfaces here as a COS store error. + await page.waitForTimeout(500) + expect(cosErrors, `cos errors on first load: ${cosErrors.join(' | ')}`).toEqual([]) + + // Second load: chunks must come from COS, not the network. The + // persistent context keeps the COS store across reloads. + const networkCosChunks: string[] = [] + page.on('response', (res) => { + if (cosChunkPattern.test(new URL(res.url()).pathname)) { + networkCosChunks.push(res.url()) + } + }) + await page.reload({ waitUntil: 'networkidle' }) + + await page.locator('button').click() + await page.locator('p', { hasText: 'count: 1' }).waitFor({ timeout: 5000 }) + expect(await page.locator('p').textContent()).toBe('count: 1') + + expect(networkCosChunks, `chunks fetched from network instead of COS: ${networkCosChunks.join(', ')}`).toEqual([]) + } + finally { + await context.close() + } + }) + }) +}) diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 0000000..b65b6e2 --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,17 @@ +import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const COS_EXTENSION_REPO = 'https://github.com/web-ai-community/cross-origin-storage-extension' + +/** + * Clone the Cross-Origin Storage browser extension so the e2e suite can + * exercise the real COS cache path. + */ +export default function setup(): void { + const extensionDir = fileURLToPath(new URL('./.cos-extension', import.meta.url)) + if (existsSync(extensionDir)) { + return + } + execSync(`git clone --depth 1 ${COS_EXTENSION_REPO} ${extensionDir}`, { stdio: 'inherit' }) +} diff --git a/test/ssr.test.ts b/test/ssr.test.ts index 349c5f4..7dc9e59 100644 --- a/test/ssr.test.ts +++ b/test/ssr.test.ts @@ -1,6 +1,21 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { $fetch, setup } from '@nuxt/test-utils/e2e' +import type { CosManifest } from '../src/runtime/loader' + +function parseManifest(html: string): CosManifest { + const start = html.indexOf('{"base":') + expect(start, 'cos manifest not found in loader script').toBeGreaterThan(-1) + + let depth = 0 + for (let i = start; i < html.length; i++) { + if (html[i] === '{') depth++ + else if (html[i] === '}' && --depth === 0) { + return JSON.parse(html.slice(start, i + 1)) as CosManifest + } + } + throw new Error('unterminated cos manifest') +} describe('ssr', async () => { await setup({ @@ -20,11 +35,19 @@ describe('ssr', async () => { expect(html).not.toMatch(/