Merge origin/master: combine boot-owned settlement with the fail-loud release

Conflicts: apps/cli/src/tui.ts (keep the release install over master's comment
rewording), packages/ui/app-boot/README* (master's new installFailLoud row
wording plus this branch's release and timeout rows).
This commit is contained in:
Turtle
2026-08-03 14:10:23 +08:00
788 changed files with 30113 additions and 3651 deletions

View File

@@ -390,6 +390,22 @@ describe('boot', () => {
}
})
it('disposes partial host setup and labels non-Error preparation failures', async () => {
const dir = tmp()
const failure = 42
let disposed = false
const task = boot(NAME, join(dir, 'cordis.yml'), undefined, (ctx) => {
ctx.effect(() => () => { disposed = true })
throw failure
})
await expect(task).rejects.toMatchObject({
message: `${NAME}: host preparation failed: ${failure}`,
cause: failure,
})
expect(disposed).toBe(true)
})
it('exposes dshHomePath to Loader config expressions', async () => {
const dir = tmp()
const dshHome = join(dir, 'home')
@@ -440,7 +456,37 @@ describe('boot', () => {
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
`${NAME}: plugin tree failed to load: failed to apply loader entry`,
)
})
it('appends the deepest cause with its original stack to the load failure', async () => {
const dir = tmp()
writeFileSync(join(dir, 'failing.mjs'), [
'export function apply() {',
" const failure = new Error('pinned activation failure')",
" failure.stack = 'Error: pinned activation failure\\n at failing-fixture'",
' throw failure',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n name: ./failing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
].join('')))
})
it('falls back to the deepest cause message when its stack was erased', async () => {
const dir = tmp()
const deepest = new Error('stackless deep failure')
delete (deepest as { stack?: string }).stack
await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
throw new Error('wrapped setup failure', { cause: deepest })
})).rejects.toThrow(
`${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
)
})
it('reports a pending real Loader fiber and the service unresolved in its own context', async () => {
@@ -456,9 +502,9 @@ describe('boot', () => {
describe('addHarnessSourceSection', () => {
const SOURCE_ROOT = `${sep}opt${sep}harness-src`
const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.`
const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`
it('adds the source path between the harness identity and the deployment persona', async () => {
it('distinguishes the source path from the current workdir between identity and persona', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })

View File

@@ -1,12 +1,7 @@
/**
* Config hot-reload resilience of the booted include tree. `dsh-app-boot`
* installs a fail-loud unhandled-rejection handler, so a `refresh()` that
* rethrows a config-file parse error would kill a live app on one bad
* `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
* callback nobody else catches). These tests pin the vendored
* `@cordisjs/plugin-include` contract that boot relies on: an invalid file
* keeps the last good tree, and a valid re-read re-applies overlay patches
* exactly like the initial load.
* Transactional config replacement through the booted Include and Loader tree.
* HMR contains rejected refreshes; direct callers receive the error after the
* previous generation has been retained or restored.
*/
import { mkdtempSync, writeFileSync } from 'node:fs'
@@ -15,6 +10,7 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import type { Include } from '@cordisjs/plugin-include'
import { Group } from '@cordisjs/plugin-loader'
import { boot } from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -27,9 +23,10 @@ interface TreeFixture {
include: Include
}
async function bootTree(configBody: string): Promise<TreeFixture> {
async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
writeFileSync(join(dir, 'cordis.yml'), configBody)
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
@@ -41,20 +38,41 @@ function entryConfig(ctx: Context, id: string): unknown {
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
}
function entryById(ctx: Context, id: string) {
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
if (!entry) throw new Error(`missing loader entry ${id}`)
return entry
}
function plugin(name: string, body = ''): string {
return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
}
async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
try {
await task
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
return
}
throw new Error(`expected loader update to fail during ${stage}`)
}
describe('include refresh with an invalid file', () => {
it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
try {
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
await expect(include.refresh()).resolves.toBeUndefined()
await expect(include.refresh()).rejects.toThrow('failed to parse config file')
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
// An empty file parses to `undefined` without a YAML error; it must be
// treated exactly like a parse failure, not crash the entry walk.
writeFileSync(join(dir, 'cordis.yml'), '')
await expect(include.refresh()).resolves.toBeUndefined()
await expect(include.refresh()).rejects.toThrow('failed to validate config file')
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
@@ -67,6 +85,200 @@ describe('include refresh with an invalid file', () => {
})
})
describe('loader entry replacement', () => {
it('imports a changed name before replacing the running plugin', async () => {
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
'old.mjs': plugin('oldPlugin'),
'new.mjs': plugin('newPlugin'),
})
try {
const entry = entryById(ctx, 'target')
await entry.update({ name: './new.mjs' })
expect(entry.options.name).toBe('./new.mjs')
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
expect(entry.options.disabled).toBeUndefined()
await entry.fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('retains the running plugin when the replacement cannot be imported', async () => {
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
'old.mjs': plugin('oldPlugin'),
})
try {
const entry = entryById(ctx, 'target')
const fiber = entry.fiber
await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
expect(entry.options.name).toBe('./old.mjs')
expect(entry.fiber === fiber).toBe(true)
await fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('restores the previous plugin after replacement application fails', async () => {
const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
'old.mjs': plugin('oldPlugin'),
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
})
try {
const entry = entryById(ctx, 'target')
const previous = entry.fiber
await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
expect(entry.options.name).toBe('./old.mjs')
expect(entry.fiber === previous).toBe(false)
expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
expect(entry.options.disabled).toBeUndefined()
await entry.fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('restores the previous config when an in-place restart fails', async () => {
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
})
try {
const entry = entryById(ctx, 'target')
const fiber = entry.fiber
await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
expect(entry.options.config).toEqual({ fail: false })
expect(entry.fiber === fiber).toBe(true)
await fiber?.await()
} finally {
await ctx.fiber.dispose()
}
})
it('does not persist a failed direct fiber update', async () => {
const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
})
try {
const entry = entryById(ctx, 'target')
const fiber = entry.fiber
if (!fiber) throw new Error('target entry has no fiber')
await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
expect(entry.options.config).toEqual({ fail: false })
expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
} finally {
await ctx.fiber.dispose()
}
})
})
describe('loader tree replacement', () => {
it('rolls back earlier updates and additions when a later entry fails', async () => {
const { ctx, dir, include } = await bootTree([
'- id: existing',
' name: ./configurable.mjs',
' config:',
' value: old',
'',
].join('\n'), {
'configurable.mjs': plugin('configurablePlugin'),
'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
})
try {
writeFileSync(join(dir, 'cordis.yml'), [
'- id: existing',
' name: ./configurable.mjs',
' config:',
' value: candidate',
'- id: added',
' name: ./noop.mjs',
'- id: bad',
' name: ./bad.mjs',
'',
].join('\n'))
await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
writeFileSync(join(dir, 'cordis.yml'), [
'- id: existing',
' name: ./configurable.mjs',
' config:',
' value: committed',
'- id: added',
' name: ./noop.mjs',
'',
].join('\n'))
await include.refresh()
expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
expect(entryById(ctx, 'added').fiber).toBeDefined()
} finally {
await ctx.fiber.dispose()
}
})
it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n')
ctx.loader.builtins.group = Group
try {
const config = (disabled: boolean) => [
'- id: parent',
' name: cordis:group',
' group: true',
` disabled: ${disabled}`,
' config:',
' - id: child',
' name: ./noop.mjs',
'',
].join('\n')
writeFileSync(join(dir, 'cordis.yml'), config(false))
await include.refresh()
expect(entryById(ctx, 'child').fiber).toBeDefined()
writeFileSync(join(dir, 'cordis.yml'), config(true))
await include.refresh()
expect(entryById(ctx, 'child').fiber).toBeUndefined()
writeFileSync(join(dir, 'cordis.yml'), config(false))
await include.refresh()
expect(entryById(ctx, 'child').fiber).toBeDefined()
} finally {
await ctx.fiber.dispose()
}
})
it('restores a programmatic entry move when its update fails', async () => {
const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', {
'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
})
ctx.loader.builtins.group = Group
try {
const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
const target = entryById(ctx, targetId)
const source = target.parent
const sourceIndex = source.data.indexOf(target.options)
const destination = entryById(ctx, groupId).subgroup
if (!destination) throw new Error('created loader group has no subgroup')
await expectUpdateFailure(
ctx.loader.update(targetId, { config: { fail: true } }, groupId),
'apply',
)
expect(target.parent).toBe(source)
expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
expect(source.data.indexOf(target.options)).toBe(sourceIndex)
expect(destination.data).not.toContain(target.options)
expect(target.options.config).toEqual({ fail: false })
} finally {
await ctx.fiber.dispose()
}
})
})
describe('include refresh with overlay patches', () => {
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
@@ -116,9 +328,9 @@ describe('include refresh with overlay patches', () => {
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'patched-v2' })
// Removing every patch must revert to the file's own values: patching
// may not bake earlier patch results into the cached parse.
await entry.update({ config: { path: './base.yml', patches: [] } })
// Omitting the patch list must remove the overlay rather than reuse the
// Include's previous config through a default parameter.
await entry.update({ config: { path: './base.yml' } })
await ctx.loader.await()
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'edited-2' })
} finally {

View File

@@ -0,0 +1,142 @@
import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import Hmr from '@cordisjs/plugin-hmr'
import Loader from '@cordisjs/plugin-loader'
import Timer from '@cordisjs/plugin-timer'
import { describe, expect, it } from 'vitest'
async function bootHmr(dir: string): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dir).href + '/'
await ctx.plugin(Loader)
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
return ctx
}
async function eventually(test: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 10_000
while (!test()) {
if (Date.now() >= deadline) throw new Error(message)
await new Promise(resolve => setTimeout(resolve, 10))
}
}
describe('HMR exact config paths', () => {
it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const filename = join(dir, 'plugins.yml')
const ctx = await bootHmr(dir)
const observed: string[] = []
try {
await ctx.hmr.registerConfig(filename, () => {
try {
observed.push(readFileSync(filename, 'utf8'))
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
observed.push('missing')
}
})
writeFileSync(filename, 'one', { flag: 'wx' })
await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
writeFileSync(filename, 'two')
await eventually(() => observed.includes('two'), 'HMR did not observe config change')
unlinkSync(filename)
await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
} finally {
await ctx.fiber.dispose()
}
})
it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const dir = join(root, 'later')
const filename = join(dir, 'plugins.yml')
const ctx = await bootHmr(root)
const observed: string[] = []
try {
await ctx.hmr.registerConfig(filename, () => {
observed.push(readFileSync(filename, 'utf8'))
})
mkdirSync(dir)
writeFileSync(filename, 'created')
await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
} finally {
await ctx.fiber.dispose()
}
})
it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const filename = join(dir, 'plugins.yml')
writeFileSync(filename, 'one')
const ctx = await bootHmr(dir)
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const observed: string[] = []
let active = 0
let maxActive = 0
try {
const dispose = await ctx.hmr.registerConfig(filename, async () => {
active += 1
maxActive = Math.max(maxActive, active)
observed.push(readFileSync(filename, 'utf8'))
if (observed.length === 1) {
started.resolve(undefined)
await release.promise
}
active -= 1
})
await started.promise
writeFileSync(filename, 'two')
// Chokidar coalesces atomic writes for 100 ms by default. Wait beyond
// that window so this edit is queued before registration disposal.
await new Promise(resolve => setTimeout(resolve, 250))
let disposed = false
const disposal = dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
release.resolve(undefined)
await disposal
expect(maxActive).toBe(1)
expect(observed).toEqual(['one', 'two'])
} finally {
release.resolve(undefined)
await ctx.fiber.dispose()
}
})
it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
const filename = join(dir, 'plugins.yml')
const ctx = await bootHmr(dir)
const failure = Promise.withResolvers<{ filename: string; error: Error }>()
let failureCount = 0
try {
ctx.on('hmr/config-update-failed', () => {
throw new Error('observer failed')
})
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
failureCount += 1
failure.resolve({ filename: failedFilename, error })
})
await ctx.hmr.registerConfig(filename, () => { throw 42 })
writeFileSync(filename, 'invalid')
const observed = await failure.promise
expect(observed.filename).toBe(filename)
expect(observed.error).toBeInstanceOf(Error)
expect(observed.error.message).toBe('42')
writeFileSync(filename, 'invalid again')
await eventually(() => failureCount === 2, 'HMR stopped broadcasting after an observer rejected')
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -4,21 +4,36 @@
* a real Loader tree.
*/
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { Context } from 'cordis'
import Hmr from '@cordisjs/plugin-hmr'
import Loader from '@cordisjs/plugin-loader'
import Timer from '@cordisjs/plugin-timer'
import {
boot,
loadPersonalPatches,
PERSONAL_CONFIG_FILENAME,
watchPersonalPatches,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-'))
async function eventually(test: () => boolean, message: string): Promise<void> {
const deadline = Date.now() + 10_000
while (!test()) {
if (Date.now() >= deadline) throw new Error(message)
await new Promise(resolve => setTimeout(resolve, 10))
}
}
const settleChokidarChangeThrottle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 75))
describe('loadPersonalPatches', () => {
afterEach(() => {
delete process.env.DSH_HOME
@@ -86,7 +101,13 @@ describe('loadPersonalPatches', () => {
describe('boot with personal patches', () => {
function writeTree(dir: string): string {
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'noop.mjs'), [
'export const name = "noop"',
'export function apply(_ctx, config = {}) {',
' if (config.fail) throw new Error("candidate config failed")',
'}',
'',
].join('\n'))
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
return join(dir, 'cordis.yml')
}
@@ -138,4 +159,112 @@ describe('boot with personal patches', () => {
await ctxEmpty.fiber.dispose()
}
})
it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
const dir = tmp()
const personal = tmp()
const filename = join(personal, PERSONAL_CONFIG_FILENAME)
const basePatches = [{ id: 'noop', config: { value: 'generated' } }]
const ctx = await boot(NAME, writeTree(dir), basePatches)
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
const failures: Array<{ filename: string; error: Error }> = []
ctx.on('hmr/config-update-failed', (failedFilename, error) => {
failures.push({ filename: failedFilename, error })
})
const dispose = await watchPersonalPatches(ctx, {
binName: NAME,
dir: personal,
compose: personalPatches => [...basePatches, ...personalPatches],
})
try {
writeFileSync(filename, '- id: noop\n config:\n value: live\n')
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'personal config addition was not applied')
writeFileSync(filename, '- id: noop\n config:\n fail: true\n')
await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
expect(failures[0]).toMatchObject({ filename })
expect(failures[0]?.error).toBeInstanceOf(Error)
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
await settleChokidarChangeThrottle()
writeFileSync(filename, 'invalid: [unclosed\n')
await eventually(() => failures.length === 2, 'parse failure was not broadcast')
expect(failures[1]?.error).toBeInstanceOf(Error)
expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
await settleChokidarChangeThrottle()
writeFileSync(filename, '- id: noop\n config:\n value: recovered\n')
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'recovered', 'valid recovery was not applied')
await settleChokidarChangeThrottle()
unlinkSync(filename)
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'generated', 'personal config removal did not restore the app-owned patch')
expect(failures).toHaveLength(2)
await settleChokidarChangeThrottle()
// Default compose: the personal overlay IS the whole patch list, so a
// fresh generation replaces the app-owned layer instead of stacking on it.
await dispose()
const disposeDefault = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
try {
writeFileSync(filename, '- id: noop\n config:\n value: identity\n')
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose personal patch was not applied')
} finally {
await disposeDefault()
}
} finally {
await dispose()
await ctx.fiber.dispose()
}
})
it('fails loud when the exact watcher lacks HMR or a root Include', async () => {
const dir = tmp()
const withoutHmr = await boot(NAME, writeTree(dir))
await expect(watchPersonalPatches(withoutHmr, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the Cordis HMR service')
await withoutHmr.fiber.dispose()
const withoutInclude = new Context()
withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href
await withoutInclude.plugin(Loader)
await withoutInclude.plugin(Timer)
await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
await expect(watchPersonalPatches(withoutInclude, { binName: NAME, dir: tmp() })).rejects.toThrow('requires the root Include entry')
await withoutInclude.fiber.dispose()
})
it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => {
// A TUI `/exit` typed during startup disposes the whole tree while
// registerConfig's effect registration is still in flight (the HMR effect
// then fails with INACTIVE_EFFECT); the app is exiting exactly as asked,
// so the watcher must not crash the process. The stub makes the race
// deterministic — the live-teardown ordering itself is not stageable.
const dir = tmp()
const ctx = await boot(NAME, writeTree(dir))
try {
const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: tmp() })
await expect(dispose()).resolves.toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
})
it('propagates registration failures other than mid-teardown', async () => {
const dir = tmp()
const personal = tmp()
const ctx = await boot(NAME, writeTree(dir))
try {
await ctx.plugin(Timer)
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
const dispose = await watchPersonalPatches(ctx, { binName: NAME, dir: personal })
// Same personal path registered twice: HMR refuses; not a teardown race.
await expect(watchPersonalPatches(ctx, { binName: NAME, dir: personal })).rejects.toThrow('already registered')
await dispose()
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -0,0 +1,159 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { BUNDLED_PNPM_VERSION, RepositoryCache, type RepositoryInstall } from '@cordisjs/plugin-loader/repository'
const execFileAsync = promisify(execFile)
const roots: string[] = []
async function temporaryRoot(name: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), `cordis-${name}-`))
roots.push(root)
return root
}
async function fakePackage(directory: string): Promise<void> {
const target = join(directory, 'node_modules', 'repository')
await mkdir(target, { recursive: true })
await writeFile(join(target, 'package.json'), '{"name":"fixture"}\n')
}
afterEach(async () => {
vi.unstubAllEnvs()
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
describe('RepositoryCache', () => {
it('single-flights and permanently reuses an exact specifier', async () => {
const root = await temporaryRoot('repository-cache')
const calls: string[] = []
const install: RepositoryInstall = async (directory) => {
calls.push(directory)
await fakePackage(directory)
}
const cache = new RepositoryCache(root, install)
const specifier = 'github:owner/repository#0123456789abcdef'
const [first, concurrent] = await Promise.all([cache.resolve(specifier), cache.resolve(specifier)])
expect(concurrent).toBe(first)
expect(calls).toHaveLength(1)
const reopened = new RepositoryCache(root, async () => { throw new Error('cache miss') })
expect(await reopened.resolve(specifier)).toBe(first)
expect(JSON.parse(await readFile(join(first, '..', '..', 'package.json'), 'utf8'))).toMatchObject({
packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`,
dependencies: { repository: specifier },
})
const second = await cache.resolve('github:owner/repository#fedcba9876543210')
expect(second).not.toBe(first)
expect(calls).toHaveLength(2)
})
it('accepts the valid winner when independent cache instances race', async () => {
const root = await temporaryRoot('repository-race')
const bothStarted = Promise.withResolvers<undefined>()
let starts = 0
const install: RepositoryInstall = async (directory) => {
await fakePackage(directory)
starts += 1
if (starts === 2) bothStarted.resolve(undefined)
await bothStarted.promise
}
const specifier = 'github:owner/repository#race'
const [first, second] = await Promise.all([
new RepositoryCache(root, install).resolve(specifier),
new RepositoryCache(root, install).resolve(specifier),
])
expect(second).toBe(first)
expect(starts).toBe(2)
expect(await readdir(root)).toHaveLength(1)
})
it('removes a failed staging tree and permits an exact retry', async () => {
const root = await temporaryRoot('repository-retry')
let attempts = 0
const cache = new RepositoryCache(root, async (directory) => {
attempts += 1
if (attempts === 1) throw new Error('install failed')
await fakePackage(directory)
})
await expect(cache.resolve('github:owner/repository#ref')).rejects.toThrow('failed to prepare repository')
expect(await readdir(root)).toEqual([])
await expect(cache.resolve('github:owner/repository#ref')).resolves.toContain('node_modules')
expect(attempts).toBe(2)
})
it('rejects empty or padded specifiers before touching the cache', async () => {
const root = await temporaryRoot('repository-input')
const cache = new RepositoryCache(root, fakePackage)
expect(() => cache.resolve('')).toThrow('non-empty unpadded string')
expect(() => cache.resolve(' github:owner/repository#ref')).toThrow('non-empty unpadded string')
await expect(readdir(root)).resolves.toEqual([])
})
it('fails loud on a corrupt published marker instead of reinstalling it', async () => {
const root = await temporaryRoot('repository-corrupt')
const specifier = 'github:owner/repository#corrupt'
const key = createHash('sha256').update(specifier).digest('hex')
const entry = join(root, key)
await mkdir(join(entry, 'node_modules', 'repository'), { recursive: true })
await writeFile(join(entry, '.repository-cache.json'), '{}\n')
const cache = new RepositoryCache(root, async () => { throw new Error('must not reinstall') })
await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid')
})
it('selects and prepares a root .dsh-plugin Git subpath through the bundled pnpm', { timeout: 60_000 }, async () => {
const root = await temporaryRoot('repository-pnpm')
const repository = join(root, 'source')
await mkdir(join(repository, '.dsh-plugin'), { recursive: true })
await mkdir(join(repository, 'skills', 'fixture'), { recursive: true })
await writeFile(join(repository, 'package.json'), `${JSON.stringify({
name: 'repository-fixture',
version: '1.0.0',
})}\n`)
await writeFile(join(repository, 'skills', 'fixture', 'SKILL.md'), 'repository skill source\n')
await writeFile(join(repository, '.dsh-plugin', 'package.json'), `${JSON.stringify({
name: 'repository-plugin-fixture',
version: '1.0.0',
scripts: { prepare: 'node prepare.mjs' },
dsh: { skills: ['../skills'] },
})}\n`)
await writeFile(join(repository, '.dsh-plugin', 'prepare.mjs'), [
"import { cp, mkdir, writeFile } from 'node:fs/promises'",
"await mkdir('dsh-plugin-assets/skills', { recursive: true })",
"await cp('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })",
"await writeFile('dsh-plugin.mjs', 'export function apply() {}\\n')",
"await writeFile('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}\\n`)",
'',
].join('\n'))
await execFileAsync('git', ['init', '--quiet'], { cwd: repository })
await execFileAsync('git', ['add', '.'], { cwd: repository })
await execFileAsync('git', [
'-c', 'user.name=Repository Fixture',
'-c', 'user.email=repository@example.invalid',
'commit', '--quiet', '-m', 'fixture',
], { cwd: repository })
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8' })
const specifier = `git+${pathToFileURL(repository).href}#${stdout.trim()}&path:/.dsh-plugin`
vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible')
vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden')
const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier)
await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent\n')
await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply')
await expect(readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))
.resolves.toBe('repository skill source\n')
await expect(readFile(join(installed, 'package.json'), 'utf8'))
.resolves.toContain('repository-plugin-fixture')
})
})