diff --git a/packages/root/src/core/hooks/useAssetMap.test.tsx b/packages/root/src/core/hooks/useAssetMap.test.tsx new file mode 100644 index 00000000..7b7f62fd --- /dev/null +++ b/packages/root/src/core/hooks/useAssetMap.test.tsx @@ -0,0 +1,120 @@ +import {renderToString} from 'preact-render-to-string'; +import {expect, test} from 'vitest'; +import {renderJsxToString} from '../../jsx/jsx-render.js'; +import type {Asset, AssetMap} from '../../render/asset-map/asset-map.js'; +import { + ASSET_MAP_CONTEXT, + useAsset, + useAssetMap, + useAssetUrl, +} from './useAssetMap.js'; + +/** + * Returns a stub asset map backed by a `src` -> `assetUrl` mapping, mimicking + * the lookups performed by `BuildAssetMap` and `DevServerAssetMap`. + */ +function testAssetMap(assetUrls: Record): AssetMap { + return { + get: (src: string): Asset | null => { + if (!(src in assetUrls)) { + return null; + } + return { + src: src, + assetUrl: assetUrls[src], + getCssDeps: async () => [], + getJsDeps: async () => [], + getModulePreloadDeps: async () => [], + }; + }, + }; +} + +const assetMap = testAssetMap({ + 'bundles/main.ts': '/assets/main.abcd1234.js', + // Route files are in the manifest for their CSS deps, but have no asset URL. + 'routes/index.tsx': '', +}); + +/** + * The renderer picks between `preact-render-to-string` and `@blinkk/root/jsx` + * depending on the `jsxRenderer` config, so the hook is verified against both. + */ +const renderers: Array<[string, (vnode: any) => string]> = [ + ['preact-render-to-string', (vnode) => renderToString(vnode)], + ['@blinkk/root/jsx', (vnode) => renderJsxToString(vnode, {mode: 'minimal'})], +]; + +/** Renders a component with the asset map provided by the renderer. */ +function render( + vnode: any, + options?: {assetMap?: AssetMap; renderJsx?: (vnode: any) => string} +) { + const renderJsx = options?.renderJsx || renderers[0][1]; + if (!options?.assetMap) { + return renderJsx(vnode); + } + return renderJsx( + + {vnode} + + ); +} + +test.each(renderers)( + 'useAssetUrl() returns the compiled asset url (%s)', + (_name, renderJsx) => { + function Page() { + return
; + } + expect(render(, {assetMap, renderJsx})).toBe( + '
' + ); + } +); + +test('useAssetUrl() accepts a leading slash', () => { + function Page() { + return
; + } + expect(render(, {assetMap})).toBe( + '
' + ); +}); + +test('useAssetUrl() throws when the src is not in the asset map', () => { + function Page() { + return
; + } + expect(() => render(, {assetMap})).toThrow( + 'asset not found: /bundles/missing.ts' + ); +}); + +test('useAssetUrl() throws when the asset has no serving url', () => { + function Page() { + return
; + } + expect(() => render(, {assetMap})).toThrow( + 'asset not found: routes/index.tsx' + ); +}); + +test('useAsset() returns the asset from the module graph', () => { + let asset: Asset | null = null; + function Page() { + asset = useAsset('/bundles/main.ts'); + return
; + } + render(, {assetMap}); + expect(asset!.src).toBe('bundles/main.ts'); + expect(asset!.assetUrl).toBe('/assets/main.abcd1234.js'); +}); + +test('useAssetMap() throws when rendered outside of the provider', () => { + function Page() { + useAssetMap(); + return
; + } + expect(() => render()).toThrow('ASSET_MAP_CONTEXT not found'); +}); diff --git a/packages/root/src/render/asset-map/dev-asset-map.test.ts b/packages/root/src/render/asset-map/dev-asset-map.test.ts new file mode 100644 index 00000000..907d82f5 --- /dev/null +++ b/packages/root/src/render/asset-map/dev-asset-map.test.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type {ModuleGraph} from 'vite'; +import {afterEach, beforeEach, expect, test, vi} from 'vitest'; +import {RootConfig} from '../../core/config.js'; +import {DevServerAssetMap} from './dev-asset-map.js'; + +let workspaceDir: string; +let rootDir: string; +let assetMap: DevServerAssetMap; + +// A module graph that never resolves a file, which exercises the fallback +// branches that serve files that never made it into vite's module graph. +const emptyModuleGraph = { + getModulesByFile: () => undefined, +} as unknown as ModuleGraph; + +beforeEach(() => { + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'root-dev-assets-')); + // `searchForWorkspaceRoot()` walks up for a `pnpm-workspace.yaml`, so the + // workspace root is deterministic within the temp dir. + fs.writeFileSync(path.join(workspaceDir, 'pnpm-workspace.yaml'), ''); + rootDir = path.join(workspaceDir, 'site'); + fs.mkdirSync(rootDir); + assetMap = new DevServerAssetMap({rootDir} as RootConfig, emptyModuleGraph); +}); + +afterEach(() => { + if (workspaceDir) { + fs.rmSync(workspaceDir, {recursive: true, force: true}); + } +}); + +test('serves files within the project from the root dir', () => { + const asset = assetMap.get('bundles/main.ts'); + expect(asset!.assetUrl).toBe('/bundles/main.ts'); +}); + +test('serves sibling dirs sharing the root dir prefix from /@fs/', () => { + // `/site-legacy` is a sibling of `/site`, not a child + // of it, even though its path starts with the root dir's path. + const filePath = path.join(workspaceDir, 'site-legacy/main.ts'); + fs.mkdirSync(path.dirname(filePath)); + fs.writeFileSync(filePath, 'export default 1;\n'); + + const asset = assetMap.get('../site-legacy/main.ts'); + expect(asset!.assetUrl).toBe(`/@fs/${filePath}`); +}); + +test('serves workspace files that do not exist on disk', () => { + const filePath = path.join(workspaceDir, 'packages/ui/main.ts'); + const asset = assetMap.get('../packages/ui/main.ts'); + expect(asset!.assetUrl).toBe(`/@fs/${filePath}`); +}); + +test('returns null for files outside of the workspace', () => { + // The missing-asset lookup logs via console.log; stub it so the test output + // stays clean, and assert it fired. + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const src = path.relative(rootDir, path.join(os.tmpdir(), 'outside.ts')); + expect(assetMap.get(src)).toBeNull(); + expect(logSpy).toHaveBeenCalledWith( + `could not find asset in asset map: ${src}` + ); + } finally { + logSpy.mockRestore(); + } +});