Merge remote-tracking branch 'origin/master' into worktree/code-diff-card
This commit is contained in:
commit
3c632ebf66
318 files changed
+9061
-3253
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: 2e74ef68cf0b8487083a2e2af0f5175c78ec5212
|
||||
README.zh.md: 553f28f3af78b18658701449678de80b086dbb15
|
||||
README.md: cfab7a4983c31a21b68157c9cefb38c1654571ff
|
||||
README.zh.md: 67d07fba1fc27bca9c2a696daddde88aac3aa39c
|
||||
+4
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, configuration errors, and boot failures exit nonzero.
|
||||
The `dsh` command is the sole supported Node application launcher: profiles are ordered stacks of plugin-bundle patch layers under the user's own overrides. SDK and ACP are profiles, not separate public bins. The Python runtime wheel packages this same command; the SDK defaults to `sdk`, and the minimal example selects `sdk-minimal`. [`src/args.ts`](src/args.ts) owns the command grammar, and [`src/bin.ts`](src/bin.ts) loads only the selected runner. Invalid commands, options from another mode, and fatal configuration or boot failures exit nonzero.
|
||||
|
||||
## Entry modes
|
||||
|
||||
@@ -45,7 +45,7 @@ Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (
|
||||
|
||||
Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
|
||||
|
||||
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution.
|
||||
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. The [startup and reload failure table](../../packages/boot/app-boot/README.md#startup-and-reload-failures) compares optional and required plugin failures with configuration HMR.
|
||||
|
||||
## Optional overlays
|
||||
|
||||
@@ -54,3 +54,5 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
|
||||
## Development
|
||||
|
||||
Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh <args...>` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
|
||||
|
||||
The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项、配置错误和启动失败都会以非零状态退出。
|
||||
`dsh` 是唯一受支持的 Node 应用启动器;profile 由多个插件组合包 patch 层按顺序叠加而成,其上再应用用户自己的覆盖配置。SDK 与 ACP(Agent Client Protocol)都是 profile,而不是独立的公开可执行命令。Python 运行时 wheel 包中也包含同一个命令;SDK 默认使用 `sdk`,极简示例选择 `sdk-minimal`。[`src/args.ts`](src/args.ts) 负责命令语法,[`src/bin.ts`](src/bin.ts) 只加载选中的运行器。无效命令、来自其他模式的选项,以及致命的配置或启动错误都会以非零状态退出。
|
||||
|
||||
## 入口模式
|
||||
|
||||
@@ -45,7 +45,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
|
||||
|
||||
使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
|
||||
|
||||
层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。
|
||||
层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。[启动与重载失败表](../../packages/boot/app-boot/README.zh.md#startup-and-reload-failures)对比 optional、required 插件启动失败与配置 HMR 的行为。
|
||||
|
||||
## 可选覆盖层
|
||||
|
||||
@@ -54,3 +54,5 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
|
||||
## 开发
|
||||
|
||||
生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
|
||||
|
||||
[Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。
|
||||
@@ -368,6 +368,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
|
||||
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
|
||||
}
|
||||
await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
|
||||
await ctx.loader.await()
|
||||
}
|
||||
await watchUserPatches(ctx, {
|
||||
binName: NAME,
|
||||
|
||||
@@ -28,6 +28,7 @@ const SPAWN_TIMEOUT_MS = 60_000
|
||||
const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
|
||||
const webReadyExitHook = new URL('./fixtures/web-browser-open/register.mjs', import.meta.url).href
|
||||
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
@@ -400,7 +401,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, SPAWN_TIMEOUT_MS * 3 + 30_000)
|
||||
|
||||
it('reports SDK startup failure when stdin reaches EOF first', async () => {
|
||||
it('ignores an optional SDK plugin import failure before stdin reaches EOF', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
|
||||
const patch = join(home, 'broken-sdk.cordis.yml')
|
||||
writeFileSync(patch, [
|
||||
@@ -415,9 +416,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
|
||||
}, home)
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stdout).toBe('')
|
||||
expect(result.stderr).toContain('plugin tree failed to load')
|
||||
expect(result.stderr).toContain('warning: 1 entry did not activate')
|
||||
expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
@@ -756,20 +757,18 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
}
|
||||
}, SPAWN_TIMEOUT_MS + 30_000)
|
||||
|
||||
it('reports a patch-overlay boot failure without hanging', async () => {
|
||||
// The HMR main watcher's initial scan once refreshed the include
|
||||
// mid-initial-apply, deadlocking the failing apply's rollback against the
|
||||
// refresh drain: dsh exited 13 with no diagnostic instead of settling
|
||||
// ([vendor/README.md](../../../vendor/README.md)).
|
||||
it('keeps serving when an optional patch-overlay plugin fails', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
|
||||
try {
|
||||
const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
|
||||
const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider, '--port', '0', '--no-open'], {
|
||||
DSH_HOME: home,
|
||||
DSH_BROWSER_OPEN_TEST_EXIT_ON_READY: '1',
|
||||
DEEPSEEK_API_KEY: 'keyless-invalid-config',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
NODE_OPTIONS: `--import=${webReadyExitHook}`,
|
||||
})
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.stdout).toBe('')
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(result.stdout).toMatch(/^dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=[A-Za-z0-9_-]+$/u)
|
||||
expect(result.stderr).toContain('llm-pi-ai')
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true })
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Invalid `--patch` overlay used to prove boot failures settle and exit.
|
||||
# Invalid optional provider override used to prove best-effort startup continues.
|
||||
|
||||
- id: llm-pi-ai
|
||||
config:
|
||||
|
||||
+4
-2
@@ -1,3 +1,5 @@
|
||||
headless-test-driver: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure
|
||||
Error: startup activation snapshot failure
|
||||
dsh: warning: 1 entry did not activate
|
||||
activation-error (./activation-error.mjs): Error: startup activation snapshot failure
|
||||
at activation-error-fixture
|
||||
dsh: reasoning:
|
||||
Inspecting the task before the tool call.
|
||||
Vendored
+1
-7
@@ -1,10 +1,4 @@
|
||||
# Activation-failure patch over the shipped headless profile.
|
||||
- id: headless-startup
|
||||
disabled: true
|
||||
|
||||
- id: headless-runner
|
||||
disabled: true
|
||||
|
||||
# Unrelated activation failure over the shipped headless profile.
|
||||
- insert:
|
||||
- id: activation-error
|
||||
name: ./activation-error.mjs
|
||||
@@ -271,19 +271,27 @@ describe('headless stream-json snapshots', () => {
|
||||
await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
it('prints the original Loader activation error through the assembled one-shot app', async () => {
|
||||
it('warns about an unrelated activation error and completes the headless task', async () => {
|
||||
const result = await runLoaderSmoke({
|
||||
label: 'headless startup activation error snapshot',
|
||||
label: 'headless best-effort startup snapshot',
|
||||
tempDirPrefix: 'headless-snapshot-startup-error-',
|
||||
binScript,
|
||||
libBinScript: binScript,
|
||||
binScript: dshBinScript,
|
||||
configPath: startupFailureConfigPath,
|
||||
binArgs: [startupFailureConfigPath, 'unreachable task'],
|
||||
binArgs: [
|
||||
'--profile', 'headless',
|
||||
'--patch', headlessOverlayPath,
|
||||
'--patch', startupFailureConfigPath,
|
||||
'Complete the task despite the unrelated startup failure.',
|
||||
],
|
||||
tsconfigPath,
|
||||
expectedExitCode: 1,
|
||||
env: {
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
|
||||
},
|
||||
})
|
||||
expect(result.stdout).toBe('')
|
||||
await expect(result.stderr.replace(startupFailurePluginUrl, './activation-error.mjs'))
|
||||
expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n')
|
||||
await expect(result.stderr.replaceAll(startupFailurePluginUrl, './activation-error.mjs'))
|
||||
.toMatchFileSnapshot(startupFailureExpected)
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
|
||||
|
||||
@@ -6,24 +6,32 @@ import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-l
|
||||
|
||||
const fixtureRoot = new URL('../../../../../../packages/mcp/mcp-client/tests/fixtures/', import.meta.url)
|
||||
const configPath = fileURLToPath(new URL('repeated-cursor.patch.yml', fixtureRoot))
|
||||
const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.patch.yml', import.meta.url))
|
||||
const expectedPath = fileURLToPath(new URL('./expected/mcp-pagination/stderr-cause.txt', import.meta.url))
|
||||
|
||||
it('reports a repeated MCP discovery cursor and exits before starting a turn', async () => {
|
||||
it('warns about a repeated MCP discovery cursor and completes the headless task', async () => {
|
||||
const { stdout, stderr } = await runLoaderSmoke({
|
||||
label: 'MCP discovery pagination cycle',
|
||||
tempDirPrefix: 'dsh-mcp-pagination-',
|
||||
binScript: fileURLToPath(new URL('../../../../src/bin.ts', import.meta.url)),
|
||||
libBinScript: fileURLToPath(new URL('../../../../lib/bin.js', import.meta.url)),
|
||||
configPath,
|
||||
binArgs: ['--profile', 'headless', '--patch', configPath, 'unreachable task'],
|
||||
binArgs: [
|
||||
'--profile', 'headless',
|
||||
'--patch', headlessOverlayPath,
|
||||
'--patch', configPath,
|
||||
'Complete the task without the failed MCP server.',
|
||||
],
|
||||
tsconfigPath: fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url)),
|
||||
expectedExitCode: 1,
|
||||
env: {
|
||||
DSH_MCP_PAGINATION_FIXTURE: fileURLToPath(new URL('repeated-cursor-server.ts', fixtureRoot)),
|
||||
DSH_PERMISSION_MODE: 'danger-full-access',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
},
|
||||
})
|
||||
expect(stdout).toBe('')
|
||||
expect(stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n')
|
||||
expect(stderr).toContain('dsh: warning: 1 entry did not activate')
|
||||
expect(stderr).toContain('mcp-pagination-cycle (@deepseek-ai/dsh-mcp-client)')
|
||||
expect(stderr).toContain('initial connection or tool synchronization failed')
|
||||
const cause = stderr.split('\n').find(line => line.startsWith('Error: mcp-client(pagination-cycle):'))
|
||||
await expect(`${cause}\n`).toMatchFileSnapshot(expectedPath)
|
||||
|
||||
@@ -32,6 +32,10 @@ const binScript = fileURLToPath(new URL('../../../../../../packages/test-support
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', import.meta.url))
|
||||
// The resumed-agent fixture in the shared config resumes exactly this id.
|
||||
const sessionId = SessionId('workspace-context-resume')
|
||||
const fixtureEnv = {
|
||||
DSH_SNAPSHOT_FILE: replayFixture,
|
||||
DSH_LOADER_SMOKE_REQUIRED_ENTRY_ID: 'resumed-agent',
|
||||
}
|
||||
|
||||
/** Persist one session with the given header version and events, returning its log path. */
|
||||
async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise<string> {
|
||||
@@ -84,7 +88,7 @@ describe('session format guard through the assembled app', () => {
|
||||
configPath,
|
||||
binArgs: [configPath, 'Continue the migrated session.'],
|
||||
tsconfigPath,
|
||||
env: { DSH_SNAPSHOT_FILE: replayFixture },
|
||||
env: fixtureEnv,
|
||||
prepare: async (runCwd) => {
|
||||
sourcePath = await seedSession(join(runCwd, '.sessions'), runCwd, 0, closedTurn())
|
||||
source = await readFile(sourcePath)
|
||||
@@ -124,7 +128,7 @@ describe('session format guard through the assembled app', () => {
|
||||
configPath,
|
||||
binArgs: [configPath, 'Try to resume.'],
|
||||
tsconfigPath,
|
||||
env: { DSH_SNAPSHOT_FILE: replayFixture },
|
||||
env: fixtureEnv,
|
||||
expectedExitCode: 1,
|
||||
prepare: async (runCwd) => {
|
||||
sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn())
|
||||
@@ -151,7 +155,7 @@ describe('session format guard through the assembled app', () => {
|
||||
configPath,
|
||||
binArgs: [configPath, 'Try to resume.'],
|
||||
tsconfigPath,
|
||||
env: { DSH_SNAPSHOT_FILE: replayFixture },
|
||||
env: fixtureEnv,
|
||||
expectedExitCode: 1,
|
||||
prepare: async (runCwd) => {
|
||||
sourcePath = generationLogPath(join(runCwd, '.sessions'), runCwd, sessionId, 2, 'none')
|
||||
@@ -197,7 +201,7 @@ describe('session format guard through the assembled app', () => {
|
||||
configPath,
|
||||
binArgs: [configPath, 'Try to resume.'],
|
||||
tsconfigPath,
|
||||
env: { DSH_SNAPSHOT_FILE: replayFixture },
|
||||
env: fixtureEnv,
|
||||
expectedExitCode: 1,
|
||||
prepare: async (runCwd) => {
|
||||
sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [
|
||||
|
||||
@@ -355,7 +355,8 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
expect(exitCode, stderr).toBe(1)
|
||||
expect(stdout).toBe('')
|
||||
expect(stderr).toContain('plugin tree failed to load')
|
||||
expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)')
|
||||
expect(stderr).toContain('required startup failure')
|
||||
expect(stderr).toContain('sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server): SyntaxError')
|
||||
expect(stderr).toContain('sometimes')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/** Built Web-profile acceptance for best-effort initial plugin activation. */
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const frontendIndex = join(repoRoot, 'apps/web/dist/index.html')
|
||||
const builtArtifactsExist = existsSync(dshBin) && existsSync(frontendIndex)
|
||||
|
||||
interface Fixture {
|
||||
root: string
|
||||
home: string
|
||||
patch: string
|
||||
events: string
|
||||
stop: string
|
||||
}
|
||||
|
||||
function createFixture(): Fixture {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-web-best-effort-'))
|
||||
const home = join(root, 'home')
|
||||
const events = join(root, 'events.log')
|
||||
const stop = join(root, 'stop')
|
||||
mkdirSync(home)
|
||||
writeFileSync(events, '')
|
||||
writeFileSync(join(root, 'good.mjs'), [
|
||||
"import { appendFileSync, existsSync } from 'node:fs'",
|
||||
'export function apply(ctx, config) {',
|
||||
" appendFileSync(config.events, 'good apply\\n')",
|
||||
' let stopping = false',
|
||||
' const watcher = setInterval(() => {',
|
||||
' if (stopping || !existsSync(config.stop)) return',
|
||||
' stopping = true',
|
||||
" process.emit('SIGTERM')",
|
||||
' }, 20)',
|
||||
' ctx.effect(() => () => {',
|
||||
' clearInterval(watcher)',
|
||||
" appendFileSync(config.events, 'good dispose\\n')",
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(root, 'sync-failure.mjs'), 'export function apply() { throw new Error("web sync apply failure") }\n')
|
||||
writeFileSync(join(root, 'async-failure.mjs'), [
|
||||
'export async function apply() {',
|
||||
' await Promise.resolve()',
|
||||
' throw new Error("web async apply failure")',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(root, 'pending.mjs'), [
|
||||
"export const inject = ['webProbeMissingService']",
|
||||
'export function apply() {}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const patch = join(root, 'failures.patch.yml')
|
||||
writeFileSync(patch, [
|
||||
'- id: tool-todo',
|
||||
' disabled: false',
|
||||
' config: {}',
|
||||
'- insert:',
|
||||
' - id: web-probe-good',
|
||||
` name: ${pathToFileURL(join(root, 'good.mjs')).href}`,
|
||||
' config:',
|
||||
` events: ${JSON.stringify(events)}`,
|
||||
` stop: ${JSON.stringify(stop)}`,
|
||||
' - id: web-probe-import-failure',
|
||||
` name: ${pathToFileURL(join(root, 'missing.mjs')).href}`,
|
||||
' - id: web-probe-sync-failure',
|
||||
` name: ${pathToFileURL(join(root, 'sync-failure.mjs')).href}`,
|
||||
' - id: web-probe-async-failure',
|
||||
` name: ${pathToFileURL(join(root, 'async-failure.mjs')).href}`,
|
||||
' - id: web-probe-pending',
|
||||
` name: ${pathToFileURL(join(root, 'pending.mjs')).href}`,
|
||||
' - id: web-probe-disabled-failure',
|
||||
` name: ${pathToFileURL(join(root, 'good.mjs')).href}`,
|
||||
' disabled: !!js "JSON.parse(\'invalid\')"',
|
||||
'',
|
||||
].join('\n'))
|
||||
return { root, home, patch, events, stop }
|
||||
}
|
||||
|
||||
async function waitForStartup(
|
||||
stdout: Readable | null,
|
||||
stderr: Readable | null,
|
||||
completion: PromiseLike<{ exitCode?: number }>,
|
||||
): Promise<{ url: string; stderr: string }> {
|
||||
if (stdout === null || stderr === null) throw new Error('Web child pipes are unavailable')
|
||||
stdout.setEncoding('utf8')
|
||||
stderr.setEncoding('utf8')
|
||||
let stdoutText = ''
|
||||
let stderrText = ''
|
||||
let url: string | undefined
|
||||
const ready = Promise.withResolvers<{ url: string; stderr: string }>()
|
||||
let settled = false
|
||||
const finish = (): void => {
|
||||
if (settled || url === undefined) return
|
||||
if (!stderrText.includes('dsh: warning: 6 entries did not activate')) return
|
||||
if (!stderrText.includes('web async apply failure')) return
|
||||
if (!stderrText.includes('webProbeMissingService')) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
ready.resolve({ url, stderr: stderrText })
|
||||
}
|
||||
stdout.on('data', (chunk: string) => {
|
||||
stdoutText += chunk
|
||||
url ??= /dsh web: (http:\/\/[^\s]+)/u.exec(stdoutText)?.[1]
|
||||
finish()
|
||||
})
|
||||
stderr.on('data', (chunk: string) => {
|
||||
stderrText += chunk
|
||||
finish()
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
ready.reject(new Error(`Web profile did not report ready and failed entries\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`))
|
||||
}, 60_000)
|
||||
void completion.then((result) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
ready.reject(new Error(`Web profile exited before readiness (code ${String(result.exitCode)})\nstdout:\n${stdoutText}\nstderr:\n${stderrText}`))
|
||||
})
|
||||
return ready.promise
|
||||
}
|
||||
|
||||
describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', () => {
|
||||
it('serves the full Web app while unrelated entries fail to start', async () => {
|
||||
const fixture = createFixture()
|
||||
const child = execa(process.execPath, [
|
||||
dshBin,
|
||||
'--profile', 'web',
|
||||
'--patch', fixture.patch,
|
||||
'--no-open',
|
||||
'--port', '0',
|
||||
], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-best-effort-no-call',
|
||||
DSH_AGENTS_HOME: join(fixture.root, '.agents'),
|
||||
DSH_HOME: fixture.home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
NODE_NO_WARNINGS: '1',
|
||||
},
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 90_000,
|
||||
killSignal: 'SIGKILL',
|
||||
})
|
||||
|
||||
let result: Awaited<typeof child>
|
||||
let events = ''
|
||||
try {
|
||||
const startup = await waitForStartup(child.stdout, child.stderr, child)
|
||||
const auth = await fetch(startup.url, { redirect: 'manual' })
|
||||
const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0]
|
||||
if (cookie === undefined) throw new Error('Web authentication response did not set a cookie')
|
||||
const page = await fetch(new URL('/', startup.url), { headers: { cookie } })
|
||||
const html = await page.text()
|
||||
expect(html).toContain('<div id="root"></div>')
|
||||
expect(html).toContain('__DSH_BOOT__')
|
||||
expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\n')
|
||||
expect(startup.stderr).toContain('web-probe-import-failure')
|
||||
expect(startup.stderr).toContain('@deepseek-ai/dsh-tool-todo')
|
||||
expect(startup.stderr).toContain('web sync apply failure')
|
||||
expect(startup.stderr).toContain('web async apply failure')
|
||||
expect(startup.stderr).toContain('pending (waiting for service: webProbeMissingService)')
|
||||
expect(startup.stderr).toContain('web-probe-disabled-failure')
|
||||
expect(startup.stderr).toContain('disabled expression failed: SyntaxError')
|
||||
} finally {
|
||||
writeFileSync(fixture.stop, 'stop')
|
||||
result = await child
|
||||
events = readFileSync(fixture.events, 'utf8')
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect({
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
events,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"events": "good apply
|
||||
good dispose
|
||||
",
|
||||
"exitCode": 0,
|
||||
"timedOut": false,
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['modules', 'missing dependency'],
|
||||
['connection', 'missing dependency'],
|
||||
['modules', 'disabled expression'],
|
||||
['connection', 'disabled expression'],
|
||||
])('fails the full Web profile on required %s %s failure', async (id, failure) => {
|
||||
const fixture = createFixture()
|
||||
const patch = failure === 'disabled expression'
|
||||
? 'disabled: !!js "JSON.parse(\'invalid\')"'
|
||||
: 'inject: [webProbeMissingRequiredService]'
|
||||
const diagnostic = failure === 'disabled expression'
|
||||
? 'disabled expression failed: SyntaxError'
|
||||
: 'pending (waiting for service: webProbeMissingRequiredService)'
|
||||
writeFileSync(fixture.patch, `${readFileSync(fixture.patch, 'utf8')}- id: ${id}\n ${patch}\n`)
|
||||
try {
|
||||
const result = await execa(process.execPath, [
|
||||
dshBin,
|
||||
'--profile', 'web',
|
||||
'--patch', fixture.patch,
|
||||
'--no-open',
|
||||
'--port', '0',
|
||||
], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-required-no-call',
|
||||
DSH_AGENTS_HOME: join(fixture.root, '.agents'),
|
||||
DSH_HOME: fixture.home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
NODE_NO_WARNINGS: '1',
|
||||
},
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 90_000,
|
||||
killSignal: 'SIGKILL',
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).not.toContain('dsh web: http://')
|
||||
expect(result.stderr).toContain('required startup failure')
|
||||
expect(result.stderr).toContain(`${id} (@deepseek-ai/dsh-client-${id}): ${diagnostic}`)
|
||||
expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n')
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails the full Web profile when its required HTTP server cannot bind', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-web-required-bind-'))
|
||||
const home = join(root, 'home')
|
||||
mkdirSync(home)
|
||||
const blocker = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const fail = (error: Error): void => { reject(error) }
|
||||
blocker.once('error', fail)
|
||||
blocker.listen(0, '127.0.0.1', () => {
|
||||
blocker.off('error', fail)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = blocker.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('port blocker did not bind a TCP address')
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await execa(process.execPath, [
|
||||
dshBin,
|
||||
'--profile', 'web',
|
||||
'--no-open',
|
||||
'--port', String(address.port),
|
||||
], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-required-bind-no-call',
|
||||
DSH_AGENTS_HOME: join(root, '.agents'),
|
||||
DSH_HOME: home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
NODE_NO_WARNINGS: '1',
|
||||
},
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 90_000,
|
||||
killSignal: 'SIGKILL',
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).not.toContain('dsh web: http://')
|
||||
expect(result.stderr).toContain('required startup failure')
|
||||
expect(result.stderr).toContain('EADDRINUSE')
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.close((error) => { if (error === undefined) resolve(); else reject(error) })
|
||||
})
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('fails and cleans up when detached work rejects after application startup', async () => {
|
||||
const fixture = createFixture()
|
||||
const plugin = join(fixture.root, 'detached.mjs')
|
||||
writeFileSync(plugin, [
|
||||
'export function apply(ctx) {',
|
||||
' ctx.effect(() => ctx.get("appReady").onReady(() => {',
|
||||
' void Promise.reject(new Error("detached Web failure"))',
|
||||
' }))',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(fixture.patch, readFileSync(fixture.patch, 'utf8') + [
|
||||
'- insert:',
|
||||
' - id: detached-probe',
|
||||
` name: ${pathToFileURL(plugin).href}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
try {
|
||||
const result = await execa(process.execPath, [
|
||||
dshBin,
|
||||
'--profile', 'web',
|
||||
'--patch', fixture.patch,
|
||||
'--no-open',
|
||||
'--port', '0',
|
||||
], {
|
||||
cwd: fixture.root,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-detached-no-call',
|
||||
DSH_AGENTS_HOME: join(fixture.root, '.agents'),
|
||||
DSH_HOME: fixture.home,
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
NODE_NO_WARNINGS: '1',
|
||||
},
|
||||
input: '',
|
||||
reject: false,
|
||||
timeout: 90_000,
|
||||
killSignal: 'SIGKILL',
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.signal).toBeUndefined()
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain('fatal load failure: Error: detached Web failure')
|
||||
expect(readFileSync(fixture.events, 'utf8')).toBe('good apply\ngood dispose\n')
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,404 @@
|
||||
/** Failure policy through the built Web process and native configuration watcher. */
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createServer } from 'node:http'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FiberState } from '@deepseek-ai/cordis'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
|
||||
const bin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
const built = existsSync(bin) && existsSync(join(repoRoot, 'apps/web/dist/index.html'))
|
||||
const failures = [
|
||||
['import', 'missing.mjs'],
|
||||
['module evaluation', 'matrix module evaluation'],
|
||||
['schema', 'matrix schema failure'],
|
||||
['config expression', 'matrix config expression'],
|
||||
['disabled expression', 'matrix disabled expression'],
|
||||
['sync apply', 'matrix sync apply'],
|
||||
['async apply', 'matrix async apply'],
|
||||
['dependency', 'matrixMissingService'],
|
||||
] as const
|
||||
type Failure = typeof failures[number][0]
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-web-failure-matrix-'))
|
||||
const home = join(root, 'home')
|
||||
mkdirSync(home)
|
||||
const events = join(root, 'events')
|
||||
const diagnostics = join(root, 'diagnostics')
|
||||
const serverUrl = join(root, 'server-url')
|
||||
const states = join(root, 'states')
|
||||
const stop = join(root, 'stop')
|
||||
const patch = join(home, 'cordis.patch.yml')
|
||||
const watcher = join(root, 'watcher.patch.yml')
|
||||
// Native delivery remains real; completed writes bypass Chokidar's 50 ms change suppression.
|
||||
writeFileSync(watcher, JSON.stringify([{ id: 'hmr', disabled: false, config: {
|
||||
root: [], awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
|
||||
} }]) + '\n')
|
||||
writeFileSync(events, '')
|
||||
writeFileSync(diagnostics, '')
|
||||
writeFileSync(states, '{}')
|
||||
const observerPath = join(root, 'observer.mjs')
|
||||
// HMR reports through Cordis logger exporters; WARN is above their default INFO threshold.
|
||||
writeFileSync(observerPath, [
|
||||
"import { appendFileSync, writeFileSync, renameSync, rmSync } from 'node:fs'",
|
||||
"import { inspect } from 'node:util'",
|
||||
'export function apply(ctx, config) {',
|
||||
' const report = message => appendFileSync(config.path, message.args.map(arg => inspect(arg)).join(" ") + "\\n")',
|
||||
' ctx.logger.buffer.forEach(report)',
|
||||
' ctx.logger.exporter({ levels: { default: 3 }, export: report })',
|
||||
' const timer = setInterval(() => {',
|
||||
' const loader = ctx.get("loader")',
|
||||
' if (!loader) return',
|
||||
' writeFileSync(config.states + ".tmp", JSON.stringify(Object.fromEntries([...loader.entries()].map(entry => [entry.options.id, entry.fiber?.state]))))',
|
||||
' renameSync(config.states + ".tmp", config.states)',
|
||||
' }, 20)',
|
||||
' ctx.effect(() => () => { clearInterval(timer) })',
|
||||
' ctx.inject(["webServer", "connection"], scope => {',
|
||||
' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
|
||||
' scope.effect(() => () => { rmSync(config.url, { force: true }) })',
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
const observer = { id: 'matrix-log-observer', name: pathToFileURL(observerPath).href, config: { path: diagnostics, url: serverUrl, states } }
|
||||
const plugin = join(root, 'probe.mjs')
|
||||
writeFileSync(plugin, [
|
||||
"import { appendFileSync, existsSync } from 'node:fs'",
|
||||
'export const Config = { "~standard": { version: 1, vendor: "matrix", validate(value) {',
|
||||
' return value.mode === "schema" ? { issues: [{ message: "matrix schema failure" }] } : { value }',
|
||||
'} } }',
|
||||
'export const apply = (ctx, config) => {',
|
||||
' if (config.mode === "sync apply") throw new Error("matrix sync apply")',
|
||||
' return activate(ctx, config)',
|
||||
'}',
|
||||
'async function activate(ctx, config) {',
|
||||
' if (config.mode === "async apply") { await Promise.resolve(); throw new Error("matrix async apply") }',
|
||||
' if (config.provider) ctx.provide(config.provider, true)',
|
||||
' if (config.mode === "detached") setImmediate(() => { void Promise.reject(new Error("matrix detached failure")) })',
|
||||
' appendFileSync(config.events, `${config.label} apply ${config.generation}\\n`)',
|
||||
' const timer = setInterval(() => { if (existsSync(config.stop)) process.emit("SIGTERM") }, 20)',
|
||||
' ctx.effect(() => () => { clearInterval(timer); appendFileSync(config.events, `${config.label} dispose ${config.generation}\\n`) })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(root, 'evaluation.mjs'), 'throw new Error("matrix module evaluation")\n')
|
||||
const url = pathToFileURL(plugin).href
|
||||
const config = (label: string, generation: number, mode = '') => ({ label, generation, mode, events, stop })
|
||||
const witness = (generation: number) => ({ id: 'matrix-witness', name: url, config: config('witness', generation) })
|
||||
const target = (id: string, failure?: Failure, generation = 1) => ({
|
||||
id,
|
||||
name: failure === 'import' ? pathToFileURL(join(root, 'missing.mjs')).href
|
||||
: failure === 'module evaluation' ? pathToFileURL(join(root, 'evaluation.mjs')).href : url,
|
||||
...(failure === 'dependency' ? { inject: ['matrixMissingService'] } : {}),
|
||||
config: config('target', generation, failure),
|
||||
})
|
||||
const render = (id: string, failure?: Failure, generation = 1) => {
|
||||
let text = `- insert: ${JSON.stringify([observer, witness(generation), target(id, failure, generation)])}`
|
||||
if (failure === 'config expression') text += `\n- id: ${id}\n config: !!js "(() => { throw new Error('matrix config expression') })()"\n`
|
||||
if (failure === 'disabled expression') text += `\n- id: ${id}\n disabled: !!js "(() => { throw new Error('matrix disabled expression') })()"\n`
|
||||
return text + '\n'
|
||||
}
|
||||
writeFileSync(patch, JSON.stringify([{ insert: [observer, witness(0)] }]) + '\n')
|
||||
return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target }
|
||||
}
|
||||
|
||||
function start(f: ReturnType<typeof fixture>, extra: string[] = []) {
|
||||
const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
|
||||
cwd: f.root,
|
||||
env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' },
|
||||
input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL',
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout?.setEncoding('utf8').on('data', (text: string) => { stdout += text })
|
||||
child.stderr?.setEncoding('utf8').on('data', (text: string) => { stderr += text })
|
||||
let exited = false
|
||||
void child.then(() => { exited = true })
|
||||
async function wait(predicate: () => boolean) {
|
||||
try {
|
||||
await expect.poll(() => {
|
||||
if (exited) throw new Error('Web process exited')
|
||||
return predicate()
|
||||
}, { timeout: 45_000 }).toBe(true)
|
||||
} catch (cause) { throw new Error(`Web condition failed\n${stdout}\n${stderr}\n${readFileSync(f.diagnostics, 'utf8')}\n${readFileSync(f.events, 'utf8')}`, { cause }) }
|
||||
}
|
||||
async function serves(currentServer = false) {
|
||||
await wait(() => /dsh web: http:\/\//u.test(stdout))
|
||||
const url = currentServer ? readFileSync(f.serverUrl, 'utf8') : /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1]
|
||||
if (!url) throw new Error('Missing Web URL')
|
||||
const auth = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(10_000) })
|
||||
const cookie = auth.headers.get('set-cookie')?.split(';', 1)[0]
|
||||
if (!cookie) throw new Error('Missing Web authentication cookie')
|
||||
const response = await fetch(new URL('/', url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
|
||||
expect(response.status).toBe(200)
|
||||
const html = await response.text()
|
||||
expect(html).toContain('__DSH_BOOT__')
|
||||
const bundlePath = /<script src="(\/plugins\/[^"]+)"/u.exec(html)?.[1]?.replaceAll('&', '&')
|
||||
if (!bundlePath) throw new Error('Missing bootstrap bundle URL')
|
||||
const bundle = await fetch(new URL(bundlePath, url), { headers: { cookie }, signal: AbortSignal.timeout(10_000) })
|
||||
expect(bundle.status).toBe(200)
|
||||
expect(await bundle.text()).not.toBe('')
|
||||
}
|
||||
async function close() {
|
||||
writeFileSync(f.stop, 'stop')
|
||||
// Assertions may fail before a probe applies; forceful teardown still awaits exit.
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), 10_000)
|
||||
try {
|
||||
const result = await child
|
||||
return { timedOut: result.timedOut, signal: result.signal, exitCode: result.exitCode, stderr: result.stderr, events: readFileSync(f.events, 'utf8') }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
rmSync(f.root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
const state = (id: string) => (JSON.parse(readFileSync(f.states, 'utf8')) as Record<string, number | undefined>)[id]
|
||||
return { child, wait, serves, close, state, stderr: () => stderr, logs: () => readFileSync(f.diagnostics, 'utf8'), events: () => readFileSync(f.events, 'utf8') }
|
||||
}
|
||||
|
||||
function exit(result: { timedOut: boolean; signal?: string | undefined; exitCode?: number | undefined; stderr: string }, code: number) {
|
||||
expect(result.timedOut, result.stderr).toBe(false)
|
||||
expect(result.signal, result.stderr).toBeUndefined()
|
||||
expect(result.exitCode, result.stderr).toBe(code)
|
||||
}
|
||||
|
||||
describe.skipIf(!built)('Web process failure matrix', () => {
|
||||
for (const required of [false, true]) {
|
||||
const id = required ? 'acp' : 'matrix-optional'
|
||||
it.each(failures)(`${required ? 'required' : 'optional'} startup %s`, async (failure, diagnostic) => {
|
||||
const f = fixture()
|
||||
writeFileSync(f.patch, f.render(id, failure))
|
||||
const app = start(f)
|
||||
try {
|
||||
if (required) {
|
||||
const result = await app.child
|
||||
exit(result, 1)
|
||||
expect(result.stdout).not.toContain('dsh web: http://')
|
||||
expect(result.stderr).toContain('required startup failure')
|
||||
expect(app.events()).toBe('witness apply 1\nwitness dispose 1\n')
|
||||
} else {
|
||||
await app.serves()
|
||||
await app.wait(() => (app.stderr() + app.logs()).includes(diagnostic))
|
||||
expect(app.stderr()).toContain('warning: 1 entry did not activate')
|
||||
expect(app.events()).toBe('witness apply 1\n')
|
||||
}
|
||||
expect(app.stderr() + app.logs()).toContain(diagnostic)
|
||||
} finally {
|
||||
const result = await app.close()
|
||||
exit(result, required ? 1 : 0)
|
||||
expect(result.events).toContain('witness dispose 1\n')
|
||||
}
|
||||
})
|
||||
|
||||
it.each(failures)(`${required ? 'required' : 'optional'} native HMR %s keeps siblings and recovers`, async (failure, diagnostic) => {
|
||||
const f = fixture()
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render(id, failure))
|
||||
await app.wait(() => app.events().includes('witness apply 1\n') && (failure === 'dependency' ? app.state(id) === FiberState.PENDING : app.logs().includes(diagnostic)))
|
||||
expect(app.events()).not.toContain('witness dispose 1\n')
|
||||
expect(app.events()).not.toContain('target apply')
|
||||
expect(readFileSync(f.patch, 'utf8')).toBe(f.render(id, failure))
|
||||
await app.serves()
|
||||
if (failure === 'dependency') {
|
||||
writeFileSync(f.patch, JSON.stringify([
|
||||
{ insert: [f.observer, f.witness(1), f.target(id, failure)] },
|
||||
{ insert: [{ id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 2), provider: 'matrixMissingService' } }] },
|
||||
]) + '\n')
|
||||
} else writeFileSync(f.patch, f.render(id, undefined, 2))
|
||||
await app.wait(() => app.events().includes(`target apply ${failure === 'dependency' ? 1 : 2}\n`))
|
||||
await app.serves()
|
||||
expect(app.stderr()).not.toContain('required startup failure')
|
||||
} finally {
|
||||
const result = await app.close()
|
||||
exit(result, 0)
|
||||
expect(result.events).toContain(`witness dispose ${failure === 'dependency' ? 1 : 2}\n`)
|
||||
expect(result.events).toContain(`target dispose ${failure === 'dependency' ? 1 : 2}\n`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it.each([
|
||||
['missing', undefined, 'failed to read overlay'],
|
||||
['unreadable directory', undefined, 'failed to read overlay'],
|
||||
['malformed', 'invalid: [unclosed\n', 'failed to parse overlay'],
|
||||
['non-array', 'entries: []\n', 'top-level YAML array'],
|
||||
['non-mapping', '- null\n', 'must be a mapping'],
|
||||
])('rejects a %s explicit overlay before readiness', async (_kind, content, diagnostic) => {
|
||||
const f = fixture()
|
||||
const overlay = join(f.root, 'invalid.patch.yml')
|
||||
if (_kind === 'unreadable directory') mkdirSync(overlay)
|
||||
else if (content !== undefined) writeFileSync(overlay, content)
|
||||
const app = start(f, ['--patch', overlay])
|
||||
try {
|
||||
const result = await app.child
|
||||
exit(result, 1)
|
||||
expect(result.stderr).toContain(diagnostic)
|
||||
expect(result.stdout).not.toContain('dsh web: http://')
|
||||
expect(app.events()).toBe('')
|
||||
} finally { exit(await app.close(), 1) }
|
||||
})
|
||||
|
||||
it.each([
|
||||
['malformed', 'invalid: [unclosed\n', 'failed to parse patches'],
|
||||
['non-array', 'entries: []\n', 'top-level YAML array'],
|
||||
['non-mapping', '- null\n', 'must be a mapping'],
|
||||
])('native HMR rejects %s patches, preserves the app and accepts a correction', async (_kind, content, diagnostic) => {
|
||||
const f = fixture()
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, content)
|
||||
await app.wait(() => app.logs().includes(diagnostic))
|
||||
expect(app.events()).toBe('witness apply 0\n')
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render('matrix-optional'))
|
||||
await app.wait(() => app.events().includes('target apply 1\n'))
|
||||
await app.serves()
|
||||
} finally { exit(await app.close(), 0) }
|
||||
})
|
||||
|
||||
it('native HMR schema failure retains the existing config until a valid correction', async () => {
|
||||
const f = fixture()
|
||||
writeFileSync(f.patch, f.render('acp', undefined, 1))
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render('acp', 'schema', 2))
|
||||
await app.wait(() => app.logs().includes('matrix schema failure') && app.events().includes('witness apply 2\n'))
|
||||
expect(app.events()).toContain('target apply 1\n')
|
||||
expect(app.events()).not.toContain('target dispose 1\n')
|
||||
expect(app.events()).not.toContain('target apply 2\n')
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render('acp', undefined, 3))
|
||||
await app.wait(() => app.events().includes('target apply 3\n'))
|
||||
expect(app.events()).toContain('target dispose 1\n')
|
||||
await app.serves()
|
||||
} finally { exit(await app.close(), 0) }
|
||||
})
|
||||
|
||||
it('ignores absent and explicitly disabled required entries at startup', async () => {
|
||||
const f = fixture()
|
||||
writeFileSync(f.patch, f.render('acp', 'import') + '- id: acp\n disabled: true\n')
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
expect(app.stderr()).not.toContain('required startup failure')
|
||||
expect(app.stderr()).not.toContain('failed to import')
|
||||
} finally { exit(await app.close(), 0) }
|
||||
})
|
||||
|
||||
it('detached rejection from a hot-loaded plugin terminates and disposes the app', async () => {
|
||||
const f = fixture()
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, JSON.stringify([{ insert: [f.observer, f.witness(0), {
|
||||
id: 'matrix-detached', name: f.url, config: f.config('detached', 1, 'detached'),
|
||||
}] }]) + '\n')
|
||||
const result = await app.child
|
||||
exit(result, 1)
|
||||
expect(result.stderr).toContain('fatal load failure: Error: matrix detached failure')
|
||||
expect(app.events()).toContain('witness dispose 0\n')
|
||||
expect(app.events()).toContain('detached dispose 1\n')
|
||||
} finally { exit(await app.close(), 1) }
|
||||
})
|
||||
|
||||
it('native HMR reports a required Web server bind failure without terminating the process', async () => {
|
||||
const f = fixture()
|
||||
const blocker = createServer()
|
||||
const app = start(f)
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once('error', reject)
|
||||
blocker.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = blocker.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Missing blocker address')
|
||||
await app.serves()
|
||||
writeFileSync(f.patch, f.render('matrix-optional') + `- id: webserver\n config:\n host: 127.0.0.1\n port: ${address.port}\n`)
|
||||
await app.wait(() => app.logs().includes('EADDRINUSE') && app.events().includes('witness apply 1\n'))
|
||||
expect(app.stderr()).not.toContain('required startup failure')
|
||||
expect(app.events()).not.toContain('witness dispose 1\n')
|
||||
expect(existsSync(f.serverUrl)).toBe(false)
|
||||
writeFileSync(f.patch, f.render('matrix-optional', undefined, 2))
|
||||
await app.wait(() => app.events().includes('witness apply 2\n') && existsSync(f.serverUrl))
|
||||
await app.serves(true)
|
||||
} finally {
|
||||
try { exit(await app.close(), 0) } finally {
|
||||
await new Promise<void>((resolve, reject) => blocker.close((error) => { if (error) reject(error); else resolve() }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['startup', 'HMR'])('optional HTTP bind failure at %s leaves Web serving', async (phase) => {
|
||||
const f = fixture()
|
||||
const blocker = createServer()
|
||||
let app: ReturnType<typeof start> | undefined
|
||||
try {
|
||||
const plugin = join(f.root, 'http.mjs')
|
||||
writeFileSync(plugin, [
|
||||
'import { createServer } from "node:http"',
|
||||
'export async function apply(ctx, config) {',
|
||||
' const server = createServer()',
|
||||
' ctx.effect(() => () => new Promise(resolve => server.close(() => resolve())))',
|
||||
' await new Promise((resolve, reject) => { server.once("error", reject); server.listen(config.port, "127.0.0.1", resolve) })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
blocker.once('error', reject)
|
||||
blocker.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = blocker.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Missing blocker address')
|
||||
const patch = JSON.stringify([{ insert: [f.observer, f.witness(0), {
|
||||
id: 'matrix-optional-http', name: pathToFileURL(plugin).href, config: { port: address.port },
|
||||
}] }]) + '\n'
|
||||
if (phase === 'startup') writeFileSync(f.patch, patch)
|
||||
app = start(f)
|
||||
const running = app
|
||||
await app.serves()
|
||||
if (phase === 'HMR') writeFileSync(f.patch, patch)
|
||||
await app.wait(() => (running.logs() + running.stderr()).includes('EADDRINUSE'))
|
||||
expect(app.events()).toBe('witness apply 0\n')
|
||||
await app.serves()
|
||||
} finally {
|
||||
try {
|
||||
if (app) exit(await app.close(), 0)
|
||||
else rmSync(f.root, { recursive: true, force: true })
|
||||
} finally {
|
||||
if (blocker.listening) {
|
||||
await new Promise<void>((resolve, reject) => blocker.close((error) => { if (error) reject(error); else resolve() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['modules', 'connection'])('native HMR recovers the shipped required %s entry', async (id) => {
|
||||
const f = fixture()
|
||||
const app = start(f)
|
||||
try {
|
||||
await app.serves()
|
||||
// Entry-level injection requirements are captured when the fiber is created.
|
||||
writeFileSync(f.patch, f.render('matrix-optional') + `- id: ${id}\n disabled: true\n`)
|
||||
await app.wait(() => app.state(id) === FiberState.DISPOSED)
|
||||
const inject = id === 'connection' ? ['webRuntime', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
|
||||
const pending = f.render('matrix-optional', undefined, 2) + `- id: ${id}\n inject: ${JSON.stringify(inject)}\n`
|
||||
writeFileSync(f.patch, pending)
|
||||
await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n'))
|
||||
expect(app.events()).not.toContain('witness dispose 2\n')
|
||||
expect(app.stderr()).not.toContain('required startup failure')
|
||||
writeFileSync(f.patch, pending + `- insert: ${JSON.stringify([{
|
||||
id: 'matrix-provider', name: f.url, config: { ...f.config('provider', 3), provider: 'matrixMissingWebDependency' },
|
||||
}])}\n`)
|
||||
await app.wait(() => app.state(id) === FiberState.ACTIVE && existsSync(f.serverUrl))
|
||||
await app.serves(true)
|
||||
} finally { exit(await app.close(), 0) }
|
||||
})
|
||||
})
|
||||
@@ -101,7 +101,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
// Skip the resident fixture's three questions, then resolve its approval so
|
||||
// the ordinary composer bar (which owns ContextMeter) resumes.
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Skip this question' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Skip' }))
|
||||
}
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/** Historical Messages provider replay preserves its recorded identity and DeepSeek model group. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/deepseek-messages-chat', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let replayFixture: string
|
||||
|
||||
beforeAll(async () => {
|
||||
replayFixture = await selectedSessionFixture(FIXTURE, false)
|
||||
scaffold = await launchWebScaffold({
|
||||
deepSeekMessages: true,
|
||||
replayFixture,
|
||||
paceMs: 5,
|
||||
replayProviders: [{
|
||||
id: 'deepseek-messages', name: 'DeepSeek',
|
||||
models: [{
|
||||
id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash',
|
||||
contextWindow: 1_000_000, defaultMaxTokens: 256_000,
|
||||
reasoningEfforts: ['off', 'low', 'high', 'max'], defaultReasoningEffort: 'high',
|
||||
}],
|
||||
}],
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
try { await browser?.close() } finally { await scaffold?.close() }
|
||||
})
|
||||
|
||||
it('replays the historical Messages provider while displaying DeepSeek in the model selector', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-chat'))
|
||||
const prompts = fixtureUserPrompts(await readFile(replayFixture, 'utf8'))
|
||||
expect(prompts).toHaveLength(1)
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByText('DeepSeek', { exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(prompts[0]!)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
const session = scaffold.ctx.sessions.get(sessionId)!
|
||||
expect(session.requestHeader()?.config.provider).toBe('deepseek-messages')
|
||||
await page.getByText('MESSAGES_WEB_READY', { exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'),
|
||||
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the recorded-session inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Opt-in Web Messages configuration, credential reuse, and recovery from a saved Chat Completions selection. */
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
|
||||
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
|
||||
|
||||
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-in', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
await scaffold?.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('offers one DeepSeek card and saves Messages settings using the existing credential reference', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-settings'))
|
||||
expect(scaffold.ctx.llm.listProviders()).toContainEqual({ id: 'deepseek-official', name: 'DeepSeek' })
|
||||
expect(scaffold.ctx.llm.listProviders().filter(provider => provider.id === 'deepseek-official')).toHaveLength(1)
|
||||
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' })
|
||||
const onboarding = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await onboarding.getByLabel('API 密钥', { exact: true }).fill('sk-messages-onboarding')
|
||||
await onboarding.getByRole('button', { name: '保存并继续' }).click()
|
||||
await onboarding.waitFor({ state: 'detached' })
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
await dialog.getByRole('button', { name: '模型', exact: true }).click()
|
||||
await dialog.getByText('DeepSeek', { exact: true }).waitFor()
|
||||
expect(await dialog.getByText('DeepSeek', { exact: true }).count()).toBe(1)
|
||||
await dialog.getByText('DeepSeek', { exact: true }).locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
|
||||
const messages = dialog
|
||||
await messages.getByText('自定义设置', { exact: true }).click()
|
||||
expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder'))
|
||||
.toBe('https://api.deepseek.com/anthropic')
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'cards.expected.md'),
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
await messages.getByLabel('API 密钥', { exact: true }).fill('sk-e2e-messages')
|
||||
await messages.getByLabel('API 地址', { exact: true }).fill('https://messages.example/anthropic')
|
||||
expect(await messages.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash')
|
||||
await messages.getByLabel('显示名称 1', { exact: true }).fill('Messages Flash')
|
||||
await messages.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor()
|
||||
|
||||
const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(settings).toContain('https://messages.example/anthropic')
|
||||
expect(settings).toContain('llm-deepseek:')
|
||||
await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
|
||||
name: 'Messages Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
|
||||
})
|
||||
expect(scaffold.ctx.settings.get('llm-deepseek')).toMatchObject({ protocol: 'messages' })
|
||||
expect(settings).not.toContain('sk-e2e-')
|
||||
const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
|
||||
expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages')
|
||||
expect(credentials).not.toContain('DEEPSEEK_MESSAGES_API_KEY')
|
||||
expect(await page.locator('body').innerText()).not.toContain('sk-e2e-')
|
||||
await page.keyboard.press('Escape')
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'messages-settings-e2e')
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).waitFor()
|
||||
await compareOrRefreshGolden(join(EXPECTED, 'picker.expected.md'),
|
||||
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode())
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps a saved Chat Completions selection available after the YAML protocol switch', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default'))
|
||||
await page.keyboard.press('Escape')
|
||||
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await expect.poll(() => input.isEnabled()).toBe(true)
|
||||
await page.getByRole('button', { name: /^选择模型/ }).click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click()
|
||||
await expect.poll(() => input.isEnabled()).toBe(true)
|
||||
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-official')
|
||||
const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(settings).toContain('provider: deepseek-official')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "插件":
|
||||
- img
|
||||
- text: 插件
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
- img "API 密钥已配置"
|
||||
- button "编辑 DeepSeek (deepseek-official)": 编辑
|
||||
- text: DeepSeek deepseek-official API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 已配置——输入新值可替换
|
||||
- group:
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: https://api.deepseek.com/anthropic
|
||||
- text: 请填写与当前连接配置兼容的 API 地址。
|
||||
- region "模型目录":
|
||||
- text: 模型目录 正在使用适配器默认模型
|
||||
- textbox "模型 ID 1":
|
||||
- /placeholder: 模型 ID
|
||||
- text: deepseek-flash
|
||||
- textbox "显示名称 1":
|
||||
- /placeholder: 显示名称
|
||||
- text: DeepSeek-V41-Flash
|
||||
- button "容量 1":
|
||||
- img
|
||||
- button "删除模型 1":
|
||||
- img
|
||||
- textbox "模型 ID 2":
|
||||
- /placeholder: 模型 ID
|
||||
- text: deepseek-v4-flash
|
||||
- textbox "显示名称 2":
|
||||
- /placeholder: 显示名称
|
||||
- text: DeepSeek-V4-Flash
|
||||
- button "容量 2":
|
||||
- img
|
||||
- button "删除模型 2":
|
||||
- img
|
||||
- textbox "模型 ID 3":
|
||||
- /placeholder: 模型 ID
|
||||
- text: deepseek-v4-pro
|
||||
- textbox "显示名称 3":
|
||||
- /placeholder: 显示名称
|
||||
- text: DeepSeek-V4-Pro
|
||||
- button "容量 3":
|
||||
- img
|
||||
- button "删除模型 3":
|
||||
- img
|
||||
- textbox "模型 ID 4":
|
||||
- /placeholder: 模型 ID
|
||||
- text: deepseek-v4-flash-vision-exp
|
||||
- textbox "显示名称 4":
|
||||
- /placeholder: 显示名称
|
||||
- text: DeepSeek-V4-Flash-Vision-Exp
|
||||
- button "容量 4":
|
||||
- img
|
||||
- button "删除模型 4":
|
||||
- img
|
||||
- button "添加模型":
|
||||
- img
|
||||
- text: 添加模型
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
- button "添加自定义提供方":
|
||||
- img
|
||||
- text: 添加自定义提供方
|
||||
@@ -0,0 +1,9 @@
|
||||
- menu "模型与推理等级":
|
||||
- group "DeepSeek":
|
||||
- text: DeepSeek
|
||||
- menuitemradio "Messages Flash" [checked]:
|
||||
- text: Messages Flash
|
||||
- img
|
||||
- menuitemradio "DeepSeek-V4-Flash"
|
||||
- menuitemradio "DeepSeek-V4-Pro"
|
||||
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
|
||||
@@ -31,6 +31,7 @@
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: https://api.deepseek.com
|
||||
- text: 请填写与当前连接配置兼容的 API 地址。
|
||||
- region "模型目录":
|
||||
- text: 模型目录 正在使用适配器默认模型
|
||||
- textbox "模型 ID 1":
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: https://api.deepseek.com
|
||||
- text: 请填写与当前连接配置兼容的 API 地址。
|
||||
- region "模型目录":
|
||||
- text: 模型目录 已自定义模型目录
|
||||
- button "恢复默认模型"
|
||||
|
||||
@@ -428,22 +428,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await recoveryPage.context().setOffline(false)
|
||||
await expect.poll(() => recoveryPage.evaluate(() => navigator.onLine)).toBe(true)
|
||||
const connecting = recoveryPage.getByRole('button', {
|
||||
name: 'Reconnecting automatically, reconnect now', exact: true,
|
||||
name: 'Reconnecting, reconnect now', exact: true,
|
||||
})
|
||||
await connecting.waitFor({ timeout: 10_000 })
|
||||
expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
|
||||
const connectingGeometry = await connectionIndicatorGeometry(connecting)
|
||||
expect(await connectionIndicatorTextAlignment(connecting)).toBe('left')
|
||||
// Animated dots must remain hidden with their state label during hover.
|
||||
await connecting.evaluate((element) => {
|
||||
for (const animation of element.getAnimations({ subtree: true })) {
|
||||
if (!(animation instanceof CSSAnimation)) continue
|
||||
animation.pause()
|
||||
animation.currentTime = 1_250
|
||||
}
|
||||
})
|
||||
// Hover keeps the state label; the pill never swaps copy or resizes.
|
||||
await connecting.hover()
|
||||
expect(await connecting.innerText()).toBe('Reconnect now')
|
||||
expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
|
||||
expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry)
|
||||
await recoveryPage.mouse.move(0, 0)
|
||||
|
||||
@@ -461,7 +454,6 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
const indicator = connecting
|
||||
expect(await connectionIndicatorGeometry(indicator)).toEqual(connectingGeometry)
|
||||
expect(await connectionIndicatorTextAlignment(indicator)).toBe('left')
|
||||
await indicator.hover()
|
||||
const snapshot = await captureStableAria(recoveryPage, '[class*="footArea"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(CONNECTION_ERROR_EXPECTED, snapshot, MODE)
|
||||
const style = await indicator.evaluate((element) => {
|
||||
@@ -498,11 +490,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await connecting.waitFor()
|
||||
await recoveryPage.clock.fastForward(500)
|
||||
await expect.poll(() => sockets.length).toBe(11)
|
||||
const idleBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await indicator.hover()
|
||||
expect(await indicator.innerText()).toBe('Reconnect now')
|
||||
expect(await indicator.innerText()).toMatch(/^Reconnecting\.{1,3}$/)
|
||||
const hoverBackground = await indicator.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
expect(hoverBackground).toBe(idleBackground)
|
||||
await recoveryPage.mouse.down()
|
||||
await expect.poll(() => indicator.evaluate(element => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(hoverBackground)
|
||||
@@ -513,7 +503,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
const recovered = recoveryPage.getByRole('status')
|
||||
await recovered.waitFor({ timeout: 10_000 })
|
||||
expect(await recovered.innerText()).toBe('Connected')
|
||||
expect(await connectionIndicatorGeometry(recovered)).toEqual(connectingGeometry)
|
||||
// The pill sizes to its current label; chrome height and icon box stay fixed.
|
||||
const recoveredGeometry = await connectionIndicatorGeometry(recovered)
|
||||
expect(recoveredGeometry.outer[3]).toBe(connectingGeometry.outer[3])
|
||||
expect(recoveredGeometry.icon).toEqual(connectingGeometry.icon)
|
||||
expect(await connectionIndicatorTextAlignment(recovered)).toBe('left')
|
||||
await recoveryPage.clock.fastForward(2_000)
|
||||
await recovered.waitFor({ state: 'detached', timeout: 5_000 })
|
||||
|
||||
@@ -345,7 +345,7 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
|
||||
|
||||
// Settle the wait so teardown is not racing a pending question.
|
||||
await composer.getByRole('button', { name: 'Skip this question' }).click()
|
||||
await composer.getByRole('button', { name: 'Skip' }).click()
|
||||
expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
|
||||
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
+30
-20
@@ -5,7 +5,7 @@
|
||||
// layer stack the profile boot composes), patched the
|
||||
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
|
||||
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
|
||||
// replay (default, keyless: normally disables the llm-deepseek row and
|
||||
// replay (default, keyless: normally disables the direct DeepSeek rows and
|
||||
// inserts dsh-llm-replay in providers mode), record (real adapter + key,
|
||||
// harvests fixtures from live session memory), refresh (keyless replay that
|
||||
// rewrites goldens). A first-run option keeps the real adapter mounted while
|
||||
@@ -18,7 +18,7 @@
|
||||
// disabled (recorded fixtures must not embed this repo's AGENTS.md);
|
||||
// session-title-llm disabled (its fire-and-forget title call would race the
|
||||
// loop for the session's replay cursor); webserver pinned to port 0 with the
|
||||
// built dist; ordinary keyless modes disable llm-deepseek and fill the open
|
||||
// built dist; ordinary keyless modes disable both direct adapters and fill the open
|
||||
// llm seam post-boot with installLlmReplay on the settled root ctx
|
||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||
// assertConsumed for the teardown fixture-consumption check).
|
||||
@@ -57,7 +57,7 @@ import {
|
||||
type NormalizeContext,
|
||||
} from '@deepseek-ai/dsh-session-snapshot'
|
||||
import {
|
||||
assertEntriesLoaded,
|
||||
auditStartupEntries,
|
||||
composeEntries,
|
||||
healProfilesModuleFallback,
|
||||
loadOverlayPatches,
|
||||
@@ -187,7 +187,7 @@ const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml
|
||||
const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
|
||||
|
||||
// Replay publishes the provider catalog the gateway routes to (providers
|
||||
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
|
||||
// mode, never catch-all: with both direct adapters disabled no adapter exists, so a
|
||||
// catch-all would leave resolveModelInfo unroutable and compaction-basic's
|
||||
// post-step pressure check would warn every step). The published
|
||||
// contextWindow keeps that pressure path provably inert for small fixtures.
|
||||
@@ -248,11 +248,14 @@ class RouteOnlyAdapter extends LlmAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
|
||||
if (contextWindow === undefined) return REPLAY_PROVIDERS
|
||||
function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS {
|
||||
return REPLAY_PROVIDERS.map(provider => ({
|
||||
...provider,
|
||||
models: provider.models.map(model => ({ ...model, contextWindow })),
|
||||
id: messages ? 'deepseek-messages' : provider.id,
|
||||
models: provider.models.map(model => ({
|
||||
...model,
|
||||
...contextWindow === undefined ? {} : { contextWindow },
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -305,7 +308,7 @@ export interface LaunchOptions {
|
||||
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
|
||||
* in replay/refresh modes; ignored in record mode (the real adapter
|
||||
* answers). Omit for scenarios issuing no model calls — a stray stream then
|
||||
* fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row
|
||||
* fails loud with NO_ADAPTER (both direct adapters are disabled and no replay row
|
||||
* mounts). With {@link replayProvidersOnly}, the fixture must record no
|
||||
* model calls (its header alone mounts the catalog).
|
||||
*/
|
||||
@@ -362,6 +365,8 @@ export interface LaunchOptions {
|
||||
* keyless first-run configuration lane; the default disables the adapter.
|
||||
*/
|
||||
deepSeekMissingCredential?: boolean
|
||||
/** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */
|
||||
deepSeekMessages?: boolean
|
||||
/** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
|
||||
welcomeNoticePending?: boolean
|
||||
/**
|
||||
@@ -443,6 +448,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
|
||||
}
|
||||
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
|
||||
const messages = options.deepSeekMessages === true
|
||||
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
|
||||
let credentialEnvironmentRestored = false
|
||||
const restoreCredentialEnvironment = (): void => {
|
||||
@@ -516,10 +522,13 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
const patches: PatchOptions[] = [
|
||||
...basePatches,
|
||||
...surfacePatches,
|
||||
// Keyless scenarios retain the recorded default; explicit scenario overlays win.
|
||||
...mode === 'record' || options.deepSeekMissingCredential === true
|
||||
? []
|
||||
: [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
|
||||
// The historical Messages fixture retains its recorded route during replay;
|
||||
// live configuration uses the shared DeepSeek route. Explicit overlays win.
|
||||
...messages
|
||||
? [{ id: 'agent-default-model', config: { provider: mode === 'record' || maskDeepSeekCredential ? 'deepseek-official' : 'deepseek-messages', model: maskDeepSeekCredential ? 'deepseek-flash' : 'deepseek-v4-flash' } }]
|
||||
: mode === 'record' || options.deepSeekMissingCredential === true
|
||||
? []
|
||||
: [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
|
||||
...extraOverlayPatches,
|
||||
// The roster's shipped presets are the plugin's own, bundled inside
|
||||
// `dsh-agent-presets` and prepended by it. Pin only the machine-local
|
||||
@@ -644,9 +653,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
baseURL: options.deepSeekSearch.baseURL,
|
||||
},
|
||||
}],
|
||||
...mode === 'record' || options.deepSeekMissingCredential === true
|
||||
? []
|
||||
: [{ id: 'llm-deepseek', disabled: true }],
|
||||
...maskDeepSeekCredential && !messages ? [] : [
|
||||
{ id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential,
|
||||
config: { protocol: messages ? 'messages' : 'chat-completions' } },
|
||||
],
|
||||
]
|
||||
|
||||
// Sessions inherit the gateway's process.cwd() default; run the boot from
|
||||
@@ -722,7 +732,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
config: { path: pathToFileURL(rootConfig).href, patches },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, 'web e2e scaffold')
|
||||
await auditStartupEntries(ctx, 'web e2e scaffold')
|
||||
if (options.welcomeNoticePending !== true) {
|
||||
await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
|
||||
@@ -735,7 +745,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
port = boundPort
|
||||
|
||||
// Fill the open llm seam on the settled root ctx. Ordinary keyless modes
|
||||
// disable llm-deepseek; the first-run lane keeps it mounted but has no
|
||||
// disable the direct adapter; the first-run lane keeps the selected adapter but has no
|
||||
// replay fixture and never streams. The direct install, unlike the plugin
|
||||
// row, returns the ReplayHandle for the teardown consumption check.
|
||||
if (options.replayProvidersOnly) {
|
||||
@@ -772,7 +782,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
if (mode !== 'record' && replayFixture !== undefined) {
|
||||
replayHandle = installLlmReplay(ctx, {
|
||||
file: replayFixture,
|
||||
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({
|
||||
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow, messages)).map(provider => ({
|
||||
...provider,
|
||||
...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }),
|
||||
})),
|
||||
@@ -787,8 +797,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// a fixture would, with streaming that still fails loud: the scenario
|
||||
// issues no model calls, and one that slipped in must not pass quietly.
|
||||
ctx.effect(() => ctx.llm.registerAdapter(
|
||||
replayProviders(options.replayContextWindow).map(provider => provider.id),
|
||||
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
|
||||
replayProviders(options.replayContextWindow, messages).map(provider => provider.id),
|
||||
new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)),
|
||||
), 'web e2e scaffold: route-only adapter')
|
||||
}
|
||||
baseUrl = `http://${browserHost}:${String(port)}`
|
||||
|
||||
@@ -79,6 +79,8 @@ afterEach(async () => {
|
||||
it('assembles the shipped Web transport, catalog, guidance, and defaults', async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||
const ctx = scaffold.ctx
|
||||
expect(ctx.llm.listProviders().some(provider => provider.id === 'deepseek-messages')).toBe(false)
|
||||
expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' })
|
||||
const index = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}`, {
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"tests/plugin-config.e2e.ts",
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
"tests/models-settings.e2e.ts",
|
||||
"tests/deepseek-messages-settings.e2e.ts",
|
||||
"tests/deepseek-messages-chat.e2e.ts",
|
||||
"tests/models-settings-recovery.e2e.ts",
|
||||
"tests/default-model.e2e.ts",
|
||||
"tests/github-ready-review.e2e.ts",
|
||||
|
||||
Reference in new issue
Block a user