···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. A failed clone is fatal: the COS test
1010+ * must not silently skip just because setup couldn't fetch the extension.
1111+ */
1212+export default function setup(): void {
1313+ const extensionDir = fileURLToPath(new URL('./.cos-extension', import.meta.url))
1414+ if (existsSync(extensionDir)) {
1515+ return
1616+ }
1717+ execSync(`git clone --depth 1 ${COS_EXTENSION_REPO} ${extensionDir}`, { stdio: 'inherit' })
1818+}
···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+}
···11+import { defineConfig } from 'tsdown'
22+33+export default defineConfig({
44+ entry: ['src/index.ts', 'src/loader.entry.ts'],
55+ format: ['es'],
66+ dts: true,
77+ // The loader entry is bundled by the plugin at build time via rolldown, so it
88+ // must be emitted as a standalone file rather than inlined into the plugin.
99+ unbundle: true,
1010+})
···44import { join } from 'node:path'
55import { afterAll, beforeAll, describe, expect, it } from 'vitest'
66import { build } from 'vite'
77-import { cosPlugin } from '../src/vite'
77+import { cosPlugin } from '../src/index'
88import type { Alias } from 'vite'
991010// Build inside the project tree so fixtures resolve packages from the project
1111// node_modules rather than a detached temp dir.
1212const scratchRoot = fileURLToPath(new URL('./.plugin-scratch', import.meta.url))
1313-const nodeModules = fileURLToPath(new URL('../node_modules', import.meta.url))
1313+// `.pnpm` lives in the workspace-root node_modules.
1414+const nodeModules = fileURLToPath(new URL('../../../node_modules', import.meta.url))
14151516function resolvePkg(glob: string): string {
1617 const match = globSync(glob, { cwd: nodeModules })[0]