Merge remote-tracking branch 'origin/master' into claude/unified-environment-credentials-c8841a
# Conflicts: # .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml # .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md # .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/reference/README.i18n.yaml # apps/cli/reference/README.md # apps/cli/reference/README.zh.md # apps/cli/src/app-cli-entry.ts # apps/cli/src/args.ts # apps/cli/src/bin.ts # apps/cli/src/config.ts # apps/cli/src/dump-config.ts # apps/cli/src/headless.ts # apps/cli/src/web.ts # apps/cli/tests/args.spec.ts # apps/cli/tests/built-bin.e2e.ts # apps/cli/tests/headless-shutdown.e2e.ts # apps/cli/tsconfig.json # docs/user/guide/config.i18n.yaml # docs/user/guide/config.md # docs/user/guide/config.zh.md # examples/mcp-memory/README.i18n.yaml # examples/mcp-memory/README.md # examples/mcp-memory/README.zh.md # packages/bundle/web-app/cordis.patch.yml # packages/cordis/repository-plugin/README.i18n.yaml # packages/cordis/repository-plugin/README.md # packages/cordis/repository-plugin/README.zh.md # packages/credentials/credentials-local/README.i18n.yaml # packages/credentials/credentials-local/README.md # packages/credentials/credentials-local/README.zh.md # packages/ui/app-boot/README.i18n.yaml # packages/ui/app-boot/README.md # packages/ui/app-boot/README.zh.md # packages/ui/app-boot/src/index.ts # packages/ui/app-boot/tests/config-reload.spec.ts # packages/ui/app-boot/tests/user-patches.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -341,11 +341,11 @@ describe('include refresh with overlay patches', () => {
|
||||
|
||||
describe('include patches layered over one base', () => {
|
||||
it('lets a later patch configure or disable a row an earlier patch inserted', async () => {
|
||||
// The surface/`--config` composition: `dsh` includes one shared base and
|
||||
// applies each source as its own patch list at the SAME include level,
|
||||
// because patches never cross an include boundary. A later layer must
|
||||
// therefore be able to reach a row an earlier layer inserted, or every
|
||||
// surface-only row would be invisible to the user's `--config` overlay.
|
||||
// The bundle/user-layer/`--patch` composition: `dsh` includes one root
|
||||
// and applies each source as its own patch list at the SAME include
|
||||
// level, because patches never cross an include boundary. A later layer
|
||||
// must therefore be able to reach a row an earlier layer inserted, or
|
||||
// bundle-only rows would be invisible to the user's patch layer.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
|
||||
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
||||
writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n')
|
||||
@@ -355,30 +355,30 @@ describe('include patches layered over one base', () => {
|
||||
' config:',
|
||||
' path: ./base.yml',
|
||||
' patches:',
|
||||
// Layer 1 (a surface overlay): patch a base row and add two of its own.
|
||||
// Layer 1 (a bundle layer): patch a base row and add two of its own.
|
||||
' - id: shared',
|
||||
' config:',
|
||||
' value: surface',
|
||||
' value: bundle',
|
||||
' - insert:',
|
||||
' - id: surface-kept',
|
||||
' - id: bundle-kept',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: surface-default',
|
||||
' - id: surface-dropped',
|
||||
' value: bundle-default',
|
||||
' - id: bundle-dropped',
|
||||
' name: ./noop.mjs',
|
||||
// Layer 2 (the user): reconfigure one inserted row and disable the other.
|
||||
' - id: surface-kept',
|
||||
' - id: bundle-kept',
|
||||
' config:',
|
||||
' value: user',
|
||||
' - id: surface-dropped',
|
||||
' - id: bundle-dropped',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n'))
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' })
|
||||
expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'user' })
|
||||
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped')
|
||||
expect(entryConfig(ctx, 'shared')).toEqual({ value: 'bundle' })
|
||||
expect(entryConfig(ctx, 'bundle-kept')).toEqual({ value: 'user' })
|
||||
const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'bundle-dropped')
|
||||
expect(dropped?.options.disabled).toBe(true)
|
||||
expect(dropped?.fiber).toBeUndefined()
|
||||
} finally {
|
||||
|
||||
245
packages/ui/app-boot/tests/profile.spec.ts
Normal file
245
packages/ui/app-boot/tests/profile.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Profile machinery of `dsh-app-boot`: directory resolution and init,
|
||||
* manifest round-trips, two-anchor bundle resolution, patch-layer loading,
|
||||
* empty-root composition, and the installation module-fallback healing.
|
||||
*/
|
||||
|
||||
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
composeEntries,
|
||||
healProfilesModuleFallback,
|
||||
initProfile,
|
||||
loadProfile,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
PROFILE_TEMPLATES,
|
||||
readProfileManifest,
|
||||
resolveBundleDir,
|
||||
resolveProfileDir,
|
||||
writeProfileManifest,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-'))
|
||||
|
||||
/** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
|
||||
function stageInstallation(bundles: Record<string, { patch?: string; deps?: Record<string, string> }>): string {
|
||||
const root = tmp()
|
||||
const appDir = join(root, 'app')
|
||||
mkdirSync(join(appDir, 'node_modules'), { recursive: true })
|
||||
const appDeps: Record<string, string> = {}
|
||||
for (const [name, spec] of Object.entries(bundles)) {
|
||||
appDeps[name] = '0.0.0'
|
||||
const dir = join(appDir, 'node_modules', name)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||||
name,
|
||||
version: '0.0.0',
|
||||
dependencies: spec.deps ?? {},
|
||||
...spec.patch === undefined ? {} : { dsh: { bundle: { patch: './cordis.patch.yml' } } },
|
||||
}))
|
||||
if (spec.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), spec.patch)
|
||||
}
|
||||
writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', dependencies: appDeps }))
|
||||
return join(appDir, 'package.json')
|
||||
}
|
||||
|
||||
describe('resolveProfileDir', () => {
|
||||
it('joins the home and rejects traversal-shaped names', () => {
|
||||
const home = tmp()
|
||||
expect(resolveProfileDir('tui', home)).toBe(join(home, 'profiles', 'tui'))
|
||||
for (const bad of ['', '.', '..', 'a/b', 'a\\b']) {
|
||||
expect(() => resolveProfileDir(bad, home)).toThrow('invalid profile name')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('initProfile', () => {
|
||||
it('creates manifest, user patch layer, and pnpm workspace once, never overwriting', () => {
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('tui', home)
|
||||
initProfile(dir, ['@deepseek-ai/dsh-base'])
|
||||
const manifest = readProfileManifest('t', dir)
|
||||
expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
|
||||
expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
|
||||
// Re-init keeps user edits.
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n')
|
||||
initProfile(dir, ['other'])
|
||||
expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
|
||||
expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifest round-trip', () => {
|
||||
it('writes and reads back, and fails loud on a broken manifest', () => {
|
||||
const dir = tmp()
|
||||
writeProfileManifest(dir, { name: 'p', dsh: { profile: { bundles: ['a'] } } })
|
||||
expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['a'])
|
||||
writeFileSync(join(dir, 'package.json'), '[]')
|
||||
expect(() => readProfileManifest('t', dir)).toThrow('must hold a JSON object')
|
||||
expect(() => readProfileManifest('t', join(dir, 'nope'))).toThrow('failed to read profile manifest')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveBundleDir', () => {
|
||||
it('prefers the installation anchor, falls back to the profile, and fails loud', () => {
|
||||
const anchor = stageInstallation({ 'in-box': { patch: '[]\n' } })
|
||||
const profileDir = tmp()
|
||||
mkdirSync(join(profileDir, 'node_modules', 'local-only'), { recursive: true })
|
||||
writeFileSync(join(profileDir, 'package.json'), '{}')
|
||||
writeFileSync(join(profileDir, 'node_modules', 'local-only', 'package.json'), JSON.stringify({ name: 'local-only', version: '0.0.0' }))
|
||||
expect(resolveBundleDir('t', 'in-box', anchor, profileDir)).toContain('in-box')
|
||||
expect(resolveBundleDir('t', 'local-only', anchor, profileDir)).toContain('local-only')
|
||||
expect(() => resolveBundleDir('t', 'absent', anchor, profileDir)).toThrow('cannot resolve profile bundle')
|
||||
})
|
||||
|
||||
it('resolves a package whose exports map omits ./package.json', () => {
|
||||
// Common on npm: an exports map without "./package.json" makes
|
||||
// require.resolve('<pkg>/package.json') throw ERR_PACKAGE_PATH_NOT_EXPORTED;
|
||||
// resolution must fall through to the paths probe instead of misreporting
|
||||
// the installed package as missing.
|
||||
const anchor = stageInstallation({})
|
||||
const profileDir = tmp()
|
||||
writeFileSync(join(profileDir, 'package.json'), '{}')
|
||||
const dir = join(profileDir, 'node_modules', 'sealed-bundle')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'sealed-bundle',
|
||||
version: '0.0.0',
|
||||
exports: { '.': './index.js' },
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}))
|
||||
writeFileSync(join(dir, 'index.js'), '')
|
||||
writeFileSync(join(dir, 'cordis.patch.yml'), '[]\n')
|
||||
expect(resolveBundleDir('t', 'sealed-bundle', anchor, profileDir)).toBe(dir)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadProfile', () => {
|
||||
it('resolves each dsh.profile.bundles entry to its patch layer in order, plus the user layer', () => {
|
||||
const anchor = stageInstallation({
|
||||
'bundle-a': { patch: '- insert:\n - id: a\n name: pkg-a\n' },
|
||||
'bundle-b': { patch: '- id: a\n config:\n v: 2\n' },
|
||||
})
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('demo', home)
|
||||
initProfile(dir, ['bundle-a', 'bundle-b'])
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: a\n config:\n v: 3\n')
|
||||
const profile = loadProfile('t', 'demo', anchor, home)
|
||||
expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b'])
|
||||
expect(profile.patches).toHaveLength(1)
|
||||
const entries = composeEntries([
|
||||
...profile.layers.map(layer => layer.patches),
|
||||
profile.patches,
|
||||
])
|
||||
expect(entries).toEqual([{ id: 'a', name: 'pkg-a', config: { v: 3 } }])
|
||||
// A hand-made profile without the user layer file or dsh section: empty layers, no throw.
|
||||
rmSync(join(dir, PROFILE_PATCH_FILENAME))
|
||||
expect(loadProfile('t', 'demo', anchor, home).patches).toEqual([])
|
||||
writeProfileManifest(dir, { name: 'bare' })
|
||||
const bare = loadProfile('t', 'demo', anchor, home)
|
||||
expect(bare.layers).toEqual([])
|
||||
})
|
||||
|
||||
it('auto-initializes only shipped templates and fails loud otherwise', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
expect(() => loadProfile('t', 'custom', anchor, home))
|
||||
.toThrow('profile "custom" does not exist')
|
||||
// The web template auto-initializes on first load. Bundle resolution
|
||||
// cannot be asserted to fail here: the source-plane test runner resolves
|
||||
// @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
|
||||
expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base')
|
||||
try {
|
||||
loadProfile('t', 'web', anchor, home)
|
||||
} catch {
|
||||
// Resolution failure is the plain-Node outcome for this empty anchor.
|
||||
}
|
||||
expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
|
||||
.toEqual([...PROFILE_TEMPLATES.web ?? []])
|
||||
})
|
||||
|
||||
it('fails loud when a listed bundle declares no dsh.bundle', () => {
|
||||
const anchor = stageInstallation({ 'not-a-bundle': {} })
|
||||
const home = tmp()
|
||||
const dir = resolveProfileDir('demo', home)
|
||||
initProfile(dir, ['not-a-bundle'])
|
||||
expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares no dsh.bundle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeEntries', () => {
|
||||
it('applies layers over an empty root and reports skipped patches', () => {
|
||||
const warnings: string[] = []
|
||||
const entries = composeEntries([
|
||||
[{ insert: [{ id: 'x', name: 'pkg-x', config: { a: 1 } }] }],
|
||||
[{ id: 'x', config: { a: 2 } }, { id: 'missing', config: {} }],
|
||||
], message => warnings.push(message))
|
||||
expect(entries).toEqual([{ id: 'x', name: 'pkg-x', config: { a: 2 } }])
|
||||
expect(warnings.join('\n')).toContain('"missing"')
|
||||
// Default warn sink: skipped patches are silently dropped (boot repeats them).
|
||||
expect(composeEntries([[{ id: 'missing', config: {} }]])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('healProfilesModuleFallback', () => {
|
||||
it('links the app and bundle dependency surface flat under profiles/node_modules', () => {
|
||||
const anchor = stageInstallation({
|
||||
'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } },
|
||||
'plain-lib': {},
|
||||
})
|
||||
// An app dependency that is declared but not installed: skipped, not fatal.
|
||||
const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record<string, string> }
|
||||
appManifest.dependencies['never-installed'] = '0.0.0'
|
||||
writeFileSync(anchor, JSON.stringify(appManifest))
|
||||
// dep-of-a lives in the installation's node_modules too.
|
||||
const modules = join(anchor, '..', 'node_modules')
|
||||
mkdirSync(join(modules, 'dep-of-a'), { recursive: true })
|
||||
writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' }))
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
// App deps, the bundle's own deps, and the bundle itself are linked; the
|
||||
// plain library is linked as an app dep (harmless), the app itself too.
|
||||
for (const name of ['bundle-a', 'plain-lib', 'dep-of-a', 'dsh-app']) {
|
||||
expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true)
|
||||
}
|
||||
// Idempotent, and a moved target is re-pointed.
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
const before = readlinkSync(join(fallback, 'dep-of-a'))
|
||||
expect(before).toContain('dep-of-a')
|
||||
})
|
||||
|
||||
it('throws when a fallback entry is a real directory', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true })
|
||||
expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink')
|
||||
})
|
||||
|
||||
it('replaces a wrong symlink', () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
mkdirSync(fallback, { recursive: true })
|
||||
symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction')
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app')
|
||||
})
|
||||
|
||||
it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => {
|
||||
// The EEXIST arm: a second process wrote the link between our lstat miss
|
||||
// and symlinkSync. Simulated by pre-creating the correct link and calling
|
||||
// the internal path through a stale-lstat shim is not possible from
|
||||
// outside, so probe the observable contract: healing twice concurrently
|
||||
// is a no-op, and a foreign REAL directory still fails loud.
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
healProfilesModuleFallback(anchor, home) // second healer sees the correct link
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true)
|
||||
})
|
||||
})
|
||||
263
packages/ui/app-boot/tests/user-patches.spec.ts
Normal file
263
packages/ui/app-boot/tests/user-patches.spec.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader
|
||||
* (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over
|
||||
* a real Loader tree, kept live through transactional HMR.
|
||||
*/
|
||||
|
||||
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 { Context } from 'cordis'
|
||||
import Hmr from '@cordisjs/plugin-hmr'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import {
|
||||
boot,
|
||||
loadOptionalPatches,
|
||||
PROFILE_PATCH_FILENAME,
|
||||
watchUserPatches,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
|
||||
|
||||
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('loadOptionalPatches', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.DSH_HOME
|
||||
})
|
||||
|
||||
it('returns undefined when no user patch file exists', () => {
|
||||
expect(loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), [
|
||||
'- id: agent-loop',
|
||||
" name: '@deepseek-ai/dsh-agent-loop'",
|
||||
' config:',
|
||||
' model: !!js process.env.DSH_SPEC_MODEL',
|
||||
'- insert:',
|
||||
' - id: llm',
|
||||
" name: '@deepseek-ai/dsh-llm-pi-ai'",
|
||||
'',
|
||||
].join('\n'))
|
||||
const patches = loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME))
|
||||
expect(patches).toHaveLength(2)
|
||||
expect(patches?.[0]).toMatchObject({
|
||||
id: 'agent-loop',
|
||||
config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } },
|
||||
})
|
||||
expect(patches?.[1]?.insert).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => {
|
||||
const dir = tmp()
|
||||
mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to read patches `))
|
||||
})
|
||||
|
||||
it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'invalid: [unclosed\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse patches `))
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config:\n a: !!js\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(new RegExp(`^${NAME}: failed to parse patches `))
|
||||
})
|
||||
|
||||
it('fails loud when the file is not a top-level array or an entry is not an object', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), 'id: not-a-list\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow('must be a top-level YAML array of loader patch entries')
|
||||
writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- just-a-string\n')
|
||||
expect(() => loadOptionalPatches(NAME, join(dir, PROFILE_PATCH_FILENAME)))
|
||||
.toThrow(`${NAME}: patches entry 1 in`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot with user patches', () => {
|
||||
function writeTree(dir: string): string {
|
||||
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')
|
||||
}
|
||||
|
||||
function entryConfig(ctx: Context, id: string): unknown {
|
||||
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
||||
}
|
||||
|
||||
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
|
||||
const dir = tmp()
|
||||
const userDir = tmp()
|
||||
writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [
|
||||
'- id: noop',
|
||||
' name: ./noop.mjs',
|
||||
' config:',
|
||||
' value: !!js process.env.DSH_APP_BOOT_USER_SPEC',
|
||||
'- insert:',
|
||||
' - id: user-extra',
|
||||
' name: ./noop.mjs',
|
||||
'',
|
||||
].join('\n'))
|
||||
process.env['DSH_APP_BOOT_USER_SPEC'] = 'user-value'
|
||||
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(userDir, PROFILE_PATCH_FILENAME)))
|
||||
try {
|
||||
const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
|
||||
// The mounted plugin received the interpolated environment value.
|
||||
expect(noop?.fiber?.config).toEqual({ value: 'user-value' })
|
||||
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'user-extra')).toBe(true)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
delete process.env['DSH_APP_BOOT_USER_SPEC']
|
||||
}
|
||||
})
|
||||
|
||||
it('mounts no patch layer for an absent or empty user layer', async () => {
|
||||
const dir = tmp()
|
||||
const ctx = await boot(NAME, writeTree(dir), loadOptionalPatches(NAME, join(tmp(), PROFILE_PATCH_FILENAME)))
|
||||
try {
|
||||
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' })
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
const empty = tmp()
|
||||
writeFileSync(join(empty, PROFILE_PATCH_FILENAME), '[]\n')
|
||||
const ctxEmpty = await boot(NAME, writeTree(tmp()), loadOptionalPatches(NAME, join(empty, PROFILE_PATCH_FILENAME)))
|
||||
try {
|
||||
expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' })
|
||||
} finally {
|
||||
await ctxEmpty.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
|
||||
const dir = tmp()
|
||||
const userDir = tmp()
|
||||
const filename = join(userDir, PROFILE_PATCH_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 watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
filename,
|
||||
compose: userPatches => [...basePatches, ...userPatches],
|
||||
})
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: live\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'live', 'user patch 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', 'user patch removal did not restore the app-owned patch')
|
||||
expect(failures).toHaveLength(2)
|
||||
await settleChokidarChangeThrottle()
|
||||
|
||||
// Default compose: the user layer IS the whole patch list, so a
|
||||
// fresh generation replaces the app-owned layer instead of stacking on it.
|
||||
await dispose()
|
||||
const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
|
||||
try {
|
||||
writeFileSync(filename, '- id: noop\n config:\n value: identity\n')
|
||||
await eventually(() => (entryConfig(ctx, 'noop') as { value?: string }).value === 'identity', 'default-compose user 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(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).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(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).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 surface can dispose 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 watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates registration failures other than mid-teardown', async () => {
|
||||
const dir = tmp()
|
||||
const filename = join(tmp(), PROFILE_PATCH_FILENAME)
|
||||
const ctx = await boot(NAME, writeTree(dir))
|
||||
try {
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
|
||||
const dispose = await watchUserPatches(ctx, { binName: NAME, filename })
|
||||
// Same user-layer path registered twice: HMR refuses; not a teardown race.
|
||||
await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
|
||||
await dispose()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user