/** * Stylesheets and package assets enter client bundles through plugin loaders, * which register their physical files as watch dependencies. Package assets * are emitted under `lib/assets/` with revisioned file names and referenced by document-relative URLs. */ import { existsSync } from 'node:fs' import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import type { UserConfig } from 'tsdown' import { afterEach, describe, expect, it } from 'vitest' import { packageAssetRevision } from '../packages/client/modules/src/assets.ts' import { clientBundle } from '../packages/client/tsdown.client.ts' interface EmittedAsset { type: 'asset' fileName: string source: Uint8Array originalFileName: string } interface PluginContext { emitFile(file: EmittedAsset): string addWatchFile(id: string): void } interface BundlePlugin { name: string buildStart?: (this: PluginContext) => void resolveId?: (source: string, importer?: string) => string | null load?: (this: PluginContext, id: string) => string | null | Promise writeBundle?: (outputOptions: { dir?: string }, bundle: Record) => Promise } type PluginName = 'dsh-css-modules-inline' | 'dsh-css-global-inline' | 'dsh-css-text-inline' | 'dsh-package-asset' const PACKAGE_NAME = '@deepseek-ai/dsh-client-asset-fixture' const ASSET_EXPORTS = { './client': './lib/client.js', './assets/*': './lib/assets/*' } const WHALE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]) const WHALE_REV = packageAssetRevision(WHALE) const WHALE_URL = `plugins/@deepseek-ai/dsh-client-asset-fixture/assets/whale-${WHALE_REV}.png` const temporaryRoots: string[] = [] afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) /** Create a spec-owned temporary directory, addressed by its real path. */ async function temporaryDirectory(prefix: string): Promise { const root = await realpath(await mkdtemp(join(tmpdir(), prefix))) temporaryRoots.push(root) return root } async function writeFiles(root: string, files: Readonly>): Promise { for (const [path, content] of Object.entries(files)) { await mkdir(dirname(join(root, path)), { recursive: true }) await writeFile(join(root, path), content) } } /** Create a client package fixture whose manifest declares the given exports. */ async function assetPackage( files: Readonly>, exports: Readonly> = ASSET_EXPORTS, ): Promise { const root = await temporaryDirectory('dsh-client-asset-') await writeFiles(root, { 'package.json': JSON.stringify({ name: PACKAGE_NAME, exports }), ...files }) return root } /** Client-face configs of one package, as its tsdown.config.ts would return them. */ function clientFaceConfigs(packageDir: string, dualClient = false): UserConfig[] { return clientBundle(packageDir, ['lib/types/index.js'], { dualClient })({ env: { DSH_BUILD_FACE: 'client' } }) } function configPlugin(config: UserConfig | undefined, name: string): BundlePlugin { if (config === undefined) throw new Error('config missing') const plugin = (config as { plugins: BundlePlugin[] }).plugins.find(candidate => candidate.name === name) if (plugin === undefined) throw new Error(`${name} missing from ${String(config.name)}`) return plugin } /** Look up plugins of one browser config, so stylesheet and asset plugins share its per-build state. */ function browserPlugins(packageDir: string): (name: PluginName) => BundlePlugin { const client = clientFaceConfigs(packageDir).find(config => config.platform === 'browser') return name => configPlugin(client, name) } function bundlePlugin(name: PluginName): BundlePlugin { return browserPlugins(join(import.meta.dirname, '../packages/client/ui-sidebar'))(name) } interface RecordingContext extends PluginContext { readonly emitted: EmittedAsset[] readonly watched: string[] } function recordingContext(): RecordingContext { const emitted: EmittedAsset[] = [] const watched: string[] = [] return { emitted, watched, emitFile(file) { emitted.push(file) return file.fileName }, addWatchFile(id) { watched.push(id) }, } } /** Emitted files with hex sources, so Buffer and Uint8Array bytes compare equal. */ function emittedFiles(context: { readonly emitted: readonly EmittedAsset[] }): object[] { return context.emitted.map(file => ({ ...file, source: Buffer.from(file.source).toString('hex') })) } function hooks(plugin: BundlePlugin): Required> { if (plugin.resolveId === undefined || plugin.load === undefined) throw new Error(`${plugin.name} hooks are incomplete`) return { resolveId: plugin.resolveId, load: plugin.load } } /** Resolve and load one import through a plugin. */ async function loadImport(plugin: BundlePlugin, context: PluginContext, source: string, importer: string): Promise { const { resolveId, load } = hooks(plugin) const id = resolveId(source, importer) if (typeof id !== 'string') throw new Error(`${plugin.name} did not resolve ${source}`) return await load.call(context, id) } describe('client bundle CSS Modules', () => { it('registers the source stylesheet as a watch dependency', async () => { const root = await temporaryDirectory('dsh-client-css-watch-') const stylesheet = join(root, 'Fixture.module.css') await writeFile(stylesheet, '.root { color: red; }\n') const context = recordingContext() const output = await loadImport(bundlePlugin('dsh-css-modules-inline'), context, './Fixture.module.css', join(root, 'index.ts')) expect(context.watched).toEqual([stylesheet]) expect(output).toContain('data-plugin-css') }) }) describe('client bundle global CSS', () => { it('compiles a side-effect stylesheet into a watched style injector', async () => { const root = await temporaryDirectory('dsh-client-global-css-watch-') const stylesheet = join(root, 'base.css') await writeFile(stylesheet, 'body { color: red; }\n') const context = recordingContext() const output = await loadImport(bundlePlugin('dsh-css-global-inline'), context, './base.css', join(root, 'index.ts')) expect(context.watched).toEqual([stylesheet]) expect(output).toContain('data-plugin-css') expect(output).toContain('body{color:red}') }) it('compiles inline stylesheets as watched text without a module side effect', async () => { const root = await temporaryDirectory('dsh-client-inline-css-watch-') const stylesheet = join(root, 'base.css') await writeFile(stylesheet, 'body { color: red; }\n') const context = recordingContext() const output = await loadImport(bundlePlugin('dsh-css-text-inline'), context, './base.css?inline', join(root, 'index.ts')) expect(context.watched).toEqual([stylesheet]) expect(output).toContain('export default "body{color:red}"') expect(output).not.toContain('data-plugin-css') }) }) describe('client bundle package asset imports', () => { it('emits an imported asset once per build and exports its revisioned URL', async () => { const root = await assetPackage({ 'src/client/assets/icons/whale@2x.png': WHALE }) const plugin = browserPlugins(root)('dsh-package-asset') const { resolveId, load } = hooks(plugin) const asset = join(root, 'src/client/assets/icons/whale@2x.png') const context = recordingContext() const id = resolveId('./assets/icons/whale@2x.png', join(root, 'lib/types/client/index.js')) if (id === null) throw new Error('asset import did not resolve') const first = await load.call(context, id) const second = await load.call(context, id) const expected = `export default "plugins/@deepseek-ai/dsh-client-asset-fixture/assets/icons/whale%402x-${WHALE_REV}.png";` expect(id).toBe(asset) expect([first, second]).toEqual([expected, expected]) expect(emittedFiles(context)).toEqual([{ type: 'asset', fileName: `assets/icons/whale@2x-${WHALE_REV}.png`, source: Buffer.from(WHALE).toString('hex'), originalFileName: asset, }]) expect(context.watched).toEqual([asset]) plugin.buildStart?.call(context) expect(await load.call(context, id)).toBe(expected) expect(context.emitted).toHaveLength(2) }) it('ignores imports and modules that are not supported assets', async () => { const root = await assetPackage({ 'src/client/index.ts': '', 'src/client/assets/font.woff2': 'font' }) const { resolveId, load } = hooks(browserPlugins(root)('dsh-package-asset')) const importer = join(root, 'src/client/index.ts') const context = recordingContext() expect(resolveId('./index.ts', importer)).toBeNull() expect(resolveId('./assets/font.woff2', importer)).toBeNull() expect(resolveId('./assets/whale.png', undefined)).toBeNull() expect(await load.call(context, importer)).toBeNull() expect(await load.call(context, 'whale.png')).toBeNull() expect(context.emitted).toEqual([]) }) it('rejects files outside the package src/client/assets directory', async () => { const other = await assetPackage({ 'src/client/assets/whale.png': WHALE }) const root = await assetPackage({ 'src/client/whale.png': WHALE, 'node_modules/dependency/package.json': JSON.stringify({ name: 'dependency' }), 'node_modules/dependency/whale.png': WHALE, }) const plugin = browserPlugins(root)('dsh-package-asset') const { load } = hooks(plugin) const importer = join(root, 'src/client/index.ts') const context = recordingContext() await expect(loadImport(plugin, context, './whale.png', importer)) .rejects.toThrow(`${join(root, 'src/client/whale.png')} is outside src/client/assets; move the asset there`) expect(() => load.call(context, join(other, 'src/client/assets/whale.png'))).toThrow('is outside src/client/assets') await expect(loadImport(plugin, context, 'dependency/whale.png', importer)).rejects.toThrow('is outside src/client/assets') expect(context.emitted).toEqual([]) }) it.each([ ['is missing', { './client': './lib/client.js' }], ['targets another directory', { './assets/*': './src/client/assets/*' }], ])('rejects an asset while the asset export %s', async (_case, exports) => { const root = await assetPackage({ 'src/client/assets/whale.png': WHALE }, exports) const { load } = hooks(browserPlugins(root)('dsh-package-asset')) expect(() => load.call(recordingContext(), join(root, 'src/client/assets/whale.png'))) .toThrow('needs package.json exports {"./assets/*":"./lib/assets/*"} and "lib/assets" in files') }) it('rejects a missing asset file by name', async () => { const root = await assetPackage({}) const asset = join(root, 'src/client/assets/missing.png') const { load } = hooks(browserPlugins(root)('dsh-package-asset')) expect(() => load.call(recordingContext(), asset)).toThrow(`${PACKAGE_NAME}: cannot resolve asset ${asset}`) }) it.each(['src/client/assets/100%.png', 'src/client/assets/icons#1/whale.png'])('rejects %s, which the asset route cannot serve', async (path) => { const root = await assetPackage({ [path]: WHALE }) const { load } = hooks(browserPlugins(root)('dsh-package-asset')) expect(() => load.call(recordingContext(), join(root, path))) .toThrow(`${join(root, path)} has a path segment containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`) }) it('rejects asset imports in the Node Client face', async () => { const root = await assetPackage({}) const node = clientFaceConfigs(root, true).find(config => config.name === `${PACKAGE_NAME}/client-node`) const { resolveId } = configPlugin(node, 'dsh-package-asset-browser-only') if (resolveId === undefined) throw new Error('browser-only guard has no resolveId hook') const importer = join(root, 'lib/types/client/index.js') expect(() => resolveId('./assets/whale.png', importer)).toThrow(`${PACKAGE_NAME}: asset imports are browser-only (./assets/whale.png)`) expect(resolveId('./index.js', importer)).toBeNull() }) }) describe('client bundle stylesheet url()', () => { it('rewrites relative CSS Modules urls and keeps urls that name no package file', async () => { const root = await assetPackage({ 'src/client/assets/whale.png': WHALE, 'src/client/View.module.css': [ '.whale { mask: url(\'./assets/whale.png\') center / 100% 100% no-repeat; }', '@supports (mask-image: url(\'\')) { .empty { background: url(\'\'); } }', '.data { background: url("data:image/svg+xml,%3Csvg%3E"); }', '.fragment { mask: url(#mask); }', '.rooted { background: url(/static/a.png); }', '.remote { background: url(https://example.com/a.png); }', ].join('\n'), }) const context = recordingContext() const output = await loadImport(browserPlugins(root)('dsh-css-modules-inline'), context, './View.module.css', join(root, 'src/client/index.ts')) expect(output).toContain(`url(${WHALE_URL})`) for (const preserved of ['(mask-image:url(\'\'))', 'background:url()', 'url(data:image/svg+xml,%3Csvg%3E)', 'url(#mask)', 'url(/static/a.png)', 'url(https://example.com/a.png)']) { expect(output).toContain(preserved) } expect(emittedFiles(context)).toEqual([{ type: 'asset', fileName: `assets/whale-${WHALE_REV}.png`, source: Buffer.from(WHALE).toString('hex'), originalFileName: join(root, 'src/client/assets/whale.png'), }]) }) it.each([ ['dsh-css-global-inline', './theme.css'], ['dsh-css-text-inline', './theme.css?inline'], ] as const)('rewrites relative urls through %s', async (name, source) => { const root = await assetPackage({ 'src/client/assets/whale.png': WHALE, 'src/client/theme.css': 'body { background: url(./assets/whale.png); }', }) const context = recordingContext() const output = await loadImport(browserPlugins(root)(name), context, source, join(root, 'src/client/index.ts')) expect(output).toContain(`url(${WHALE_URL})`) expect(context.emitted.map(file => file.fileName)).toEqual([`assets/whale-${WHALE_REV}.png`]) }) it('leaves stylesheets outside the package src untouched', async () => { const root = await assetPackage({ 'node_modules/@fortune-sheet/react/dist/index.css': '.sheet { background: url("EwaAntH.gif"); }', }) const context = recordingContext() const output = await loadImport( browserPlugins(root)('dsh-css-global-inline'), context, '../../node_modules/@fortune-sheet/react/dist/index.css', join(root, 'src/client/index.ts'), ) expect(output).toContain('url(EwaAntH.gif)') expect(context.emitted).toEqual([]) }) it.each([ ['./assets/font.woff2', 'is not a supported asset; supported extensions: .svg, .png, .webp, .jpg, .jpeg'], ['./whale.png', 'is outside src/client/assets; move the asset there'], ['./assets/missing.png', 'cannot resolve asset'], ])('rejects url(%s)', async (url, message) => { const root = await assetPackage({ 'src/client/whale.png': WHALE, 'src/client/assets/font.woff2': 'font', 'src/client/View.module.css': `.x { background: url(${url}); }`, }) await expect(loadImport(browserPlugins(root)('dsh-css-modules-inline'), recordingContext(), './View.module.css', join(root, 'src/client/index.ts'))) .rejects.toThrow(message) }) }) describe('client bundle asset output', () => { it('prunes lib/assets files this build did not emit and the directories they leave empty', async () => { const root = await assetPackage({ 'lib/client.js': '', 'lib/assets/whale.png': WHALE, 'lib/assets/icons/kept.svg': '', 'lib/assets/icons/stale.svg': '', 'lib/assets/old/deep/stale.webp': 'webp', }) const { writeBundle } = browserPlugins(root)('dsh-package-asset') if (writeBundle === undefined) throw new Error('asset plugin has no writeBundle hook') const out = join(root, 'lib') await writeBundle({ dir: out }, { 'client.js': {}, 'assets/whale.png': {}, 'assets/icons/kept.svg': {} }) expect(['client.js', 'assets/whale.png', 'assets/icons/kept.svg', 'assets/icons/stale.svg', 'assets/old'] .map(path => [path, existsSync(join(out, path))])) .toEqual([ ['client.js', true], ['assets/whale.png', true], ['assets/icons/kept.svg', true], ['assets/icons/stale.svg', false], ['assets/old', false], ]) await writeBundle({ dir: out }, { 'client.js': {} }) expect([existsSync(join(out, 'client.js')), existsSync(join(out, 'assets'))]).toEqual([true, false]) await writeBundle({ dir: out }, { 'client.js': {} }) }) })