|
|
@@ -3,10 +3,12 @@
|
|
|
* 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).
|
|
|
+ * that disposing the chooser removes the mounted entry again (HMR safety),
|
|
|
+ * joining the backend's own teardown before the disposer settles.
|
|
|
*/
|
|
|
|
|
|
-import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
|
+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'
|
|
|
@@ -25,21 +27,27 @@ 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
|
|
|
- if (root !== undefined) await rm(root, { recursive: true, force: true })
|
|
|
+ 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')
|
|
|
- await mkdir(dist)
|
|
|
+ mkdirSync(dist)
|
|
|
const distIndex = join(dist, 'index.html')
|
|
|
await writeFile(distIndex, '<head></head><body>shell</body>')
|
|
|
const configPath = join(root, 'cordis.yml')
|
|
|
@@ -83,17 +91,26 @@ function entryNames(ctx: Context): string[] {
|
|
|
return [...ctx.loader.entries()].map(entry => entry.options.name)
|
|
|
}
|
|
|
|
|
|
-/** Force every signal of an attended host: no SSH launch, a display on any platform. */
|
|
|
+/**
|
|
|
+ * 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', () => {
|
|
|
- // Real-Loader composition resolves workspace packages through tsx at test
|
|
|
- // time; first resolution after the host/client program split is slow enough
|
|
|
- // to trip the default 5s budget on cold caches.
|
|
|
+ // 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')
|
|
|
@@ -110,12 +127,19 @@ describe('real Loader composition', () => {
|
|
|
// 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.
|
|
|
+ // 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()
|
|
|
- await ctx.loader.await()
|
|
|
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 () => {
|
|
|
@@ -136,4 +160,17 @@ describe('real Loader composition', () => {
|
|
|
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')
|
|
|
+ })
|
|
|
})
|