assembled-boot.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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 fixture Connection RPC
  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 { globSync, 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, WebBootGraph } 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 workspacePackageManifests = new Map(globSync('packages/*/*/package.json', { cwd: REPO_ROOT }).map((relative) => {
  62. const path = join(REPO_ROOT, relative)
  63. const pkg = JSON.parse(readFileSync(path, 'utf8')) as ClientPackageManifest
  64. if (pkg.name === undefined) throw new Error(`assembled boot: workspace package has no name: ${path}`)
  65. return [pkg.name, path]
  66. }))
  67. const appBoot = await import(pathToFileURL(webBundleResolver.resolve('@deepseek-ai/dsh-app-boot')).href) as unknown as BootComposition
  68. function resolvePackageManifest(specifier: string): string | undefined {
  69. return workspacePackageManifests.get(specifier)
  70. }
  71. function resolveClientExport(packagePath: string, pkg: ClientPackageManifest): string {
  72. const declared = pkg.exports?.['./client']
  73. const relative = typeof declared === 'string' ? declared : declared?.default
  74. if (relative === undefined) {
  75. throw new Error(`assembled boot: ${pkg.name ?? packagePath} declares dsh.client without a ./client export`)
  76. }
  77. return resolve(dirname(packagePath), relative)
  78. }
  79. const comboUrl = (ids: readonly string[], rev: string): string =>
  80. `/plugins/??${ids.map(id => `${id}/client.js`).join(',')}&rev=${rev}`
  81. /** Derive the assembled browser graph from the same bundle patches and package declarations as `dsh web`. */
  82. function loadAssembledPlugins(): readonly AssembledPlugin[] {
  83. const entries = appBoot.composeEntries(BUNDLE_LAYERS.map(layer =>
  84. appBoot.loadOverlayPatches('assembled boot', layer.patch)))
  85. const plugins = new Map<string, AssembledPlugin>()
  86. for (const entry of entries) {
  87. if (entry.disabled === true || typeof entry.name !== 'string') continue
  88. const packagePath = resolvePackageManifest(entry.name)
  89. if (packagePath === undefined) continue
  90. const pkg = JSON.parse(readFileSync(packagePath, 'utf8')) as ClientPackageManifest
  91. const declaration = pkg.dsh?.client
  92. if (declaration?.platform !== 'web') continue
  93. if (pkg.name !== entry.name) {
  94. throw new Error(`assembled boot: ${entry.name} resolved package ${pkg.name ?? '<unnamed>'}`)
  95. }
  96. plugins.set(entry.name, {
  97. id: entry.name,
  98. bundlePath: resolveClientExport(packagePath, pkg),
  99. url: comboUrl([entry.name], 'fx'),
  100. rev: 'fx',
  101. ...(declaration.inject === undefined ? {} : { inject: declaration.inject }),
  102. ...(declaration.external === undefined ? {} : { external: declaration.external }),
  103. ...(declaration.immediately === true ? { immediately: true } : {}),
  104. })
  105. }
  106. return orderByModuleGraph([...plugins.values()]).map(({ id }) => {
  107. const plugin = plugins.get(id)
  108. /* v8 ignore next -- orderByModuleGraph returns the input row identities */
  109. if (plugin === undefined) throw new Error(`assembled boot: ordered unknown client package ${id}`)
  110. return plugin
  111. })
  112. }
  113. const PLUGINS = loadAssembledPlugins()
  114. const BOOTSTRAP_IDS = ['@deepseek-ai/dsh-client-modules'] as const
  115. /** Build the fixture graph after applying per-scenario package exclusions. */
  116. function bootGraph(plugins: readonly AssembledPlugin[]): WebBootGraph {
  117. const bootstrapEntries = plugins
  118. .map(plugin => plugin.id)
  119. .filter(id => BOOTSTRAP_IDS.includes(id as typeof BOOTSTRAP_IDS[number]))
  120. const applicationEntries = plugins
  121. .map(plugin => plugin.id)
  122. .filter(id => !BOOTSTRAP_IDS.includes(id as typeof BOOTSTRAP_IDS[number]))
  123. return {
  124. rev: 'fx',
  125. entries: plugins.map(({ bundlePath: _bundlePath, ...plugin }) => plugin),
  126. batches: [
  127. ...(bootstrapEntries.length === 0 ? [] : [{
  128. phase: 'bootstrap' as const,
  129. url: comboUrl(bootstrapEntries, 'fx'),
  130. rev: 'fx',
  131. entries: bootstrapEntries,
  132. }]),
  133. ...(applicationEntries.length === 0 ? [] : [{
  134. phase: 'application' as const,
  135. url: comboUrl(applicationEntries, 'fx'),
  136. rev: 'fx',
  137. entries: applicationEntries,
  138. }]),
  139. ],
  140. }
  141. }
  142. /** Build single-resource and startup combo script bodies for one fixture composition. */
  143. function bundleTable(graph: WebBootGraph, plugins: readonly AssembledPlugin[]): Map<string, string> {
  144. const bundles = new Map(plugins.map(plugin => [
  145. plugin.url,
  146. readFileSync(plugin.bundlePath, 'utf8'),
  147. ]))
  148. for (const batch of graph.batches) {
  149. bundles.set(batch.url, batch.entries.map((id) => {
  150. const plugin = plugins.find(candidate => candidate.id === id)
  151. if (plugin === undefined) throw new Error(`assembled boot: batch names unknown plugin ${id}`)
  152. const code = bundles.get(plugin.url)
  153. if (code === undefined) throw new Error(`assembled boot: missing built bundle ${plugin.url}`)
  154. return code
  155. }).join('\n;\n'))
  156. }
  157. return bundles
  158. }
  159. interface FixtureWindow extends Window {
  160. __DSH_BOOT__?: WebBootGraph
  161. __ModuleLoader__?: ClientModuleLoaderTarget
  162. }
  163. class ResizeObserverStub {
  164. observe(): void {}
  165. disconnect(): void {}
  166. unobserve(): void {}
  167. }
  168. class EventSourceStub {
  169. addEventListener(): void {}
  170. close(): void {}
  171. }
  172. const win = window as FixtureWindow
  173. let unmount: (() => Promise<void>) | undefined
  174. /**
  175. * Register the per-test jsdom setup and teardown the assembled boot needs:
  176. * English pinned before boot so role/text locators stay deterministic across
  177. * localized component migrations (the newEnglishPage e2e convention), the
  178. * observers and frame callbacks jsdom lacks, and a full reset of the document,
  179. * the boot globals, and the injected plugin styles afterwards.
  180. */
  181. export function installAssembledBootEnv(): void {
  182. // jsdom implements no scroll geometry: the trigger menu reveals its
  183. // highlight with scrollIntoView on open, which a pasted leading token now
  184. // reaches in this lane (the editor re-tracks at the settled caret).
  185. if (typeof Element.prototype.scrollIntoView !== 'function') {
  186. Element.prototype.scrollIntoView = () => {}
  187. }
  188. // jsdom implements no Range geometry either: Lexical's selection reveal
  189. // measures the caret with one after a programmatic edit settles focus.
  190. if (typeof Range.prototype.getBoundingClientRect !== 'function') {
  191. Range.prototype.getBoundingClientRect = () => ({
  192. top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
  193. })
  194. }
  195. beforeEach(() => {
  196. localStorage.clear()
  197. // The locale service derives its provisional locale from the browser and
  198. // takes an explicit choice only from Host settings, which this lane's
  199. // fixture transport does not serve; pinning the navigator is what selects
  200. // English here.
  201. Object.defineProperty(navigator, 'languages', { value: ['en-US'], configurable: true })
  202. Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true })
  203. document.title = 'DeepSeek Harness'
  204. vi.stubGlobal('ResizeObserver', ResizeObserverStub)
  205. vi.stubGlobal('EventSource', EventSourceStub)
  206. vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
  207. setTimeout(() => { callback(0) }, 0) as unknown as number)
  208. vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
  209. })
  210. afterEach(async () => {
  211. await act(async () => { await unmount?.() })
  212. unmount = undefined
  213. cleanup()
  214. delete win.__DSH_BOOT__
  215. delete win.__ModuleLoader__
  216. document.body.innerHTML = ''
  217. document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
  218. document.title = ''
  219. history.replaceState(null, '', '/')
  220. // Deleting the own properties uncovers jsdom's own accessors again
  221. // (Navigator declares both readonly, hence the erased receiver).
  222. const ownNavigator = navigator as unknown as Record<string, unknown>
  223. delete ownNavigator.languages
  224. delete ownNavigator.language
  225. vi.unstubAllGlobals()
  226. })
  227. }
  228. /**
  229. * Mount the assembled application on the fixture transport; the teardown
  230. * registered by installAssembledBootEnv disposes it.
  231. * @param search - fixture query string used to select deterministic host behavior.
  232. * @param options - composition changes applied to this mount.
  233. */
  234. export function mountAssembledApp(search = '?fixture', options: AssembledBootOptions = {}): void {
  235. const excluded = new Set(options.exclude)
  236. const plugins = PLUGINS.filter(plugin => !excluded.has(plugin.id))
  237. history.replaceState(null, '', `/${search}`)
  238. const root = document.createElement('div')
  239. root.id = 'root'
  240. document.body.appendChild(root)
  241. const graph = bootGraph(plugins)
  242. const bundles = bundleTable(graph, plugins)
  243. win.__DSH_BOOT__ = graph
  244. const [facadeRow] = bootInjections(win.__DSH_BOOT__)
  245. if (facadeRow?.kind !== 'script') throw new Error('missing injected ModuleLoader facade row')
  246. ;(0, eval)(facadeRow.text)
  247. // Mirror the blocking Host-injected bootstrap batch before the Vite entry calls create().
  248. const bootstrapUrl = graph.batches.find(batch => batch.phase === 'bootstrap')?.url
  249. const bootstrap = bootstrapUrl === undefined ? undefined : bundles.get(bootstrapUrl)
  250. if (bootstrap === undefined) throw new Error('missing parser-preloaded fixture batch')
  251. ;(0, eval)(bootstrap)
  252. act(() => {
  253. const entry = new AppWebEntry(root, {
  254. loadBundle: async (url) => {
  255. const code = bundles.get(url)
  256. if (code === undefined) throw new Error(`missing built bundle ${url}`)
  257. ;(0, eval)(code)
  258. },
  259. })
  260. void entry.run()
  261. unmount = () => entry.dispose()
  262. })
  263. }
  264. /**
  265. * Match a CSS-module class by its logical name.
  266. * Module class names carry a per-build hash in one of two schemes —
  267. * ui-primitives emits `_<name>_<hash>` (name bounded by underscores),
  268. * feature bundles emit `<hash>_<name>` (name at the end) — and a longer name
  269. * containing this one must not match (`line` must not hit `lineNumber`).
  270. * @param el - element whose class list is inspected.
  271. * @param name - logical (unhashed) module class name.
  272. * @returns whether the element carries that module class.
  273. */
  274. export function hasClass(el: Element, name: string): boolean {
  275. return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`))
  276. }
  277. /**
  278. * Whether this run rewrites its golden instead of comparing against it, set by
  279. * the snapshot gate's `DSH_SNAPSHOT` mode (`record` re-runs the scenarios from
  280. * scratch, `refresh` re-derives the expected text from the existing ones).
  281. */
  282. export const REFRESHING_GOLDEN = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'