[READ-ONLY] Mirror of https://github.com/danielroe/cross-origin-storage. Load shared dependencies from Cross-Origin Storage (COS).
cross-origin-storage experimental nuxt vite vite-plugin
0

Configure Feed

Select the types of activity you want to include in your feed.

test: add e2e test suite

+297 -14
+6 -1
.github/workflows/ci.yml
··· 45 45 - name: Playground prepare 46 46 run: npm run dev:prepare 47 47 48 + - name: Install Playwright browsers 49 + run: npx playwright-core install --with-deps chromium 50 + 48 51 - name: Test 49 - run: npm run test 52 + # xvfb provides a display so the COS extension can run in a headed 53 + # Chrome for Testing instance; extensions do not load headless. 54 + run: xvfb-run --auto-servernum npm run test
+1
.gitignore
··· 54 54 Network Trash Folder 55 55 Temporary Items 56 56 .apdisk 57 + test/.cos-extension
+1
package.json
··· 47 47 "changelogen": "^0.6.2", 48 48 "eslint": "^10.4.1", 49 49 "nuxt": "^4.4.8", 50 + "playwright-core": "^1.61.1", 50 51 "typescript": "~6.0.3", 51 52 "vitest": "^4.1.8", 52 53 "vue-tsc": "^3.3.3"
+16 -5
pnpm-lock.yaml
··· 32 32 version: 4.4.8 33 33 '@nuxt/test-utils': 34 34 specifier: ^4.0.3 35 - 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))) 35 + 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))) 36 36 '@types/node': 37 37 specifier: latest 38 38 version: 26.0.0 ··· 45 45 nuxt: 46 46 specifier: ^4.4.8 47 47 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) 48 + playwright-core: 49 + specifier: ^1.61.1 50 + version: 1.61.1 48 51 typescript: 49 52 specifier: ~6.0.3 50 53 version: 6.0.3 ··· 3735 3738 pkg-types@2.3.1: 3736 3739 resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} 3737 3740 3741 + playwright-core@1.61.1: 3742 + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} 3743 + engines: {node: '>=18'} 3744 + hasBin: true 3745 + 3738 3746 pluralize@8.0.0: 3739 3747 resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} 3740 3748 engines: {node: '>=4'} ··· 5932 5940 rc9: 3.0.1 5933 5941 std-env: 4.1.0 5934 5942 5935 - '@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)))': 5943 + '@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)))': 5936 5944 dependencies: 5937 5945 '@clack/prompts': 1.2.0 5938 5946 '@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 5969 tinyexec: 1.2.4 5962 5970 ufo: 1.6.4 5963 5971 unplugin: 3.0.0 5964 - 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))) 5972 + 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))) 5965 5973 vue: 3.5.38(typescript@6.0.3) 5966 5974 optionalDependencies: 5975 + playwright-core: 1.61.1 5967 5976 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)) 5968 5977 transitivePeerDependencies: 5969 5978 - crossws ··· 8777 8786 exsolve: 1.0.8 8778 8787 pathe: 2.0.3 8779 8788 8789 + playwright-core@1.61.1: {} 8790 + 8780 8791 pluralize@8.0.0: {} 8781 8792 8782 8793 postcss-calc@10.1.1(postcss@8.5.15): ··· 9831 9842 terser: 5.48.0 9832 9843 yaml: 2.9.0 9833 9844 9834 - 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))): 9845 + 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))): 9835 9846 dependencies: 9836 - '@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))) 9847 + '@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))) 9837 9848 transitivePeerDependencies: 9838 9849 - '@cucumber/cucumber' 9839 9850 - '@jest/globals'
+17 -2
src/module.ts
··· 1 1 import { createHash } from 'node:crypto' 2 2 import { defineNuxtModule, addServerPlugin, addVitePlugin, createResolver } from '@nuxt/kit' 3 3 import { rolldown } from 'rolldown' 4 - import { runCosLoader } from './runtime/loader' 5 4 import type { CosManifest } from './runtime/loader' 5 + 6 + const MANIFEST_PLACEHOLDER = '__COS_MANIFEST__' 7 + 8 + /** 9 + * Bundle the runtime loader into a self-contained IIFE with rolldown, leaving 10 + * `__COS_MANIFEST__` as a literal token for the caller to substitute. 11 + */ 12 + async function bundleLoader(entry: string): Promise<string> { 13 + const builder = await rolldown({ input: entry, platform: 'browser', treeshake: true }) 14 + const { output } = await builder.generate({ format: 'iife', minify: true }) 15 + await builder.close() 16 + return output[0].code 17 + } 6 18 7 19 export interface ModuleOptions { 8 20 /** ··· 41 53 const resolver = createResolver(import.meta.url) 42 54 const packages = options.packages.map(p => typeof p === 'string' ? new RegExp(`^${p}$`) : p) 43 55 56 + const loaderEntry = resolver.resolve('./runtime/loader.entry') 57 + let loaderTemplate: Promise<string> | undefined 44 58 let scriptContent = '' 45 59 46 60 nuxt.options.nitro.virtual ||= {} ··· 159 173 } 160 174 161 175 const manifest: CosManifest = { base: '/_nuxt/', entry, chunks: managed } 162 - scriptContent = `(${runCosLoader.toString()})(${JSON.stringify(manifest)})` 176 + loaderTemplate ??= bundleLoader(loaderEntry) 177 + scriptContent = (await loaderTemplate).replace(MANIFEST_PLACEHOLDER, JSON.stringify(manifest)) 163 178 }, 164 179 }), { client: true, server: false }) 165 180 },
+6
src/runtime/loader.entry.ts
··· 1 + import { runCosLoader } from './loader' 2 + import type { CosManifest } from './loader' 3 + 4 + declare const __COS_MANIFEST__: CosManifest 5 + 6 + runCosLoader(__COS_MANIFEST__)
+125
test/browser.test.ts
··· 1 + import { mkdtemp, rm } from 'node:fs/promises' 2 + import { tmpdir } from 'node:os' 3 + import { join } from 'node:path' 4 + import { fileURLToPath } from 'node:url' 5 + import { afterAll, beforeAll, describe, expect, it } from 'vitest' 6 + import { setup, url } from '@nuxt/test-utils/e2e' 7 + import type { Page } from 'playwright-core' 8 + import { assertExtensionRunnable, launchPlainBrowser, launchWithExtension, skipExtensionTest } from './utils/browser' 9 + 10 + const cosChunkPattern = /\/_nuxt\/[a-f0-9]{64}\.js$/ 11 + 12 + // The COS extension's content scripts match `http://localhost:*` but not the 13 + // `127.0.0.1` host that @nuxt/test-utils binds to; both are loopback. 14 + function localhost(target: string): string { 15 + return target.replace('127.0.0.1', 'localhost') 16 + } 17 + 18 + async function hydrate(page: Page): Promise<{ errors: string[], failed: string[] }> { 19 + const errors: string[] = [] 20 + const failed: string[] = [] 21 + page.on('console', msg => msg.type() === 'error' && errors.push(msg.text())) 22 + page.on('requestfailed', req => failed.push(req.url())) 23 + 24 + await page.goto(url('/'), { waitUntil: 'networkidle' }) 25 + 26 + const importMap = await page.locator('script[type="importmap"]').textContent() 27 + expect(importMap).toMatch(/cos1:[a-f0-9]{64}/) 28 + 29 + // Hydration only completes if the whole cos chunk graph resolved and ran. 30 + await page.locator('button').click() 31 + await page.locator('p', { hasText: 'count: 1' }).waitFor({ timeout: 5000 }) 32 + expect(await page.locator('p').textContent()).toBe('count: 1') 33 + 34 + return { errors, failed } 35 + } 36 + 37 + describe('browser', async () => { 38 + await setup({ 39 + rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), 40 + build: true, 41 + }) 42 + 43 + describe('without the cos extension (network fallback)', () => { 44 + it('hydrates the app and loads every managed chunk over the network', async () => { 45 + const browser = await launchPlainBrowser() 46 + try { 47 + const page = await browser.newPage() 48 + const cosChunks = new Set<string>() 49 + page.on('response', (res) => { 50 + const path = new URL(res.url()).pathname 51 + if (cosChunkPattern.test(path)) { 52 + expect(res.status(), `${path} returned ${res.status()}`).toBe(200) 53 + cosChunks.add(path) 54 + } 55 + }) 56 + 57 + const { errors, failed } = await hydrate(page) 58 + 59 + expect(await page.evaluate(() => 'crossOriginStorage' in navigator)).toBe(false) 60 + // vue + runtime-dom + runtime-core + reactivity + shared 61 + expect(cosChunks.size).toBe(5) 62 + expect(failed, `failed requests: ${failed.join(', ')}`).toEqual([]) 63 + expect(errors, `console errors: ${errors.join(', ')}`).toEqual([]) 64 + } 65 + finally { 66 + await browser.close() 67 + } 68 + }) 69 + }) 70 + 71 + // Skip only when explicitly opted out; otherwise a missing extension or 72 + // browser is a setup failure and throws in beforeAll. 73 + describe.skipIf(skipExtensionTest())('with the cos extension', () => { 74 + let userDataDir: string 75 + 76 + beforeAll(async () => { 77 + assertExtensionRunnable() 78 + userDataDir = await mkdtemp(join(tmpdir(), 'cos-ext-')) 79 + }) 80 + 81 + afterAll(async () => { 82 + await rm(userDataDir, { recursive: true, force: true }) 83 + }) 84 + 85 + it('stores chunks in cos on first load, then serves them from cos without the network', async () => { 86 + const context = await launchWithExtension(userDataDir) 87 + try { 88 + const page = await context.newPage() 89 + const cosErrors: string[] = [] 90 + page.on('console', (msg) => { 91 + if (msg.type() === 'error' && msg.text().includes('[cos]')) cosErrors.push(msg.text()) 92 + }) 93 + 94 + // First load: cold cache, the extension injects the API and the loader 95 + // stores every managed chunk in COS. 96 + await page.goto(localhost(url('/')), { waitUntil: 'networkidle' }) 97 + expect(await page.evaluate(() => 'crossOriginStorage' in navigator)).toBe(true) 98 + 99 + // The store is async; give it a beat to settle. A declared hash that 100 + // disagrees with the served bytes surfaces here as a COS store error. 101 + await page.waitForTimeout(500) 102 + expect(cosErrors, `cos errors on first load: ${cosErrors.join(' | ')}`).toEqual([]) 103 + 104 + // Second load: chunks must come from COS, not the network. The 105 + // persistent context keeps the COS store across reloads. 106 + const networkCosChunks: string[] = [] 107 + page.on('response', (res) => { 108 + if (cosChunkPattern.test(new URL(res.url()).pathname)) { 109 + networkCosChunks.push(res.url()) 110 + } 111 + }) 112 + await page.reload({ waitUntil: 'networkidle' }) 113 + 114 + await page.locator('button').click() 115 + await page.locator('p', { hasText: 'count: 1' }).waitFor({ timeout: 5000 }) 116 + expect(await page.locator('p').textContent()).toBe('count: 1') 117 + 118 + expect(networkCosChunks, `chunks fetched from network instead of COS: ${networkCosChunks.join(', ')}`).toEqual([]) 119 + } 120 + finally { 121 + await context.close() 122 + } 123 + }) 124 + }) 125 + })
+17
test/global-setup.ts
··· 1 + import { execSync } from 'node:child_process' 2 + import { existsSync } from 'node:fs' 3 + import { fileURLToPath } from 'node:url' 4 + 5 + const COS_EXTENSION_REPO = 'https://github.com/web-ai-community/cross-origin-storage-extension' 6 + 7 + /** 8 + * Clone the Cross-Origin Storage browser extension so the e2e suite can 9 + * exercise the real COS cache path. 10 + */ 11 + export default function setup(): void { 12 + const extensionDir = fileURLToPath(new URL('./.cos-extension', import.meta.url)) 13 + if (existsSync(extensionDir)) { 14 + return 15 + } 16 + execSync(`git clone --depth 1 ${COS_EXTENSION_REPO} ${extensionDir}`, { stdio: 'inherit' }) 17 + }
+29 -6
test/ssr.test.ts
··· 1 1 import { fileURLToPath } from 'node:url' 2 2 import { describe, expect, it } from 'vitest' 3 3 import { $fetch, setup } from '@nuxt/test-utils/e2e' 4 + import type { CosManifest } from '../src/runtime/loader' 5 + 6 + function parseManifest(html: string): CosManifest { 7 + const start = html.indexOf('{"base":') 8 + expect(start, 'cos manifest not found in loader script').toBeGreaterThan(-1) 9 + 10 + let depth = 0 11 + for (let i = start; i < html.length; i++) { 12 + if (html[i] === '{') depth++ 13 + else if (html[i] === '}' && --depth === 0) { 14 + return JSON.parse(html.slice(start, i + 1)) as CosManifest 15 + } 16 + } 17 + throw new Error('unterminated cos manifest') 18 + } 4 19 5 20 describe('ssr', async () => { 6 21 await setup({ ··· 20 35 expect(html).not.toMatch(/<script type="module"[^>]*src="\/_nuxt\/[^"]*"/) 21 36 }) 22 37 23 - it('inlines a manifest whose entry resolves to a managed chunk', async () => { 24 - const html = await $fetch('/') 25 - const entry = html.match(/"entry":"(cos1:[a-f0-9]{64})"/)?.[1] 26 - expect(entry).toBeDefined() 27 - const chunks = html.match(/"chunks":\{(.+?)\}\}\)/)?.[1] ?? '' 28 - expect(chunks).toContain(`"${entry}":`) 38 + it('keys every managed chunk by the content hash it declares', async () => { 39 + const { chunks } = parseManifest(await $fetch('/')) 40 + expect(Object.keys(chunks).length).toBe(5) 41 + for (const [specifier, { hash }] of Object.entries(chunks)) { 42 + expect(specifier).toBe(`cos1:${hash}`) 43 + } 44 + }) 45 + 46 + it('declares an entry that is loaded outside cos', async () => { 47 + const { entry, chunks } = parseManifest(await $fetch('/')) 48 + expect(entry.specifier).toBe('cos1:entry') 49 + expect(entry.file).toBeTruthy() 50 + // The entry is app-specific and must not be a COS-managed chunk. 51 + expect(chunks[entry.specifier]).toBeUndefined() 29 52 }) 30 53 })
+67
test/utils/browser.ts
··· 1 + import { existsSync } from 'node:fs' 2 + import { fileURLToPath } from 'node:url' 3 + import { chromium } from 'playwright-core' 4 + import type { Browser, BrowserContext } from 'playwright-core' 5 + 6 + export const extensionDir = fileURLToPath(new URL('../.cos-extension', import.meta.url)) 7 + 8 + /** 9 + * The COS extension only loads in a full, headed Chrome for Testing build: 10 + * headless mode and the `chrome-headless-shell` binary both disable extensions, 11 + * and the system Chrome blocks `--load-extension`. 12 + */ 13 + function hasFullChromium(): boolean { 14 + try { 15 + const executable = chromium.executablePath() 16 + return existsSync(executable) && !executable.includes('headless-shell') 17 + } 18 + catch { 19 + return false 20 + } 21 + } 22 + 23 + /** 24 + * Whether the COS test may skip itself. A missing extension-capable browser is 25 + * fatal by default so CI cannot pass green without running the real COS path; 26 + * only an environment that explicitly cannot run a headed browser (e.g. a 27 + * sandbox, via `COS_SKIP_EXTENSION_TEST=1`) is allowed to skip. 28 + */ 29 + export function skipExtensionTest(): boolean { 30 + return process.env.COS_SKIP_EXTENSION_TEST === '1' 31 + } 32 + 33 + export function assertExtensionRunnable(): void { 34 + if (!existsSync(extensionDir)) { 35 + throw new Error( 36 + `COS extension missing at ${extensionDir}. Global setup should have cloned it; ` 37 + + `run the suite via vitest so global-setup runs, or clone it manually.`, 38 + ) 39 + } 40 + if (!hasFullChromium()) { 41 + throw new Error( 42 + 'COS test requires a full Chrome for Testing build (extensions do not load in ' 43 + + 'headless mode or chrome-headless-shell). Run `npx playwright-core install chromium`. ' 44 + + 'Set COS_SKIP_EXTENSION_TEST=1 only in an environment that genuinely cannot run a headed browser.', 45 + ) 46 + } 47 + } 48 + 49 + export async function launchPlainBrowser(): Promise<Browser> { 50 + return chromium.launch({ headless: true }) 51 + } 52 + 53 + export async function launchWithExtension(userDataDir: string): Promise<BrowserContext> { 54 + const context = await chromium.launchPersistentContext(userDataDir, { 55 + headless: false, 56 + ignoreDefaultArgs: ['--disable-extensions'], 57 + args: [ 58 + `--disable-extensions-except=${extensionDir}`, 59 + `--load-extension=${extensionDir}`, 60 + '--no-first-run', 61 + '--no-default-browser-check', 62 + ], 63 + }) 64 + // Give the extension's service worker time to register before navigating. 65 + await new Promise(resolve => setTimeout(resolve, 2000)) 66 + return context 67 + }
+12
vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config' 2 + 3 + export default defineConfig({ 4 + test: { 5 + globalSetup: ['./test/global-setup.ts'], 6 + testTimeout: 30_000, 7 + hookTimeout: 240_000, 8 + // The fixture is built into shared `.output` / `.nuxt` dirs; running test 9 + // files in parallel makes their builds clobber each other. 10 + fileParallelism: false, 11 + }, 12 + })