loader-composition.spec.ts 7.7 KB

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