assembled-boot.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. // Shared scaffolding for the assembled-jsdom snapshots: the real built
  2. // workspace `lib/client.js` artifacts booted through AppWebEntry's
  3. // ModuleLoader path (loadBundle) against the keyless FixtureApiClient
  4. // transport. Every file that mounts this graph needs the same boot entry list,
  5. // the same bundle map, the same jsdom globals, and the same mount call, and
  6. // differs only in what it asserts afterwards, so the scaffolding lives here.
  7. //
  8. // Keyless and deterministic: the fixture is the fake server, so nothing here
  9. // reaches a model or the network.
  10. import { readFileSync } from 'node:fs'
  11. import { createRequire } from 'node:module'
  12. import { dirname, join, resolve } from 'node:path'
  13. import { pathToFileURL } from 'node:url'
  14. import { act, cleanup } from '@testing-library/react'
  15. import { afterEach, beforeEach, vi } from 'vitest'
  16. import { injectBootManifest, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
  17. import type { ClientModuleLoaderTarget, WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
  18. import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
  19. interface AssembledPlugin extends WebBootEntry {
  20. /** Absolute path to the built client artifact declared by this package. */
  21. bundlePath: string
  22. }
  23. interface ClientPackageManifest {
  24. name?: string
  25. exports?: Record<string, string | { default?: string }>
  26. dsh?: {
  27. client?: {
  28. platform?: string
  29. inject?: string[]
  30. external?: string[]
  31. immediately?: boolean
  32. }
  33. }
  34. }
  35. interface ComposedEntry {
  36. name?: unknown
  37. disabled?: unknown
  38. }
  39. interface BootComposition {
  40. loadOverlayPatches(binName: string, file: string): unknown[]
  41. composeEntries(layers: readonly unknown[][]): ComposedEntry[]
  42. }
  43. const REPO_ROOT = process.cwd()
  44. const BUNDLE_LAYERS = [
  45. {
  46. manifest: join(REPO_ROOT, 'packages/bundle/base/package.json'),
  47. patch: join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml'),
  48. },
  49. {
  50. manifest: join(REPO_ROOT, 'packages/bundle/web-app/package.json'),
  51. patch: join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml'),
  52. },
  53. ] as const
  54. const bundleResolvers = BUNDLE_LAYERS.map(layer => createRequire(layer.manifest))
  55. const webBundleResolver = bundleResolvers[1]
  56. if (webBundleResolver === undefined) throw new Error('assembled boot: web bundle resolver missing')
  57. const appBoot = await import(pathToFileURL(webBundleResolver.resolve('@deepseek-ai/dsh-app-boot')).href) as unknown as BootComposition
  58. function resolvePackageManifest(specifier: string): string | undefined {
  59. for (const require of bundleResolvers) {
  60. try {
  61. return require.resolve(`${specifier}/package.json`)
  62. } catch {
  63. continue
  64. }
  65. }
  66. return undefined
  67. }
  68. function resolveClientExport(packagePath: string, pkg: ClientPackageManifest): string {
  69. const declared = pkg.exports?.['./client']
  70. const relative = typeof declared === 'string' ? declared : declared?.default
  71. if (relative === undefined) {
  72. throw new Error(`assembled boot: ${pkg.name ?? packagePath} declares dsh.client without a ./client export`)
  73. }
  74. return resolve(dirname(packagePath), relative)
  75. }
  76. /** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */
  77. function loadAssembledPlugins(): readonly AssembledPlugin[] {
  78. const entries = appBoot.composeEntries(BUNDLE_LAYERS.map(layer =>
  79. appBoot.loadOverlayPatches('assembled boot', layer.patch)))
  80. const plugins = new Map<string, AssembledPlugin>()
  81. for (const entry of entries) {
  82. if (entry.disabled === true || typeof entry.name !== 'string') continue
  83. const packagePath = resolvePackageManifest(entry.name)
  84. if (packagePath === undefined) continue
  85. const pkg = JSON.parse(readFileSync(packagePath, 'utf8')) as ClientPackageManifest
  86. const declaration = pkg.dsh?.client
  87. if (declaration?.platform !== 'web') continue
  88. if (pkg.name !== entry.name) {
  89. throw new Error(`assembled boot: ${entry.name} resolved package ${pkg.name ?? '<unnamed>'}`)
  90. }
  91. plugins.set(entry.name, {
  92. id: entry.name,
  93. bundlePath: resolveClientExport(packagePath, pkg),
  94. url: `/plugins/${entry.name}/client.js?rev=fx`,
  95. rev: 'fx',
  96. ...(declaration.inject === undefined ? {} : { inject: declaration.inject }),
  97. ...(declaration.external === undefined ? {} : { external: declaration.external }),
  98. ...(declaration.immediately === true ? { immediately: true } : {}),
  99. })
  100. }
  101. return orderByModuleGraph([...plugins.values()]).map(({ id }) => {
  102. const plugin = plugins.get(id)
  103. /* v8 ignore next -- orderByModuleGraph returns the input row identities */
  104. if (plugin === undefined) throw new Error(`assembled boot: ordered unknown client package ${id}`)
  105. return plugin
  106. })
  107. }
  108. const PLUGINS = loadAssembledPlugins()
  109. const bundles = new Map(PLUGINS.map(plugin => [
  110. plugin.url,
  111. readFileSync(plugin.bundlePath, 'utf8'),
  112. ]))
  113. interface FixtureWindow extends Window {
  114. __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
  115. __ModuleLoader__?: ClientModuleLoaderTarget
  116. }
  117. class ResizeObserverStub {
  118. observe(): void {}
  119. disconnect(): void {}
  120. unobserve(): void {}
  121. }
  122. class EventSourceStub {
  123. addEventListener(): void {}
  124. close(): void {}
  125. }
  126. const win = window as FixtureWindow
  127. let unmount: (() => Promise<void>) | undefined
  128. /**
  129. * Register the per-test jsdom setup and teardown the assembled boot needs:
  130. * English pinned before boot so role/text locators stay deterministic across
  131. * localized component migrations (the newEnglishPage e2e convention), the
  132. * observers and frame callbacks jsdom lacks, and a full reset of the document,
  133. * the boot globals, and the injected plugin styles afterwards.
  134. */
  135. export function installAssembledBootEnv(): void {
  136. beforeEach(() => {
  137. localStorage.clear()
  138. // The locale service derives its provisional locale from the browser and
  139. // takes an explicit choice only from Host settings, which this lane's
  140. // fixture transport does not serve; pinning the navigator is what selects
  141. // English here.
  142. Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true })
  143. Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
  144. document.title = 'DeepSeek Harness'
  145. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  146. vi.stubGlobal('EventSource', EventSourceStub)
  147. vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
  148. setTimeout(() => { callback(0) }, 0) as unknown as number)
  149. vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
  150. })
  151. afterEach(async () => {
  152. await act(async () => { await unmount?.() })
  153. unmount = undefined
  154. cleanup()
  155. delete win.__DSH_BOOT__
  156. delete win.__ModuleLoader__
  157. document.body.innerHTML = ''
  158. document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
  159. document.title = ''
  160. history.replaceState(null, '', '/')
  161. // Deleting the own properties uncovers jsdom's own accessors again
  162. // (Navigator declares both readonly, hence the erased receiver).
  163. const ownNavigator = navigator as unknown as Record<string, unknown>
  164. delete ownNavigator.languages
  165. delete ownNavigator.language
  166. vi.unstubAllGlobals()
  167. })
  168. }
  169. /**
  170. * Mount the assembled application on the fixture transport; the teardown
  171. * registered by installAssembledBootEnv disposes it.
  172. * @param search - fixture query string used to select deterministic host behavior.
  173. */
  174. export function mountAssembledApp(search = '?fixture'): void {
  175. history.replaceState(null, '', `/${search}`)
  176. const root = document.createElement('div')
  177. root.id = 'root'
  178. document.body.appendChild(root)
  179. win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
  180. const html = injectBootManifest('<head></head>', win.__DSH_BOOT__)
  181. const facadeSource = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
  182. if (facadeSource === undefined) throw new Error('missing injected ModuleLoader facade')
  183. ;(0, eval)(facadeSource)
  184. // Mirror the blocking Host-injected scripts before the Vite entry calls create().
  185. for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) {
  186. const plugin = PLUGINS.find(candidate => candidate.id === id)
  187. if (plugin === undefined) throw new Error(`missing parser-preloaded fixture row ${id}`)
  188. const code = bundles.get(plugin.url)
  189. if (code === undefined) throw new Error(`missing built bundle ${plugin.url}`)
  190. ;(0, eval)(code)
  191. }
  192. act(() => {
  193. const entry = new AppWebEntry(root, {
  194. loadBundle: async (url) => {
  195. const code = bundles.get(url)
  196. if (code === undefined) throw new Error(`missing built bundle ${url}`)
  197. ;(0, eval)(code)
  198. },
  199. })
  200. void entry.run()
  201. unmount = () => entry.dispose()
  202. })
  203. }
  204. /**
  205. * Match a CSS-module class by its logical name.
  206. * Module class names carry a per-build hash in one of two schemes —
  207. * ui-primitives emits `_<name>_<hash>` (name bounded by underscores),
  208. * feature bundles emit `<hash>_<name>` (name at the end) — and a longer name
  209. * containing this one must not match (`line` must not hit `lineNumber`).
  210. * @param el - element whose class list is inspected.
  211. * @param name - logical (unhashed) module class name.
  212. * @returns whether the element carries that module class.
  213. */
  214. export function hasClass(el: Element, name: string): boolean {
  215. return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`))
  216. }
  217. /**
  218. * Whether this run rewrites its golden instead of comparing against it, set by
  219. * the snapshot gate's `DSH_SNAPSHOT` mode (`record` re-runs the scenarios from
  220. * scratch, `refresh` re-derives the expected text from the existing ones).
  221. */
  222. export const REFRESHING_GOLDEN = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'