loader-composition.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. /**
  2. * REAL-composition coverage: a test-only cordis.yml booted through the
  3. * vendored Loader mounts the webserver row plus the adaptive chooser, and the
  4. * assertions observe the durable outcome — which backend and surface entries
  5. * the chooser mounted into the Loader store, the capability the seam then
  6. * serves, and that disposing the chooser removes both mounted entries again
  7. * (HMR safety), joining the backend's own teardown before the disposer settles.
  8. */
  9. import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'
  10. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  11. import { tmpdir } from 'node:os'
  12. import { join } from 'node:path'
  13. import { pathToFileURL } from 'node:url'
  14. import { afterEach, describe, expect, it, vi } from 'vitest'
  15. import { Context } from '@deepseek-ai/cordis'
  16. import Loader from '@deepseek-ai/cordis-plugin-loader'
  17. import Include from '@deepseek-ai/cordis-plugin-include'
  18. import HttpServer from '@deepseek-ai/dsh-host-webserver'
  19. import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
  20. import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse'
  21. import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native'
  22. import * as DirectoryPickerAuto from '../src/index.ts'
  23. const renameControl = vi.hoisted(() => ({
  24. attempts: 0,
  25. failureCode: 'EPERM',
  26. injectedFailures: 0,
  27. remainingFailures: 0,
  28. }))
  29. vi.mock('node:fs/promises', async (importOriginal) => {
  30. const actual = await importOriginal<typeof import('node:fs/promises')>()
  31. return {
  32. ...actual,
  33. async rename(oldPath: string, newPath: string): Promise<void> {
  34. renameControl.attempts++
  35. if (renameControl.remainingFailures > 0) {
  36. renameControl.remainingFailures--
  37. renameControl.injectedFailures++
  38. throw Object.assign(new Error(`injected rename failure for ${newPath}`), { code: renameControl.failureCode })
  39. }
  40. await actual.rename(oldPath, newPath)
  41. },
  42. }
  43. })
  44. const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
  45. const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
  46. const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
  47. const NATIVE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-native'
  48. const BROWSE_SURFACE = '@deepseek-ai/dsh-client-ui-directory-picker-browse'
  49. /**
  50. * Loader-visible stand-in for a client surface package: the surfaces belong to
  51. * the Client program and publish browser entry points only, so a Host-face spec
  52. * can neither name them in a static import nor resolve them from source. What
  53. * the chooser owns is the mounting decision, which every case observes through
  54. * the Loader store; the surface's own browser contributions belong to the
  55. * assembled web coverage.
  56. *
  57. * @param name Surface package specifier the chooser mounts.
  58. * @returns A function-plugin module the Loader can mount under that specifier.
  59. */
  60. function surfaceModule(name: string): unknown {
  61. return { name, apply: () => undefined }
  62. }
  63. let root: string | undefined
  64. let fakeBin: string | undefined
  65. let context: Context | undefined
  66. afterEach(async () => {
  67. vi.unstubAllEnvs()
  68. await context?.fiber.dispose()
  69. context = undefined
  70. for (const dir of [root, fakeBin]) {
  71. // maxRetries absorbs teardown stragglers (e.g. an unawaited fiber's late
  72. // file handle) that can otherwise race the recursive scan into ENOTEMPTY.
  73. if (dir !== undefined) await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 })
  74. }
  75. root = undefined
  76. fakeBin = undefined
  77. renameControl.attempts = 0
  78. renameControl.failureCode = 'EPERM'
  79. renameControl.injectedFailures = 0
  80. renameControl.remainingFailures = 0
  81. })
  82. /** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
  83. async function loadComposition(
  84. bindHost: '127.0.0.1' | '0.0.0.0',
  85. options: { failSurface?: boolean } = {},
  86. ): Promise<{ ctx: Context; configPath: string }> {
  87. root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
  88. const configPath = join(root, 'cordis.yml')
  89. await writeFile(configPath, [
  90. "- name: '@deepseek-ai/dsh-host-webserver'",
  91. ' config:',
  92. ` host: '${bindHost}'`,
  93. ' port: 0',
  94. `- name: '${AUTO}'`,
  95. '',
  96. ].join('\n'))
  97. context = new Context()
  98. context.baseUrl = pathToFileURL(root).href + '/'
  99. await context.plugin(Loader)
  100. context.loader.builtins.include = Include
  101. const modules = new Map<string, unknown>([
  102. ['@deepseek-ai/dsh-host-webserver', HttpServer],
  103. [AUTO, DirectoryPickerAuto],
  104. [NATIVE, NativeDirectoryPicker],
  105. [BROWSE, BrowseDirectoryPicker],
  106. [NATIVE_SURFACE, surfaceModule(NATIVE_SURFACE)],
  107. [BROWSE_SURFACE, surfaceModule(BROWSE_SURFACE)],
  108. ])
  109. context.loader.internal = {
  110. version: 'v2',
  111. async import(specifier: string) {
  112. if (options.failSurface === true && (specifier === NATIVE_SURFACE || specifier === BROWSE_SURFACE)) {
  113. throw new Error(`surface import failed: ${specifier}`)
  114. }
  115. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  116. return modules.get(specifier)
  117. },
  118. } as unknown as NonNullable<typeof context.loader.internal>
  119. await context.loader.create({
  120. name: 'cordis:include',
  121. config: { path: pathToFileURL(configPath).href },
  122. })
  123. await context.loader.await()
  124. return { ctx: context, configPath }
  125. }
  126. /** Entry names currently present in the loader store (root tree plus subtrees). */
  127. function entryNames(ctx: Context): string[] {
  128. return [...ctx.loader.entries()].map(entry => entry.options.name)
  129. }
  130. /**
  131. * Force every signal of an attended host on any platform: no SSH launch, a
  132. * display, and a PATH holding one executable chooser binary so the real
  133. * probe resolves identically on hosts with and without zenity/kdialog.
  134. */
  135. function stubAttendedHost(): void {
  136. fakeBin = mkdtempSync(join(tmpdir(), 'dsh-picker-bin-'))
  137. const zenity = join(fakeBin, 'zenity')
  138. writeFileSync(zenity, '#!/bin/sh\n')
  139. chmodSync(zenity, 0o755)
  140. vi.stubEnv('PATH', fakeBin)
  141. vi.stubEnv('SSH_CONNECTION', '')
  142. vi.stubEnv('SSH_TTY', '')
  143. vi.stubEnv('DISPLAY', ':0')
  144. }
  145. describe('real Loader composition', () => {
  146. // The 60s budget covers this file's static imports (webserver plus both
  147. // backend node halves through tsx), which dominate on cold caches; the
  148. // Loader itself resolves nothing here — `loader.internal` is a module map.
  149. it('mounts the native backend for an attended loopback host and unmounts it on disposal', { timeout: 60_000 }, async () => {
  150. stubAttendedHost()
  151. const { ctx, configPath } = await loadComposition('127.0.0.1')
  152. const unloaded = [...ctx.loader.entries()]
  153. .filter(entry => entry.fiber === undefined && !entry.disabled)
  154. .map(entry => entry.options.name)
  155. expect(unloaded).toEqual([])
  156. expect(entryNames(ctx)).toContain(NATIVE)
  157. expect(entryNames(ctx)).toContain(NATIVE_SURFACE)
  158. expect(entryNames(ctx)).not.toContain(BROWSE)
  159. expect(entryNames(ctx)).not.toContain(BROWSE_SURFACE)
  160. const picker = ctx.get('directoryPicker') as DirectoryPicker
  161. expect(picker.capability().kind).toBe('native')
  162. // The mounted row lives in the Loader's in-memory root tree only — the
  163. // booted config file must never gain the resolved backend row.
  164. expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
  165. // HMR safety: disposing the chooser's fiber removes the entry it created,
  166. // and the disposer joins the backend's teardown — the service is gone the
  167. // moment dispose() settles, with no further loader await.
  168. const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
  169. await autoEntry.fiber!.dispose()
  170. expect(entryNames(ctx)).not.toContain(NATIVE)
  171. expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
  172. expect(ctx.get('directoryPicker')).toBeUndefined()
  173. // Self-disposing an include-tree entry persists `disabled: true` (loader
  174. // behavior, not the chooser's); await that debounced write so it cannot
  175. // race the temp-dir removal, and pin that the persisted row is the
  176. // chooser itself — the resolved backend still never reaches the file.
  177. await expect.poll(
  178. async () => await readFile(configPath, 'utf8'),
  179. { timeout: 15_000 },
  180. ).toContain('disabled: true')
  181. expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
  182. })
  183. it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => {
  184. stubAttendedHost()
  185. vi.stubEnv('SSH_CONNECTION', '10.0.0.2 55 10.0.0.9 22')
  186. const { ctx } = await loadComposition('127.0.0.1')
  187. expect(entryNames(ctx)).toContain(BROWSE)
  188. expect(entryNames(ctx)).toContain(BROWSE_SURFACE)
  189. expect(entryNames(ctx)).not.toContain(NATIVE)
  190. expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
  191. const picker = ctx.get('directoryPicker') as DirectoryPicker
  192. expect(picker.capability().kind).toBe('browse')
  193. })
  194. it('mounts the browse backend for an all-interfaces bind even on an attended host', { timeout: 60_000 }, async () => {
  195. stubAttendedHost()
  196. const { ctx } = await loadComposition('0.0.0.0')
  197. expect(entryNames(ctx)).toContain(BROWSE)
  198. expect(entryNames(ctx)).toContain(BROWSE_SURFACE)
  199. expect(entryNames(ctx)).not.toContain(NATIVE)
  200. expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
  201. })
  202. it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => {
  203. stubAttendedHost()
  204. await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/)
  205. // Setup owns both entries until it returns its disposer, so a failed surface
  206. // must take the mounted backend with it: otherwise a retry collides with the
  207. // directoryPicker registration this backend already made.
  208. expect(entryNames(context!)).not.toContain(NATIVE)
  209. expect(context!.get('directoryPicker')).toBeUndefined()
  210. })
  211. it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => {
  212. stubAttendedHost()
  213. const { ctx, configPath } = await loadComposition('127.0.0.1')
  214. const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
  215. await ctx.loader.remove(backendEntry.id)
  216. const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
  217. renameControl.remainingFailures = 1
  218. await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
  219. expect(entryNames(ctx)).not.toContain(NATIVE)
  220. expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
  221. // Same self-dispose persistence as above: let the write land before teardown.
  222. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
  223. expect(renameControl.injectedFailures).toBe(1)
  224. expect(renameControl.remainingFailures).toBe(0)
  225. expect(renameControl.attempts).toBeGreaterThanOrEqual(2)
  226. })
  227. it('reports a terminal debounced-write failure again to the teardown owner', { timeout: 60_000 }, async () => {
  228. stubAttendedHost()
  229. const { ctx } = await loadComposition('127.0.0.1')
  230. const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
  231. const include = [...ctx.loader.entries()]
  232. .find(entry => entry.options.name === 'cordis:include')?.subtree as Include | undefined
  233. if (include === undefined) throw new Error('expected the root Include tree')
  234. renameControl.failureCode = 'EIO'
  235. renameControl.remainingFailures = 1
  236. await autoEntry.fiber!.dispose()
  237. await expect.poll(() => renameControl.injectedFailures).toBe(1)
  238. await expect(include.stop()).rejects.toMatchObject({ code: 'EIO' })
  239. await expect(ctx.fiber.dispose()).resolves.not.toThrow()
  240. context = undefined
  241. })
  242. })