Преглед изворни кода

test(web): cover keyless DeepSeek onboarding

Yichen Jiang пре 2 месеци
родитељ
комит
0b689e0d2c

+ 83 - 0
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<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'])
+  })
+})

+ 43 - 12
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<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')
     },
   }

+ 12 - 0
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]

+ 1 - 0
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",

+ 1 - 0
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",