[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
1import { mkdtemp, rm } from 'node:fs/promises'
2import { tmpdir } from 'node:os'
3import { join } from 'node:path'
4import { fileURLToPath } from 'node:url'
5import { afterAll, beforeAll, describe, expect, it } from 'vitest'
6import { setup, url } from '@nuxt/test-utils/e2e'
7import type { Page } from 'playwright-core'
8import { assertExtensionRunnable, launchPlainBrowser, launchWithExtension, skipExtensionTest } from './utils/browser'
9
10const 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.
14function localhost(target: string): string {
15 return target.replace('127.0.0.1', 'localhost')
16}
17
18async 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
37describe('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})