Merge remote-tracking branch 'origin/master' into HEAD

# Conflicts:
#	apps/cli/reference/README.i18n.yaml
This commit is contained in:
Huanqi Cao
2026-08-08 12:09:04 +08:00
488 changed files with 17066 additions and 2369 deletions

View File

@@ -18,6 +18,12 @@ flowchart LR
cfg --> plugin_dsh_base_llm
plugin_dsh_base_session["session<br/>@deepseek-ai/dsh-session"]
cfg --> plugin_dsh_base_session
plugin_dsh_base_typert["typert<br/>@deepseek-ai/dsh-typert-registry"]
cfg --> plugin_dsh_base_typert
plugin_dsh_base_typert_loader["typert-loader<br/>@deepseek-ai/dsh-typert-loader"]
cfg --> plugin_dsh_base_typert_loader
plugin_dsh_base_typert_gateway["typert-gateway<br/>@deepseek-ai/dsh-api-gateway"]
cfg --> plugin_dsh_base_typert_gateway
plugin_dsh_base_session_title["session-title<br/>@deepseek-ai/dsh-session-title"]
cfg --> plugin_dsh_base_session_title
plugin_dsh_base_session_title_llm["session-title-llm<br/>@deepseek-ai/dsh-session-title-first-message-llm"]
@@ -159,6 +165,9 @@ flowchart LR
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
| `llm` | `@deepseek-ai/dsh-llm` |
| `session` | `@deepseek-ai/dsh-session` |
| `typert` | `@deepseek-ai/dsh-typert-registry` |
| `typert-loader` | `@deepseek-ai/dsh-typert-loader` |
| `typert-gateway` | `@deepseek-ai/dsh-api-gateway` |
| `session-title` | `@deepseek-ai/dsh-session-title` |
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
| `user-interaction` | `@deepseek-ai/dsh-user-interaction` |

View File

@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-frontend-static": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",

View File

@@ -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/reference/README.md
README.md: 07b162517b90215a6552810124f15c435144f7d4
README.zh.md: 80030707474e59e76583ed7edc5ddce56334cc03
README.md: ca39f89c2bb6f6ab184644d4ebaec72d62a4a0fa
README.zh.md: a0e9ae936855377410199600fdc3d7a84a54b144

View File

@@ -59,7 +59,7 @@ New sessions default to the `workspace-write` permission preset. Bash and filesy
## Shared deployment behavior
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials live in `$DSH_HOME/.env` or the ambient environment and remain rotatable because the launcher never hoists the credential file into `process.env`. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it.
Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision.

View File

@@ -59,7 +59,7 @@ dsh web --dump-config
## 共享部署行为
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据存放在 `$DSH_HOME/.env` 或环境中;启动器从不把凭据文件提升到 `process.env`,因此凭据可以轮换。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env``$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。
会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。

View File

@@ -10,7 +10,7 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
import { loadLayeredEnv } from '@deepseek-ai/dsh-app-boot'
import { parseDshArgs } from './args.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
@@ -24,13 +24,13 @@ function readVersion(): string {
return typeof manifest.version === 'string' ? manifest.version : '0.0.0'
}
loadEnv('dsh')
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
switch (invocation.mode) {
case 'profile': {
const { runProfile } = await import('./profile-boot.ts')
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
patchFiles: invocation.patches,
...invocation.task !== undefined && { task: invocation.task },
@@ -39,7 +39,7 @@ switch (invocation.mode) {
}
case 'web': {
const { runWeb } = await import('./web.ts')
await runWeb(invocation)
await runWeb(invocation, loadLayeredEnv('dsh'))
break
}
case 'plugin': {

View File

@@ -25,6 +25,7 @@ import {
type Profile,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
import { resolveWindowsShellLayer } from './windows-shell.ts'
@@ -174,6 +175,8 @@ export interface RunProfileOptions {
task?: string
/** Surface setup registered after Loader installation and before any config-tree entry mounts. */
prepare?: (ctx: Context, rows: ProfileRows) => Promise<void> | void
/** This run's frozen environment snapshot, provided to the tree before any entry mounts. */
environment: EnvironmentSnapshot
}
/**
@@ -238,6 +241,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
// application must not mutate the objects later reloads recompose from.
const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
app.current = hostCtx
// Before any config-tree entry mounts, so a plugin that resolves a
// user-facing value at construction already sees this run's layers.
hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment)
if (options.task !== undefined) {
const io: HeadlessIo = {
stdout: process.stdout,

View File

@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import { runProfile, type ProfileRows } from './profile-boot.ts'
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
@@ -124,9 +125,11 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean {
* every boot. The URL line is printed by the web-app bundle's runtime row
* after Loader settlement.
* @param flags - the parsed `dsh web` flag family.
* @param environment - this run's frozen environment snapshot.
*/
export async function runWeb(flags: WebFlags): Promise<void> {
export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise<void> {
await runProfile({
environment,
profile: 'web',
patchFiles: flags.patches,
deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags),

View File

@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { execa } from 'execa'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -13,14 +14,21 @@ const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordi
async function runBuiltBin(
args: readonly string[] = [],
env: Record<string, string> = {},
env: Readonly<Record<string, string | undefined>> = {},
cwd?: string,
): Promise<{ stdout: string; code: number; stderr: string }> {
const childEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env })
.filter((entry): entry is [string, string] => entry[1] !== undefined),
)
const result = await execa(process.execPath, [dshBin, ...args], {
input: '',
timeout: 25_000,
killSignal: 'SIGKILL',
reject: false,
env,
env: childEnv,
extendEnv: false,
...cwd === undefined ? {} : { cwd },
})
if (result.timedOut) {
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
@@ -127,6 +135,44 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) {
})
}
function createEnvironmentProbeProfile(home: string, project: string): void {
const pluginFile = join(project, 'environment-probe.mjs')
writeFileSync(pluginFile, [
"export const name = 'environment-probe'",
"export const inject = ['llm']",
'export function apply(ctx) {',
' void ctx.loader.await().then(async () => {',
" let text = ''",
' for await (const chunk of ctx.llm.stream({',
" provider: 'deepseek-official',",
" model: 'deepseek-v4-flash',",
' messages: [],',
' maxTokens: 32,',
' })) {',
" if (chunk.type === 'text-delta') text += chunk.text",
' }',
' process.stdout.write(`${text}\\n`)',
" process.kill(process.pid, 'SIGTERM')",
' })',
'}',
'',
].join('\n'))
const profileDir = join(home, 'profiles', 'environment-probe')
mkdirSync(profileDir, { recursive: true })
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
name: 'dsh-profile-environment-probe',
private: true,
dependencies: {},
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
}, undefined, 2))
writeFileSync(join(profileDir, 'cordis.patch.yml'), [
'- insert:',
' - id: environment-probe',
` name: ${pathToFileURL(pluginFile).href}`,
'',
].join('\n'))
}
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
it('requires --profile and rejects removed commands', async () => {
const bare = await runBuiltBin()
@@ -144,6 +190,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}
}, 30_000)
it('does not load a project environment for --version', async () => {
const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
try {
const result = await runBuiltBin(['--version'], {}, project)
expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' })
} finally {
rmSync(project, { recursive: true, force: true })
}
})
it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
try {
@@ -156,6 +213,47 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}
}, 30_000)
it('uses the Harness-home environment and managed credential through the published entry', async () => {
const apiKey = 'built-home-layer-key'
const server = await startMockLlmServer({
sequence: ['success'],
apiKey,
successText: 'home environment reached the mock',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
writeFileSync(join(home, '.env'), `DEEPSEEK_BASE_URL=${server.baseURL}\n`)
writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
createEnvironmentProbeProfile(home, project)
try {
const result = await runBuiltBin(
['--profile', 'environment-probe'],
{
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: undefined,
DEEPSEEK_BASE_URL: undefined,
},
project,
)
expect(
result.code,
`${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
).toBe(0)
expect(result.stdout).toBe('home environment reached the mock')
expect(result.stdout).not.toContain(apiKey)
expect(result.stderr).not.toContain(apiKey)
expect(server.requests).toHaveLength(1)
expect(server.requests[0]?.path).toBe('/chat/completions')
expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
} finally {
await server.close()
rmSync(home, { recursive: true, force: true })
rmSync(project, { recursive: true, force: true })
}
}, 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

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>DeepSeek Harness</title>
</head>

View File

@@ -0,0 +1,16 @@
{
"id": "/",
"name": "DeepSeek Harness",
"short_name": "DSH",
"start_url": "/",
"scope": "/",
"display": "fullscreen",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}

View File

@@ -1,5 +1,5 @@
// Shared scaffolding for the assembled-jsdom snapshots: the real built
// `packages/client/*/lib/client.js` artifacts booted through AppWebEntry's
// workspace `lib/client.js` artifacts booted through AppWebEntry's
// ModuleLoader path (loadBundle) against the keyless FixtureApiClient
// transport. Every file that mounts this graph needs the same boot entry list,
// the same bundle map, the same jsdom globals, and the same mount call, and
@@ -14,18 +14,21 @@ import { afterEach, beforeEach, vi } from 'vitest'
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
/** Boot entries for the minimal assembled graph, each carrying the workspace directory its bundle is read from. */
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
/** Boot entries for the minimal assembled graph, each carrying the workspace bundle it loads. */
const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
{ id: '@deepseek-ai/dsh-typert-registry', bundlePath: 'packages/typert/registry/lib/client.js', url: '/plugins/typert-registry.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-typert-registry'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
dir: 'ui-workspace',
bundlePath: 'packages/client/ui-workspace/lib/client.js',
url: '/plugins/ui-workspace.js',
rev: 'fx',
inject: [
@@ -34,12 +37,12 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
'@deepseek-ai/dsh-client-ui-sidebar',
],
},
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
]
const bundles = new Map(PLUGINS.map(plugin => [
plugin.url,
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
readFileSync(join(process.cwd(), plugin.bundlePath), 'utf8'),
]))
interface FixtureWindow extends Window {
@@ -97,7 +100,7 @@ export function mountAssembledApp(): void {
const root = document.createElement('div')
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
act(() => {
const entry = new AppWebEntry(root, {
loadBundle: async (url) => {

View File

@@ -121,7 +121,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
await page.getByText('Into the Unknown', { exact: false }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)

View File

@@ -75,8 +75,8 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
const originalSource = await readFile(sourcePath)
const originalBundle = await readFile(bundlePath)
const oldText = "Let's start building"
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
const oldText = 'Into the Unknown'
const sourceNeedle = "'hero.headline': 'Into the Unknown'"
const newText = `HMR UPDATED ${'x'.repeat(80)}`
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)

View File

@@ -159,7 +159,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
}
// The blank frame renders the hero, not the resident composer: the
// headline plus the guidance placeholder are the empty state's anchors.
await expect.poll(() => page.getByText("Let's start building", { exact: false }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { timeout: 15_000 }).toBe(1)
const input = page.locator('textarea').first()
await input.waitFor({ timeout: 10_000 })
if (MODE !== 'record') {

View File

@@ -134,7 +134,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
await dialog.getByRole('button', { name: '保存', exact: true }).click()
// The profile lands in settings.yaml with only the derived reference, the
// key value lands in the harness home's .env, the dormant route
// key value lands in the harness home's .credentials.yaml, the dormant route
// registers, and the topology frame invalidates the page into the row.
await expect.poll(
async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
@@ -146,11 +146,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(document).toContain('minimax-cn:')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
expect(document).not.toContain('sk-e2e-minimax')
const credentialFile = join(scaffold.harnessHome, '.env')
const credentialFile = join(scaffold.harnessHome, '.credentials.yaml')
await expect.poll(
async () => readFile(credentialFile, 'utf8').catch(() => ''),
{ timeout: 10_000 },
).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
).toContain('MINIMAX_CN_API_KEY: sk-e2e-minimax')
expect(await page.content()).not.toContain('sk-e2e-minimax')
}, 60_000)
@@ -230,7 +230,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
{ timeout: 10_000 },
).not.toContain('minimax-cn:')
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
expect(await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8'))
.not.toContain('MINIMAX_CN_API_KEY')
await expect.poll(
async () => page.getByRole('dialog', { name: '删除 minimax-cn' }).count(),

View File

@@ -113,8 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await settings.getByRole('button', { name: '保存', exact: true }).click()
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
expect((await page.content()).includes(secret)).toBe(false)
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
expect(browserConsole.some(line => line.includes(secret))).toBe(false)

View File

@@ -0,0 +1,27 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { expect, it } from 'vitest'
const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
it('ships install metadata with the built web application', async () => {
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
expect(index).toContain('<link rel="manifest" href="/manifest.webmanifest" />')
const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
expect(manifest).toEqual({
id: '/',
name: 'DeepSeek Harness',
short_name: 'DSH',
start_url: '/',
scope: '/',
display: 'fullscreen',
icons: [{
src: '/favicon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
}],
})
})

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// Assembled search-card snapshot: boots the real built `packages/client/*/lib/
// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless
// Assembled search-card snapshot: boots the real built workspace client bundles
// through AppWebEntry's ModuleLoader path against the keyless
// FixtureApiClient transport (no API key, no model round), opens the fixture
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
// the assembled application. The built-boot smoke proves the graph boots but

View File

@@ -1,7 +1,8 @@
// Web e2e scenarios: the settings surface — the modal shell (trigger, nav,
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token +
// browser theme-color metadata)
// the Language row (settings-scoped localization + persisted dsh.locale),
// the busy-state Enter preference, plus Permission as the persisted default
// for subsequently created sessions.
@@ -154,17 +155,37 @@ describe('web e2e: settings modal and General preferences', () => {
it('flips the theme through the Appearance cubes and persists across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
await page.evaluate(() => ({
interface ThemeState {
attr: boolean
background: string
stored: string | null
themeColor: string | null
themeColorCount: number
token: string
}
const readState = async (): Promise<ThemeState> => await page.evaluate(() => {
const metas = document.head.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')
const computed = getComputedStyle(document.body)
return {
attr: document.body.hasAttribute('data-ds-dark-theme'),
token: getComputedStyle(document.body).getPropertyValue('--dsw-alias-bg-base').trim(),
background: computed.backgroundColor,
stored: localStorage.getItem('dsh.theme'),
}))
themeColor: metas[0]?.content ?? null,
themeColorCount: metas.length,
token: computed.getPropertyValue('--dsw-alias-bg-base').trim(),
}
})
const expectThemeColorSynchronized = (state: ThemeState): void => {
expect(state.themeColorCount).toBe(1)
expect(state.background).not.toBe('rgba(0, 0, 0, 0)')
expect(state.themeColor).toBe(state.background)
}
// Pin the OS scheme to light so the default `system` preference resolves
// light and the dark flip below is unambiguously the gesture's doing.
await page.emulateMedia({ colorScheme: 'light' })
const light = await readState()
expect(light.attr).toBe(false)
expectThemeColorSynchronized(light)
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置' })
@@ -179,6 +200,7 @@ describe('web e2e: settings modal and General preferences', () => {
expect(dark.attr).toBe(true)
expect(dark.stored).toBe('dark')
expect(dark.token).not.toBe(light.token)
expectThemeColorSynchronized(dark)
await page.keyboard.press('Escape')
// Reload: the preference survives boot (restore + presenter initial apply).
@@ -190,6 +212,7 @@ describe('web e2e: settings modal and General preferences', () => {
const reloaded = await readState()
expect(reloaded.attr).toBe(true)
expect(reloaded.stored).toBe('dark')
expectThemeColorSynchronized(reloaded)
// `system` follows the emulated OS scheme (dark stays dark, light clears).
await page.getByRole('button', { name: '设置', exact: true }).click()
@@ -197,12 +220,15 @@ describe('web e2e: settings modal and General preferences', () => {
await systemCube.click()
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
expectThemeColorSynchronized(await readState())
await page.emulateMedia({ colorScheme: 'dark' })
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(true)
expectThemeColorSynchronized(await readState())
// Restore for the specs that follow: light preference beats the emulated
// dark OS scheme, leaving the shared page in the light default.
await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '浅色' }).click()
await expect.poll(async () => (await readState()).attr, { timeout: 5_000 }).toBe(false)
expectThemeColorSynchronized(await readState())
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building Preview
- text: Into the Unknown Preview
- button "Choose workspace":
- img
- text: workspace

View File

@@ -20,7 +20,7 @@
- button "Settings":
- img
- text: Settings
- text: Let's start building Preview
- text: Into the Unknown Preview
- button "Choose workspace":
- img
- text: workspace

View File

@@ -145,7 +145,7 @@ describe('web e2e: startup auto-selection', () => {
// seat with `visibility:hidden`, which Playwright reports as not visible).
await page.waitForSelector(ROOT_PHASE, { timeout: 15_000 })
expect(await page.locator(ROOT_PHASE).first().getAttribute('data-phase')).toBe('hero')
expect(await page.getByText("Let's start building").isVisible()).toBe(true)
expect(await page.getByText('Into the Unknown').isVisible()).toBe(true)
expect(await page.locator('textarea').first().isVisible()).toBe(true)
releaseHistory()