loader-composition.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  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 entry the chooser
  5. * mounted into the Loader store, the capability the seam then serves, and
  6. * that disposing the chooser removes the mounted entry again (HMR safety),
  7. * 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. let root: string | undefined
  48. let fakeBin: string | undefined
  49. let context: Context | undefined
  50. afterEach(async () => {
  51. vi.unstubAllEnvs()
  52. await context?.fiber.dispose()
  53. context = undefined
  54. for (const dir of [root, fakeBin]) {
  55. // maxRetries absorbs teardown stragglers (e.g. an unawaited fiber's late
  56. // file handle) that can otherwise race the recursive scan into ENOTEMPTY.
  57. if (dir !== undefined) await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 })
  58. }
  59. root = undefined
  60. fakeBin = undefined
  61. renameControl.attempts = 0
  62. renameControl.failureCode = 'EPERM'
  63. renameControl.injectedFailures = 0
  64. renameControl.remainingFailures = 0
  65. })
  66. /** Write a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
  67. async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
  68. root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
  69. const configPath = join(root, 'cordis.yml')
  70. await writeFile(configPath, [
  71. "- name: '@deepseek-ai/dsh-host-webserver'",
  72. ' config:',
  73. ` host: '${bindHost}'`,
  74. ' port: 0',
  75. `- name: '${AUTO}'`,
  76. '',
  77. ].join('\n'))
  78. context = new Context()
  79. context.baseUrl = pathToFileURL(root).href + '/'
  80. await context.plugin(Loader)
  81. context.loader.builtins.include = Include
  82. const modules = new Map<string, unknown>([
  83. ['@deepseek-ai/dsh-host-webserver', HttpServer],
  84. [AUTO, DirectoryPickerAuto],
  85. [NATIVE, NativeDirectoryPicker],
  86. [BROWSE, BrowseDirectoryPicker],
  87. ])
  88. context.loader.internal = {
  89. version: 'v2',
  90. async import(specifier: string) {
  91. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  92. return modules.get(specifier)
  93. },
  94. } as unknown as NonNullable<typeof context.loader.internal>
  95. await context.loader.create({
  96. name: 'cordis:include',
  97. config: { path: pathToFileURL(configPath).href },
  98. })
  99. await context.loader.await()
  100. return { ctx: context, configPath }
  101. }
  102. /** Entry names currently present in the loader store (root tree plus subtrees). */
  103. function entryNames(ctx: Context): string[] {
  104. return [...ctx.loader.entries()].map(entry => entry.options.name)
  105. }
  106. /**
  107. * Force every signal of an attended host on any platform: no SSH launch, a
  108. * display, and a PATH holding one executable chooser binary so the real
  109. * probe resolves identically on hosts with and without zenity/kdialog.
  110. */
  111. function stubAttendedHost(): void {
  112. fakeBin = mkdtempSync(join(tmpdir(), 'dsh-picker-bin-'))
  113. const zenity = join(fakeBin, 'zenity')
  114. writeFileSync(zenity, '#!/bin/sh\n')
  115. chmodSync(zenity, 0o755)
  116. vi.stubEnv('PATH', fakeBin)
  117. vi.stubEnv('SSH_CONNECTION', '')
  118. vi.stubEnv('SSH_TTY', '')
  119. vi.stubEnv('DISPLAY', ':0')
  120. }
  121. describe('real Loader composition', () => {
  122. // The 60s budget covers this file's static imports (webserver plus both
  123. // backend node halves through tsx), which dominate on cold caches; the
  124. // Loader itself resolves nothing here — `loader.internal` is a module map.
  125. it('mounts the native backend for an attended loopback host and unmounts it on disposal', { timeout: 60_000 }, async () => {
  126. stubAttendedHost()
  127. const { ctx, configPath } = await loadComposition('127.0.0.1')
  128. const unloaded = [...ctx.loader.entries()]
  129. .filter(entry => entry.fiber === undefined && !entry.disabled)
  130. .map(entry => entry.options.name)
  131. expect(unloaded).toEqual([])
  132. expect(entryNames(ctx)).toContain(NATIVE)
  133. expect(entryNames(ctx)).not.toContain(BROWSE)
  134. const picker = ctx.get('directoryPicker') as DirectoryPicker
  135. expect(picker.capability().kind).toBe('native')
  136. // The mounted row lives in the Loader's in-memory root tree only — the
  137. // booted config file must never gain the resolved backend row.
  138. expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
  139. // HMR safety: disposing the chooser's fiber removes the entry it created,
  140. // and the disposer joins the backend's teardown — the service is gone the
  141. // moment dispose() settles, with no further loader await.
  142. const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
  143. await autoEntry.fiber!.dispose()
  144. expect(entryNames(ctx)).not.toContain(NATIVE)
  145. expect(ctx.get('directoryPicker')).toBeUndefined()
  146. // Self-disposing an include-tree entry persists `disabled: true` (loader
  147. // behavior, not the chooser's); await that debounced write so it cannot
  148. // race the temp-dir removal, and pin that the persisted row is the
  149. // chooser itself — the resolved backend still never reaches the file.
  150. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
  151. expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
  152. })
  153. it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => {
  154. stubAttendedHost()
  155. vi.stubEnv('SSH_CONNECTION', '10.0.0.2 55 10.0.0.9 22')
  156. const { ctx } = await loadComposition('127.0.0.1')
  157. expect(entryNames(ctx)).toContain(BROWSE)
  158. expect(entryNames(ctx)).not.toContain(NATIVE)
  159. const picker = ctx.get('directoryPicker') as DirectoryPicker
  160. expect(picker.capability().kind).toBe('browse')
  161. })
  162. it('mounts the browse backend for an all-interfaces bind even on an attended host', { timeout: 60_000 }, async () => {
  163. stubAttendedHost()
  164. const { ctx } = await loadComposition('0.0.0.0')
  165. expect(entryNames(ctx)).toContain(BROWSE)
  166. expect(entryNames(ctx)).not.toContain(NATIVE)
  167. })
  168. it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => {
  169. stubAttendedHost()
  170. const { ctx, configPath } = await loadComposition('127.0.0.1')
  171. const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
  172. await ctx.loader.remove(backendEntry.id)
  173. const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
  174. renameControl.remainingFailures = 1
  175. await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
  176. expect(entryNames(ctx)).not.toContain(NATIVE)
  177. // Same self-dispose persistence as above: let the write land before teardown.
  178. await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
  179. expect(renameControl.injectedFailures).toBe(1)
  180. expect(renameControl.remainingFailures).toBe(0)
  181. expect(renameControl.attempts).toBeGreaterThanOrEqual(2)
  182. })
  183. it('reports a terminal debounced-write failure again to the teardown owner', { timeout: 60_000 }, async () => {
  184. stubAttendedHost()
  185. const { ctx } = await loadComposition('127.0.0.1')
  186. const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
  187. const include = [...ctx.loader.entries()]
  188. .find(entry => entry.options.name === 'cordis:include')?.subtree as Include | undefined
  189. if (include === undefined) throw new Error('expected the root Include tree')
  190. renameControl.failureCode = 'EIO'
  191. renameControl.remainingFailures = 1
  192. await autoEntry.fiber!.dispose()
  193. await expect.poll(() => renameControl.injectedFailures).toBe(1)
  194. await expect(include.stop()).rejects.toMatchObject({ code: 'EIO' })
  195. await expect(ctx.fiber.dispose()).resolves.not.toThrow()
  196. context = undefined
  197. })
  198. })