test(web): cover keyless DeepSeek onboarding

This commit is contained in:
Yichen Jiang
2026-07-30 12:41:22 +08:00
parent 9182db00ef
commit 0b689e0d2c
5 changed files with 140 additions and 12 deletions

View File

@@ -0,0 +1,83 @@
// Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
// credential is absent, onboarding writes the effective reference through
// the real wire into an isolated harness home, and the live page converges
// without a reload or model call.
import { randomBytes } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const browserConsole: string[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
tripwire = watchConsole(page)
page.on('console', message => browserConsole.push(message.text()))
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('stores a key write-only and observes configured state without restarting', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
const dialog = page.getByRole('dialog', { name: '添加 DeepSeek API 密钥' })
await dialog.waitFor({ timeout: 15_000 })
expect(await dialog.getByLabel('提供方').inputValue()).toBe('DeepSeek')
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
await dialog.getByLabel('API 密钥', { exact: true }).fill(secret)
await dialog.getByRole('button', { name: '保存并继续' }).click()
await dialog.waitFor({ state: 'detached', timeout: 15_000 })
const stored = await readFile(join(scaffold.harnessHome, '.env'), '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)
// The same running composition reuses the refreshed join. Opening Models
// and its credential control proves the configured view without reload.
await page.getByRole('button', { name: '设置', exact: true }).click()
const settings = page.getByRole('dialog', { name: '设置' })
await settings.getByRole('button', { name: '模型' }).click()
const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
await deepSeekRow.waitFor({ timeout: 10_000 })
await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
await settings.getByText('已配置', { exact: true }).waitFor({ timeout: 10_000 })
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)
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md'])
})
})

View File

@@ -4,18 +4,20 @@
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
// replay (default, keyless: llm-deepseek row disabled, dsh-llm-replay row
// inserted in providers mode), record (real adapter + key, harvests fixtures
// from live session memory), refresh (keyless replay that rewrites goldens).
// replay (default, keyless: normally disables the llm-deepseek row 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
// masking its credential, without making a model call.
//
// Composition divergences from `dsh web`, all deliberate, all via include
// patches over the SAME tree (never a second yml): temp persistenceRoot;
// workspace-context 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; keyless modes disable llm-deepseek 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
// port 0 with the built dist; ordinary keyless modes disable llm-deepseek 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).
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
@@ -125,6 +127,12 @@ export interface LaunchOptions {
* remain reconstructable without making the tools a product default.
*/
cordisTools?: boolean
/**
* Keep the shipped DeepSeek adapter mounted while masking the process
* environment's DEEPSEEK_API_KEY for this scaffold lifetime. This is the
* keyless first-run configuration lane; the default disables the adapter.
*/
deepSeekMissingCredential?: boolean
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -151,6 +159,21 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
}
}
if (mode === 'record' && options.deepSeekMissingCredential === true) {
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
}
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
let credentialEnvironmentRestored = false
const restoreCredentialEnvironment = (): void => {
if (credentialEnvironmentRestored || !maskDeepSeekCredential) return
credentialEnvironmentRestored = true
if (originalDeepSeekCredential === undefined) {
Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
} else {
process.env.DEEPSEEK_API_KEY = originalDeepSeekCredential
}
}
const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')))
// Isolated harness home: the settings/credentials rows resolve $DSH_HOME
// paths at load, and an in-process boot must NEVER touch the developer's
@@ -165,6 +188,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
throw error
}
if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
// The include patch set — the same mechanism AppCLIEntry and the ACP
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
@@ -187,7 +211,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
...options.cordisTools === true
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
: [],
...mode === 'record' ? [] : [{ id: 'llm-deepseek', disabled: true }],
...mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'llm-deepseek', disabled: true }],
]
// Sessions inherit the gateway's process.cwd() default; run the boot from
@@ -216,10 +242,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}
port = boundPort
// Fill the open llm seam on the settled root ctx (llm-deepseek is disabled
// in keyless modes; a scenario with no fixture leaves the seam empty so a
// stray stream fails loud with NO_ADAPTER). The direct install, unlike the
// plugin row, returns the ReplayHandle for the teardown consumption check.
// 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
// replay fixture and never streams. The direct install, unlike the plugin
// row, returns the ReplayHandle for the teardown consumption check.
if (mode !== 'record' && options.replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: options.replayFixture,
@@ -231,6 +257,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
} catch (error) {
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
restoreCredentialEnvironment()
if (cleanupFailures.length > 0) {
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
}
@@ -279,7 +306,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
} catch (error) {
failures.push(error)
}
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
try {
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
} finally {
restoreCredentialEnvironment()
}
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
},
}

View File

@@ -0,0 +1,12 @@
- dialog "添加 DeepSeek API 密钥":
- heading "添加 DeepSeek API 密钥" [level=2]
- button "稍后配置":
- img
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
- text: 提供方
- textbox "提供方": DeepSeek
- text: API 密钥
- textbox "API 密钥":
- /placeholder: 输入 DeepSeek API 密钥
- button "模型高级设置"
- button "保存并继续" [disabled]

View File

@@ -30,6 +30,7 @@
"tests/lifecycle-chrome.e2e.ts",
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/seeded-history.e2e.ts",