loader-composition.spec.ts 7.5 KB

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