assembled-boot.ts 13 KB

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