···4545 - name: Playground prepare
4646 run: npm run dev:prepare
47474848+ - name: Install Playwright browsers
4949+ run: npx playwright-core install --with-deps chromium
5050+4851 - name: Test
4949- run: npm run test
5252+ # xvfb provides a display so the COS extension can run in a headed
5353+ # Chrome for Testing instance; extensions do not load headless.
5454+ run: xvfb-run --auto-servernum npm run test
···11import { createHash } from 'node:crypto'
22import { defineNuxtModule, addServerPlugin, addVitePlugin, createResolver } from '@nuxt/kit'
33import { rolldown } from 'rolldown'
44-import { runCosLoader } from './runtime/loader'
54import type { CosManifest } from './runtime/loader'
55+66+const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__'
77+88+/**
99+ * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving
1010+ * `__COS_MANIFEST__` as a literal token for the caller to substitute.
1111+ */
1212+async function bundleLoader(entry: string): Promise<string> {
1313+ const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true })
1414+ const { output } = await builder.generate({ format: 'iife', minify: true })
1515+ await builder.close()
1616+ return output[0].code
1717+}
618719export interface ModuleOptions {
820 /**
···4153 const resolver = createResolver(import.meta.url)
4254 const packages = options.packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p)
43555656+ const loaderEntry = resolver.resolve('./runtime/loader.entry')
5757+ let loaderTemplate: Promise<string> | undefined
4458 let scriptContent = ''
45594660 nuxt.options.nitro.virtual ||= {}
···159173 }
160174161175 const manifest: CosManifest = { base: '/_nuxt/', entry, chunks: managed }
162162- scriptContent = `(${runCosLoader.toString()})(${JSON.stringify(manifest)})`
176176+ loaderTemplate ??= bundleLoader(loaderEntry)
177177+ scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest))
163178 },
164179 }), { client: true, server: false })
165180 },
+6
src/runtime/loader.entry.ts
···11+import { runCosLoader } from './loader'
22+import type { CosManifest } from './loader'
33+44+declare const __COS_MANIFEST__: CosManifest
55+66+runCosLoader(__COS_MANIFEST__)
+125
test/browser.test.ts
···11+import { mkdtemp, rm } from 'node:fs/promises'
22+import { tmpdir } from 'node:os'
33+import { join } from 'node:path'
44+import { fileURLToPath } from 'node:url'
55+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
66+import { setup, url } from '@nuxt/test-utils/e2e'
77+import type { Page } from 'playwright-core'
88+import { assertExtensionRunnable, launchPlainBrowser, launchWithExtension, skipExtensionTest } from './utils/browser'
99+1010+const cosChunkPattern = /\/_nuxt\/[a-f0-9]{64}\.js$/
1111+1212+// The COS extension's content scripts match `http://localhost:*` but not the
1313+// `127.0.0.1` host that @nuxt/test-utils binds to; both are loopback.
1414+function localhost(target: string): string {
1515+ return target.replace('127.0.0.1', 'localhost')
1616+}
1717+1818+async function hydrate(page: Page): Promise<{ errors: string[], failed: string[] }> {
1919+ const errors: string[] = []
2020+ const failed: string[] = []
2121+ page.on('console', msg => msg.type() === 'error' && errors.push(msg.text()))
2222+ page.on('requestfailed', req => failed.push(req.url()))
2323+2424+ await page.goto(url('/'), { waitUntil: 'networkidle' })
2525+2626+ const importMap = await page.locator('script[type="importmap"]').textContent()
2727+ expect(importMap).toMatch(/cos1:[a-f0-9]{64}/)
2828+2929+ // Hydration only completes if the whole cos chunk graph resolved and ran.
3030+ await page.locator('button').click()
3131+ await page.locator('p', { hasText: 'count: 1' }).waitFor({ timeout: 5000 })
3232+ expect(await page.locator('p').textContent()).toBe('count: 1')
3333+3434+ return { errors, failed }
3535+}
3636+3737+describe('browser', async () => {
3838+ await setup({
3939+ rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
4040+ build: true,
4141+ })
4242+4343+ describe('without the cos extension (network fallback)', () => {
4444+ it('hydrates the app and loads every managed chunk over the network', async () => {
4545+ const browser = await launchPlainBrowser()
4646+ try {
4747+ const page = await browser.newPage()
4848+ const cosChunks = new Set<string>()
4949+ page.on('response', (res) => {
5050+ const path = new URL(res.url()).pathname
5151+ if (cosChunkPattern.test(path)) {
5252+ expect(res.status(), `${path} returned ${res.status()}`).toBe(200)
5353+ cosChunks.add(path)
5454+ }
5555+ })
5656+5757+ const { errors, failed } = await hydrate(page)
5858+5959+ expect(await page.evaluate(() => 'crossOriginStorage' in navigator)).toBe(false)
6060+ // vue + runtime-dom + runtime-core + reactivity + shared
6161+ expect(cosChunks.size).toBe(5)
6262+ expect(failed, `failed requests: ${failed.join(', ')}`).toEqual([])
6363+ expect(errors, `console errors: ${errors.join(', ')}`).toEqual([])
6464+ }
6565+ finally {
6666+ await browser.close()
6767+ }
6868+ })
6969+ })
7070+7171+ // Skip only when explicitly opted out; otherwise a missing extension or
7272+ // browser is a setup failure and throws in beforeAll.
7373+ describe.skipIf(skipExtensionTest())('with the cos extension', () => {
7474+ let userDataDir: string
7575+7676+ beforeAll(async () => {
7777+ assertExtensionRunnable()
7878+ userDataDir = await mkdtemp(join(tmpdir(), 'cos-ext-'))
7979+ })
8080+8181+ afterAll(async () => {
8282+ await rm(userDataDir, { recursive: true, force: true })
8383+ })
8484+8585+ it('stores chunks in cos on first load, then serves them from cos without the network', async () => {
8686+ const context = await launchWithExtension(userDataDir)
8787+ try {
8888+ const page = await context.newPage()
8989+ const cosErrors: string[] = []
9090+ page.on('console', (msg) => {
9191+ if (msg.type() === 'error' && msg.text().includes('[cos]')) cosErrors.push(msg.text())
9292+ })
9393+9494+ // First load: cold cache, the extension injects the API and the loader
9595+ // stores every managed chunk in COS.
9696+ await page.goto(localhost(url('/')), { waitUntil: 'networkidle' })
9797+ expect(await page.evaluate(() => 'crossOriginStorage' in navigator)).toBe(true)
9898+9999+ // The store is async; give it a beat to settle. A declared hash that
100100+ // disagrees with the served bytes surfaces here as a COS store error.
101101+ await page.waitForTimeout(500)
102102+ expect(cosErrors, `cos errors on first load: ${cosErrors.join(' | ')}`).toEqual([])
103103+104104+ // Second load: chunks must come from COS, not the network. The
105105+ // persistent context keeps the COS store across reloads.
106106+ const networkCosChunks: string[] = []
107107+ page.on('response', (res) => {
108108+ if (cosChunkPattern.test(new URL(res.url()).pathname)) {
109109+ networkCosChunks.push(res.url())
110110+ }
111111+ })
112112+ await page.reload({ waitUntil: 'networkidle' })
113113+114114+ await page.locator('button').click()
115115+ await page.locator('p', { hasText: 'count: 1' }).waitFor({ timeout: 5000 })
116116+ expect(await page.locator('p').textContent()).toBe('count: 1')
117117+118118+ expect(networkCosChunks, `chunks fetched from network instead of COS: ${networkCosChunks.join(', ')}`).toEqual([])
119119+ }
120120+ finally {
121121+ await context.close()
122122+ }
123123+ })
124124+ })
125125+})
+17
test/global-setup.ts
···11+import { execSync } from 'node:child_process'
22+import { existsSync } from 'node:fs'
33+import { fileURLToPath } from 'node:url'
44+55+const COS_EXTENSION_REPO = 'https://github.com/web-ai-community/cross-origin-storage-extension'
66+77+/**
88+ * Clone the Cross-Origin Storage browser extension so the e2e suite can
99+ * exercise the real COS cache path.
1010+ */
1111+export default function setup(): void {
1212+ const extensionDir = fileURLToPath(new URL('./.cos-extension', import.meta.url))
1313+ if (existsSync(extensionDir)) {
1414+ return
1515+ }
1616+ execSync(`git clone --depth 1 ${COS_EXTENSION_REPO} ${extensionDir}`, { stdio: 'inherit' })
1717+}
+29-6
test/ssr.test.ts
···11import { fileURLToPath } from 'node:url'
22import { describe, expect, it } from 'vitest'
33import { $fetch, setup } from '@nuxt/test-utils/e2e'
44+import type { CosManifest } from '../src/runtime/loader'
55+66+function parseManifest(html: string): CosManifest {
77+ const start = html.indexOf('{"base":')
88+ expect(start, 'cos manifest not found in loader script').toBeGreaterThan(-1)
99+1010+ let depth = 0
1111+ for (let i = start; i < html.length; i++) {
1212+ if (html[i] === '{') depth++
1313+ else if (html[i] === '}' && --depth === 0) {
1414+ return JSON.parse(html.slice(start, i + 1)) as CosManifest
1515+ }
1616+ }
1717+ throw new Error('unterminated cos manifest')
1818+}
419520describe('ssr', async () => {
621 await setup({
···2035 expect(html).not.toMatch(/<script type="module"[^>]*src="\/_nuxt\/[^"]*"/)
2136 })
22372323- it('inlines a manifest whose entry resolves to a managed chunk', async () => {
2424- const html = await $fetch('/')
2525- const entry = html.match(/"entry":"(cos1:[a-f0-9]{64})"/)?.[1]
2626- expect(entry).toBeDefined()
2727- const chunks = html.match(/"chunks":\{(.+?)\}\}\)/)?.[1] ?? ''
2828- expect(chunks).toContain(`"${entry}":`)
3838+ it('keys every managed chunk by the content hash it declares', async () => {
3939+ const { chunks } = parseManifest(await $fetch('/'))
4040+ expect(Object.keys(chunks).length).toBe(5)
4141+ for (const [specifier, { hash }] of Object.entries(chunks)) {
4242+ expect(specifier).toBe(`cos1:${hash}`)
4343+ }
4444+ })
4545+4646+ it('declares an entry that is loaded outside cos', async () => {
4747+ const { entry, chunks } = parseManifest(await $fetch('/'))
4848+ expect(entry.specifier).toBe('cos1:entry')
4949+ expect(entry.file).toBeTruthy()
5050+ // The entry is app-specific and must not be a COS-managed chunk.
5151+ expect(chunks[entry.specifier]).toBeUndefined()
2952 })
3053})
+67
test/utils/browser.ts
···11+import { existsSync } from 'node:fs'
22+import { fileURLToPath } from 'node:url'
33+import { chromium } from 'playwright-core'
44+import type { Browser, BrowserContext } from 'playwright-core'
55+66+export const extensionDir = fileURLToPath(new URL('../.cos-extension', import.meta.url))
77+88+/**
99+ * The COS extension only loads in a full, headed Chrome for Testing build:
1010+ * headless mode and the `chrome-headless-shell` binary both disable extensions,
1111+ * and the system Chrome blocks `--load-extension`.
1212+ */
1313+function hasFullChromium(): boolean {
1414+ try {
1515+ const executable = chromium.executablePath()
1616+ return existsSync(executable) && !executable.includes('headless-shell')
1717+ }
1818+ catch {
1919+ return false
2020+ }
2121+}
2222+2323+/**
2424+ * Whether the COS test may skip itself. A missing extension-capable browser is
2525+ * fatal by default so CI cannot pass green without running the real COS path;
2626+ * only an environment that explicitly cannot run a headed browser (e.g. a
2727+ * sandbox, via `COS_SKIP_EXTENSION_TEST=1`) is allowed to skip.
2828+ */
2929+export function skipExtensionTest(): boolean {
3030+ return process.env.COS_SKIP_EXTENSION_TEST === '1'
3131+}
3232+3333+export function assertExtensionRunnable(): void {
3434+ if (!existsSync(extensionDir)) {
3535+ throw new Error(
3636+ `COS extension missing at ${extensionDir}. Global setup should have cloned it; `
3737+ + `run the suite via vitest so global-setup runs, or clone it manually.`,
3838+ )
3939+ }
4040+ if (!hasFullChromium()) {
4141+ throw new Error(
4242+ 'COS test requires a full Chrome for Testing build (extensions do not load in '
4343+ + 'headless mode or chrome-headless-shell). Run `npx playwright-core install chromium`. '
4444+ + 'Set COS_SKIP_EXTENSION_TEST=1 only in an environment that genuinely cannot run a headed browser.',
4545+ )
4646+ }
4747+}
4848+4949+export async function launchPlainBrowser(): Promise<Browser> {
5050+ return chromium.launch({ headless: true })
5151+}
5252+5353+export async function launchWithExtension(userDataDir: string): Promise<BrowserContext> {
5454+ const context = await chromium.launchPersistentContext(userDataDir, {
5555+ headless: false,
5656+ ignoreDefaultArgs: ['--disable-extensions'],
5757+ args: [
5858+ `--disable-extensions-except=${extensionDir}`,
5959+ `--load-extension=${extensionDir}`,
6060+ '--no-first-run',
6161+ '--no-default-browser-check',
6262+ ],
6363+ })
6464+ // Give the extension's service worker time to register before navigating.
6565+ await new Promise(resolve => setTimeout(resolve, 2000))
6666+ return context
6767+}
+12
vitest.config.ts
···11+import { defineConfig } from 'vitest/config'
22+33+export default defineConfig({
44+ test: {
55+ globalSetup: ['./test/global-setup.ts'],
66+ testTimeout: 30_000,
77+ hookTimeout: 240_000,
88+ // The fixture is built into shared `.output` / `.nuxt` dirs; running test
99+ // files in parallel makes their builds clobber each other.
1010+ fileParallelism: false,
1111+ },
1212+})