feat(web): unify onboarding dialogs

This commit is contained in:
ZiyaZhang
2026-08-13 01:19:33 -07:00
parent fb0f0ba799
commit 9ee5aef98c
54 changed files with 1388 additions and 342 deletions

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/web/tests/README.md
README.md: 4d7236ef69189845dbc2847289d497fc54c94ae9
README.zh.md: 21c2f2ce7ccc8fe5314d766c06235453aa292031
README.md: acb0c300bafe221f6a92f0168908bebf965377b9
README.zh.md: fd3d950a106375bb038b205415407b4a31bd2b32

View File

@@ -30,7 +30,8 @@ artifact it produces itself.
When a scenario needs a Client-owned constant or pure function, mirror it here
instead, next to the commented-out import that names the source module. A drift
then surfaces as a missed selector or a stale mirrored value — a loud failure,
never a silent pass.
never a silent pass. `scaffold.ts` follows this rule for the welcome-notice
namespace, acknowledgement field, version, and asserted Chinese copy.
Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so
it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest

View File

@@ -24,7 +24,7 @@ Client face而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/re
当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的
import 点明源模块。这样漂移会表现为选择器未命中或镜像值过期——是响亮的失败,绝不会是静默
通过。
通过。`scaffold.ts` 按此规则镜像欢迎声明的 namespace、确认字段、版本和被断言的中文文案。
有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从
`@deepseek-ai/dsh-client-web` import `AppWebEntry`、从

View File

@@ -26,7 +26,7 @@ describe('web e2e: goal bar clear convergence', () => {
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)

View File

@@ -1,6 +1,7 @@
// Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
// credential is absent, onboarding routes to the real Models editor, and its
// write lands in an isolated harness home without a reload or model call.
// credential is absent, both ordered steps share the shipped modal chrome,
// and the inline key write lands in an isolated harness home without a reload
// or model call.
import { randomBytes } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
@@ -8,13 +9,17 @@ 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 { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
WELCOME_NOTICE_VERSION,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
const MODE = webSnapshotMode()
@@ -27,7 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
const browserConsole: string[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
browser = await chromium.launch()
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
@@ -44,53 +49,40 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
it('stores a key write-only and observes configured state without restarting', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
await credentialStep.waitFor({ timeout: 15_000 })
const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
for (const paragraph of WELCOME_NOTICE_COPY.zh.body.split('\n\n')) {
expect(await welcome.getByText(paragraph, { exact: true }).count()).toBe(1)
}
expect(await welcome.getByRole('button').allTextContents()).toEqual([
WELCOME_NOTICE_COPY.zh.continueLabel,
])
const welcomeAria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
const mask = page.locator('[class*="onboardingMask"]')
expect(await mask.count()).toBe(1)
const maskStyles = await mask.evaluate((mask) => {
const style = getComputedStyle(mask)
const rect = mask.getBoundingClientRect()
return {
position: style.position,
left: style.left,
right: style.right,
top: style.top,
bottom: style.bottom,
background: style.backgroundColor,
backdropFilter: style.backdropFilter,
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
}
})
expect(maskStyles).toEqual({
position: 'absolute',
left: '0px',
right: '0px',
top: '80px',
bottom: '0px',
background: 'rgba(0, 0, 0, 0.24)',
backdropFilter: 'blur(2px)',
rect: { left: 0, top: 80, right: 1440, bottom: 960 },
})
// Observation is not acknowledgement: the exact version is persisted
// only by the explicit action, so a reload still presents this dialog.
const firstReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
await credentialStep.getByRole('button', { name: '前往配置' }).click()
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
const keyInput = settings.getByLabel('API 密钥', { exact: true })
const credentialStep = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
await credentialStep.waitFor({ timeout: 15_000 })
const keyInput = credentialStep.getByLabel('API 密钥', { exact: true })
await keyInput.waitFor({ timeout: 10_000 })
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
await keyInput.fill(secret)
await settings.getByRole('button', { name: '保存', exact: true }).click()
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
await credentialStep.getByRole('button', { name: '保存并继续' }).click()
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
@@ -98,8 +90,15 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
// The same open Models surface reuses the refreshed join and exposes the
const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
// The ordinary Models surface reuses the refreshed join and exposes the
// configured write-only placeholder without a reload.
await page.getByRole('button', { name: '设置', exact: true }).click()
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
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()
@@ -114,7 +113,21 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
// An old acknowledgement means materially revised copy: welcome returns,
// while the already-configured provider step remains complete.
await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
}])
const thirdReloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
expect((await page.content()).includes(secret)).toBe(false)
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
@@ -125,10 +138,9 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
it('never paints the takeover chrome on a configured reload, even with the settings join held open', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-configured-reload'))
// Regression pin for the reload white flash: the credential step is
// satisfied (credential configured), yet it must LOAD its
// private join before it can decide not to show. The chrome lives inside
// the step (OnboardingSurface), so the deciding window paints and blocks
// Regression pin for the reload flash: both steps are satisfied, yet each
// must load private facts before deciding not to show. Dialog chrome lives
// inside each visible branch, so the deciding window paints and blocks
// nothing. Holding settings.describe widens that window from loopback
// RTT scale to a deterministic hundreds of milliseconds, removing all
// timing dependence from the sampler assertions below.
@@ -141,7 +153,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
const sightings: string[] = []
;(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings = sightings
setInterval(() => {
if (document.querySelector('[class*="onboardingStage"], [class*="onboardingMask"]') !== null) {
if (document.querySelector(
'[role="dialog"][aria-label="内测声明"], '
+ '[role="dialog"][aria-label="添加一个 API Key 开始使用"]',
) !== null) {
sightings.push('chrome')
}
if (document.getElementById('root')?.inert === true) sightings.push('inert')
@@ -171,7 +186,8 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
expect(await page.evaluate(() =>
(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings)).toEqual([])
expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
@@ -228,7 +244,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
it('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(
SNAPSHOT_DIR,
['missing.expected.md', 'models.expected.md'],
['welcome.expected.md', 'missing.expected.md', 'models.expected.md'],
)
})
})

View File

@@ -44,14 +44,17 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
it('closes the setup card without discarding the add card beside it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-setup-card-cancel'))
const credentialStep = page.getByRole('region', { name: CREDENTIAL_STEP })
const credentialStep = page.getByRole('dialog', { name: CREDENTIAL_STEP })
await credentialStep.waitFor({ timeout: 15_000 })
await credentialStep.getByRole('button', { name: '前往配置' }).click()
await credentialStep.getByRole('button', { name: '稍后配置' }).click()
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
await page.getByRole('button', { name: '设置', exact: true }).click()
const settings = page.getByRole('dialog', { name: '设置' })
await settings.waitFor({ timeout: 10_000 })
// Nothing is reachable yet, so DeepSeek presents itself as its open card.
// The onboarding step no longer navigates into Settings on dismissal, so
// enter the Models section explicitly before exercising its normal cards.
await settings.getByRole('button', { name: '模型' }).click()
const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true })
await setupKey.waitFor({ timeout: 10_000 })
@@ -66,15 +69,14 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
{ timeout: 10_000 },
).toBe(2)
// Cancelling the setup card is the regression: it used to leave itself open
// and close the add card, discarding that draft.
// Cancelling the setup card must not close the independent add-provider
// draft beside it.
await settings.getByRole('button', { name: '取消', exact: true }).first().click()
expect(await settings.getByLabel('提供方').count()).toBe(1)
await expect.poll(
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
{ timeout: 10_000 },
).toBe(1)
// DeepSeek is now an ordinary row: a missing-key dot and an Edit button.
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
@@ -104,10 +106,9 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
// The regression: the step read only the official route's credential, so a
// fully configured user was taken over on every blank session.
await expect.poll(
async () => page.getByRole('region', { name: CREDENTIAL_STEP }).count(),
async () => page.getByRole('dialog', { name: CREDENTIAL_STEP }).count(),
{ timeout: 10_000 },
).toBe(0)
expect(await page.locator('[class*="onboardingStage"]').count()).toBe(0)
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
// The Models page agrees: DeepSeek stays a row rather than reopening its

View File

@@ -0,0 +1,60 @@
// Trusted non-loopback Web access cannot call the loopback-only settings API;
// the notice therefore advances for this browser process and returns on reload.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
WELCOME_NOTICE_COPY,
type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
const MODE = webSnapshotMode()
describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
remoteAuthority: 'remote.localhost',
welcomeNoticePending: true,
})
browser = await chromium.launch()
page = await browser.newPage({
viewport: { width: 1440, height: 960 },
locale: ZH_BROWSER_LOCALE,
})
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('#root', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('advances process-locally and presents the notice again after reload', async () => {
const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
await welcome.waitFor({ timeout: 15_000 })
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
await expect.poll(
() => page.locator('#root').evaluate(root => (root as HTMLElement).inert),
{ timeout: 15_000 },
).toBe(false)
const reloadWarnings = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
acknowledgeReloadConnectionLoss(tripwire, reloadWarnings)
await welcome.waitFor({ timeout: 15_000 })
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})

View File

@@ -41,6 +41,7 @@ import {
loadOverlayPatches,
} from '@deepseek-ai/dsh-app-boot'
import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type {
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
@@ -62,6 +63,25 @@ import type {} from '@deepseek-ai/dsh-agent'
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { REPO_ROOT, requireDist } from './support.ts'
// Host-side web e2e cannot import a browser package: doing so would pull that
// package's complete TS project into this graph. Mirrored from
// packages/client/ui-settings-models/src/onboarding-copy.ts; drift makes the
// default pre-acknowledgement stop suppressing the notice and fails loudly.
// import {
// WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
// WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
// } from '@deepseek-ai/dsh-client-ui-settings-models'
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
export const WELCOME_NOTICE_VERSION = '2026-08-13.1'
export const WELCOME_NOTICE_COPY = {
zh: {
title: '内测声明',
body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起在开源、开放、可复用、可组合的基础设施之上共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
continueLabel: '继续',
},
} as const
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
@@ -213,6 +233,8 @@ export interface LaunchOptions {
* keyless first-run configuration lane; the default disables the adapter.
*/
deepSeekMissingCredential?: boolean
/** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
welcomeNoticePending?: boolean
/**
* Patch the shipped DeepSeek search row to a deterministic endpoint and
* credential reference. Browser search scenarios keep the real provider and
@@ -510,6 +532,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
})
await ctx.loader.await()
assertEntriesLoaded(ctx, 'web e2e scaffold')
if (options.welcomeNoticePending !== true) {
await ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
}])
}
const boundPort = ctx.get('webServer')?.port
if (boundPort === undefined) {
throw new Error('web e2e scaffold: webServer service missing after settled boot')

View File

@@ -1,5 +1,8 @@
- region "添加一个 API Key 开始使用":
- dialog "添加一个 API Key 开始使用":
- heading "添加一个 API Key 开始使用" [level=2]
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
- text: API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥
- button "稍后配置"
- button "前往配置"
- button "保存并继续" [disabled]

View File

@@ -0,0 +1,5 @@
- dialog "内测声明":
- heading "内测声明" [level=2]
- paragraph: DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。
- paragraph: 我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。
- button "继续"