refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
409
packages/scaffold/helper/tests/documents.spec.ts
Normal file
409
packages/scaffold/helper/tests/documents.spec.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Writable } from 'node:stream'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { CordisYamlFile, JsExpression } from '../src/documents/cordis-yaml-file.ts'
|
||||
import { EnvFile } from '../src/documents/env-file.ts'
|
||||
import { PackageJsonFile } from '../src/documents/package-json-file.ts'
|
||||
import { PnpmWorkspaceFile } from '../src/documents/pnpm-workspace-file.ts'
|
||||
import { TsConfigFile } from '../src/documents/tsconfig-file.ts'
|
||||
import { TextProjectFile, withTrailingNewline } from '../src/documents/project-file.ts'
|
||||
import { featureId, resourceKey } from '../src/ids.ts'
|
||||
import { LinkWorkspace } from '../src/package-managers/link-workspace.ts'
|
||||
import { LocalPluginBlueprint } from '../src/plugins/local-plugin-blueprint.ts'
|
||||
import {
|
||||
NpmPackageManager,
|
||||
NodeCommandRunner,
|
||||
PnpmPackageManager,
|
||||
YarnPackageManager,
|
||||
createPackageManager,
|
||||
inferPackageManagerName,
|
||||
probePackageManagerVersion,
|
||||
scrubEnvironment,
|
||||
type CommandRunner,
|
||||
} from '../src/package-managers/package-manager.ts'
|
||||
import { createBaselineProjectArtifacts } from '../src/templates/project-template.ts'
|
||||
import { loadHelperTemplate } from '../src/templates/template-assets.ts'
|
||||
import { TextTemplate } from '../src/templates/text-template.ts'
|
||||
import { resolveNpmDependency } from '../src/project/npm-dependency-policy.ts'
|
||||
|
||||
const temporary: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('structured project documents', () => {
|
||||
it('normalizes trailing newlines and preserves managed package fields', () => {
|
||||
expect(withTrailingNewline('a\n\n')).toBe('a\n')
|
||||
const manifest = PackageJsonFile.parse('{"name":"demo","custom":1,"dependencies":{"z":"1"}}\n')
|
||||
manifest.setScript('start', 'node index.js')
|
||||
manifest.setNpmDependency('dependencies', 'a', '2')
|
||||
manifest.setNpmDependency('devDependencies', 'typescript', '3')
|
||||
manifest.removeNpmDependency('dependencies', 'z')
|
||||
manifest.addWorkspace('plugins/*')
|
||||
manifest.addWorkspace('plugins/*')
|
||||
manifest.setPackageManager('pnpm@10.0.0')
|
||||
manifest.setResolution('a', 'portal:../a')
|
||||
manifest.validate()
|
||||
expect(manifest.npmDependency('a')).toEqual({ section: 'dependencies', spec: '2' })
|
||||
expect(manifest.npmDependencyNames()).toEqual(['a', 'typescript'])
|
||||
expect(JSON.parse(manifest.serialize())).toMatchObject({
|
||||
name: 'demo',
|
||||
custom: 1,
|
||||
dependencies: { a: '2' },
|
||||
workspaces: ['plugins/*'],
|
||||
})
|
||||
manifest.setPackageManager(undefined)
|
||||
expect(manifest.value().packageManager).toBeUndefined()
|
||||
expect(() => PackageJsonFile.parse('[]')).toThrow('root must be an object')
|
||||
expect(() => PackageJsonFile.parse('{')).toThrow('invalid package.json')
|
||||
for (const [text, diagnostic] of [
|
||||
['{}', 'name must be'],
|
||||
['{"name":"x","scripts":null}', 'scripts must be an object'],
|
||||
['{"name":"x","dependencies":[]}', 'dependencies must be an object'],
|
||||
['{"name":"x","devDependencies":{"bad":""}}', 'must be a non-empty string'],
|
||||
['{"name":"x","workspaces":"bad"}', 'workspaces must be an array'],
|
||||
] as const) expect(() => { PackageJsonFile.parse(text).validate() }).toThrow(diagnostic)
|
||||
const minimal = PackageJsonFile.parse('{"name":"x"}')
|
||||
minimal.validate()
|
||||
expect(minimal.npmDependency('missing')).toBeUndefined()
|
||||
expect(minimal.serialize()).toBe('{\n "name": "x"\n}\n')
|
||||
expect(PackageJsonFile.parse('{"name":"x","devDependencies":{"a":"1"}}').npmDependencyNames()).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('round-trips Cordis comments and !!js while editing owned fields', () => {
|
||||
const created = CordisYamlFile.create().clone()
|
||||
created.addEntry({ id: 'created', name: 'created-package' }, `Uncomment this example.
|
||||
config:
|
||||
value: true`)
|
||||
expect(created.serialize()).toMatch(/^- id: created/m)
|
||||
expect(created.serialize()).toContain(' # Uncomment this example.\n # config:\n # value: true')
|
||||
expect(created.serialize()).not.toMatch(/^\[/)
|
||||
const flow = CordisYamlFile.parse('[{ id: flow, name: flow-package, config: { root: ./flow } }]\n')
|
||||
expect(flow.serialize()).toContain('- id: flow\n name: flow-package\n config:\n root: ./flow')
|
||||
expect(flow.serialize()).not.toContain('{')
|
||||
const document = CordisYamlFile.parse(`# lead
|
||||
- id: provider
|
||||
name: 'provider-package'
|
||||
config:
|
||||
endpoint: !!js process.env.PROVIDER_URL
|
||||
custom: keep
|
||||
`)
|
||||
const endpoint = document.entry('provider')?.config?.endpoint
|
||||
expect(endpoint).toBeInstanceOf(JsExpression)
|
||||
document.updateOwnedConfig('provider', ['endpoint'], { endpoint: new JsExpression('process.env.NEXT_URL') })
|
||||
document.setDisabled('provider', true)
|
||||
document.addEntry({ id: 'tool', name: 'demo-tool' })
|
||||
document.validate()
|
||||
const text = document.serialize()
|
||||
expect(text).toContain('# lead')
|
||||
expect(text).toContain('!!js process.env.NEXT_URL')
|
||||
expect(text).toContain('custom: keep')
|
||||
expect(document.removeEntry('tool')).toBe(true)
|
||||
expect(document.removeEntry('tool')).toBe(false)
|
||||
document.setDisabled('provider', false)
|
||||
expect(document.entry('provider')?.disabled).toBeUndefined()
|
||||
expect(() => { document.addEntry({ id: 'provider', name: 'duplicate' }) }).toThrow('already exists')
|
||||
expect(() => CordisYamlFile.parse('{}')).toThrow('root must be a sequence')
|
||||
})
|
||||
|
||||
it('rejects malformed Cordis config entries and missing mutation targets', () => {
|
||||
expect(() => new JsExpression(' ')).toThrow('must not be empty')
|
||||
expect(() => CordisYamlFile.parse('[')).toThrow('invalid cordis.yml')
|
||||
for (const [text, diagnostic] of [
|
||||
['- nope\n', 'every entry must be a mapping'],
|
||||
['- name: pkg\n', 'id must be'],
|
||||
['- id: x\n', 'name must be'],
|
||||
['- id: x\n name: pkg\n config: nope\n', 'config must be'],
|
||||
['- id: x\n name: pkg\n disabled: nope\n', 'disabled must be'],
|
||||
] as const) expect(() => CordisYamlFile.parse(text).entries()).toThrow(diagnostic)
|
||||
const duplicate = CordisYamlFile.parse('- id: x\n name: one\n- id: x\n name: two\n')
|
||||
expect(() => { duplicate.validate() }).toThrow('duplicate Cordis config entry id')
|
||||
const document = CordisYamlFile.create()
|
||||
expect(() => { document.setDisabled('missing', true) }).toThrow('does not exist')
|
||||
expect(() => { document.updateOwnedConfig('missing', [], {}) }).toThrow('does not exist')
|
||||
document.addEntry({ id: 'plain', name: 'pkg' })
|
||||
document.updateOwnedConfig('plain', [], { value: 1 })
|
||||
expect(document.entry('plain')?.config).toEqual({ value: 1 })
|
||||
document.updateOwnedConfig('plain', ['value'], {})
|
||||
expect(document.entry('plain')?.config).toBeUndefined()
|
||||
const scalar = CordisYamlFile.parse('- id: plain\n name: pkg\n config: value\n')
|
||||
expect(() => { scalar.updateOwnedConfig('plain', [], {}) }).toThrow('config is not a mapping')
|
||||
expect(() => { CordisYamlFile.parse('- nope\n').setDisabled('missing', true) }).toThrow('does not exist')
|
||||
})
|
||||
|
||||
it('keeps .env append-only while managing .env.example strictly', () => {
|
||||
const document = EnvFile.parse('.env', '# keep\nA=1\nexport B=2\n')
|
||||
expect(document.get('A')).toBe('1')
|
||||
expect(document.get('B')).toBe('2')
|
||||
expect(document.append('A', 'next')).toBe(false)
|
||||
expect(document.append('C', '', 'Required')).toBe(true)
|
||||
expect(document.serialize()).toBe('# keep\nA=1\nexport B=2\n# Required\nC=\n')
|
||||
expect(() => { document.append('bad-name', 'x') }).toThrow('invalid environment variable')
|
||||
expect(() => { document.append('D', '', '') }).toThrow('non-empty line')
|
||||
expect(() => { document.append('D', '', 'bad\ncomment') }).toThrow('non-empty line')
|
||||
expect(() => { document.set('A', 'next') }).toThrow('.env is append-only')
|
||||
expect(() => { document.remove('A') }).toThrow('.env is append-only')
|
||||
const duplicateEnv = EnvFile.parse('.env', 'A=1\nA=2\n')
|
||||
expect(duplicateEnv.get('A')).toBe('2')
|
||||
expect(duplicateEnv.append('A', 'next')).toBe(false)
|
||||
expect(() => { duplicateEnv.validate() }).not.toThrow()
|
||||
|
||||
const example = EnvFile.parse('.env.example', '# keep\nA=1\n')
|
||||
example.set('A', 'next')
|
||||
example.set('C', '')
|
||||
example.remove('C')
|
||||
expect(example.serialize()).toBe('# keep\nA=next\n')
|
||||
expect(() => { example.append('C', '') }).toThrow('.env.example is SDK-managed')
|
||||
expect(() => { example.set('bad-name', 'x') }).toThrow('invalid environment variable')
|
||||
const duplicate = EnvFile.parse('.env.example', 'A=1\nA=2\n')
|
||||
expect(() => { duplicate.validate() }).toThrow('duplicate variable A')
|
||||
expect(() => duplicate.get('A')).toThrow('duplicate variable A')
|
||||
expect(() => { duplicate.set('A', 'next') }).toThrow('duplicate variable A')
|
||||
expect(() => { duplicate.remove('A') }).toThrow('duplicate variable A')
|
||||
example.remove('missing')
|
||||
expect(document.get('missing')).toBeUndefined()
|
||||
expect(EnvFile.parse('.env', '').clone().serialize()).toBe('\n')
|
||||
})
|
||||
|
||||
it('patches JSONC references without erasing comments', () => {
|
||||
const document = TsConfigFile.parse(`{
|
||||
// retained
|
||||
"references": [{ "path": "./plugins/a" }]
|
||||
}`)
|
||||
document.addReference('./plugins/a')
|
||||
document.addReference('./plugins/b')
|
||||
document.validate()
|
||||
expect(document.serialize()).toContain('// retained')
|
||||
expect(document.serialize()).toContain('./plugins/b')
|
||||
expect(() => TsConfigFile.parse('{')).toThrow('valid JSONC object')
|
||||
const malformed = TsConfigFile.parse('{"references": {}}')
|
||||
expect(() => { malformed.addReference('./plugins/x') }).toThrow('must be an array')
|
||||
const badItem = TsConfigFile.parse('{"references":[null]}')
|
||||
expect(() => { badItem.addReference('./plugins/x') }).toThrow('must contain')
|
||||
expect(() => { badItem.validate() }).toThrow('must contain')
|
||||
const created = TsConfigFile.create()
|
||||
created.validate()
|
||||
expect(created.clone().serialize()).toContain('"references": []')
|
||||
TsConfigFile.parse('{}').validate()
|
||||
const noReferences = TsConfigFile.parse('{}')
|
||||
noReferences.addReference('./plugin')
|
||||
expect(noReferences.serialize()).toContain('./plugin')
|
||||
expect(() => { TsConfigFile.parse('{"references":{}}').validate() }).toThrow('must be an array')
|
||||
})
|
||||
|
||||
it('creates and parses pnpm workspace policy', () => {
|
||||
const document = PnpmWorkspaceFile.create()
|
||||
document.addPackage('plugins/*')
|
||||
document.addPackage('plugins/*')
|
||||
document.disableAutoInstallPeers()
|
||||
document.validate()
|
||||
expect(document.serialize()).toContain('autoInstallPeers: false')
|
||||
const parsed = PnpmWorkspaceFile.parse(document.serialize())
|
||||
expect(parsed.clone().serialize()).toBe(document.serialize())
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: [')).toThrow('invalid pnpm-workspace.yaml')
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: nope')).toThrow('packages must be an array')
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: [{}]')).toThrow('packages must be an array')
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: [1]')).toThrow('packages must be an array')
|
||||
expect(() => PnpmWorkspaceFile.parse('[]')).toThrow('root must be an object')
|
||||
expect(() => PnpmWorkspaceFile.parse('packages: []\nautoInstallPeers: nope')).toThrow('must be boolean')
|
||||
const invalid = PnpmWorkspaceFile.create()
|
||||
invalid.addPackage(' ')
|
||||
expect(() => { invalid.validate() }).toThrow('must not be empty')
|
||||
expect(PnpmWorkspaceFile.parse('packages: []\n').serialize()).not.toContain('autoInstallPeers')
|
||||
const preserved = PnpmWorkspaceFile.parse(`# keep workspace settings
|
||||
packages:
|
||||
- apps/*
|
||||
catalog:
|
||||
react: ^19.0.0
|
||||
overrides:
|
||||
legacy: modern
|
||||
`)
|
||||
preserved.disableAutoInstallPeers()
|
||||
const preservedText = preserved.clone().serialize()
|
||||
expect(preservedText).toContain('# keep workspace settings')
|
||||
expect(preservedText).toContain('catalog:\n react: ^19.0.0')
|
||||
expect(preservedText).toContain('overrides:\n legacy: modern')
|
||||
expect(preservedText).toContain('autoInstallPeers: false')
|
||||
})
|
||||
|
||||
it('renders strict complete-file templates without escaping code text', () => {
|
||||
const template = new TextTemplate<{ value: string }>('value={{value}} missing={{missing}}')
|
||||
expect(() => template.render({ value: '<code>' })).toThrow()
|
||||
const valid = new TextTemplate<{ value: string }>('value={{value}}')
|
||||
expect(valid.render({ value: '<code>' })).toBe('value=<code>')
|
||||
expect(new TextTemplate<Record<string, never>>('\\{{model}}').render({})).toBe('{{model}}')
|
||||
expect(() => new TextProjectFile('/absolute', 'x')).toThrow('stay inside')
|
||||
expect(() => new TextProjectFile('../outside', 'x')).toThrow('stay inside')
|
||||
expect(new TextProjectFile('inside', 'x').clone().serialize()).toBe('x\n')
|
||||
expect(() => featureId('Bad Id')).toThrow('invalid feature id')
|
||||
expect(() => resourceKey('')).toThrow('must not be empty')
|
||||
expect(() => loadHelperTemplate('../bad.tpl')).toThrow('must not contain a directory')
|
||||
expect(createBaselineProjectArtifacts({
|
||||
name: 'demo', description: 'demo', releaseVersion: '0.0.1', model: 'model', modelLiteral: '"model"', packageManager: 'yarn',
|
||||
isAcp: false, isEmbed: true,
|
||||
installArgs: 'install', buildArgs: 'build',
|
||||
}).map(document => document.relativePath)).toContain('.yarnrc.yml')
|
||||
expect(() => new LocalPluginBlueprint('---', 'plugin')).toThrow('invalid local plugin name')
|
||||
expect(new LocalPluginBlueprint('tool', 'tool').packageName('@scope/project')).toBe('@scope/project-tool')
|
||||
expect(new LocalPluginBlueprint('tool', 'tool').packageName('@invalid')).toBe('@invalid-tool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('package manager strategies', () => {
|
||||
it('owns workspace fields, execution commands, and supported version floors', () => {
|
||||
const npm = new NpmPackageManager('10.1.0')
|
||||
const pnpm = new PnpmPackageManager('10.2.0')
|
||||
const yarn = new YarnPackageManager('4.0.0')
|
||||
for (const manager of [npm, pnpm, yarn]) manager.validateVersion()
|
||||
expect(npm.localPluginSpec()).toBe('*')
|
||||
expect(npm.linkSpec('../x')).toBe('file:../x')
|
||||
expect(npm.configureWorkspace(PackageJsonFile.create('{"name":"demo"}'))).toEqual([])
|
||||
const pnpmManifest = PackageJsonFile.create('{"name":"demo"}')
|
||||
expect(pnpm.configureWorkspace(pnpmManifest)[0]).toBeInstanceOf(PnpmWorkspaceFile)
|
||||
expect(pnpm.localPluginSpec()).toBe('workspace:*')
|
||||
expect(pnpm.linkSpec('../x')).toBe('link:../x')
|
||||
const yarnManifest = PackageJsonFile.create('{"name":"demo"}')
|
||||
expect(yarn.configureWorkspace(yarnManifest)).toEqual([])
|
||||
expect(yarn.localPluginSpec()).toBe('workspace:*')
|
||||
expect(yarn.linkSpec('../x')).toBe('portal:../x')
|
||||
expect(yarn.buildCommand()).toEqual(['build'])
|
||||
expect(npm.installCommand()).toEqual(['install'])
|
||||
expect(npm.buildCommand()).toEqual(['run', 'build'])
|
||||
expect(() => createPackageManager('npm', '9.0.0')).toThrow('npm >=10')
|
||||
expect(() => createPackageManager('pnpm', '9.0.0')).toThrow('pnpm >=10')
|
||||
expect(() => createPackageManager('yarn', '1.22.0')).toThrow('Yarn >=2')
|
||||
expect(inferPackageManagerName(undefined, 'pnpm/10.0.0 node/v24')).toBe('pnpm')
|
||||
expect(inferPackageManagerName(undefined, 'unknown/1')).toBeUndefined()
|
||||
expect(inferPackageManagerName('yarn', undefined)).toBe('yarn')
|
||||
expect(() => createPackageManager('npm', 'invalid')).toThrow('invalid package manager version')
|
||||
expect(resolveNpmDependency('cordis', 'devDependencies', '0.0.1')).toEqual({
|
||||
section: 'devDependencies', spec: '^4.0.0-rc.7',
|
||||
})
|
||||
expect(resolveNpmDependency('@cordisjs/plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15')
|
||||
expect(resolveNpmDependency('tsdown', 'devDependencies', '0.0.1').spec).toBe('0.22.2')
|
||||
expect(resolveNpmDependency('@deepseek-ai/dsh-tools', 'dependencies', '1.2.3').spec).toBe('^1.2.3')
|
||||
expect(() => resolveNpmDependency('unknown', 'dependencies', '0.0.1')).toThrow('no generated-project')
|
||||
})
|
||||
|
||||
it('checks install/build process outcomes and scrubs credential-shaped names', async () => {
|
||||
const calls: string[][] = []
|
||||
const runner: CommandRunner = {
|
||||
run: async (command, args) => {
|
||||
calls.push([command, ...args])
|
||||
return { exitCode: 0, signal: null }
|
||||
},
|
||||
}
|
||||
const npm = new NpmPackageManager('10.0.0')
|
||||
await npm.install('/tmp', runner)
|
||||
await npm.build('/tmp', runner)
|
||||
expect(calls).toEqual([['npm', 'install'], ['npm', 'run', 'build']])
|
||||
await npm.add('some-pkg@1.0.0', '/tmp', runner)
|
||||
const pnpm = createPackageManager('pnpm', '10.0.0')
|
||||
await pnpm.add('github:o/r#sha', '/tmp', runner)
|
||||
expect(calls).toContainEqual(['npm', 'install', 'some-pkg@1.0.0'])
|
||||
expect(calls).toContainEqual(['pnpm', 'add', 'github:o/r#sha'])
|
||||
const failed: CommandRunner = { run: async () => ({ exitCode: 2, signal: null }) }
|
||||
await expect(npm.install('/tmp', failed)).rejects.toThrow('exited with code 2')
|
||||
const killed: CommandRunner = { run: async () => ({ exitCode: null, signal: 'SIGTERM' }) }
|
||||
await expect(npm.build('/tmp', killed)).rejects.toThrow('killed by SIGTERM')
|
||||
expect(scrubEnvironment({
|
||||
PATH: '/bin',
|
||||
API_KEY: 'secret',
|
||||
DB_PASSWORD: 'secret',
|
||||
TOKEN_VALUE: 'secret',
|
||||
})).toEqual({ PATH: '/bin' })
|
||||
})
|
||||
|
||||
it('probes versions and runs real child-process boundaries', async () => {
|
||||
await expect(probePackageManagerVersion('npm', process.cwd())).resolves.toMatch(/^\d+/)
|
||||
await expect(probePackageManagerVersion('npm', '/missing/dsh-cwd')).rejects.toThrow('cannot run npm --version')
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-empty-version-'))
|
||||
temporary.push(root)
|
||||
const executable = join(root, 'npm')
|
||||
await writeFile(executable, '#!/bin/sh\nexit 0\n')
|
||||
await chmod(executable, 0o755)
|
||||
const before = process.env.PATH
|
||||
process.env.PATH = root
|
||||
await expect(probePackageManagerVersion('npm', root)).rejects.toThrow('empty version output')
|
||||
process.env.PATH = before
|
||||
const runner = new NodeCommandRunner()
|
||||
await expect(runner.run(process.execPath, ['-e', ''], root)).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
await expect(runner.run('missing-dsh-command', [], root)).rejects.toThrow()
|
||||
let redirected = ''
|
||||
const output = new Writable({
|
||||
write(chunk, _encoding, callback) { redirected += String(chunk); callback() },
|
||||
})
|
||||
const redirecting = new NodeCommandRunner(output)
|
||||
await expect(redirecting.run(
|
||||
process.execPath,
|
||||
['-e', 'process.stdout.write("child-out"); process.stderr.write("child-err")'],
|
||||
root,
|
||||
)).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
expect(redirected).toContain('child-out')
|
||||
expect(redirected).toContain('child-err')
|
||||
await expect(redirecting.run('missing-dsh-command', [], root)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('discovers and rewrites a repository-local NPM dependency closure', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-link-workspace-'))
|
||||
temporary.push(root)
|
||||
await mkdir(join(root, 'vendor', 'cordis'), { recursive: true })
|
||||
await mkdir(join(root, 'packages', 'sdk', 'scripts'), { recursive: true })
|
||||
await mkdir(join(root, 'packages', 'sdk', 'helper'), { recursive: true })
|
||||
await writeFile(join(root, 'vendor', 'cordis', 'package.json'), JSON.stringify({ name: 'cordis' }))
|
||||
await writeFile(join(root, 'packages', 'sdk', 'helper', 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-helper' }))
|
||||
await writeFile(join(root, 'packages', 'sdk', 'scripts', 'package.json'), JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-scripts', dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1' }, peerDependencies: { cordis: '^4' },
|
||||
}))
|
||||
const workspace = await LinkWorkspace.open(root)
|
||||
expect(workspace.closure(['@deepseek-ai/dsh-scripts'])).toEqual([
|
||||
'@deepseek-ai/dsh-helper', '@deepseek-ai/dsh-scripts', 'cordis',
|
||||
])
|
||||
const manifest = PackageJsonFile.create('{"name":"consumer","description":"test"}')
|
||||
manifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
|
||||
const pnpmWorkspace = PnpmWorkspaceFile.create()
|
||||
workspace.apply(join(root, 'consumer'), manifest, new PnpmPackageManager('10.0.0'), [pnpmWorkspace])
|
||||
expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/)
|
||||
expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false')
|
||||
expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis'))
|
||||
expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')
|
||||
expect(workspace.packageDirectory('missing')).toBeUndefined()
|
||||
const yarnManifest = PackageJsonFile.create('{"name":"consumer"}')
|
||||
yarnManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
|
||||
workspace.apply(join(root, 'consumer-yarn'), yarnManifest, new YarnPackageManager('4.0.0'), [])
|
||||
expect(yarnManifest.value().resolutions).toBeDefined()
|
||||
const pnpmManifest = PackageJsonFile.create('{"name":"consumer"}')
|
||||
pnpmManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1')
|
||||
expect(() => { workspace.apply(join(root, 'consumer-pnpm'), pnpmManifest, new PnpmPackageManager('10.0.0'), []) })
|
||||
.toThrow('requires pnpm-workspace.yaml')
|
||||
})
|
||||
|
||||
it('rejects malformed linked repositories', async () => {
|
||||
const missing = await mkdtemp(join(tmpdir(), 'dsh-link-missing-'))
|
||||
temporary.push(missing)
|
||||
await mkdir(join(missing, 'vendor'), { recursive: true })
|
||||
await mkdir(join(missing, 'packages'), { recursive: true })
|
||||
await expect(LinkWorkspace.open(missing)).rejects.toThrow('not a DeepSeek Harness repository root')
|
||||
const unreadable = await mkdtemp(join(tmpdir(), 'dsh-link-unreadable-'))
|
||||
temporary.push(unreadable)
|
||||
await mkdir(join(unreadable, 'vendor', 'bad'), { recursive: true })
|
||||
await mkdir(join(unreadable, 'packages'), { recursive: true })
|
||||
await expect(LinkWorkspace.open(unreadable)).rejects.toThrow('cannot read linked package')
|
||||
const unnamed = await mkdtemp(join(tmpdir(), 'dsh-link-unnamed-'))
|
||||
temporary.push(unnamed)
|
||||
await mkdir(join(unnamed, 'vendor', 'unnamed'), { recursive: true })
|
||||
await mkdir(join(unnamed, 'packages'), { recursive: true })
|
||||
await writeFile(join(unnamed, 'vendor', 'unnamed', 'package.json'), '{}')
|
||||
await expect(LinkWorkspace.open(unnamed)).rejects.toThrow('not a DeepSeek Harness repository root')
|
||||
const duplicate = await mkdtemp(join(tmpdir(), 'dsh-link-duplicate-'))
|
||||
temporary.push(duplicate)
|
||||
await mkdir(join(duplicate, 'vendor', 'one'), { recursive: true })
|
||||
await mkdir(join(duplicate, 'packages', 'group', 'two'), { recursive: true })
|
||||
await writeFile(join(duplicate, 'vendor', 'one', 'package.json'), '{"name":"duplicate"}')
|
||||
await writeFile(join(duplicate, 'packages', 'group', 'two', 'package.json'), '{"name":"duplicate"}')
|
||||
await expect(LinkWorkspace.open(duplicate)).rejects.toThrow('duplicate linked package name')
|
||||
})
|
||||
})
|
||||
95
packages/scaffold/helper/tests/headless-prompt-port.spec.ts
Normal file
95
packages/scaffold/helper/tests/headless-prompt-port.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts'
|
||||
|
||||
/** Unwrap an answered outcome or fail the test. */
|
||||
async function answered<T>(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise<T> {
|
||||
const outcome = await promise
|
||||
if (outcome.status !== 'answered') throw new Error('expected an answered outcome')
|
||||
return outcome.value
|
||||
}
|
||||
|
||||
describe('HeadlessPromptError', () => {
|
||||
it('names the unanswered prompt', () => {
|
||||
const error = new HeadlessPromptError('DeepSeek API key')
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect(error.name).toBe('HeadlessPromptError')
|
||||
expect(error.prompt).toBe('DeepSeek API key')
|
||||
expect(error.message).toContain('DeepSeek API key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HeadlessPromptPort', () => {
|
||||
const port = new HeadlessPromptPort()
|
||||
|
||||
describe('text', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent')
|
||||
})
|
||||
|
||||
it('falls back to the default value', async () => {
|
||||
expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent')
|
||||
})
|
||||
|
||||
it('prefers the initial value over the default value', async () => {
|
||||
expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given')
|
||||
})
|
||||
|
||||
it('fails loud when no default exists', async () => {
|
||||
await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
|
||||
it('fails loud when the default is invalid', async () => {
|
||||
await expect(port.text({
|
||||
message: 'name',
|
||||
defaultValue: '',
|
||||
validate: value => value.length === 0 ? 'required' : undefined,
|
||||
})).rejects.toThrow(/required/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('secret', () => {
|
||||
it('always fails loud', async () => {
|
||||
await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('select', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm')
|
||||
})
|
||||
|
||||
it('fails loud without an initial value', async () => {
|
||||
await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiselect', () => {
|
||||
it('returns the initial values', async () => {
|
||||
expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('returns an empty selection when none are supplied and none are required', async () => {
|
||||
expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([])
|
||||
})
|
||||
|
||||
it('fails loud when required and nothing is preselected', async () => {
|
||||
await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm', () => {
|
||||
it('takes the initial value when present', async () => {
|
||||
expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false)
|
||||
})
|
||||
|
||||
it('fails loud without an initial value', async () => {
|
||||
await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nestedMultiselect', () => {
|
||||
it('always fails loud', async () => {
|
||||
await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError)
|
||||
})
|
||||
})
|
||||
})
|
||||
999
packages/scaffold/helper/tests/project.spec.ts
Normal file
999
packages/scaffold/helper/tests/project.spec.ts
Normal file
@@ -0,0 +1,999 @@
|
||||
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
FeatureOption,
|
||||
ExclusiveOptionFeature,
|
||||
MultiOptionFeature,
|
||||
FixedFeature,
|
||||
type FeatureProjectView,
|
||||
} from '../src/features/feature.ts'
|
||||
import { createBuiltinRegistry } from '../src/features/builtin/index.ts'
|
||||
import {
|
||||
npmCordisConfigEntry,
|
||||
cordisConfigEntry,
|
||||
environment as environmentResource,
|
||||
optionalString,
|
||||
ownedTextFile,
|
||||
requiredString,
|
||||
stringArray,
|
||||
} from '../src/features/builtin/helpers.ts'
|
||||
import { defineFeatures, defineFeature } from '../src/features/define-feature.ts'
|
||||
import { FeatureRegistry } from '../src/features/registry.ts'
|
||||
import { ProjectContribution } from '../src/features/resources.ts'
|
||||
import type { CordisConfigEntryResource, ProjectResource } from '../src/features/resources.ts'
|
||||
import type { CordisConfigEntry } from '../src/documents/cordis-yaml-file.ts'
|
||||
import { PackageJsonFile } from '../src/documents/package-json-file.ts'
|
||||
import { TextProjectFile } from '../src/documents/project-file.ts'
|
||||
import { featureId, resourceKey } from '../src/ids.ts'
|
||||
import { NpmPackageManager } from '../src/package-managers/package-manager.ts'
|
||||
import { LocalPluginBlueprint } from '../src/plugins/local-plugin-blueprint.ts'
|
||||
import { SdkProject } from '../src/project/sdk-project.ts'
|
||||
import type {
|
||||
FeatureSelection,
|
||||
ProjectCreationRequest,
|
||||
ProjectProfile,
|
||||
} from '../src/project/types.ts'
|
||||
|
||||
const temporary: string[] = []
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
function selection(id: string, options: readonly string[], secrets?: Record<string, string>): FeatureSelection {
|
||||
return { id: featureId(id), options, ...secrets ? { secrets } : {} }
|
||||
}
|
||||
|
||||
function request(
|
||||
extra: readonly FeatureSelection[] = [],
|
||||
plugins: readonly LocalPluginBlueprint[] = [],
|
||||
app: 'acp' | 'embed' = 'embed',
|
||||
bash: 'local' | 'sandbox' = 'local',
|
||||
): ProjectCreationRequest {
|
||||
return {
|
||||
name: 'test-agent',
|
||||
description: 'test project',
|
||||
runtime: { model: 'deepseek-v4-flash' },
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
features: [
|
||||
selection('provider', ['deepseek-official'], { apiKey: 'test-key' }),
|
||||
selection('bash', [bash]),
|
||||
selection('app', [app]),
|
||||
selection('persistence', ['jsonl']),
|
||||
...extra,
|
||||
],
|
||||
localPlugins: plugins,
|
||||
}
|
||||
}
|
||||
|
||||
async function createCommitted(
|
||||
extra: readonly FeatureSelection[] = [],
|
||||
plugins: readonly LocalPluginBlueprint[] = [],
|
||||
): Promise<SdkProject> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-project-domain-'))
|
||||
temporary.push(root)
|
||||
const creation = request(extra, plugins)
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
for (const plugin of plugins) edit.addPlugin(plugin)
|
||||
return (await edit.commit()).project
|
||||
}
|
||||
|
||||
describe('SdkProject and ProjectEditSession', () => {
|
||||
it('derives existing-project profiles and tolerates malformed optional documents', async () => {
|
||||
const make = async (
|
||||
name: string,
|
||||
manifest: Record<string, unknown>,
|
||||
cordis: string,
|
||||
extras: Record<string, string> = {},
|
||||
): Promise<SdkProject> => {
|
||||
const root = await mkdtemp(join(tmpdir(), `${name}-`))
|
||||
temporary.push(root)
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify(manifest))
|
||||
await writeFile(join(root, 'cordis.yml'), cordis)
|
||||
for (const [path, text] of Object.entries(extras)) await writeFile(join(root, path), text)
|
||||
return SdkProject.open(root)
|
||||
}
|
||||
const acp = await make('dsh-open-acp', {
|
||||
name: 'acp', description: 'ACP', packageManager: 'pnpm@10.1.0',
|
||||
dependencies: { '@deepseek-ai/dsh-scripts': '^1.2.3' },
|
||||
}, `- id: acp
|
||||
name: '@deepseek-ai/dsh-acp'
|
||||
config: { model: app-model }
|
||||
`, { '.env': 'KEY=value\n', 'tsconfig.json': '{bad', 'pnpm-workspace.yaml': 'bad' })
|
||||
expect(acp.profile).toMatchObject({
|
||||
runInterface: 'acp', runtime: { model: 'app-model' }, releaseVersion: '1.2.3', description: 'ACP',
|
||||
})
|
||||
expect(acp.profile.packageManager.name).toBe('pnpm')
|
||||
expect(acp.readEnvironment('.env', 'KEY')).toBe('value')
|
||||
expect(() => acp.readEnvironment('.env.example', 'KEY')).not.toThrow()
|
||||
expect(acp.document('tsconfig.json')).toBeInstanceOf(TextProjectFile)
|
||||
await expect(make('dsh-open-tui', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
- id: tui
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
await expect(make('dsh-open-tui-subpath', {}, `- id: tui-prompt
|
||||
name: '@deepseek-ai/dsh-tui/prompt'
|
||||
`)).rejects.toThrow('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
|
||||
const embedded = await make('dsh-open-embed', {}, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [provider-model] }
|
||||
`, { 'yarn.lock': '' })
|
||||
expect(embedded.profile.runInterface).toBe('embed')
|
||||
expect(embedded.profile.runtime.model).toBe('provider-model')
|
||||
expect(embedded.profile.packageManager.name).toBe('yarn')
|
||||
expect(embedded.profile.name).toBe(embedded.root.split('/').at(-1))
|
||||
const pnpm = await make('dsh-open-pnpm', { name: 'pnpm' }, '[]\n', { 'pnpm-lock.yaml': '' })
|
||||
expect(pnpm.profile.packageManager.name).toBe('pnpm')
|
||||
const defaults = await make('dsh-open-default', { name: 'default', packageManager: 'npm@10.0.0' }, '[]\n')
|
||||
expect(defaults.profile).toMatchObject({
|
||||
runInterface: 'embed', runtime: { model: 'deepseek-v4-flash' }, releaseVersion: '0.0.1',
|
||||
})
|
||||
expect(() => SdkProject.create(defaults.root, { ...request(), features: [] })).toThrow('requires one app')
|
||||
await expect(make('dsh-open-invalid-manager', { name: 'bad', packageManager: 'bad' }, '[]\n'))
|
||||
.rejects.toThrow('invalid packageManager field')
|
||||
const providerFallback = await make('dsh-open-provider-fallback', { name: 'fallback' }, `- id: provider
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config: { models: [fallback-model] }
|
||||
`)
|
||||
expect(providerFallback.profile.runtime.model).toBe('fallback-model')
|
||||
const pnpmRequest = { ...request(), packageManager: new (await import('../src/package-managers/package-manager.ts')).PnpmPackageManager('10.0.0') }
|
||||
expect(SdkProject.create(join(defaults.root, 'pnpm'), pnpmRequest).hasDocument('pnpm-workspace.yaml')).toBe(true)
|
||||
})
|
||||
|
||||
it('commits a complete blueprint and round-trips every installed feature', async () => {
|
||||
const project = await createCommitted([
|
||||
selection('hmr', ['default']),
|
||||
selection('fs', ['local']),
|
||||
selection('todo', ['default']),
|
||||
selection('web', ['exa'], { apiKey: 'exa-key' }),
|
||||
selection('subagent', ['fork']),
|
||||
selection('workflow', ['workerthread']),
|
||||
selection('hooks', ['claude', 'codex']),
|
||||
], [new LocalPluginBlueprint('sample', 'plugin'), new LocalPluginBlueprint('lookup', 'tool')])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const inspections = registry.inspect(project)
|
||||
expect(inspections.filter(item => item.state === 'enabled').map(item => item.id)).toEqual([
|
||||
'provider', 'spine', 'bash', 'app', 'persistence', 'hmr', 'fs', 'todo', 'web', 'subagent', 'workflow', 'hooks',
|
||||
])
|
||||
expect(inspections.find(item => item.id === 'subagent')?.options).toEqual(['spawn', 'fork'])
|
||||
expect(project.cordisConfigEntries().map(entry => entry.id)).toContain('lookup')
|
||||
const index = await readFile(join(project.root, 'index.ts'), 'utf8')
|
||||
expect(index).toContain('SdkBootContext')
|
||||
expect(index).toContain('agents.create')
|
||||
expect(index).not.toContain('boot.args.resume')
|
||||
expect(index).not.toContain('AgentId')
|
||||
expect(index).toContain('SessionId(`main-session-${randomUUID()}`)')
|
||||
expect(project.packageManifest().scripts).toEqual({
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
build: 'dsh-sdk build',
|
||||
typecheck: 'tsc -b',
|
||||
start: 'dsh-sdk start index.js',
|
||||
config: 'dsh-sdk config',
|
||||
})
|
||||
expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=')
|
||||
expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(project.cordis.entry('session-invariant')?.name).toBe('@deepseek-ai/dsh-session/invariant')
|
||||
expect(project.cordis.entry('agent-invariant')?.name).toBe('@deepseek-ai/dsh-agent/invariant')
|
||||
expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant')
|
||||
expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant')
|
||||
expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}')
|
||||
expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2')
|
||||
expect(project.packageManifest().dependencies?.['@cordisjs/plugin-hmr']).toBe('^1.0.15')
|
||||
expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1')
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant')
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin')
|
||||
expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' })
|
||||
expect(project.cordis.entry('llm-deepseek')).not.toHaveProperty('config.apiKey')
|
||||
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL')
|
||||
expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models')
|
||||
})
|
||||
|
||||
it.each(['spawn', 'fork'] as const)('mounts Task controls for %s subagents', async (option) => {
|
||||
const project = await createCommitted([selection('subagent', [option])])
|
||||
expect(project.cordis.entry('tasks')?.name).toBe('@deepseek-ai/dsh-tasks-local')
|
||||
expect(project.cordis.entry('tool-tasks')?.name).toBe('@deepseek-ai/dsh-tool-tasks')
|
||||
expect(project.packageManifest().dependencies).toMatchObject({
|
||||
'@deepseek-ai/dsh-tasks-local': '^0.0.1',
|
||||
'@deepseek-ai/dsh-tool-tasks': '^0.0.1',
|
||||
})
|
||||
expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-tasks')
|
||||
})
|
||||
|
||||
it('round-trips embed app projects without a front-door Cordis config entry', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-embed-app-'))
|
||||
temporary.push(root)
|
||||
const creation = request([], [], 'embed')
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
const committed = (await edit.commit()).project
|
||||
const app = createBuiltinRegistry(committed.profile).get(featureId('app')).inspect(committed)
|
||||
expect(app).toMatchObject({ state: 'enabled', options: ['embed'] })
|
||||
expect(app.selection).toEqual(selection('app', ['embed']))
|
||||
expect(committed.cordis.entry('agent-loop')?.config).toEqual({ agents: [] })
|
||||
expect(committed.cordis.entry('acp')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emits the sandbox workspace-write example as inactive Cordis config', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-sandbox-bash-'))
|
||||
temporary.push(root)
|
||||
const creation = request([], [], 'embed', 'sandbox')
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
await edit.commit()
|
||||
const cordis = await readFile(join(root, 'cordis.yml'), 'utf8')
|
||||
expect(cordis).toContain(`- id: bash
|
||||
name: "@deepseek-ai/dsh-bash-sandbox"
|
||||
# Uncomment to allow writes under the project workspace.
|
||||
# config:
|
||||
# mode: workspace-write
|
||||
# workspaceRoot: !!js process.cwd()`)
|
||||
})
|
||||
|
||||
it('round-trips the custom pi-ai provider with explicit endpoint and default model', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-custom-provider-'))
|
||||
temporary.push(root)
|
||||
const base = request()
|
||||
const creation: ProjectCreationRequest = {
|
||||
...base,
|
||||
features: [
|
||||
{
|
||||
id: featureId('provider'),
|
||||
options: ['custom'],
|
||||
values: { baseURL: 'https://custom.example/v1' },
|
||||
secrets: { apiKey: 'custom-key' },
|
||||
},
|
||||
...base.features.filter(item => item.id !== 'provider'),
|
||||
],
|
||||
}
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
const committed = (await edit.commit()).project
|
||||
expect(committed.cordis.entry('llm-pi-ai')).toMatchObject({
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai',
|
||||
config: { baseURL: 'https://custom.example/v1' },
|
||||
})
|
||||
expect(committed.cordis.entry('llm-pi-ai')?.config).not.toHaveProperty('models')
|
||||
expect(createBuiltinRegistry(committed.profile).get(featureId('provider')).inspect(committed)).toMatchObject({
|
||||
state: 'enabled', options: ['custom'],
|
||||
})
|
||||
})
|
||||
|
||||
it('switches exclusive options and refuses disabling a required feature', async () => {
|
||||
const project = await createCommitted([selection('subagent', ['spawn']), selection('workflow', ['workerthread'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
const persistence = registry.get(featureId('persistence'))
|
||||
edit.configureFeature(persistence, selection('persistence', ['sqlite']))
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('subagent'))) }).toThrow('required by workflow')
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('app'))) }).toThrow('required feature')
|
||||
const committed = await edit.commit()
|
||||
expect(committed.project.cordis.entry('session-persistence')?.name).toContain('sqlite')
|
||||
expect(committed.changes.npmDependenciesChanged).toBe(true)
|
||||
})
|
||||
|
||||
it('switches app-owned files and scripts while protecting user edits', async () => {
|
||||
const project = await createCommitted()
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
|
||||
const acp = (await edit.commit()).project
|
||||
expect(acp.profile.runInterface).toBe('acp')
|
||||
expect(acp.cordis.entry('commands')).toBeUndefined()
|
||||
expect(acp.cordis.entry('user-interaction')).toBeUndefined()
|
||||
expect(acp.packageManifest().scripts).toMatchObject({
|
||||
dev: 'dsh-sdk dev index.ts',
|
||||
start: 'dsh-sdk start index.js',
|
||||
})
|
||||
expect(await readFile(join(acp.root, 'README.md'), 'utf8')).toContain('Run as an ACP automation server')
|
||||
expect(await readFile(join(acp.root, 'index.ts'), 'utf8')).not.toContain('agents.create')
|
||||
|
||||
const acpRegistry = createBuiltinRegistry(acp.profile)
|
||||
const embedEdit = acp.edit(acpRegistry)
|
||||
embedEdit.configureFeature(acpRegistry.get(featureId('app')), selection('app', ['embed']))
|
||||
const embed = (await embedEdit.commit()).project
|
||||
expect(embed.profile.runInterface).toBe('embed')
|
||||
expect(await readFile(join(embed.root, 'README.md'), 'utf8')).toContain('Embed the harness')
|
||||
const embedIndex = await readFile(join(embed.root, 'index.ts'), 'utf8')
|
||||
expect(embedIndex).toContain('agents.create')
|
||||
expect(embedIndex).toContain("import { SessionId } from '@deepseek-ai/dsh-session'")
|
||||
expect(embedIndex).not.toContain('AgentId')
|
||||
|
||||
await writeFile(join(embed.root, 'README.md'), '# Custom README\n')
|
||||
const modified = await SdkProject.open(embed.root)
|
||||
const modifiedRegistry = createBuiltinRegistry(modified.profile)
|
||||
expect(() => { modified.edit(modifiedRegistry).configureFeature(
|
||||
modifiedRegistry.get(featureId('app')),
|
||||
selection('app', ['acp']),
|
||||
) }).toThrow('feature-owned file was modified: README.md')
|
||||
|
||||
const manifest = PackageJsonFile.parse(await readFile(join(embed.root, 'package.json'), 'utf8'))
|
||||
manifest.removeScript('dev')
|
||||
await writeFile(join(embed.root, 'package.json'), manifest.serialize())
|
||||
const incomplete = await SdkProject.open(embed.root)
|
||||
expect(createBuiltinRegistry(incomplete.profile).get(featureId('app')).inspect(incomplete).diagnostics)
|
||||
.toContain('missing package.json script dev')
|
||||
})
|
||||
|
||||
it('supports disabled feature reconfiguration and rejects invalid state operations', async () => {
|
||||
const project = await createCommitted([selection('todo', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
expect(edit.inspections()).not.toHaveLength(0)
|
||||
const todo = registry.get(featureId('todo'))
|
||||
edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek-official']))
|
||||
edit.disableFeature(todo)
|
||||
edit.configureFeature(todo, selection('todo', ['default']))
|
||||
edit.enableFeature(todo)
|
||||
expect(() => { edit.enableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed')
|
||||
expect(() => { edit.disableFeature(registry.get(featureId('workflow'))) }).toThrow('not installed')
|
||||
expect(() => { edit.setCustomPluginDisabled('missing', true) }).toThrow('does not exist')
|
||||
const committed = await edit.commit()
|
||||
expect(committed.changes.enabledFeatures).toContain('todo')
|
||||
expect(() => { edit.enableFeature(todo) }).toThrow('already committed')
|
||||
})
|
||||
|
||||
it('preserves custom entries and toggles only their Loader disabled state', async () => {
|
||||
const project = await createCommitted([], [new LocalPluginBlueprint('sample', 'plugin')])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.setCustomPluginDisabled('sample', true)
|
||||
expect(edit.cordisConfigEntries().find(entry => entry.id === 'sample')?.disabled).toBe(true)
|
||||
expect(() => { edit.setCustomPluginDisabled('agent-loop', true) }).toThrow('builtin feature')
|
||||
const next = (await edit.commit()).project
|
||||
const enable = next.edit(createBuiltinRegistry(next.profile))
|
||||
enable.setCustomPluginDisabled('sample', false)
|
||||
expect((await enable.commit()).project.cordis.entry('sample')?.disabled).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects local plugin collisions and invalid optional document shapes', async () => {
|
||||
const project = await createCommitted([], [new LocalPluginBlueprint('sample', 'plugin')])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const npmDependencyConflict = project.edit(registry)
|
||||
expect(() => { npmDependencyConflict.addPlugin(new LocalPluginBlueprint('sample', 'plugin')) })
|
||||
.toThrow('NPM dependency already exists')
|
||||
await writeFile(join(project.root, 'tsconfig.json'), 'not-json\n')
|
||||
const malformed = await SdkProject.open(project.root)
|
||||
expect(() => { malformed.edit(createBuiltinRegistry(malformed.profile)).addPlugin(
|
||||
new LocalPluginBlueprint('other', 'plugin'),
|
||||
) }).toThrow('requires a valid tsconfig')
|
||||
const entryProject = await createCommitted()
|
||||
const entryEdit = entryProject.edit(createBuiltinRegistry(entryProject.profile))
|
||||
;(entryEdit as unknown as { cordis(): { addEntry(entry: CordisConfigEntry): void } }).cordis()
|
||||
.addEntry({ id: 'sample', name: 'manual' })
|
||||
expect(() => { entryEdit.addPlugin(new LocalPluginBlueprint('sample', 'plugin')) }).toThrow('entry already exists')
|
||||
const fileEdit = entryProject.edit(createBuiltinRegistry(entryProject.profile))
|
||||
;(fileEdit as unknown as { documents: Map<string, TextProjectFile> }).documents
|
||||
.set('plugins/other/package.json', new TextProjectFile('plugins/other/package.json', '{}'))
|
||||
expect(() => { fileEdit.addPlugin(new LocalPluginBlueprint('other', 'plugin')) }).toThrow('file already exists')
|
||||
})
|
||||
|
||||
it('reinstalls existing/disabled features and detects requirement cycles', async () => {
|
||||
const project = await createCommitted([selection('todo', ['default']), selection('subagent', ['spawn'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
const todo = registry.get(featureId('todo'))
|
||||
edit.installFeature(todo, selection('todo', ['default']))
|
||||
edit.disableFeature(todo)
|
||||
edit.installFeature(todo, selection('todo', ['default']))
|
||||
const subagent = registry.get(featureId('subagent'))
|
||||
edit.disableFeature(subagent)
|
||||
edit.installFeature(registry.get(featureId('workflow')), selection('workflow', ['workerthread']))
|
||||
expect(edit.cordisConfigEntries().find(entry => entry.id === 'subagent-spawn')?.disabled).toBeUndefined()
|
||||
|
||||
class Cyclic extends FixedFeature {
|
||||
override readonly summary = 'cyclic'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
override readonly id
|
||||
override readonly requires
|
||||
constructor(id: string, required: string) {
|
||||
super()
|
||||
this.id = featureId(id)
|
||||
this.requires = [featureId(required)]
|
||||
}
|
||||
}
|
||||
const one = new Cyclic('cycle-one', 'cycle-two')
|
||||
const two = new Cyclic('cycle-two', 'cycle-one')
|
||||
const cycleRegistry = new FeatureRegistry([one, two], project.profile)
|
||||
expect(() => { project.edit(cycleRegistry).installFeature(one, selection('cycle-one', ['one'])) })
|
||||
.toThrow('cyclic feature requirement')
|
||||
})
|
||||
|
||||
it('removes clean owned files and detects files disappearing before commit', async () => {
|
||||
const project = await createCommitted([selection('hooks', ['claude', 'codex']), selection('todo', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const remove = project.edit(registry)
|
||||
remove.configureFeature(registry.get(featureId('hooks')), selection('hooks', ['codex']))
|
||||
const committed = await remove.commit()
|
||||
expect(committed.changes.changedFiles).toContain('hooks.json')
|
||||
const edit = committed.project.edit(createBuiltinRegistry(committed.project.profile))
|
||||
edit.disableFeature(createBuiltinRegistry(committed.project.profile).get(featureId('todo')))
|
||||
await rm(join(committed.project.root, 'cordis.yml'))
|
||||
await expect(edit.commit()).rejects.toThrow('cannot verify project file cordis.yml')
|
||||
})
|
||||
|
||||
it('guards internal resource collisions and malformed aggregate documents', async () => {
|
||||
const project = await createCommitted()
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
type Internals = {
|
||||
documents: Map<string, TextProjectFile>
|
||||
states: Map<ReturnType<typeof featureId>, unknown>
|
||||
applyResource(resource: ProjectResource, previous: ProjectResource | undefined): void
|
||||
removeResource(resource: ProjectResource): void
|
||||
replaceContribution(previous: ProjectContribution | undefined, next: ProjectContribution): void
|
||||
finalProfile(): ProjectProfile
|
||||
manifest(): unknown
|
||||
cordis(): unknown
|
||||
environment(path: '.env' | '.env.example'): unknown
|
||||
state(feature: FixedFeature): unknown
|
||||
}
|
||||
const internals = edit as unknown as Internals
|
||||
const collidingEntry: ProjectResource = {
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:agent-loop'),
|
||||
entry: { id: 'agent-loop', name: 'other-package' }, ownedConfigKeys: [],
|
||||
}
|
||||
expect(() => { internals.applyResource(collidingEntry, undefined) }).toThrow('is owned by')
|
||||
const existingFile: ProjectResource = {
|
||||
kind: 'owned-file', key: resourceKey('file:tsconfig.json'),
|
||||
document: new TextProjectFile('tsconfig.json', 'replacement'), removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
expect(() => { internals.applyResource(existingFile, undefined) }).toThrow('already exists')
|
||||
internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'old'))
|
||||
const previousFile: ProjectResource = {
|
||||
...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'old'),
|
||||
}
|
||||
const nextFile: ProjectResource = {
|
||||
...existingFile, key: resourceKey('file:owned.txt'), document: new TextProjectFile('owned.txt', 'replacement'),
|
||||
}
|
||||
internals.applyResource(nextFile, previousFile)
|
||||
expect(internals.documents.get('owned.txt')?.serialize()).toBe('replacement\n')
|
||||
internals.documents.set('owned.txt', new TextProjectFile('owned.txt', 'user edit'))
|
||||
expect(() => { internals.applyResource(nextFile, previousFile) }).toThrow('was modified')
|
||||
const existingScript: ProjectResource = {
|
||||
kind: 'package-script', key: resourceKey('package-script:build'),
|
||||
name: 'build', command: 'other build', removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
expect(() => { internals.applyResource(existingScript, undefined) }).toThrow('script already exists')
|
||||
const transientScript: ProjectResource = {
|
||||
kind: 'package-script', key: resourceKey('package-script:transient'),
|
||||
name: 'transient', command: 'first', removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
internals.applyResource(transientScript, undefined)
|
||||
const nextScript: ProjectResource = { ...transientScript, command: 'second' }
|
||||
internals.applyResource(nextScript, transientScript)
|
||||
internals.applyResource(nextScript, transientScript)
|
||||
;(internals.manifest() as PackageJsonFile).setScript('transient', 'user edit')
|
||||
expect(() => { internals.applyResource(transientScript, nextScript) }).toThrow('script was modified')
|
||||
expect(() => { internals.removeResource(nextScript) }).toThrow('script was modified')
|
||||
;(internals.manifest() as PackageJsonFile).setScript('transient', 'second')
|
||||
internals.removeResource(nextScript)
|
||||
expect(() => { internals.removeResource(nextScript) }).toThrow('script is missing')
|
||||
expect(() => { internals.removeResource({
|
||||
...existingFile, key: resourceKey('file:missing.txt'), document: new TextProjectFile('missing.txt', 'missing'),
|
||||
}) }).toThrow('owned file is missing')
|
||||
expect(() => { internals.removeResource({
|
||||
kind: 'cordis-config-entry', key: resourceKey('cordis-config-entry:missing'),
|
||||
entry: { id: 'missing', name: 'missing' }, ownedConfigKeys: [],
|
||||
}) }).toThrow('cannot confirm old Cordis resource')
|
||||
const transient: ProjectResource = {
|
||||
kind: 'owned-file', key: resourceKey('file:transient.txt'),
|
||||
document: new TextProjectFile('transient.txt', 'transient'), removeOnlyWhenUnchanged: true,
|
||||
}
|
||||
internals.applyResource(transient, undefined)
|
||||
internals.removeResource(transient)
|
||||
internals.replaceContribution(
|
||||
new ProjectContribution([{ kind: 'npm-dependency', key: resourceKey('shared'), name: 'cordis', section: 'dependencies' }]),
|
||||
new ProjectContribution([{
|
||||
kind: 'cordis-config-entry', key: resourceKey('shared'), entry: { id: 'new', name: 'new' }, ownedConfigKeys: [],
|
||||
}]),
|
||||
)
|
||||
internals.replaceContribution(
|
||||
new ProjectContribution([{
|
||||
kind: 'environment', key: resourceKey('environment:SAME'), name: 'SAME', value: 'old', exampleValue: '',
|
||||
}]),
|
||||
new ProjectContribution([{
|
||||
kind: 'environment', key: resourceKey('environment:SAME'), name: 'SAME', value: 'new', exampleValue: '',
|
||||
}]),
|
||||
)
|
||||
internals.documents.set('.env', new TextProjectFile('.env', 'bad'))
|
||||
expect(() => edit.readEnvironment('.env', 'KEY')).toThrow('not an environment document')
|
||||
expect(() => { internals.environment('.env') }).toThrow('not an environment document')
|
||||
internals.documents.delete('package.json')
|
||||
expect(() => { internals.manifest() }).toThrow('package.json is missing')
|
||||
internals.documents.delete('cordis.yml')
|
||||
expect(() => { internals.cordis() }).toThrow('cordis.yml is missing')
|
||||
class Foreign extends FixedFeature {
|
||||
override readonly id = featureId('foreign')
|
||||
override readonly summary = 'foreign'
|
||||
override readonly options = []
|
||||
}
|
||||
expect(() => { internals.state(new Foreign()) }).toThrow('not applicable')
|
||||
internals.states.delete(featureId('app'))
|
||||
expect(internals.finalProfile()).toBe(project.profile)
|
||||
const sourceDocuments = (project as unknown as { documents: Map<string, TextProjectFile> }).documents
|
||||
sourceDocuments.set('.env', new TextProjectFile('.env', 'bad'))
|
||||
expect(() => project.readEnvironment('.env', 'KEY')).toThrow('not an environment document')
|
||||
sourceDocuments.delete('package.json')
|
||||
expect(() => project.packageJson).toThrow('package.json is missing or invalid')
|
||||
sourceDocuments.delete('cordis.yml')
|
||||
expect(() => project.cordis).toThrow('cordis.yml is missing or invalid')
|
||||
})
|
||||
|
||||
it('rejects external edits before writing any affected file', async () => {
|
||||
const project = await createCommitted([selection('todo', ['default'])])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.disableFeature(registry.get(featureId('todo')))
|
||||
const manifestBefore = await readFile(join(project.root, 'package.json'), 'utf8')
|
||||
await writeFile(join(project.root, 'cordis.yml'), '# external\n[]\n')
|
||||
await expect(edit.commit()).rejects.toThrow('changed outside this edit session')
|
||||
expect(await readFile(join(project.root, 'package.json'), 'utf8')).toBe(manifestBefore)
|
||||
})
|
||||
|
||||
it('rejects a create target file that appeared after the edit session opened', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-create-conflict-'))
|
||||
temporary.push(root)
|
||||
const creation = request()
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
await writeFile(join(root, 'README.md'), 'external\n')
|
||||
await expect(edit.commit()).rejects.toThrow('changed outside this edit session: README.md')
|
||||
})
|
||||
|
||||
it('uses Cordis config entries as the installation anchor and rejects partial resources', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-inconsistent-'))
|
||||
temporary.push(root)
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({
|
||||
name: 'partial', dependencies: { '@deepseek-ai/dsh-llm-deepseek': '^0.0.1' },
|
||||
}))
|
||||
await writeFile(join(root, 'cordis.yml'), '[]\n')
|
||||
const project = await SdkProject.open(root)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
expect(registry.get(featureId('provider')).inspect(project)).toMatchObject({
|
||||
state: 'absent', diagnostics: [],
|
||||
})
|
||||
|
||||
const partialRoot = await mkdtemp(join(tmpdir(), 'dsh-entry-partial-'))
|
||||
temporary.push(partialRoot)
|
||||
await writeFile(join(partialRoot, 'package.json'), JSON.stringify({ name: 'partial-entry' }))
|
||||
await writeFile(join(partialRoot, 'cordis.yml'), `- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKeyEnv: DEEPSEEK_API_KEY
|
||||
`)
|
||||
const partial = await SdkProject.open(partialRoot)
|
||||
const installation = createBuiltinRegistry(partial.profile)
|
||||
.get(featureId('provider')).inspect(partial)
|
||||
expect(installation.state).toBe('inconsistent')
|
||||
expect(installation.diagnostics).toContain('missing package.json dependencies entry @deepseek-ai/dsh-llm-deepseek')
|
||||
const partialEdit = partial.edit(createBuiltinRegistry(partial.profile))
|
||||
const provider = createBuiltinRegistry(partial.profile).get(featureId('provider'))
|
||||
expect(() => { partialEdit.configureFeature(provider, selection('provider', ['deepseek-official'])) }).toThrow('inconsistent')
|
||||
expect(() => { partialEdit.enableFeature(provider) }).toThrow('inconsistent')
|
||||
expect(() => { partialEdit.disableFeature(provider) }).toThrow('required feature')
|
||||
})
|
||||
|
||||
it('rejects inconsistent optional features and incompatible requirement options', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-optional-inconsistent-'))
|
||||
temporary.push(root)
|
||||
await writeFile(join(root, 'package.json'), '{"name":"partial"}')
|
||||
await writeFile(join(root, 'cordis.yml'), `- id: web-search-exa
|
||||
name: '@deepseek-ai/dsh-web-search-exa'
|
||||
`)
|
||||
const project = await SdkProject.open(root)
|
||||
const builtin = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(builtin)
|
||||
const web = builtin.get(featureId('web'))
|
||||
expect(() => { edit.disableFeature(web) }).toThrow('inconsistent')
|
||||
expect(() => { edit.installFeature(web, selection('web', ['deepseek-official'])) }).toThrow('inconsistent')
|
||||
|
||||
class RequiresWeb extends FixedFeature {
|
||||
override readonly id = featureId('requires-web')
|
||||
override readonly summary = 'requires web'
|
||||
override readonly requires = [featureId('web')]
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
}
|
||||
const requiresWeb = new RequiresWeb()
|
||||
const webRegistry = new FeatureRegistry([web, requiresWeb], project.profile)
|
||||
expect(() => { project.edit(webRegistry).installFeature(requiresWeb, selection('requires-web', ['one'])) })
|
||||
.toThrow('required feature web is inconsistent')
|
||||
|
||||
class RequiresAcp extends FixedFeature {
|
||||
override readonly id = featureId('requires-acp')
|
||||
override readonly summary = 'requires acp'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
override requirements(): readonly [{ id: ReturnType<typeof featureId>; options: readonly string[] }] {
|
||||
return [{ id: featureId('app'), options: ['acp'] }]
|
||||
}
|
||||
}
|
||||
const requiring = new RequiresAcp()
|
||||
const complete = await createCommitted()
|
||||
const app = createBuiltinRegistry(complete.profile).get(featureId('app'))
|
||||
const registry = new FeatureRegistry([app, requiring], complete.profile)
|
||||
expect(() => { complete.edit(registry).installFeature(requiring, selection('requires-acp', ['one'])) })
|
||||
.toThrow('does not satisfy the option requirement')
|
||||
})
|
||||
|
||||
it('removes obsolete environment resources when switching options', async () => {
|
||||
const project = await createCommitted([selection('web', ['exa'], { apiKey: 'exa' })])
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
edit.configureFeature(registry.get(featureId('web')), selection('web', ['deepseek-official']))
|
||||
expect(edit.readEnvironment('.env.example', 'EXA_API_KEY')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves duplicate and existing .env values while appending differently named secrets', async () => {
|
||||
const project = await createCommitted()
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(join(project.root, '.env'))).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
const original = '# keep\nDEEPSEEK_API_KEY=first\nDEEPSEEK_API_KEY=second\n'
|
||||
await writeFile(join(project.root, '.env'), original)
|
||||
if (process.platform !== 'win32') await chmod(join(project.root, '.env'), 0o640)
|
||||
const reopened = await SdkProject.open(project.root)
|
||||
const registry = createBuiltinRegistry(reopened.profile)
|
||||
expect(registry.get(featureId('provider')).inspect(reopened)).toMatchObject({
|
||||
state: 'enabled', selection: { secrets: { apiKey: 'second' } },
|
||||
})
|
||||
const edit = reopened.edit(registry)
|
||||
edit.configureFeature(
|
||||
registry.get(featureId('provider')),
|
||||
selection('provider', ['deepseek-official'], { apiKey: 'replacement' }),
|
||||
)
|
||||
edit.installFeature(registry.get(featureId('web')), selection('web', ['exa'], { apiKey: 'exa-key' }))
|
||||
const withExa = (await edit.commit()).project
|
||||
expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`)
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await stat(join(withExa.root, '.env'))).mode & 0o777).toBe(0o640)
|
||||
}
|
||||
const nextRegistry = createBuiltinRegistry(withExa.profile)
|
||||
const remove = withExa.edit(nextRegistry)
|
||||
remove.configureFeature(nextRegistry.get(featureId('web')), selection('web', ['deepseek-official']))
|
||||
await remove.commit()
|
||||
expect(await readFile(join(withExa.root, '.env'), 'utf8')).toBe(`${original}EXA_API_KEY=exa-key\n`)
|
||||
})
|
||||
|
||||
it('refuses to remove a feature-owned file after user edits', async () => {
|
||||
const project = await createCommitted([selection('hooks', ['claude', 'codex'])])
|
||||
await writeFile(join(project.root, 'hooks.json'), '{"hooks":{}}\n')
|
||||
const reopened = await SdkProject.open(project.root)
|
||||
const registry = createBuiltinRegistry(reopened.profile)
|
||||
const edit = reopened.edit(registry)
|
||||
expect(() => { edit.configureFeature(
|
||||
registry.get(featureId('hooks')),
|
||||
selection('hooks', ['codex']),
|
||||
) }).toThrow('owned file was modified: hooks.json')
|
||||
})
|
||||
|
||||
it('does not mistake a linked NPM dependency closure for an installed feature', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-link-closure-inspection-'))
|
||||
temporary.push(root)
|
||||
const base = request([selection('hooks', ['claude'])])
|
||||
const creation: ProjectCreationRequest = { ...base, linkWorkspaceRoot: repoRoot }
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
const committed = (await edit.commit()).project
|
||||
expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/)
|
||||
expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent')
|
||||
})
|
||||
|
||||
it('mounts an external plugin dependency and rejects missing deps or duplicate entries', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-external-plugin-'))
|
||||
temporary.push(root)
|
||||
const creation = request()
|
||||
const project = SdkProject.create(root, creation)
|
||||
const registry = createBuiltinRegistry(project.profile)
|
||||
const edit = project.edit(registry)
|
||||
for (const item of creation.features) edit.installFeature(registry.get(item.id), item)
|
||||
await edit.commit()
|
||||
const manifestPath = join(root, 'package.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { dependencies?: Record<string, string> }
|
||||
manifest.dependencies = { ...manifest.dependencies, 'ext-plugin': 'github:o/r#sha' }
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
|
||||
const reopened = await SdkProject.open(root)
|
||||
const edit2 = reopened.edit(createBuiltinRegistry(reopened.profile))
|
||||
edit2.addExternalPlugin('ext-plugin', 'ext-plugin')
|
||||
expect(() => { edit2.addExternalPlugin('ext-plugin', 'ext-plugin') }).toThrow('already exists')
|
||||
expect(() => { edit2.addExternalPlugin('missing', 'not-a-dep') }).toThrow('not installed')
|
||||
const commit = await edit2.commit()
|
||||
expect(commit.project.cordis.entry('ext-plugin')?.name).toBe('ext-plugin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extension points', () => {
|
||||
const profile: ProjectProfile = {
|
||||
name: 'test', description: 'test', runtime: { model: 'm' }, runInterface: 'embed',
|
||||
packageManager: new NpmPackageManager('10.0.0'), releaseVersion: '0.0.1',
|
||||
}
|
||||
|
||||
it('rejects cross-feature resource ownership conflicts at registry construction', () => {
|
||||
class TestOption extends FeatureOption {
|
||||
override readonly id = 'default'
|
||||
override readonly label = 'Default'
|
||||
override contribution(): ProjectContribution {
|
||||
return new ProjectContribution([{
|
||||
kind: 'npm-dependency', key: resourceKey('npm-dependency:shared'), name: 'shared', section: 'dependencies',
|
||||
}])
|
||||
}
|
||||
}
|
||||
class TestFeature extends FixedFeature {
|
||||
override readonly id
|
||||
override readonly summary = 'test'
|
||||
override readonly options = [new TestOption()]
|
||||
constructor(id: string) {
|
||||
super()
|
||||
this.id = featureId(id)
|
||||
}
|
||||
}
|
||||
expect(() => new FeatureRegistry([
|
||||
new TestFeature('one'), new TestFeature('two'),
|
||||
], profile)).toThrow('declared by both one and two')
|
||||
expect(new TestFeature('one').defaultOptions()).toEqual(['default'])
|
||||
expect(() => new FeatureRegistry([
|
||||
new TestFeature('one'), new TestFeature('one'),
|
||||
], profile)).toThrow('duplicate feature id')
|
||||
})
|
||||
|
||||
it('validates selection modes and declarative feature definitions', () => {
|
||||
const option = { id: 'one', label: 'One', default: true, resources: [] } as const
|
||||
expect(() => defineFeature({ id: 'bad-single', summary: 'bad', mode: 'single', options: [] }))
|
||||
.toThrow('requires one default option')
|
||||
expect(() => defineFeature({
|
||||
id: 'bad-exclusive', summary: 'bad', mode: 'exclusive', options: [{ ...option, default: false }],
|
||||
})).toThrow('exactly one default option')
|
||||
expect(() => defineFeature({
|
||||
id: 'bad-multiple', summary: 'bad', mode: 'multiple', options: [{ ...option, default: false }],
|
||||
})).toThrow('at least one default option')
|
||||
const exclusive = defineFeature({
|
||||
id: 'defined', summary: 'Defined', mode: 'exclusive', supportedInterfaces: ['embed'],
|
||||
requires: [{ id: 'base' }], suggests: ['suggested'],
|
||||
baseResources: [{ kind: 'npm-dependency', name: 'base', section: 'devDependencies' }],
|
||||
options: [
|
||||
{
|
||||
id: 'one', label: 'One', default: true,
|
||||
requires: [{ id: 'option', options: ['required'] }],
|
||||
secrets: [{ id: 'token', environment: 'TOKEN', message: 'Token', required: true }],
|
||||
resources: [
|
||||
{
|
||||
kind: 'npm-cordis-config-entry', id: 'one', package: 'one-package',
|
||||
config: { nested: { value: 1 }, list: ['x'], nullable: null },
|
||||
},
|
||||
{ kind: 'owned-file', path: 'one.txt', text: 'one', removeOnlyWhenUnchanged: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'two', label: 'Two', resources: [
|
||||
{ kind: 'file-cordis-config-entry', id: 'two', path: './two.ts' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(exclusive.defaultOptions(profile)).toEqual(['one'])
|
||||
expect(exclusive.isApplicable(profile)).toBe(true)
|
||||
expect(exclusive.isApplicable({ ...profile, runInterface: 'acp' })).toBe(false)
|
||||
expect(exclusive.requirements(selection('defined', ['one']))).toEqual([
|
||||
{ id: 'base' }, { id: 'option', options: ['required'] },
|
||||
])
|
||||
const contribution = exclusive.contribution({
|
||||
id: featureId('defined'), options: ['one'], secrets: { token: 'secret' },
|
||||
}, profile)
|
||||
expect(contribution.resources.map(resource => resource.kind)).toEqual([
|
||||
'npm-dependency', 'npm-dependency', 'cordis-config-entry', 'owned-file', 'environment',
|
||||
])
|
||||
const entry = contribution.resources.find(resource => resource.kind === 'cordis-config-entry')
|
||||
expect(entry?.validateConfig?.({ nested: { value: 2 }, list: ['a', 'b'], nullable: null })).toEqual([])
|
||||
expect(entry?.validateConfig?.({ nested: [], list: 'bad' })).toHaveLength(3)
|
||||
expect(() => exclusive.normalizeSelection(selection('other', ['one']), profile)).toThrow('does not belong')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one']), { ...profile, runInterface: 'acp' }))
|
||||
.toThrow('not available')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['missing']), profile)).toThrow('unknown')
|
||||
expect(() => exclusive.normalizeSelection(selection('defined', ['one', 'two']), profile)).toThrow('exactly one')
|
||||
expect(defineFeatures([exclusive, {
|
||||
id: 'fixed', summary: 'Fixed', mode: 'single', options: [option],
|
||||
}])).toHaveLength(2)
|
||||
expect(() => new FeatureRegistry([], profile).get(featureId('missing'))).toThrow('unknown feature')
|
||||
expect(new FeatureRegistry([exclusive], profile).ownerOfPackage('one-package', { ...profile, runInterface: 'acp' }))
|
||||
.toBeUndefined()
|
||||
class Unsupported extends FixedFeature {
|
||||
override readonly id = featureId('unsupported')
|
||||
override readonly summary = 'unsupported'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
override readonly supportedInterfaces = []
|
||||
}
|
||||
expect(() => new FeatureRegistry([new Unsupported()], profile)).toThrow('supports no run interface')
|
||||
})
|
||||
|
||||
it('covers feature base classes and resource conflict checks', () => {
|
||||
class EmptySimple extends FixedFeature {
|
||||
override readonly id = featureId('empty')
|
||||
override readonly summary = 'empty'
|
||||
override readonly options = []
|
||||
}
|
||||
expect(() => new EmptySimple().defaultOptions()).toThrow('has no option')
|
||||
class BadSimple extends FixedFeature {
|
||||
override readonly id = featureId('bad-simple')
|
||||
override readonly summary = 'bad'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})(), new (class extends FeatureOption {
|
||||
override readonly id = 'two'
|
||||
override readonly label = 'Two'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
}
|
||||
expect(() => new BadSimple().normalizeSelection(selection('bad-simple', ['one']), profile)).toThrow('one fixed option')
|
||||
class EmptyMulti extends MultiOptionFeature {
|
||||
override readonly id = featureId('multi')
|
||||
override readonly summary = 'multi'
|
||||
override readonly options = []
|
||||
override defaultOptions(): readonly string[] { return [] }
|
||||
}
|
||||
expect(() => new EmptyMulti().normalizeSelection(selection('multi', []), profile)).toThrow('at least one')
|
||||
class EmptyExclusive extends ExclusiveOptionFeature {
|
||||
override readonly id = featureId('exclusive')
|
||||
override readonly summary = 'exclusive'
|
||||
override readonly options = []
|
||||
override defaultOptions(): readonly string[] { return [] }
|
||||
}
|
||||
expect(() => new EmptyExclusive().normalizeSelection(selection('exclusive', []), profile)).toThrow('exactly one')
|
||||
const resource = {
|
||||
kind: 'npm-dependency' as const, key: resourceKey('same'), name: 'one', section: 'dependencies' as const,
|
||||
}
|
||||
expect(() => new ProjectContribution([resource, resource])).toThrow('duplicate contribution')
|
||||
expect(() => ProjectContribution.merge(
|
||||
new ProjectContribution([resource]),
|
||||
new ProjectContribution([{ ...resource, name: 'two' }]),
|
||||
)).toThrow('conflicting definitions')
|
||||
expect(ProjectContribution.merge(new ProjectContribution([resource]), new ProjectContribution([resource])).byKey().size)
|
||||
.toBe(1)
|
||||
expect(ownedTextFile('owner', 'file.txt', 'text').document).toBeInstanceOf(TextProjectFile)
|
||||
expect(optionalString({ value: 1 }, 'value')).toHaveLength(1)
|
||||
expect(optionalString({}, 'value')).toEqual([])
|
||||
expect(requiredString({ value: 'x' }, 'value')).toEqual([])
|
||||
expect(stringArray({ value: ['a'] }, 'value')).toEqual([])
|
||||
expect(stringArray({ value: [1] }, 'value')).toHaveLength(1)
|
||||
expect(cordisConfigEntry('owner', { id: 'entry', name: 'pkg' }).ownedConfigKeys).toEqual([])
|
||||
expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg' })[1].ownedConfigKeys).toEqual([])
|
||||
expect(npmCordisConfigEntry('owner', { id: 'entry', name: '@scope/pkg/subpath' })[0].name).toBe('@scope/pkg')
|
||||
expect(npmCordisConfigEntry('owner', { id: 'entry', name: 'pkg/subpath' })[0].name).toBe('pkg')
|
||||
for (const invalid of ['', '@scope', '@scope/']) {
|
||||
expect(() => npmCordisConfigEntry('owner', { id: 'entry', name: invalid })).toThrow('invalid bare package specifier')
|
||||
}
|
||||
expect(environmentResource('owner', 'EMPTY', undefined)).not.toHaveProperty('value')
|
||||
const builtins = createBuiltinRegistry(profile)
|
||||
expect(builtins.get(featureId('app')).defaultOptions(profile)).toEqual(['embed'])
|
||||
expect(builtins.get(featureId('hmr')).defaultOptions(profile)).toEqual(['default'])
|
||||
const app = builtins.get(featureId('app'))
|
||||
const acpEntry = builtins.get(featureId('app')).contribution(selection('app', ['acp']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp')
|
||||
expect(acpEntry?.entry.id).toBe('acp')
|
||||
expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1)
|
||||
const embedOption = app.options.find(option => option.id === 'embed')
|
||||
expect(embedOption?.markerConfigEntries(profile)).toEqual([])
|
||||
expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([
|
||||
'owned-file', 'owned-file', 'package-script', 'package-script',
|
||||
])
|
||||
expect(embedOption?.matchesConfigEntries([
|
||||
{ id: 'agent-loop', name: '@deepseek-ai/dsh-agent-loop' },
|
||||
{ id: 'acp', name: '@deepseek-ai/dsh-acp' },
|
||||
], profile)).toBe(false)
|
||||
const spineAgentLoop = builtins.get(featureId('spine')).contribution(selection('spine', ['default']), profile).resources
|
||||
.find((resource): resource is CordisConfigEntryResource =>
|
||||
resource.kind === 'cordis-config-entry' && resource.entry.id === 'agent-loop')
|
||||
expect(spineAgentLoop?.validateConfig?.({ agents: 'main' })).toEqual(['agents must be an array'])
|
||||
expect(spineAgentLoop?.validateConfig?.({ agents: ['main'] })).toEqual(['agents must be empty'])
|
||||
expect(spineAgentLoop?.validateConfig?.({ agents: [] })).toEqual([])
|
||||
expect(builtins.get(featureId('provider')).defaultOptions(profile)).toEqual(['deepseek-official'])
|
||||
expect(() => builtins.get(featureId('provider')).contribution({
|
||||
id: featureId('provider'), options: ['custom'], values: { baseURL: 1 },
|
||||
}, profile)).toThrow('baseURL must be a string')
|
||||
const alternateModel = builtins.get(featureId('provider')).contribution({
|
||||
id: featureId('provider'), options: ['deepseek-official'],
|
||||
}, { ...profile, runtime: { model: 'other' } }).resources
|
||||
.find(resource => resource.kind === 'cordis-config-entry')
|
||||
expect(alternateModel?.entry.config?.models).toEqual(['other'])
|
||||
class RequiringSimple extends BadSimple {
|
||||
override readonly requires = [featureId('npm-dependency')]
|
||||
}
|
||||
expect(new RequiringSimple().requirements(selection('bad-simple', ['one']))).toEqual([{ id: 'npm-dependency' }])
|
||||
expect(builtins.get(featureId('bash')).defaultOptions(profile)).toEqual(['local'])
|
||||
})
|
||||
|
||||
it('reports every inconsistent feature resource shape', () => {
|
||||
const feature = defineFeature({
|
||||
id: 'inspectable', summary: 'Inspectable', mode: 'single',
|
||||
baseResources: [{ kind: 'file-cordis-config-entry', id: 'base', path: 'pkg' }],
|
||||
options: [{
|
||||
id: 'one', label: 'One', default: true,
|
||||
secrets: [{ id: 'token', environment: 'TOKEN', message: 'Token', required: true }],
|
||||
resources: [
|
||||
{ kind: 'file-cordis-config-entry', id: 'one', path: 'pkg', config: { value: 'x' } },
|
||||
{ kind: 'npm-dependency', name: 'dep' },
|
||||
{ kind: 'owned-file', path: 'owned.txt', text: 'owned' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const view = (entries: readonly CordisConfigEntry[]): FeatureProjectView => ({
|
||||
profile,
|
||||
cordisConfigEntries: () => entries,
|
||||
packageManifest: () => ({}),
|
||||
hasDocument: () => false,
|
||||
readEnvironment: (path) => {
|
||||
if (path === '.env.example') throw new Error('bad env')
|
||||
return 'secret'
|
||||
},
|
||||
})
|
||||
expect(feature.inspect(view([])).state).toBe('absent')
|
||||
const inconsistent = feature.inspect(view([
|
||||
{ id: 'one', name: 'pkg', config: { value: 1 } },
|
||||
{ id: 'extra', name: 'pkg', disabled: true },
|
||||
]))
|
||||
expect(inconsistent.state).toBe('inconsistent')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('missing Cordis config entry base')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('unexpected owned Cordis config entry extra')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('missing package.json dependencies entry dep')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('missing owned file owned.txt')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('bad env')
|
||||
expect(inconsistent.diagnostics.join('\n')).toContain('mixed enabled states')
|
||||
expect(feature.inspect(view([{ id: 'unknown', name: 'pkg' }])).state).toBe('inconsistent')
|
||||
const ambiguous = defineFeature({
|
||||
id: 'ambiguous', summary: 'Ambiguous', mode: 'exclusive',
|
||||
options: [
|
||||
{ id: 'one', label: 'One', default: true, resources: [], markers: [{ id: 'one', name: 'pkg' }] },
|
||||
{ id: 'two', label: 'Two', resources: [], markers: [{ id: 'two', name: 'pkg' }] },
|
||||
],
|
||||
})
|
||||
expect(ambiguous.inspect(view([{ id: 'one', name: 'pkg' }, { id: 'two', name: 'pkg' }])).state)
|
||||
.toBe('inconsistent')
|
||||
const noValidator = defineFeature({
|
||||
id: 'no-validator', summary: 'No validator', mode: 'single',
|
||||
options: [{
|
||||
id: 'one', label: 'One', default: true,
|
||||
resources: [{ kind: 'file-cordis-config-entry', id: 'plain', path: 'plain-package' }],
|
||||
}],
|
||||
})
|
||||
expect(noValidator.inspect(view([{ id: 'plain', name: 'plain-package' }])).state).toBe('enabled')
|
||||
const app = createBuiltinRegistry(profile).get(featureId('app'))
|
||||
expect(app.inspect(view([{ id: 'acp', name: '@deepseek-ai/dsh-acp' }])).state)
|
||||
.toBe('inconsistent')
|
||||
})
|
||||
})
|
||||
484
packages/scaffold/helper/tests/questions.spec.ts
Normal file
484
packages/scaffold/helper/tests/questions.spec.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { stripVTControlCharacters } from 'node:util'
|
||||
import { S_CHECKBOX_SELECTED, S_RADIO_ACTIVE, S_WARN } from '@clack/prompts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createBuiltinRegistry } from '../src/features/builtin/index.ts'
|
||||
import { FeatureConfigurator } from '../src/features/feature-configurator.ts'
|
||||
import { FeatureOption, ExclusiveOptionFeature } from '../src/features/feature.ts'
|
||||
import { ProjectContribution } from '../src/features/resources.ts'
|
||||
import { featureId } from '../src/ids.ts'
|
||||
import { NpmPackageManager } from '../src/package-managers/package-manager.ts'
|
||||
import { ClackPromptPort } from '../src/questions/clack-prompt-port.ts'
|
||||
import {
|
||||
PromptCancelledError,
|
||||
requireAnswer,
|
||||
type ConfirmPromptRequest,
|
||||
type MultiSelectPromptRequest,
|
||||
type NestedMultiSelectRequest,
|
||||
type NestedMultiSelectValue,
|
||||
type PromptOutcome,
|
||||
type PromptPort,
|
||||
type SecretPromptRequest,
|
||||
type SelectPromptRequest,
|
||||
type TextPromptRequest,
|
||||
} from '../src/questions/prompt-port.ts'
|
||||
import {
|
||||
ConfirmQuestion,
|
||||
MultiSelectQuestion,
|
||||
SecretQuestion,
|
||||
SelectQuestion,
|
||||
TextQuestion,
|
||||
} from '../src/questions/question.ts'
|
||||
import type { ProjectProfile } from '../src/project/types.ts'
|
||||
import { clackNestedMultiselect } from '../src/questions/clack-nested-multiselect.ts'
|
||||
|
||||
function validateString(
|
||||
outcome: PromptOutcome<string>,
|
||||
validate: ((value: string) => string | undefined) | undefined,
|
||||
): PromptOutcome<string> {
|
||||
if (outcome.status === 'answered') {
|
||||
const diagnostic = validate?.(outcome.value)
|
||||
if (diagnostic) throw new Error(diagnostic)
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
class QueuePromptPort implements PromptPort {
|
||||
readonly answers: unknown[]
|
||||
readonly requests: string[] = []
|
||||
|
||||
constructor(answers: unknown[]) {
|
||||
this.answers = [...answers]
|
||||
}
|
||||
|
||||
next<T>(message: string): PromptOutcome<T> {
|
||||
this.requests.push(message)
|
||||
const value = this.answers.shift()
|
||||
return value === QueuePromptPort.cancel ? { status: 'cancelled' } : { status: 'answered', value: value as T }
|
||||
}
|
||||
|
||||
async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return validateString(this.next<string>(request.message), request.validate)
|
||||
}
|
||||
|
||||
async secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
|
||||
return validateString(this.next<string>(request.message), request.validate)
|
||||
}
|
||||
|
||||
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
|
||||
return Promise.resolve(this.next(request.message))
|
||||
}
|
||||
|
||||
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
|
||||
return Promise.resolve(this.next(request.message))
|
||||
}
|
||||
|
||||
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
|
||||
return Promise.resolve(this.next(request.message))
|
||||
}
|
||||
nestedMultiselect<TValue, TChoice>(
|
||||
request: NestedMultiSelectRequest<TValue, TChoice>,
|
||||
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
|
||||
return Promise.resolve(this.next(request.message))
|
||||
}
|
||||
|
||||
static readonly cancel = Symbol('cancel')
|
||||
}
|
||||
|
||||
describe('typed questions', () => {
|
||||
it('uses and validates prefilled answers without prompting', async () => {
|
||||
const port = new QueuePromptPort([])
|
||||
const text = new TextQuestion({ id: 'name', message: 'Name', validate: value => value ? undefined : 'required' })
|
||||
await expect(text.resolve(port, 'demo')).resolves.toEqual({ status: 'answered', value: 'demo' })
|
||||
await expect(text.resolve(port, '')).rejects.toThrow('name: required')
|
||||
const select = new SelectQuestion({
|
||||
id: 'choice', message: 'Choice', options: [{ value: 'a', label: 'A' }], initialValue: 'a',
|
||||
})
|
||||
await expect(select.resolve(port, 'b')).rejects.toThrow('unknown or disabled option')
|
||||
await expect(select.resolve(port, 'a')).resolves.toMatchObject({ value: 'a' })
|
||||
const disabled = new SelectQuestion({
|
||||
id: 'disabled', message: 'Disabled', options: [{ value: 'a', label: 'A', disabled: true }],
|
||||
})
|
||||
await expect(disabled.resolve(port, 'a')).rejects.toThrow('disabled option')
|
||||
const multi = new MultiSelectQuestion({
|
||||
id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }], required: true,
|
||||
})
|
||||
await expect(multi.resolve(port, [])).rejects.toThrow('choose at least one')
|
||||
await expect(multi.resolve(port, ['missing'])).rejects.toThrow('unknown or disabled option')
|
||||
await expect(new MultiSelectQuestion({
|
||||
id: 'disabled-many', message: 'Disabled many', options: [{ value: 'a', label: 'A', disabled: true }],
|
||||
}).resolve(port, ['a'])).rejects.toThrow('disabled option')
|
||||
await expect(new MultiSelectQuestion({
|
||||
id: 'optional', message: 'Optional', options: [{ value: 'a', label: 'A' }],
|
||||
}).resolve(port, [])).resolves.toMatchObject({ value: [] })
|
||||
const secret = new SecretQuestion({ id: 'secret', message: 'Secret', validate: value => value ? undefined : 'required' })
|
||||
await expect(secret.resolve(port, 'value')).resolves.toMatchObject({ value: 'value' })
|
||||
await expect(secret.resolve(port, '')).rejects.toThrow('secret: required')
|
||||
await expect(new ConfirmQuestion({ id: 'confirm', message: 'Confirm' }).resolve(port, false))
|
||||
.resolves.toEqual({ status: 'answered', value: false })
|
||||
expect(port.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('delegates each interaction shape and propagates cancellation', async () => {
|
||||
const port = new QueuePromptPort(['text', 'secret', 'a', ['a'], true, QueuePromptPort.cancel])
|
||||
await expect(new TextQuestion({ id: 't', message: 'Text' }).resolve(port)).resolves.toMatchObject({ value: 'text' })
|
||||
await expect(new SecretQuestion({ id: 's', message: 'Secret' }).resolve(port)).resolves.toMatchObject({ value: 'secret' })
|
||||
await expect(new SelectQuestion({
|
||||
id: 'one', message: 'One', options: [{ value: 'a', label: 'A' }],
|
||||
}).resolve(port)).resolves.toMatchObject({ value: 'a' })
|
||||
await expect(new MultiSelectQuestion({
|
||||
id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }],
|
||||
}).resolve(port)).resolves.toMatchObject({ value: ['a'] })
|
||||
await expect(new ConfirmQuestion({ id: 'yes', message: 'Yes?' }).resolve(port)).resolves.toMatchObject({ value: true })
|
||||
const cancelled = await new ConfirmQuestion({ id: 'cancel', message: 'Cancel?' }).resolve(port)
|
||||
expect(() => requireAnswer(cancelled)).toThrow(PromptCancelledError)
|
||||
const optionsPort = new QueuePromptPort(['full', 'a', ['a']])
|
||||
await new TextQuestion({
|
||||
id: 'full', message: 'Full', placeholder: 'p', initialValue: 'i', defaultValue: 'd', validate: () => undefined,
|
||||
}).resolve(optionsPort)
|
||||
await new SelectQuestion({
|
||||
id: 'initial', message: 'Initial', options: [{ value: 'a', label: 'A' }], initialValue: 'a',
|
||||
}).resolve(optionsPort)
|
||||
await new MultiSelectQuestion({
|
||||
id: 'initial-many', message: 'Initial many', options: [{ value: 'a', label: 'A' }],
|
||||
initialValues: ['a'], required: true,
|
||||
}).resolve(optionsPort)
|
||||
})
|
||||
|
||||
it('accepts a visible placeholder default before required validation', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = new ClackPromptPort(input, output).text({
|
||||
message: 'Directory',
|
||||
placeholder: 'my-agent',
|
||||
defaultValue: 'my-agent',
|
||||
validate: value => value ? undefined : 'required',
|
||||
})
|
||||
setTimeout(() => input.write('\r'), 0)
|
||||
await expect(pending).resolves.toEqual({ status: 'answered', value: 'my-agent' })
|
||||
})
|
||||
|
||||
it('renders warning confirmations with a yellow warning marker', async () => {
|
||||
const input = new PassThrough()
|
||||
let screen = ''
|
||||
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
|
||||
const pending = new ClackPromptPort(input, output).confirm({
|
||||
message: 'Keep empty?',
|
||||
initialValue: true,
|
||||
tone: 'warning',
|
||||
})
|
||||
setTimeout(() => input.write('\r'), 0)
|
||||
await expect(pending).resolves.toEqual({ status: 'answered', value: true })
|
||||
expect(stripVTControlCharacters(screen)).toContain(`${S_WARN} Keep empty?`)
|
||||
})
|
||||
|
||||
it('adapts secret, select, multiselect, nested, and cancellation prompts', async () => {
|
||||
const run = async <T>(
|
||||
start: (port: ClackPromptPort) => Promise<PromptOutcome<T>>,
|
||||
keys: string,
|
||||
): Promise<PromptOutcome<T>> => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = start(new ClackPromptPort(input, output))
|
||||
setTimeout(() => input.write(keys), 0)
|
||||
return pending
|
||||
}
|
||||
await expect(run(port => port.secret({ message: 'Secret', validate: value => value ? undefined : 'required' }), 'key\r'))
|
||||
.resolves.toEqual({ status: 'answered', value: 'key' })
|
||||
await expect(run(port => port.secret({ message: 'Secret' }), 'plain\r'))
|
||||
.resolves.toEqual({ status: 'answered', value: 'plain' })
|
||||
await expect(run(port => port.text({ message: 'Text', initialValue: 'seed' }), '\r'))
|
||||
.resolves.toEqual({ status: 'answered', value: 'seed' })
|
||||
let validated = 'unset'
|
||||
await expect(run(port => port.text({
|
||||
message: 'Empty', validate: (value) => { validated = value; return undefined },
|
||||
}), '\r')).resolves.toEqual({ status: 'answered', value: '' })
|
||||
expect(validated).toBe('')
|
||||
await expect(run(port => port.select({
|
||||
message: 'Select', options: [{ value: 'a', label: 'A', hint: 'hint' }, { value: 'b', label: 'B', disabled: true }],
|
||||
initialValue: 'a',
|
||||
}), '\r')).resolves.toEqual({ status: 'answered', value: 'a' })
|
||||
await expect(run(port => port.multiselect({
|
||||
message: 'Many', options: [{ value: 'a', label: 'A' }], initialValues: ['a'], required: true,
|
||||
}), '\r')).resolves.toEqual({ status: 'answered', value: ['a'] })
|
||||
await expect(run(port => port.multiselect({
|
||||
message: 'Many', options: [{ value: 'a', label: 'A' }],
|
||||
}), ' \r')).resolves.toEqual({ status: 'answered', value: ['a'] })
|
||||
await expect(run(port => port.nestedMultiselect({
|
||||
message: 'Nested', options: [{ value: 'a', label: 'A', default: true }],
|
||||
}), '\r')).resolves.toEqual({ status: 'answered', value: [{ value: 'a', choices: [] }] })
|
||||
await expect(run(port => port.confirm({ message: 'Cancel' }), '\u0003')).resolves.toEqual({ status: 'cancelled' })
|
||||
expect(new ClackPromptPort()).toBeInstanceOf(ClackPromptPort)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nested Clack picker', () => {
|
||||
it('navigates root options, ignores disabled rows, and toggles optional rows', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', showChanges: true, input, output,
|
||||
options: [
|
||||
{ value: 'required', label: 'Required', required: true },
|
||||
{ value: 'optional', label: 'Optional', default: true },
|
||||
{ value: 'added', label: 'Added' },
|
||||
{ value: 'disabled', label: 'Disabled', disabled: true, warning: 'disabled warning' },
|
||||
],
|
||||
})
|
||||
setTimeout(() => input.write('\x1b[A \x1b[B\x1b[B \x1b[B \x1b[A\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered',
|
||||
value: [{ value: 'required', choices: [] }, { value: 'added', choices: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels from the root layer', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output, options: [{ value: 'one', label: 'One' }],
|
||||
})
|
||||
setTimeout(() => input.write('\u0003'), 0)
|
||||
await expect(pending).resolves.toEqual({ status: 'cancelled' })
|
||||
})
|
||||
|
||||
it('enters an exclusive child with Right and commits the selected option', async () => {
|
||||
const input = new PassThrough()
|
||||
let screen = ''
|
||||
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features',
|
||||
showChanges: true,
|
||||
input,
|
||||
output,
|
||||
options: [
|
||||
{
|
||||
value: 'persistence',
|
||||
label: 'Session storage',
|
||||
required: true,
|
||||
default: true,
|
||||
choiceMode: 'exclusive',
|
||||
choices: [
|
||||
{ value: 'jsonl', label: 'JSONL', default: true },
|
||||
{ value: 'sqlite', label: 'SQLite' },
|
||||
],
|
||||
},
|
||||
{ value: 'fs', label: 'Filesystem', default: true },
|
||||
],
|
||||
})
|
||||
setTimeout(() => input.write('\x1b[C\x1b[B\x1b[A\x1b[B\x1b[C\r\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered',
|
||||
value: [
|
||||
{ value: 'persistence', choices: ['sqlite'] },
|
||||
{ value: 'fs', choices: [] },
|
||||
],
|
||||
})
|
||||
const rendered = stripVTControlCharacters(screen)
|
||||
expect(rendered).toContain(`› ${S_CHECKBOX_SELECTED} Session storage`)
|
||||
expect(rendered).toContain(`› ${S_RADIO_ACTIVE} SQLite`)
|
||||
expect(rendered).toContain('● changed')
|
||||
})
|
||||
|
||||
it('highlights and blocks a selected multiple feature with no child option', async () => {
|
||||
const input = new PassThrough()
|
||||
let screen = ''
|
||||
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features',
|
||||
input,
|
||||
output,
|
||||
options: [{
|
||||
value: 'hooks',
|
||||
label: 'Hooks',
|
||||
default: true,
|
||||
choiceMode: 'multiple',
|
||||
choices: [
|
||||
{ value: 'claude', label: 'Claude', default: true },
|
||||
{ value: 'codex', label: 'Codex' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
setTimeout(() => input.write('\x1b[C \x1b[D \x1b[D\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered',
|
||||
value: [{ value: 'hooks', choices: ['claude'] }],
|
||||
})
|
||||
expect(stripVTControlCharacters(screen)).toContain('▲ choose at least one')
|
||||
})
|
||||
|
||||
it('blocks root submission for an exclusive feature with no selected option', async () => {
|
||||
const input = new PassThrough()
|
||||
let screen = ''
|
||||
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output,
|
||||
options: [{
|
||||
value: 'provider', label: 'Provider', default: true, choiceMode: 'exclusive',
|
||||
choices: [{ value: 'one', label: 'One' }],
|
||||
}],
|
||||
})
|
||||
setTimeout(() => input.write('\r\x1b[C\x1b[C\r\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered', value: [{ value: 'provider', choices: ['one'] }],
|
||||
})
|
||||
expect(stripVTControlCharacters(screen)).toContain('Choose one Provider option')
|
||||
})
|
||||
|
||||
it('blocks root submission for a multiple feature with no selected option', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output,
|
||||
options: [{
|
||||
value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple',
|
||||
choices: [{ value: 'one', label: 'One' }],
|
||||
}],
|
||||
})
|
||||
setTimeout(() => input.write('\r\x1b[C \r\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered', value: [{ value: 'hooks', choices: ['one'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders an unchanged checked option while another child is focused', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output,
|
||||
options: [{
|
||||
value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple',
|
||||
choices: [
|
||||
{ value: 'one', label: 'One', default: true },
|
||||
{ value: 'two', label: 'Two', default: true },
|
||||
],
|
||||
}],
|
||||
})
|
||||
setTimeout(() => input.write('\x1b[C\x1b[B\x1b[D\r'), 0)
|
||||
await expect(pending).resolves.toEqual({
|
||||
status: 'answered', value: [{ value: 'hooks', choices: ['one', 'two'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('submits an empty optional selection', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
|
||||
const pending = clackNestedMultiselect({
|
||||
message: 'Features', input, output, options: [{ value: 'one', label: 'One' }],
|
||||
})
|
||||
setTimeout(() => input.write('\r'), 0)
|
||||
await expect(pending).resolves.toEqual({ status: 'answered', value: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('feature configurator', () => {
|
||||
const profile: ProjectProfile = {
|
||||
name: 'demo',
|
||||
description: 'demo',
|
||||
runtime: { model: 'deepseek-v4-flash' },
|
||||
runInterface: 'embed',
|
||||
packageManager: new NpmPackageManager('10.0.0'),
|
||||
releaseVersion: '0.0.1',
|
||||
}
|
||||
|
||||
it('shares exclusive, multiple, fixed, and secret behavior', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const port = new QueuePromptPort(['sqlite', ['spawn', 'fork'], 'deepseek-official', 'new-key'])
|
||||
const configurator = new FeatureConfigurator(port)
|
||||
await expect(configurator.configure(registry.get(featureId('persistence')), profile)).resolves.toMatchObject({
|
||||
options: ['sqlite'],
|
||||
})
|
||||
await expect(configurator.configure(registry.get(featureId('subagent')), profile)).resolves.toMatchObject({
|
||||
options: ['spawn', 'fork'],
|
||||
})
|
||||
await expect(configurator.configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'old-key' } },
|
||||
)).resolves.toMatchObject({ secrets: { apiKey: 'new-key' } })
|
||||
expect(port.requests).toEqual([
|
||||
'Choose durable session storage',
|
||||
'Choose delegate work to child agents',
|
||||
'Choose model provider',
|
||||
'DeepSeek API key (leave empty to keep current)',
|
||||
])
|
||||
})
|
||||
|
||||
it('validates feature values, defaults, and retained secrets', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const fixed = new FeatureConfigurator(new QueuePromptPort([]))
|
||||
await expect(fixed.configure(registry.get(featureId('bash')), profile, undefined, ['local'])).resolves.toMatchObject({
|
||||
options: ['local'],
|
||||
})
|
||||
const requiredSecret = new FeatureConfigurator(new QueuePromptPort([]))
|
||||
await expect(requiredSecret.configure(
|
||||
registry.get(featureId('provider')), profile, undefined, ['deepseek-official'], { apiKey: '' },
|
||||
)).rejects.toThrow('required')
|
||||
const keep = new FeatureConfigurator(new QueuePromptPort(['deepseek-official', '']))
|
||||
await expect(keep.configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'old' } },
|
||||
)).resolves.toMatchObject({ secrets: { apiKey: 'old' } })
|
||||
const custom = registry.get(featureId('provider'))
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort(['custom'])).configure(
|
||||
custom,
|
||||
profile,
|
||||
{ id: featureId('provider'), options: ['custom'], values: { baseURL: 1 }, secrets: { apiKey: 'old' } },
|
||||
)).rejects.toThrow('current value must be a string')
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort(['custom', ''])).configure(
|
||||
custom, profile, undefined,
|
||||
)).rejects.toThrow('required')
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort(['custom', 'https://next', ''])).configure(
|
||||
custom,
|
||||
profile,
|
||||
{
|
||||
id: featureId('provider'), options: ['custom'],
|
||||
values: { baseURL: 'https://old' }, secrets: { apiKey: 'old' },
|
||||
},
|
||||
)).resolves.toMatchObject({ values: { baseURL: 'https://next' }, secrets: { apiKey: 'old' } })
|
||||
class EmptyExclusive extends ExclusiveOptionFeature {
|
||||
override readonly id = featureId('empty-exclusive')
|
||||
override readonly summary = 'Empty'
|
||||
override readonly options = [new (class extends FeatureOption {
|
||||
override readonly id = 'one'
|
||||
override readonly label = 'One'
|
||||
override contribution(): ProjectContribution { return new ProjectContribution([]) }
|
||||
})()]
|
||||
override defaultOptions(): readonly string[] { return [] }
|
||||
}
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile))
|
||||
.rejects.toThrow('has no default option')
|
||||
})
|
||||
|
||||
it('configures fully from prefilled options, values, and secrets without prompting', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
const port = new QueuePromptPort([])
|
||||
const result = await new FeatureConfigurator(port).configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
undefined,
|
||||
['custom'],
|
||||
{ apiKey: 'prefilled-key' },
|
||||
{ baseURL: 'https://prefilled' },
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
options: ['custom'],
|
||||
values: { baseURL: 'https://prefilled' },
|
||||
secrets: { apiKey: 'prefilled-key' },
|
||||
})
|
||||
expect(port.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-string prefilled feature value', async () => {
|
||||
const registry = createBuiltinRegistry(profile)
|
||||
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(
|
||||
registry.get(featureId('provider')),
|
||||
profile,
|
||||
undefined,
|
||||
['custom'],
|
||||
{ apiKey: 'k' },
|
||||
{ baseURL: 123 },
|
||||
)).rejects.toThrow('must be a string')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user