|
@@ -0,0 +1,176 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * REAL-composition coverage: a test-only cordis.yml booted through the
|
|
|
|
|
+ * vendored Loader mounts the webserver row plus the adaptive chooser, and the
|
|
|
|
|
+ * assertions observe the durable outcome — which backend entry the chooser
|
|
|
|
|
+ * mounted into the Loader store, the capability the seam then serves, and
|
|
|
|
|
+ * that disposing the chooser removes the mounted entry again (HMR safety),
|
|
|
|
|
+ * joining the backend's own teardown before the disposer settles.
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
|
|
|
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
|
|
|
+import { tmpdir } from 'node:os'
|
|
|
|
|
+import { join } from 'node:path'
|
|
|
|
|
+import { pathToFileURL } from 'node:url'
|
|
|
|
|
+import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
|
+import { Context } from 'cordis'
|
|
|
|
|
+import Loader from '@cordisjs/plugin-loader'
|
|
|
|
|
+import Include from '@cordisjs/plugin-include'
|
|
|
|
|
+import HttpServer from '@deepseek-ai/dsh-host-webserver'
|
|
|
|
|
+import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
|
|
|
|
|
+import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse'
|
|
|
|
|
+import NativeDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-native'
|
|
|
|
|
+import * as DirectoryPickerAuto from '../src/index.ts'
|
|
|
|
|
+
|
|
|
|
|
+const AUTO = '@deepseek-ai/dsh-host-directory-picker-auto'
|
|
|
|
|
+const NATIVE = '@deepseek-ai/dsh-host-directory-picker-native'
|
|
|
|
|
+const BROWSE = '@deepseek-ai/dsh-host-directory-picker-browse'
|
|
|
|
|
+
|
|
|
|
|
+let root: string | undefined
|
|
|
|
|
+let fakeBin: string | undefined
|
|
|
|
|
+let context: Context | undefined
|
|
|
|
|
+
|
|
|
|
|
+afterEach(async () => {
|
|
|
|
|
+ vi.unstubAllEnvs()
|
|
|
|
|
+ await context?.fiber.dispose()
|
|
|
|
|
+ context = undefined
|
|
|
|
|
+ for (const dir of [root, fakeBin]) {
|
|
|
|
|
+ // maxRetries absorbs teardown stragglers (e.g. an unawaited fiber's late
|
|
|
|
|
+ // file handle) that can otherwise race the recursive scan into ENOTEMPTY.
|
|
|
|
|
+ if (dir !== undefined) await rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 })
|
|
|
|
|
+ }
|
|
|
|
|
+ root = undefined
|
|
|
|
|
+ fakeBin = undefined
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+/** Write a dist fixture and a two-row cordis.yml (webserver + chooser), then boot it through the real Loader. */
|
|
|
|
|
+async function loadComposition(bindHost: '127.0.0.1' | '0.0.0.0'): Promise<{ ctx: Context; configPath: string }> {
|
|
|
|
|
+ root = await mkdtemp(join(tmpdir(), 'dsh-directory-picker-auto-'))
|
|
|
|
|
+ const dist = join(root, 'dist')
|
|
|
|
|
+ mkdirSync(dist)
|
|
|
|
|
+ const distIndex = join(dist, 'index.html')
|
|
|
|
|
+ await writeFile(distIndex, '<head></head><body>shell</body>')
|
|
|
|
|
+ const configPath = join(root, 'cordis.yml')
|
|
|
|
|
+ await writeFile(configPath, [
|
|
|
|
|
+ "- name: '@deepseek-ai/dsh-host-webserver'",
|
|
|
|
|
+ ' config:',
|
|
|
|
|
+ ` host: '${bindHost}'`,
|
|
|
|
|
+ ' port: 0',
|
|
|
|
|
+ ` distIndex: '${distIndex}'`,
|
|
|
|
|
+ `- name: '${AUTO}'`,
|
|
|
|
|
+ '',
|
|
|
|
|
+ ].join('\n'))
|
|
|
|
|
+
|
|
|
|
|
+ context = new Context()
|
|
|
|
|
+ context.baseUrl = pathToFileURL(root).href + '/'
|
|
|
|
|
+ await context.plugin(Loader)
|
|
|
|
|
+ context.loader.builtins.include = Include
|
|
|
|
|
+ const modules = new Map<string, unknown>([
|
|
|
|
|
+ ['@deepseek-ai/dsh-host-webserver', HttpServer],
|
|
|
|
|
+ [AUTO, DirectoryPickerAuto],
|
|
|
|
|
+ [NATIVE, NativeDirectoryPicker],
|
|
|
|
|
+ [BROWSE, BrowseDirectoryPicker],
|
|
|
|
|
+ ])
|
|
|
|
|
+ context.loader.internal = {
|
|
|
|
|
+ version: 'v2',
|
|
|
|
|
+ async import(specifier: string) {
|
|
|
|
|
+ if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
|
|
|
|
+ return modules.get(specifier)
|
|
|
|
|
+ },
|
|
|
|
|
+ } as unknown as NonNullable<typeof context.loader.internal>
|
|
|
|
|
+ await context.loader.create({
|
|
|
|
|
+ name: 'cordis:include',
|
|
|
|
|
+ config: { path: pathToFileURL(configPath).href },
|
|
|
|
|
+ })
|
|
|
|
|
+ await context.loader.await()
|
|
|
|
|
+ return { ctx: context, configPath }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Entry names currently present in the loader store (root tree plus subtrees). */
|
|
|
|
|
+function entryNames(ctx: Context): string[] {
|
|
|
|
|
+ return [...ctx.loader.entries()].map(entry => entry.options.name)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Force every signal of an attended host on any platform: no SSH launch, a
|
|
|
|
|
+ * display, and a PATH holding one executable chooser binary so the real
|
|
|
|
|
+ * probe resolves identically on hosts with and without zenity/kdialog.
|
|
|
|
|
+ */
|
|
|
|
|
+function stubAttendedHost(): void {
|
|
|
|
|
+ fakeBin = mkdtempSync(join(tmpdir(), 'dsh-picker-bin-'))
|
|
|
|
|
+ const zenity = join(fakeBin, 'zenity')
|
|
|
|
|
+ writeFileSync(zenity, '#!/bin/sh\n')
|
|
|
|
|
+ chmodSync(zenity, 0o755)
|
|
|
|
|
+ vi.stubEnv('PATH', fakeBin)
|
|
|
|
|
+ vi.stubEnv('SSH_CONNECTION', '')
|
|
|
|
|
+ vi.stubEnv('SSH_TTY', '')
|
|
|
|
|
+ vi.stubEnv('DISPLAY', ':0')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+describe('real Loader composition', () => {
|
|
|
|
|
+ // The 60s budget covers this file's static imports (webserver plus both
|
|
|
|
|
+ // backend node halves through tsx), which dominate on cold caches; the
|
|
|
|
|
+ // Loader itself resolves nothing here — `loader.internal` is a module map.
|
|
|
|
|
+ it('mounts the native backend for an attended loopback host and unmounts it on disposal', { timeout: 60_000 }, async () => {
|
|
|
|
|
+ stubAttendedHost()
|
|
|
|
|
+ const { ctx, configPath } = await loadComposition('127.0.0.1')
|
|
|
|
|
+
|
|
|
|
|
+ const unloaded = [...ctx.loader.entries()]
|
|
|
|
|
+ .filter(entry => entry.fiber === undefined && !entry.disabled)
|
|
|
|
|
+ .map(entry => entry.options.name)
|
|
|
|
|
+ expect(unloaded).toEqual([])
|
|
|
|
|
+ expect(entryNames(ctx)).toContain(NATIVE)
|
|
|
|
|
+ expect(entryNames(ctx)).not.toContain(BROWSE)
|
|
|
|
|
+ const picker = ctx.get('directoryPicker') as DirectoryPicker
|
|
|
|
|
+ expect(picker.capability().kind).toBe('native')
|
|
|
|
|
+ // The mounted row lives in the Loader's in-memory root tree only — the
|
|
|
|
|
+ // booted config file must never gain the resolved backend row.
|
|
|
|
|
+ expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
|
|
|
|
|
+
|
|
|
|
|
+ // HMR safety: disposing the chooser's fiber removes the entry it created,
|
|
|
|
|
+ // and the disposer joins the backend's teardown — the service is gone the
|
|
|
|
|
+ // moment dispose() settles, with no further loader await.
|
|
|
|
|
+ const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
|
|
|
|
+ await autoEntry.fiber!.dispose()
|
|
|
|
|
+ expect(entryNames(ctx)).not.toContain(NATIVE)
|
|
|
|
|
+ expect(ctx.get('directoryPicker')).toBeUndefined()
|
|
|
|
|
+ // Self-disposing an include-tree entry persists `disabled: true` (loader
|
|
|
|
|
+ // behavior, not the chooser's); await that debounced write so it cannot
|
|
|
|
|
+ // race the temp-dir removal, and pin that the persisted row is the
|
|
|
|
|
+ // chooser itself — the resolved backend still never reaches the file.
|
|
|
|
|
+ await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
|
|
|
|
|
+ expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('mounts the browse backend under an SSH launch', { timeout: 60_000 }, async () => {
|
|
|
|
|
+ stubAttendedHost()
|
|
|
|
|
+ vi.stubEnv('SSH_CONNECTION', '10.0.0.2 55 10.0.0.9 22')
|
|
|
|
|
+ const { ctx } = await loadComposition('127.0.0.1')
|
|
|
|
|
+
|
|
|
|
|
+ expect(entryNames(ctx)).toContain(BROWSE)
|
|
|
|
|
+ expect(entryNames(ctx)).not.toContain(NATIVE)
|
|
|
|
|
+ const picker = ctx.get('directoryPicker') as DirectoryPicker
|
|
|
|
|
+ expect(picker.capability().kind).toBe('browse')
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('mounts the browse backend for an all-interfaces bind even on an attended host', { timeout: 60_000 }, async () => {
|
|
|
|
|
+ stubAttendedHost()
|
|
|
|
|
+ const { ctx } = await loadComposition('0.0.0.0')
|
|
|
|
|
+
|
|
|
|
|
+ expect(entryNames(ctx)).toContain(BROWSE)
|
|
|
|
|
+ expect(entryNames(ctx)).not.toContain(NATIVE)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it('tolerates the mounted entry being removed by the tree before the chooser unloads', { timeout: 60_000 }, async () => {
|
|
|
|
|
+ stubAttendedHost()
|
|
|
|
|
+ const { ctx, configPath } = await loadComposition('127.0.0.1')
|
|
|
|
|
+
|
|
|
|
|
+ const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
|
|
|
|
|
+ ctx.loader.remove(backendEntry.id)
|
|
|
|
|
+ const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
|
|
|
|
|
+ await expect(autoEntry.fiber!.dispose()).resolves.not.toThrow()
|
|
|
|
|
+ expect(entryNames(ctx)).not.toContain(NATIVE)
|
|
|
|
|
+ // Same self-dispose persistence as above: let the write land before teardown.
|
|
|
|
|
+ await expect.poll(async () => await readFile(configPath, 'utf8')).toContain('disabled: true')
|
|
|
|
|
+ })
|
|
|
|
|
+})
|