assembled-boot.ts 9.9 KB

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