diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts new file mode 100644 index 0000000000..2dfb701c96 --- /dev/null +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -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 + 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']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 90e98ddf57..14bf7883c4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -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 { + 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 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 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 0) throw new AggregateError(failures, 'web scaffold teardown failed') }, } diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md new file mode 100644 index 0000000000..d8c6e27e56 --- /dev/null +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md @@ -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] diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 59b544fc89..6780b7b5ca 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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", diff --git a/tsconfig.host.json b/tsconfig.host.json index 383718dffa..94d030febd 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -17,6 +17,7 @@ "apps/web/tests/lifecycle-chrome.e2e.ts", "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", + "apps/web/tests/onboarding-deepseek-config.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts",