mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
1248 lines
54 KiB
TypeScript
1248 lines
54 KiB
TypeScript
/** Node-half composition diagnostics for package metadata and built client bundles. */
|
|
|
|
import { Hash } from 'node:crypto'
|
|
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
import { SourceMap } from 'node:module'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { runInNewContext } from 'node:vm'
|
|
import { Context, FiberState, type Fiber } from '@deepseek-ai/cordis'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
import * as modulesClient from '../src/client/index.ts'
|
|
import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
|
|
import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
|
|
|
|
/**
|
|
* Paths whose synchronous read refuses, as a browser Worker refuses a file
|
|
* whose bytes arrive only through an asynchronous fetch.
|
|
*/
|
|
const syncRefused = vi.hoisted(() => new Set<string>())
|
|
|
|
vi.mock('node:fs', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:fs')>()
|
|
return {
|
|
...actual,
|
|
readFileSync: ((path: Parameters<typeof actual.readFileSync>[0], ...rest: unknown[]) => {
|
|
if (syncRefused.has(String(path))) {
|
|
throw Object.assign(new Error(`synchronous read refused for ${String(path)}`), { code: 'ERR_DSH_VFS_DEFERRED' })
|
|
}
|
|
return (actual.readFileSync as (...args: unknown[]) => unknown)(path, ...rest)
|
|
}) as typeof actual.readFileSync,
|
|
}
|
|
})
|
|
|
|
/** Paths whose next asynchronous read fails once, as a lazily fetched file whose download fails. */
|
|
const asyncFailOnce = vi.hoisted(() => new Set<string>())
|
|
|
|
vi.mock('node:fs/promises', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
|
return {
|
|
...actual,
|
|
readFile: ((path: Parameters<typeof actual.readFile>[0], ...rest: unknown[]) => {
|
|
if (typeof path === 'string' && asyncFailOnce.delete(path)) return Promise.reject(new Error(`download failed for ${path}`))
|
|
return (actual.readFile as (...args: unknown[]) => unknown)(path, ...rest)
|
|
}) as typeof actual.readFile,
|
|
}
|
|
})
|
|
|
|
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
|
|
const UI_RENDERER_ID = '@deepseek-ai/dsh-client-ui-renderer'
|
|
|
|
// Graph rows and batch descriptors carry app-directory-relative references;
|
|
// a host request reaches the backend route once the document's directory
|
|
// resolves them, which these origin-root fixtures reduce to a leading slash.
|
|
const comboReference = (ids: readonly string[], rev: string): string =>
|
|
`plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
|
|
const mapUrl = (url: string): string => url.replace(/\/client\.js(?=,|&rev=)/g, '/client.js.map')
|
|
const mapReference = (url: string): string => mapUrl(url).replace(/^\/?plugins\//u, '')
|
|
// A chunk script is served from its package's own directory, so its
|
|
// source-map reference resolves against the chunk's own URL: the bare map
|
|
// file name, not the route key the response table holds.
|
|
const chunkMapReference = (fileName: string, rev: string): string => `${fileName}.map?rev=${rev}`
|
|
const chunkReference = (id: string, fileName: string, rev: string): string =>
|
|
`plugins/${id}/${fileName}?rev=${rev}`
|
|
const BOOTSTRAP_URL = comboReference([MODULES_ID], 'boot')
|
|
const APPLICATION_URL = comboReference([UI_RENDERER_ID], 'app')
|
|
|
|
let root: string | undefined
|
|
const contexts: { ctx: Context; ready?: Promise<WebRoute> }[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => {
|
|
await ready
|
|
await ctx.fiber.dispose()
|
|
}))
|
|
if (root !== undefined) rmSync(root, { recursive: true, force: true })
|
|
root = undefined
|
|
syncRefused.clear()
|
|
})
|
|
|
|
it.each([false, true])('tracks the Web carrier lifetime when server-first is %s', async (serverFirst) => {
|
|
const ctx = new Context()
|
|
contexts.push({ ctx })
|
|
ctx.provide('loader', { entries: () => [] })
|
|
const routes = new Set<WebRoute>()
|
|
const mountServer = () => ctx.plugin((serverCtx) => {
|
|
serverCtx.provide('webServer', {
|
|
register: (route: WebRoute) => {
|
|
routes.add(route)
|
|
return () => { routes.delete(route) }
|
|
},
|
|
} as WebServer)
|
|
})
|
|
let server = serverFirst ? await mountServer() : undefined
|
|
const modules = await ctx.plugin(ClientModuleRegistry)
|
|
const service = ctx.get('clientModules')!
|
|
expect(service.graph().entries).toEqual([])
|
|
expect((await service.fetchBundle(new Request('http://localhost/plugins/missing'))).status).toBe(404)
|
|
if (!serverFirst) {
|
|
expect(routes.size).toBe(0)
|
|
server = await mountServer()
|
|
}
|
|
await expect.poll(() => routes.size).toBe(1)
|
|
await server!.dispose()
|
|
await expect.poll(() => routes.size).toBe(0)
|
|
expect(modules.state).toBe(FiberState.ACTIVE)
|
|
expect((await service.fetchBundle(new Request('http://localhost/plugins/missing'))).status).toBe(404)
|
|
await mountServer()
|
|
await expect.poll(() => routes.size).toBe(1)
|
|
await modules.dispose()
|
|
expect(routes.size).toBe(0)
|
|
})
|
|
|
|
it('publishes this package version and the build commit as the Host release in every composed graph', async () => {
|
|
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
|
|
const mount = async (commit: string | undefined) => {
|
|
vi.stubEnv('DSH_CLIENT_COMMIT_HASH', commit)
|
|
const ctx = new Context()
|
|
contexts.push({ ctx })
|
|
ctx.provide('loader', { entries: () => [] })
|
|
await ctx.plugin(ClientModuleRegistry)
|
|
vi.unstubAllEnvs()
|
|
return ctx.get('clientModules')!.graph()
|
|
}
|
|
const graph = await mount('abc1234')
|
|
expect(graph.release).toEqual({ version, commit: 'abc1234' })
|
|
expect(bootInjections(graph).at(-1)).toEqual({ kind: 'global', name: '__DSH_BOOT__', value: graph })
|
|
expect(modulesClient.parseBootManifest(graph).release).toEqual({ version, commit: 'abc1234' })
|
|
expect((await mount(undefined)).release).toEqual({ version })
|
|
})
|
|
|
|
/** Create a resolvable package whose client export points at the returned path. */
|
|
function writePackage(
|
|
packageName: string,
|
|
metadata: Record<string, unknown> = { dsh: { client: { platform: 'web' } } },
|
|
): string {
|
|
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
|
|
const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
|
|
const clientPath = join(pkgRoot, 'lib', 'client.js')
|
|
mkdirSync(pkgRoot, { recursive: true })
|
|
writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
|
|
name: packageName,
|
|
exports: {
|
|
'./client': './lib/client.js',
|
|
'./package.json': './package.json',
|
|
},
|
|
...metadata,
|
|
}))
|
|
return clientPath
|
|
}
|
|
|
|
/** Create a built package with the supplied client declaration. */
|
|
function writeBuiltPackage(packageName: string, client: Record<string, unknown>): void {
|
|
const clientPath = writePackage(packageName, { dsh: { client: { platform: 'web', ...client } } })
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
}
|
|
|
|
/** Construct the node-half service and capture its plugin-bundle route. */
|
|
function constructWithRoute(
|
|
packageNames: string[],
|
|
options: {
|
|
contextBaseUrl?: string
|
|
entryBaseUrl?: string
|
|
internal?: NonNullable<Context['loader']['internal']>
|
|
} = {},
|
|
): { context: Context; service: ClientModuleRegistry; route: Promise<WebRoute> } {
|
|
const ctx = new Context()
|
|
const owned: typeof contexts[number] = { ctx }
|
|
contexts.push(owned)
|
|
ctx.baseUrl = options.contextBaseUrl ?? pathToFileURL(root!).href + '/'
|
|
ctx.provide('loader', {
|
|
internal: options.internal,
|
|
*entries() {
|
|
for (const packageName of packageNames) {
|
|
yield {
|
|
options: { name: packageName },
|
|
fiber: {},
|
|
disabled: false,
|
|
parent: { tree: { ctx: { baseUrl: options.entryBaseUrl ?? ctx.baseUrl } } },
|
|
}
|
|
}
|
|
},
|
|
})
|
|
const route = Promise.withResolvers<WebRoute>()
|
|
const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
|
|
port: 0,
|
|
register: (candidate) => {
|
|
if (candidate.path === '/plugins') route.resolve(candidate)
|
|
return () => {}
|
|
},
|
|
tapIndex: () => () => {},
|
|
}
|
|
ctx.provide('webServer', webServer as WebServer)
|
|
const service = new ClientModuleRegistry(ctx)
|
|
owned.ready = route.promise
|
|
return { context: ctx, service, route: route.promise }
|
|
}
|
|
|
|
/** Construct the node-half service over the enabled fixture entries. */
|
|
function construct(packageNames: string[]): ClientModuleRegistry {
|
|
return constructWithRoute(packageNames).service
|
|
}
|
|
|
|
/** Invoke the registered plugin route and capture status, headers, and bytes. */
|
|
async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET'): Promise<{
|
|
status: number
|
|
headers: Record<string, string> | undefined
|
|
body: Buffer
|
|
}> {
|
|
let status = 0
|
|
let headers: Record<string, string> | undefined
|
|
let body = Buffer.alloc(0)
|
|
const response = {
|
|
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
|
|
status = nextStatus
|
|
headers = nextHeaders
|
|
return response
|
|
},
|
|
end(chunk?: Uint8Array) {
|
|
body = chunk === undefined ? Buffer.alloc(0) : Buffer.from(chunk)
|
|
return response
|
|
},
|
|
} as unknown as ServerResponse
|
|
await (await route).handler({ method, url, headers: {} } as IncomingMessage, response)
|
|
return { status, headers, body }
|
|
}
|
|
|
|
/** Execute the exact first inline script emitted by the Host boot rows. */
|
|
function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
|
|
const html = renderIndexInjections(
|
|
'<html><head></head><body><script type="module" src="/index.js"></script></body></html>',
|
|
bootInjections(graph),
|
|
)
|
|
const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
|
|
if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
|
|
const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
|
|
runInNewContext(source, { window })
|
|
if (window.__ModuleLoader__ === undefined) throw new Error('facade script did not install __ModuleLoader__')
|
|
return { html, target: window.__ModuleLoader__ }
|
|
}
|
|
|
|
const bootGraph = (): WebBootGraph => ({
|
|
rev: 'graph',
|
|
entries: [
|
|
{ id: MODULES_ID, url: comboReference([MODULES_ID], 'm'), rev: 'm' },
|
|
{ id: UI_RENDERER_ID, url: comboReference([UI_RENDERER_ID], 'r'), rev: 'r' },
|
|
],
|
|
batches: [
|
|
{
|
|
phase: 'bootstrap',
|
|
url: BOOTSTRAP_URL,
|
|
rev: 'boot',
|
|
entries: [MODULES_ID],
|
|
},
|
|
{
|
|
phase: 'application',
|
|
url: APPLICATION_URL,
|
|
rev: 'app',
|
|
entries: [UI_RENDERER_ID],
|
|
},
|
|
],
|
|
})
|
|
|
|
describe('HTML bootstrap facade', () => {
|
|
it('precedes blocking preloads and the boot graph, then becomes the live registration target', async () => {
|
|
const graph = bootGraph()
|
|
const { html, target } = injectedFacade(graph)
|
|
const facadeAt = html.indexOf('window.__ModuleLoader__=')
|
|
const applicationAt = html.indexOf(
|
|
`<link rel="preload" as="script" href="${APPLICATION_URL.replaceAll('&', '&')}">`,
|
|
)
|
|
const bootstrapAt = html.indexOf(`<script src="${BOOTSTRAP_URL.replaceAll('&', '&')}"></script>`)
|
|
const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
|
|
const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
|
|
expect([facadeAt, applicationAt, bootstrapAt, graphAt, entryAt]).toEqual([...new Set([
|
|
facadeAt, applicationAt, bootstrapAt, graphAt, entryAt,
|
|
])].sort((a, b) => a - b))
|
|
|
|
target.load({ id: MODULES_ID, factory: () => modulesClient })
|
|
const system = target.create({
|
|
boot: graph,
|
|
staticModules: {},
|
|
loadBundle: async (url) => {
|
|
expect(url).toBe(APPLICATION_URL)
|
|
target.load({ id: UI_RENDERER_ID, factory: () => ({ marker: 'ui-renderer' }) })
|
|
},
|
|
})
|
|
|
|
expect(target.mode).toBe('live')
|
|
expect(target.pendingQueue).toEqual([])
|
|
expect(system.manifest.rev).toBe('graph')
|
|
expect(await system.import(MODULES_ID)).toBe(modulesClient)
|
|
expect(await system.import(`${UI_RENDERER_ID}/client`)).toEqual({ marker: 'ui-renderer' })
|
|
expect(() => target.create({ boot: graph, staticModules: {} }))
|
|
.toThrow('create called after module-system boot')
|
|
})
|
|
|
|
it('preloads every application combo', () => {
|
|
const graph = bootGraph()
|
|
const secondId = '@fixture/second-application-combo'
|
|
const secondUrl = comboReference([secondId], 'app-2')
|
|
graph.entries.push({ id: secondId, url: comboReference([secondId], 'row-2'), rev: 'row-2' })
|
|
graph.batches.push({ phase: 'application', url: secondUrl, rev: 'app-2', entries: [secondId] })
|
|
expect(bootInjections(graph).flatMap(row => row.kind === 'script-preload' ? [row.src] : []))
|
|
.toEqual([APPLICATION_URL, secondUrl])
|
|
})
|
|
|
|
it('rejects a page that did not preload the modules bundle', () => {
|
|
const graph = bootGraph()
|
|
const { target } = injectedFacade(graph)
|
|
expect(() => target.create({ boot: graph, staticModules: {} }))
|
|
.toThrow(`HTML did not preload ${MODULES_ID}/client.js`)
|
|
})
|
|
|
|
it('rejects a bootstrap bundle with a runtime external', () => {
|
|
const graph = bootGraph()
|
|
const { target } = injectedFacade(graph)
|
|
target.load({
|
|
id: MODULES_ID,
|
|
factory: (require) => {
|
|
require('react')
|
|
return modulesClient
|
|
},
|
|
})
|
|
expect(() => target.create({ boot: graph, staticModules: {} }))
|
|
.toThrow(`${MODULES_ID}/client.js requested external "react"`)
|
|
})
|
|
|
|
it.each([
|
|
null,
|
|
{ ...modulesClient, createClientModuleSystem: undefined },
|
|
{ ...modulesClient, apply: undefined },
|
|
])('rejects a bootstrap bundle without the complete module face', (exports) => {
|
|
const graph = bootGraph()
|
|
const { target } = injectedFacade(graph)
|
|
target.load({ id: MODULES_ID, factory: () => exports as unknown as Record<string, unknown> })
|
|
expect(() => target.create({ boot: graph, staticModules: {} }))
|
|
.toThrow(`${MODULES_ID}/client.js did not export the bootstrap module face`)
|
|
})
|
|
})
|
|
|
|
describe('client bundle activation', () => {
|
|
it.each(['v1', 'v2'] as const)(
|
|
'resolves %s package metadata from the owning entry tree',
|
|
(version) => {
|
|
const packageName = `@fixture/entry-base-${version}`
|
|
const clientPath = writePackage(packageName)
|
|
const hostPath = join(dirname(clientPath), 'index.js')
|
|
mkdirSync(dirname(hostPath), { recursive: true })
|
|
writeFileSync(hostPath, 'export default {}\n')
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
const contextBaseUrl = pathToFileURL(join(root!, 'profile')).href + '/'
|
|
const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
|
|
const calls: unknown[][] = []
|
|
const resolveSync = (...args: unknown[]) => {
|
|
calls.push(args)
|
|
return { format: 'module' as const, url: pathToFileURL(hostPath).href }
|
|
}
|
|
const internal = { version, resolveSync }
|
|
|
|
const { service } = constructWithRoute([packageName], {
|
|
contextBaseUrl,
|
|
entryBaseUrl,
|
|
internal: internal as NonNullable<Context['loader']['internal']>,
|
|
})
|
|
|
|
expect(calls).toEqual(version === 'v2'
|
|
? [[entryBaseUrl, { specifier: packageName, attributes: {} }]]
|
|
: [[packageName, entryBaseUrl, {}]])
|
|
expect(service.clientPath(packageName)).toBe(clientPath)
|
|
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
|
},
|
|
)
|
|
|
|
it('derives the browser module id from a file entry owning manifest', () => {
|
|
const packageName = '@fixture/file-entry'
|
|
const clientPath = writePackage(packageName)
|
|
const hostPath = join(dirname(clientPath), 'index.js')
|
|
mkdirSync(dirname(hostPath), { recursive: true })
|
|
writeFileSync(hostPath, 'export default {}\n')
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
|
|
const service = construct([pathToFileURL(hostPath).href])
|
|
|
|
expect(service.clientPath(packageName)).toBe(clientPath)
|
|
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
|
})
|
|
|
|
it.each(['relative', 'absolute'] as const)(
|
|
'finds the owning manifest through the %s-path fallback without Node loader internals',
|
|
(kind) => {
|
|
const packageName = `@fixture/${kind}-fallback-entry`
|
|
const clientPath = writePackage(packageName)
|
|
const packageRoot = dirname(dirname(clientPath))
|
|
const hostPath = join(packageRoot, 'index.js')
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(hostPath, 'export default {}\n')
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
const loaderName = kind === 'relative' ? './index.js' : hostPath
|
|
|
|
const { service } = constructWithRoute([loaderName], {
|
|
entryBaseUrl: pathToFileURL(packageRoot).href + '/',
|
|
})
|
|
|
|
expect(service.clientPath(packageName)).toBe(clientPath)
|
|
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
|
},
|
|
)
|
|
|
|
it.each(['v1', 'v2', 'worker'] as const)(
|
|
'derives a file entry package id through the %s Loader resolver',
|
|
(version) => {
|
|
const packageName = `@fixture/file-entry-${version}`
|
|
const clientPath = writePackage(packageName)
|
|
const hostPath = join(dirname(clientPath), 'index.js')
|
|
mkdirSync(dirname(hostPath), { recursive: true })
|
|
writeFileSync(hostPath, 'export default {}\n')
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
const loaderName = pathToFileURL(hostPath).href
|
|
const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/'
|
|
const calls: unknown[][] = []
|
|
const resolveSync = (...args: unknown[]) => {
|
|
calls.push(args)
|
|
return { format: 'module' as const, url: loaderName }
|
|
}
|
|
const internal = { version, resolveSync }
|
|
|
|
const { service } = constructWithRoute([loaderName], {
|
|
entryBaseUrl,
|
|
internal: internal as NonNullable<Context['loader']['internal']>,
|
|
})
|
|
|
|
expect(calls).toEqual(version === 'v2'
|
|
? [[entryBaseUrl, { specifier: loaderName, attributes: {} }]]
|
|
: [[loaderName, entryBaseUrl, {}]])
|
|
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
|
},
|
|
)
|
|
|
|
it('rejects distinct active Loader sources for one browser package', () => {
|
|
const packageName = '@fixture/duplicate-source'
|
|
const clientPath = writePackage(packageName)
|
|
const hostPath = join(dirname(clientPath), 'index.js')
|
|
mkdirSync(dirname(hostPath), { recursive: true })
|
|
writeFileSync(hostPath, 'export default {}\n')
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
const alias = './duplicate-source.js'
|
|
const internal = {
|
|
version: 'v2' as const,
|
|
resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
|
|
}
|
|
|
|
expect(() => constructWithRoute([packageName, alias], {
|
|
internal: internal as unknown as NonNullable<Context['loader']['internal']>,
|
|
})).toThrow(
|
|
`client-modules: package ${packageName} resolves from multiple active Loader sources:`,
|
|
)
|
|
})
|
|
|
|
it('promotes the remaining Loader source after the selected alias unloads', async () => {
|
|
const packageName = '@fixture/duplicate-source-recovery'
|
|
const clientPath = writePackage(packageName)
|
|
const hostPath = join(dirname(clientPath), 'index.js')
|
|
mkdirSync(dirname(hostPath), { recursive: true })
|
|
writeFileSync(hostPath, 'export default {}\n')
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
const alias = './duplicate-source-recovery.js'
|
|
const entries = [packageName]
|
|
const internal = {
|
|
version: 'v2' as const,
|
|
resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }),
|
|
}
|
|
const { context, service } = constructWithRoute(entries, {
|
|
internal: internal as unknown as NonNullable<Context['loader']['internal']>,
|
|
})
|
|
const firstRevision = service.graph().entries[0]!.rev
|
|
const warning = vi.spyOn(context.logger, 'warn').mockImplementation(() => undefined)
|
|
|
|
entries.push(alias)
|
|
emitLoaderEntryChange(context, alias)
|
|
await Promise.resolve()
|
|
expect(warning).toHaveBeenCalledWith(expect.objectContaining({
|
|
message: expect.stringContaining(`package ${packageName} resolves from multiple active Loader sources`) as string,
|
|
}))
|
|
expect(service.graph().entries[0]!.rev).toBe(firstRevision)
|
|
|
|
entries.splice(entries.indexOf(packageName), 1)
|
|
emitLoaderEntryChange(context, packageName)
|
|
await Promise.resolve()
|
|
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
|
expect(service.graph().entries[0]!.rev).toBe(firstRevision)
|
|
expect(service.clientPath(packageName)).toBe(clientPath)
|
|
})
|
|
|
|
it('uses owning-tree package resolution for an import-only Worker module loader', () => {
|
|
const packageName = '@fixture/worker-loader'
|
|
writeBuiltPackage(packageName, {})
|
|
const internal = {
|
|
version: 'worker',
|
|
import: async () => ({}),
|
|
} as unknown as NonNullable<Context['loader']['internal']>
|
|
|
|
const { service } = constructWithRoute([packageName], { internal })
|
|
|
|
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
|
|
})
|
|
|
|
it('allows sibling dsh roles', () => {
|
|
const currentName = '@fixture/current-client-field'
|
|
const clientPath = writePackage(currentName, {
|
|
dsh: {
|
|
bundle: { patch: './cordis.patch.yml' },
|
|
client: { platform: 'web' },
|
|
profile: { bundles: [] },
|
|
},
|
|
})
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
expect(construct([currentName]).graph().entries.map(entry => entry.id)).toEqual([currentName])
|
|
})
|
|
|
|
it('groups missing bundles under one source-build instruction with a package/path list', () => {
|
|
const firstName = '@fixture/missing-first'
|
|
const secondName = '@fixture/missing-second'
|
|
const firstPath = writePackage(firstName)
|
|
const secondPath = writePackage(secondName)
|
|
expect(() => construct([firstName, secondName])).toThrow([
|
|
'client-modules: 2 client packages failed to compose:',
|
|
' client bundles not found; run `pnpm run build` before launch:',
|
|
` - package: ${firstName}`,
|
|
` path: ${firstPath}`,
|
|
` - package: ${secondName}`,
|
|
` path: ${secondPath}`,
|
|
].join('\n'))
|
|
})
|
|
|
|
it('does not report other bundle read failures as missing builds', () => {
|
|
const packageName = '@fixture/unreadable-client'
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(clientPath, { recursive: true })
|
|
let thrown: unknown
|
|
try {
|
|
construct([packageName])
|
|
} catch (error) {
|
|
thrown = error
|
|
}
|
|
expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
|
|
expect(String(thrown)).toContain(' other failures:')
|
|
expect(String(thrown)).toContain('EISDIR')
|
|
expect(String(thrown)).not.toContain('pnpm run build')
|
|
})
|
|
|
|
it('falls back to a generated-file map when an authored map is malformed', async () => {
|
|
const packageName = '@fixture/malformed-source-map'
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
writeFileSync(`${clientPath}.map`, '{')
|
|
const torn = constructWithRoute([packageName])
|
|
const tornRow = torn.service.graph().entries[0]!
|
|
expect((await routeRequest(torn.route, tornRow.url)).body.toString('utf8'))
|
|
.toContain(`sourceMappingURL=${mapReference(tornRow.url)}`)
|
|
const fallback = await routeRequest(torn.route, mapUrl(torn.service.graph().batches[0]!.url))
|
|
expect(JSON.parse(fallback.body.toString('utf8'))).toMatchObject({
|
|
sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
|
|
})
|
|
|
|
writeFileSync(`${clientPath}.map`, '{"version":3,"sources":[null]}\n')
|
|
expect(() => construct([packageName])).not.toThrow()
|
|
|
|
writeFileSync(`${clientPath}.map`, JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sourceRoot: 'http://[',
|
|
sources: ['src/index.ts'],
|
|
}))
|
|
const invalidUrl = constructWithRoute([packageName])
|
|
const invalidMapUrl = mapUrl(invalidUrl.service.graph().batches[0]!.url)
|
|
expect(JSON.parse((await routeRequest(invalidUrl.route, invalidMapUrl)).body.toString('utf8'))).toMatchObject({
|
|
sections: [{ map: { sources: [`/plugins/${packageName}/client.js`] } }],
|
|
})
|
|
})
|
|
|
|
it('reads a source map only on its first map GET', async () => {
|
|
const packageName = '@fixture/lazy-source-map'
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
|
|
writeFileSync(`${clientPath}.map`, '{')
|
|
const { service, route } = constructWithRoute([packageName])
|
|
const batch = service.graph().batches[0]!
|
|
const sourceMapUrl = mapUrl(batch.url)
|
|
|
|
const script = await routeRequest(route, batch.url)
|
|
expect(script.status).toBe(200)
|
|
expect((await routeRequest(route, sourceMapUrl, 'HEAD')).body).toHaveLength(0)
|
|
writeFileSync(`${clientPath}.map`, JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sources: ['src/first.ts'],
|
|
}))
|
|
const first = await routeRequest(route, sourceMapUrl)
|
|
expect(JSON.parse(first.body.toString('utf8'))).toMatchObject({
|
|
sections: [{ map: { sources: [`/plugins/${packageName}/src/first.ts`] } }],
|
|
})
|
|
|
|
writeFileSync(`${clientPath}.map`, JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sources: ['src/second.ts'],
|
|
}))
|
|
expect((await routeRequest(route, sourceMapUrl)).body).toEqual(first.body)
|
|
expect((await routeRequest(route, batch.url)).body).toEqual(script.body)
|
|
})
|
|
|
|
it('retains a materialized resource when an unrelated row recomposes the graph', async () => {
|
|
const stablePackage = '@fixture/stable-source-map'
|
|
const rebuiltPackage = '@fixture/rebuilt-neighbor'
|
|
const stablePath = writePackage(stablePackage)
|
|
const rebuiltPath = writePackage(rebuiltPackage)
|
|
for (const clientPath of [stablePath, rebuiltPath]) {
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
|
|
writeFileSync(`${clientPath}.map`, JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sources: ['src/first.ts'],
|
|
}))
|
|
}
|
|
const { service, route } = constructWithRoute([stablePackage, rebuiltPackage])
|
|
const stableUrl = service.graph().entries.find(entry => entry.id === stablePackage)!.url
|
|
const stableMapUrl = mapUrl(stableUrl)
|
|
const first = await routeRequest(route, stableMapUrl)
|
|
const stableChunkPath = join(dirname(stablePath), 'client.stable.js')
|
|
writeFileSync(stableChunkPath, 'module.exports = { generation: 1 }\n')
|
|
const stableRow = service.graph().entries.find(entry => entry.id === stablePackage)!
|
|
const stableChunkUrl = chunkReference(stablePackage, 'client.stable.js', stableRow.rev)
|
|
const firstChunk = await routeRequest(route, stableChunkUrl)
|
|
|
|
writeFileSync(`${stablePath}.map`, JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sources: ['src/second.ts'],
|
|
}))
|
|
writeFileSync(stableChunkPath, 'module.exports = { generation: 2 }\n')
|
|
writeFileSync(rebuiltPath, 'module.exports = { rebuilt: true }\n')
|
|
service.rebuilt(rebuiltPackage)
|
|
|
|
expect((await routeRequest(route, stableMapUrl)).body).toEqual(first.body)
|
|
expect((await routeRequest(route, stableChunkUrl)).body).toEqual(firstChunk.body)
|
|
})
|
|
|
|
it('maps packed combo sections back to each generated client bundle', async () => {
|
|
const names = ['@fixture/generated-first', '@fixture/generated-second']
|
|
for (const [index, packageName] of names.entries()) {
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(
|
|
clientPath,
|
|
`window.generation = ${String(index)}\n//# sourceURL=packages/client/generated-${String(index)}/lib/client.js`,
|
|
)
|
|
}
|
|
|
|
const { service, route } = constructWithRoute(names)
|
|
const batch = service.graph().batches[0]!
|
|
const script = (await routeRequest(route, batch.url)).body.toString('utf8')
|
|
expect(script).not.toContain('//# sourceURL=')
|
|
expect(script).toContain(`//# sourceMappingURL=${mapReference(batch.url)}`)
|
|
const payload = JSON.parse((await routeRequest(route, mapUrl(batch.url))).body.toString('utf8')) as {
|
|
sections: { map: { mappings: string; sources: string[]; sourcesContent: string[] } }[]
|
|
}
|
|
expect(payload.sections.map(section => section.map)).toEqual([
|
|
{
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sources: ['/packages/client/generated-0/lib/client.js'],
|
|
sourcesContent: ['window.generation = 0\n'],
|
|
},
|
|
{
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sources: ['/packages/client/generated-1/lib/client.js'],
|
|
sourcesContent: ['window.generation = 1\n'],
|
|
},
|
|
])
|
|
const consumer = new SourceMap(payload as unknown as ConstructorParameters<typeof SourceMap>[0])
|
|
expect(consumer.findEntry(0, 0)).toMatchObject({
|
|
originalSource: '/packages/client/generated-0/lib/client.js',
|
|
})
|
|
expect(consumer.findEntry(2, 0)).toMatchObject({
|
|
originalSource: '/packages/client/generated-1/lib/client.js',
|
|
})
|
|
})
|
|
|
|
it('retains one prior immutable batch generation across rebuild recomposition', async () => {
|
|
const packageName = '@fixture/batch-rebuild-race'
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
// Distinct sizes keep all three revisions distinct even within one filesystem clock tick.
|
|
writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
|
|
const { service, route } = constructWithRoute([packageName])
|
|
const first = service.graph().batches[0]!.url
|
|
const firstSize = service.artifactBaseline(packageName)!.size
|
|
|
|
writeFileSync(clientPath, 'module.exports = { generation: 200 }\n')
|
|
service.rebuilt(packageName)
|
|
const second = service.graph().batches[0]!.url
|
|
const secondSize = service.artifactBaseline(packageName)!.size
|
|
expect(second).not.toBe(first)
|
|
expect(secondSize).toBeGreaterThan(firstSize)
|
|
expect((await routeRequest(route, first)).status).toBe(200)
|
|
expect((await routeRequest(route, second)).status).toBe(200)
|
|
|
|
writeFileSync(clientPath, 'module.exports = { generation: 30000 }\n')
|
|
service.rebuilt(packageName)
|
|
const third = service.graph().batches[0]!.url
|
|
expect(third).not.toBe(first)
|
|
expect(third).not.toBe(second)
|
|
expect(service.artifactBaseline(packageName)!.size).toBeGreaterThan(secondSize)
|
|
expect((await routeRequest(route, first)).status).toBe(404)
|
|
expect((await routeRequest(route, second)).status).toBe(200)
|
|
expect((await routeRequest(route, third)).status).toBe(200)
|
|
})
|
|
|
|
it('preserves artifact revisions across registry restarts and scan order changes', () => {
|
|
const firstName = '@fixture/startup-revision-first'
|
|
const secondName = '@fixture/startup-revision-second'
|
|
writeBuiltPackage(firstName, {})
|
|
writeBuiltPackage(secondName, {})
|
|
|
|
const service = construct([firstName, secondName])
|
|
expect(construct([firstName, secondName]).graph()).toEqual(service.graph())
|
|
const reversed = construct([secondName, firstName]).graph()
|
|
for (const entry of service.graph().entries) {
|
|
expect(reversed.entries.find(row => row.id === entry.id)).toEqual(entry)
|
|
}
|
|
const firstPath = service.clientPath(firstName)!
|
|
const firstStat = statSync(firstPath)
|
|
expect(service.artifactBaseline(firstName)).toEqual({
|
|
path: firstPath,
|
|
mtimeMs: firstStat.mtimeMs,
|
|
ctimeMs: firstStat.ctimeMs,
|
|
size: firstStat.size,
|
|
})
|
|
expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
|
|
})
|
|
|
|
it('keeps the initial graph and subscribers unchanged when a rebuild has identical artifacts', () => {
|
|
const packageName = '@fixture/unchanged-rebuild'
|
|
writeBuiltPackage(packageName, {})
|
|
const service = construct([packageName])
|
|
const graph = service.graph()
|
|
const onRebuilt = vi.fn()
|
|
const onGraphChanged = vi.fn()
|
|
service.onRebuilt(onRebuilt)
|
|
service.onGraphChanged(onGraphChanged)
|
|
|
|
expect(service.rebuilt(packageName)).toBe(graph.entries[0]!.rev)
|
|
expect(service.graph()).toBe(graph)
|
|
expect(onRebuilt).not.toHaveBeenCalled()
|
|
expect(onGraphChanged).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('hashes only metadata when publishing initial and rebuilt artifact revisions', () => {
|
|
const packageName = '@fixture/metadata-revision'
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
const bundle = `module.exports = ${JSON.stringify('x'.repeat(64 * 1024))}\n`
|
|
writeFileSync(clientPath, bundle)
|
|
const update = vi.spyOn(Hash.prototype, 'update')
|
|
try {
|
|
const service = construct([packageName])
|
|
const first = service.graph().entries[0]!.rev
|
|
const before = statSync(clientPath)
|
|
writeFileSync(clientPath, `module.exports = ${JSON.stringify('y'.repeat(64 * 1024))}\n`)
|
|
utimesSync(clientPath, before.atime, new Date(before.mtimeMs + 1_000))
|
|
expect(service.rebuilt(packageName)).not.toBe(first)
|
|
const inputBytes = update.mock.calls.map(([input]) => Buffer.byteLength(input))
|
|
expect(inputBytes.length).toBeGreaterThan(0)
|
|
expect(inputBytes.reduce((total, bytes) => total + bytes, 0)).toBeLessThan(4 * 1024)
|
|
} finally {
|
|
update.mockRestore()
|
|
}
|
|
})
|
|
|
|
it.each(['bytes', 'build stamp'])('agrees with a fresh registry after rebuilding changed %s', (change) => {
|
|
const packageName = '@fixture/changed-rebuild'
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
const timestamp = new Date(1_700_000_000_000)
|
|
utimesSync(clientPath, timestamp, timestamp)
|
|
const service = construct([packageName])
|
|
const before = service.graph()
|
|
const initialStat = statSync(clientPath)
|
|
if (change === 'bytes') {
|
|
writeFileSync(clientPath, 'module.exports = { changed: true }\n')
|
|
utimesSync(clientPath, initialStat.atime, initialStat.mtime)
|
|
} else {
|
|
utimesSync(clientPath, initialStat.atime, new Date(initialStat.mtimeMs + 1_000))
|
|
}
|
|
|
|
const rev = service.rebuilt(packageName)
|
|
expect(rev).not.toBe(before.entries[0]!.rev)
|
|
expect(construct([packageName]).graph()).toEqual(service.graph())
|
|
})
|
|
|
|
it('changes revisions when a same-size rewrite preserves mtime', async () => {
|
|
const packageName = '@fixture/restored-mtime'
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
|
|
const timestamp = new Date(1_700_000_000_000)
|
|
utimesSync(clientPath, timestamp, timestamp)
|
|
const service = construct([packageName])
|
|
const before = service.artifactBaseline(packageName)!
|
|
const first = service.graph()
|
|
// Filesystem ctime can advance more coarsely than Date.now(); the fixture needs a distinct value.
|
|
await expect.poll(() => {
|
|
writeFileSync(clientPath, 'module.exports = { generation: 2 }\n')
|
|
utimesSync(clientPath, timestamp, timestamp)
|
|
return statSync(clientPath).ctimeMs
|
|
}).not.toBe(before.ctimeMs)
|
|
expect(statSync(clientPath)).toMatchObject({ mtimeMs: before.mtimeMs, size: before.size })
|
|
|
|
expect(service.rebuilt(packageName)).not.toBe(first.entries[0]!.rev)
|
|
expect(construct([packageName]).graph()).toEqual(service.graph())
|
|
})
|
|
|
|
it('splits startup combos before the map-form URL exceeds 3 KiB', async () => {
|
|
const packageNames = Array.from({ length: 48 }, (_, index) => (
|
|
`@fixture/combo-url-${String(index).padStart(3, '0')}-${'x'.repeat(40)}`
|
|
))
|
|
const sourceMap = JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sources: ['src/index.ts'],
|
|
})
|
|
for (const packageName of packageNames) {
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
writeFileSync(`${clientPath}.map`, sourceMap)
|
|
}
|
|
|
|
const { service, route } = constructWithRoute(packageNames)
|
|
const batches = service.graph().batches.filter(batch => batch.phase === 'application')
|
|
expect(batches.length).toBeGreaterThan(1)
|
|
expect(batches.flatMap(batch => batch.entries)).toEqual(packageNames)
|
|
for (const batch of batches) {
|
|
expect(Buffer.byteLength(batch.url)).toBeLessThanOrEqual(3 * 1024)
|
|
expect(Buffer.byteLength(mapUrl(batch.url))).toBeLessThanOrEqual(3 * 1024)
|
|
expect((await routeRequest(route, batch.url)).status).toBe(200)
|
|
expect((await routeRequest(route, mapUrl(batch.url))).status).toBe(200)
|
|
}
|
|
for (let index = 0; index < batches.length - 1; index += 1) {
|
|
const entries = [...batches[index]!.entries, batches[index + 1]!.entries[0]!]
|
|
expect(Buffer.byteLength(mapUrl(comboReference(entries, '0'.repeat(12))))).toBeGreaterThan(3 * 1024)
|
|
}
|
|
})
|
|
|
|
it('serves the source map beside a registered client bundle', async () => {
|
|
const packageName = '@fixture/source-map'
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = {}\n//# sourceMappingURL=client.js.map')
|
|
const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["../../../packages/client/demo/src/index.tsx","https://cdn.example.test/library.js"]}\n'
|
|
writeFileSync(`${clientPath}.map`, map)
|
|
const { service, route } = constructWithRoute([packageName])
|
|
const row = service.graph().entries[0]!
|
|
const singleScript = await routeRequest(route, row.url)
|
|
expect(singleScript.body.toString('utf8')).toContain(`sourceMappingURL=${mapReference(row.url)}`)
|
|
const singleMap = await routeRequest(route, mapUrl(row.url))
|
|
expect(singleMap.status).toBe(200)
|
|
expect(singleMap.headers).toEqual({
|
|
'content-type': 'application/json; charset=utf-8',
|
|
'cache-control': 'public, max-age=31536000, immutable',
|
|
})
|
|
expect(JSON.parse(singleMap.body.toString('utf8'))).toMatchObject({
|
|
version: 3,
|
|
file: 'client.js',
|
|
sections: [{
|
|
offset: { line: 0, column: 0 },
|
|
map: {
|
|
...(JSON.parse(map) as Record<string, unknown>),
|
|
sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
|
|
},
|
|
}],
|
|
})
|
|
|
|
const batch = service.graph().batches[0]!
|
|
expect(batch).toMatchObject({ phase: 'application', entries: [packageName] })
|
|
const batchScript = await routeRequest(route, batch.url)
|
|
expect(batchScript.status).toBe(200)
|
|
expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
|
|
expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapReference(batch.url)}`)
|
|
const shellResponse = await service.fetchBundle(new Request(`dsh-app://app/${batch.url}`))
|
|
expect(shellResponse.status).toBe(200)
|
|
expect(shellResponse.headers.get('cache-control')).toBe('public, max-age=31536000, immutable')
|
|
expect(await shellResponse.text()).toBe(batchScript.body.toString('utf8'))
|
|
expect((await routeRequest(route, batch.url, 'HEAD')).body).toHaveLength(0)
|
|
expect((await routeRequest(route, batch.url, 'POST')).status).toBe(405)
|
|
const batchMap = await routeRequest(route, mapUrl(batch.url))
|
|
const parsedBatchMap: unknown = JSON.parse(batchMap.body.toString('utf8'))
|
|
const parsedPluginMap = JSON.parse(map) as Record<string, unknown>
|
|
expect(parsedBatchMap).toMatchObject({
|
|
version: 3,
|
|
file: 'client.js',
|
|
sections: [{
|
|
offset: { line: 0, column: 0 },
|
|
map: {
|
|
...parsedPluginMap,
|
|
sources: ['/packages/client/demo/src/index.tsx', 'https://cdn.example.test/library.js'],
|
|
},
|
|
}],
|
|
})
|
|
expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
|
|
|
|
writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
|
|
const entryStat = statSync(clientPath)
|
|
utimesSync(clientPath, entryStat.atime, new Date(entryStat.mtimeMs + 1_000))
|
|
const nextRev = service.rebuilt(packageName)
|
|
expect(nextRev).not.toBe(row.rev)
|
|
const nextRow = service.graph().entries[0]!
|
|
expect(nextRow.rev).toBe(nextRev)
|
|
const nextMap = await routeRequest(route, mapUrl(nextRow.url))
|
|
expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({
|
|
sections: [{ map: { sources: ['/plugins/@fixture/source-map/src/changed.tsx'] } }],
|
|
})
|
|
})
|
|
|
|
it('serves a package-local chunk only after its versioned URL is requested', async () => {
|
|
const packageName = '@fixture/chunked'
|
|
const clientPath = writePackage(packageName)
|
|
const chunkPath = join(dirname(clientPath), 'client.terminal.js')
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = { load: () => require.async("./client.terminal.js") }\n')
|
|
const { service, route } = constructWithRoute([packageName])
|
|
const row = service.graph().entries[0]!
|
|
|
|
const startup = await routeRequest(route, row.url)
|
|
expect(startup.status).toBe(200)
|
|
expect(startup.body.toString('utf8')).not.toContain('terminal loaded')
|
|
expect((await routeRequest(route, chunkReference(packageName, 'client.terminal.js', row.rev))).status).toBe(404)
|
|
|
|
writeFileSync(chunkPath, 'module.exports = { marker: "terminal loaded" }\n')
|
|
const url = chunkReference(packageName, 'client.terminal.js', row.rev)
|
|
const head = await routeRequest(route, url, 'HEAD')
|
|
expect(head.status).toBe(200)
|
|
expect(head.body).toHaveLength(0)
|
|
const chunk = await routeRequest(route, url)
|
|
expect(chunk.status).toBe(200)
|
|
expect(chunk.body.toString('utf8')).toContain('terminal loaded')
|
|
expect(chunk.body.toString('utf8')).toContain(`sourceMappingURL=${chunkMapReference('client.terminal.js', row.rev)}`)
|
|
expect((await routeRequest(route, url.replace('.js?', '.js.map?'))).status).toBe(200)
|
|
expect((await routeRequest(route, url.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
|
|
})
|
|
|
|
it('reads a package-local chunk and its map body only through the asynchronous filesystem face', async () => {
|
|
const packageName = '@fixture/async-chunk'
|
|
const clientPath = writePackage(packageName)
|
|
const chunkPath = join(dirname(clientPath), 'client.pdf.js')
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = { load: () => require.async("./client.pdf.js") }\n')
|
|
writeFileSync(chunkPath, 'module.exports = { marker: "pdf loaded" }\n')
|
|
syncRefused.add(chunkPath)
|
|
const { service, route } = constructWithRoute([packageName])
|
|
const row = service.graph().entries[0]!
|
|
const url = chunkReference(packageName, 'client.pdf.js', row.rev)
|
|
|
|
const chunk = await routeRequest(route, url)
|
|
expect(chunk.status).toBe(200)
|
|
expect(chunk.body.toString('utf8')).toContain('pdf loaded')
|
|
const map = await routeRequest(route, url.replace('.js?', '.js.map?'))
|
|
expect(map.status).toBe(200)
|
|
expect(JSON.parse(map.body.toString('utf8'))).toMatchObject({ version: 3 })
|
|
})
|
|
|
|
it('reads a package-local chunk again after its first asynchronous read fails', async () => {
|
|
const packageName = '@fixture/retried-chunk'
|
|
const clientPath = writePackage(packageName)
|
|
const chunkPath = join(dirname(clientPath), 'client.excel.js')
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = { load: () => require.async("./client.excel.js") }\n')
|
|
writeFileSync(chunkPath, 'module.exports = { marker: "excel loaded" }\n')
|
|
asyncFailOnce.add(chunkPath)
|
|
const { service, route } = constructWithRoute([packageName])
|
|
const url = chunkReference(packageName, 'client.excel.js', service.graph().entries[0]!.rev)
|
|
|
|
await expect(routeRequest(route, url)).rejects.toThrow(`download failed for ${chunkPath}`)
|
|
const retried = await routeRequest(route, url)
|
|
expect(retried.status).toBe(200)
|
|
expect(retried.body.toString('utf8')).toContain('excel loaded')
|
|
})
|
|
|
|
it('loads a renamed browser export at the logical URL without exposing Node bundles as chunks', async () => {
|
|
const packageName = '@fixture/dual-client'
|
|
const legacyPath = writePackage(packageName, {
|
|
dsh: { client: { platform: 'web' } },
|
|
exports: {
|
|
'./client': {
|
|
types: './lib/types/client/index.d.ts',
|
|
node: './lib/client.node.js',
|
|
browser: './lib/client.browser.js',
|
|
default: './lib/client.browser.js',
|
|
},
|
|
'./package.json': './package.json',
|
|
},
|
|
})
|
|
mkdirSync(dirname(legacyPath), { recursive: true })
|
|
writeFileSync(join(dirname(legacyPath), 'client.browser.js'), 'module.exports = { browser: true }\n')
|
|
for (const name of ['client.node.js', 'client.node.helper.js']) {
|
|
writeFileSync(join(dirname(legacyPath), name), 'throw new Error("Node code must not reach a browser")\n')
|
|
}
|
|
const { service, route } = constructWithRoute([packageName])
|
|
const row = service.graph().entries[0]!
|
|
expect(row.url).toContain(`${packageName}/client.js`)
|
|
expect((await routeRequest(route, row.url)).body.toString('utf8')).toContain('browser: true')
|
|
for (const name of ['client.node.js', 'client.node.helper.js']) {
|
|
for (const suffix of ['', '.map']) {
|
|
expect((await routeRequest(route, chunkReference(packageName, name + suffix, row.rev))).status).toBe(404)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('publishes a new chunk revision when a completed build rewrites only the entry timestamp', async () => {
|
|
const packageName = '@fixture/chunk-only-rebuild'
|
|
const clientPath = writePackage(packageName)
|
|
const chunkPath = join(dirname(clientPath), 'client.terminal.js')
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = { load: () => require.async("./client.terminal.js") }\n')
|
|
writeFileSync(chunkPath, 'module.exports = { generation: 1 }\n')
|
|
const { service, route } = constructWithRoute([packageName])
|
|
const firstRow = service.graph().entries[0]!
|
|
const firstUrl = chunkReference(packageName, 'client.terminal.js', firstRow.rev)
|
|
expect((await routeRequest(route, firstUrl)).body.toString('utf8')).toContain('generation: 1')
|
|
|
|
writeFileSync(chunkPath, 'module.exports = { generation: 2 }\n')
|
|
const entryStat = statSync(clientPath)
|
|
const completed = new Date(entryStat.mtimeMs + 1_000)
|
|
utimesSync(clientPath, entryStat.atime, completed)
|
|
const nextRev = service.rebuilt(packageName)!
|
|
expect(nextRev).not.toBe(firstRow.rev)
|
|
expect((await routeRequest(route, firstUrl)).status).toBe(404)
|
|
const nextUrl = chunkReference(packageName, 'client.terminal.js', nextRev)
|
|
expect((await routeRequest(route, nextUrl)).body.toString('utf8')).toContain('generation: 2')
|
|
})
|
|
|
|
it('applies sourceRoot before relocating absolute-looking section sources', async () => {
|
|
const packageName = '@fixture/source-root'
|
|
const clientPath = writePackage(packageName)
|
|
mkdirSync(dirname(clientPath), { recursive: true })
|
|
writeFileSync(clientPath, 'module.exports = {}\n')
|
|
writeFileSync(`${clientPath}.map`, JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sourceRoot: '../root',
|
|
sources: ['/absolute.ts'],
|
|
}))
|
|
const { service, route } = constructWithRoute([packageName])
|
|
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
|
|
const map = JSON.parse(response.body.toString('utf8')) as {
|
|
sections: { map: { sourceRoot?: string; sources: string[] } }[]
|
|
}
|
|
expect(map.sections[0]?.map).toMatchObject({
|
|
sources: ['/plugins/@fixture/root/absolute.ts'],
|
|
})
|
|
expect(map.sections[0]?.map).not.toHaveProperty('sourceRoot')
|
|
})
|
|
|
|
it('maps a non-zero second batch section through a standard source-map consumer', async () => {
|
|
const firstName = '@fixture/offset-first'
|
|
const secondName = '@fixture/offset-second'
|
|
const firstPath = writePackage(firstName)
|
|
const secondPath = writePackage(secondName)
|
|
for (const [path, source] of [
|
|
[firstPath, '../../../packages/demo/first.ts'],
|
|
[secondPath, '../../../packages/demo/second.ts'],
|
|
] as const) {
|
|
mkdirSync(dirname(path), { recursive: true })
|
|
writeFileSync(path, 'window.first = true\nwindow.second = true\n')
|
|
writeFileSync(`${path}.map`, JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sources: [source],
|
|
sourcesContent: ['export {}\n'],
|
|
}))
|
|
}
|
|
const { service, route } = constructWithRoute([firstName, secondName])
|
|
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
|
|
const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
|
|
const sections = (payload as unknown as {
|
|
sections: { offset: { line: number; column: number } }[]
|
|
}).sections
|
|
expect(sections.map(section => section.offset)).toEqual([
|
|
{ line: 0, column: 0 },
|
|
{ line: 3, column: 0 },
|
|
])
|
|
const consumer = new SourceMap(payload)
|
|
expect(consumer.findEntry(0, 0)).toMatchObject({ originalSource: '/packages/demo/first.ts' })
|
|
expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' })
|
|
})
|
|
|
|
it('combines a generated-file fallback with a later authored map', async () => {
|
|
const unmappedName = '@fixture/unmapped-first'
|
|
const mappedName = '@fixture/mapped-second'
|
|
const unmappedPath = writePackage(unmappedName)
|
|
const mappedPath = writePackage(mappedName)
|
|
mkdirSync(dirname(unmappedPath), { recursive: true })
|
|
mkdirSync(dirname(mappedPath), { recursive: true })
|
|
writeFileSync(unmappedPath, 'window.unmapped = true\n')
|
|
writeFileSync(mappedPath, 'window.mapped = true\n')
|
|
writeFileSync(`${mappedPath}.map`, JSON.stringify({
|
|
version: 3,
|
|
names: [],
|
|
mappings: 'AAAA',
|
|
sources: ['../../../packages/demo/mapped.ts'],
|
|
sourcesContent: ['export {}\n'],
|
|
}))
|
|
|
|
const { service, route } = constructWithRoute([unmappedName, mappedName])
|
|
const response = await routeRequest(route, mapUrl(service.graph().batches[0]!.url))
|
|
const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
|
|
const consumer = new SourceMap(payload)
|
|
expect(consumer.findEntry(0, 0)).toMatchObject({
|
|
originalSource: `/plugins/${unmappedName}/client.js`,
|
|
})
|
|
expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
|
|
})
|
|
})
|
|
|
|
function emitLoaderEntryChange(context: Context, name: string): void {
|
|
context.emit('internal/plugin', {
|
|
entry: { options: { name } },
|
|
} as unknown as Fiber)
|
|
}
|
|
|
|
describe('shared module declarations', () => {
|
|
it('accepts external requests and carries them onto the graph row', () => {
|
|
const packageName = '@fixture/shared-declared'
|
|
writeBuiltPackage(packageName, { external: ['react'] })
|
|
const { service } = constructWithRoute([packageName])
|
|
const row = service.graph().entries[0]
|
|
if (row === undefined) throw new Error('shared-declared graph row missing')
|
|
const revisionMatcher: unknown = expect.any(String)
|
|
// The browser resolves this reference against its own document; the
|
|
// response table still answers the absolute route it resolves to.
|
|
expect(row).toEqual({
|
|
id: packageName,
|
|
url: `plugins/??${packageName}/client.js&rev=${row.rev}`,
|
|
rev: revisionMatcher,
|
|
external: ['react'],
|
|
})
|
|
})
|
|
|
|
it('omits external when the package declares no requests', () => {
|
|
const packageName = '@fixture/shared-absent'
|
|
writeBuiltPackage(packageName, {})
|
|
const [row] = construct([packageName]).graph().entries
|
|
expect(row).not.toHaveProperty('external')
|
|
})
|
|
|
|
it('rejects a non-array external', () => {
|
|
const packageName = '@fixture/external-not-array'
|
|
writeBuiltPackage(packageName, { external: 'react' })
|
|
expect(() => construct([packageName]))
|
|
.toThrow(`client-modules: ${packageName} dsh.client.external must be a string array`)
|
|
})
|
|
})
|
|
|
|
describe('module graph order', () => {
|
|
const entry = (id: string, fields: Partial<WebBootEntry> = {}): WebBootEntry =>
|
|
({ id, url: comboReference([id], '0'), rev: '0', ...fields })
|
|
const ids = (entries: readonly WebBootEntry[]): string[] => entries.map(row => row.id)
|
|
|
|
it('places every requested package row before its consumers along a chain', () => {
|
|
expect(ids(orderByModuleGraph([
|
|
entry('ui', { external: ['slots'] }),
|
|
entry('slots', { external: ['render'] }),
|
|
entry('render'),
|
|
]))).toEqual(['render', 'slots', 'ui'])
|
|
})
|
|
|
|
it('places a shared package row before both arms of a diamond', () => {
|
|
expect(ids(orderByModuleGraph([
|
|
entry('app', { external: ['left', 'right'] }),
|
|
entry('left', { external: ['vendor'] }),
|
|
entry('right', { external: ['vendor'] }),
|
|
entry('vendor'),
|
|
]))).toEqual(['vendor', 'left', 'right', 'app'])
|
|
})
|
|
|
|
it('resolves a /client request onto the requested package row', () => {
|
|
expect(ids(orderByModuleGraph([
|
|
entry('ui', { external: ['runtime/client'] }),
|
|
entry('runtime'),
|
|
]))).toEqual(['runtime', 'ui'])
|
|
})
|
|
|
|
it('leaves a request no row answers to the static assembly channel', () => {
|
|
expect(ids(orderByModuleGraph([
|
|
entry('consumer', { external: ['@deepseek-ai/cordis'] }),
|
|
entry('other'),
|
|
]))).toEqual(['consumer', 'other'])
|
|
})
|
|
|
|
it('rejects a cycle and names the packages on it', () => {
|
|
expect(() => orderByModuleGraph([
|
|
entry('a', { external: ['b'] }),
|
|
entry('b', { external: ['a'] }),
|
|
])).toThrow('client-modules: module graph cycle a -> b -> a')
|
|
})
|
|
|
|
it('rejects a row requesting its own package name', () => {
|
|
expect(() => orderByModuleGraph([entry('solo', { external: ['solo'] })]))
|
|
.toThrow('client-modules: "solo" requests module "solo" that it answers itself')
|
|
})
|
|
|
|
it('composes the served graph in module-graph order', () => {
|
|
const consumerName = '@fixture/order-consumer'
|
|
const dependencyName = '@fixture/order-dependency'
|
|
writeBuiltPackage(consumerName, { external: [dependencyName] })
|
|
writeBuiltPackage(dependencyName, {})
|
|
expect(ids(construct([consumerName, dependencyName]).graph().entries))
|
|
.toEqual([dependencyName, consumerName])
|
|
})
|
|
|
|
it('fails activation loud when scanned packages form a module cycle', () => {
|
|
writeBuiltPackage('@fixture/cycle-a', { external: ['@fixture/cycle-b'] })
|
|
writeBuiltPackage('@fixture/cycle-b', { external: ['@fixture/cycle-a'] })
|
|
expect(() => construct(['@fixture/cycle-a', '@fixture/cycle-b']))
|
|
.toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
|
|
})
|
|
})
|