onboarding-deepseek-config.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. // Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
  2. // credential is absent, both ordered steps share the shipped modal chrome,
  3. // and the inline key write lands in an isolated harness home without a reload
  4. // or model call.
  5. import { randomBytes } from 'node:crypto'
  6. import { assertModelInputLayout } from './model-input-layout.ts'
  7. import { readFile } from 'node:fs/promises'
  8. import { fileURLToPath } from 'node:url'
  9. import { join } from 'node:path'
  10. import type { Browser, Page } from 'playwright'
  11. import { chromium } from 'playwright'
  12. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  13. import {
  14. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  15. launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
  16. WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
  17. WELCOME_NOTICE_VERSION,
  18. } from './scaffold.ts'
  19. import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
  20. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/onboarding-deepseek-config', import.meta.url))
  21. const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
  22. const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
  23. const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
  24. const DEFAULT_MODELS_EXPECTED = join(SNAPSHOT_DIR, 'default-models.expected.md')
  25. const MODE = webSnapshotMode()
  26. describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
  27. let scaffold: WebScaffold
  28. let browser: Browser
  29. let page: Page
  30. let tripwire: ReturnType<typeof watchConsole>
  31. const browserConsole: string[] = []
  32. beforeAll(async () => {
  33. scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
  34. browser = await chromium.launch()
  35. // The scenario asserts the shipped Chinese copy, so the browser asks for it.
  36. page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
  37. tripwire = watchConsole(page)
  38. page.on('console', message => browserConsole.push(message.text()))
  39. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  40. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  41. }, 120_000)
  42. afterAll(async () => {
  43. await browser?.close()
  44. await scaffold?.close()
  45. })
  46. it('stores a key write-only and observes configured state without restarting', async () => {
  47. onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
  48. const welcome = page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title })
  49. await welcome.waitFor({ timeout: 15_000 })
  50. expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
  51. for (const paragraph of WELCOME_NOTICE_COPY.zh.body.split('\n\n')) {
  52. expect(await welcome.getByText(paragraph, { exact: true }).count()).toBe(1)
  53. }
  54. expect(await welcome.getByRole('button').allTextContents()).toEqual([
  55. WELCOME_NOTICE_COPY.zh.continueLabel,
  56. ])
  57. const welcomeAria = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  58. await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
  59. // Observation is not acknowledgement: the exact version is persisted
  60. // only by the explicit action, so a reload still presents this dialog.
  61. const firstReloadWarnings = tripwire.warnings.length
  62. await page.reload({ waitUntil: 'load' })
  63. acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
  64. await welcome.waitFor({ timeout: 15_000 })
  65. await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
  66. await welcome.waitFor({ state: 'detached', timeout: 15_000 })
  67. const credentialStep = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
  68. await credentialStep.waitFor({ timeout: 15_000 })
  69. const keyInput = credentialStep.getByLabel('API 密钥', { exact: true })
  70. await keyInput.waitFor({ timeout: 10_000 })
  71. const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  72. await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
  73. const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
  74. await keyInput.fill(secret)
  75. await credentialStep.getByRole('button', { name: '保存并继续' }).click()
  76. await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
  77. expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
  78. const stored = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
  79. expect(stored.includes(`DEEPSEEK_API_KEY: ${secret}`)).toBe(true)
  80. expect((await page.content()).includes(secret)).toBe(false)
  81. expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
  82. expect(browserConsole.some(line => line.includes(secret))).toBe(false)
  83. const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  84. expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
  85. // The ordinary Models surface reuses the refreshed join and exposes the
  86. // configured write-only placeholder without a reload.
  87. await page.getByRole('button', { name: '设置', exact: true }).click()
  88. const settings = page.getByRole('dialog', { name: '设置' })
  89. await settings.waitFor({ timeout: 10_000 })
  90. await settings.getByRole('button', { name: '模型' }).click()
  91. const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
  92. await deepSeekRow.waitFor({ timeout: 10_000 })
  93. await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
  94. const configuredInput = settings.getByLabel('API 密钥', { exact: true })
  95. await configuredInput.waitFor({ timeout: 10_000 })
  96. await expect.poll(
  97. () => configuredInput.getAttribute('placeholder'),
  98. { timeout: 10_000 },
  99. ).toBe('已配置——输入新值可替换')
  100. const secondReloadWarnings = tripwire.warnings.length
  101. await page.reload({ waitUntil: 'load' })
  102. acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
  103. await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
  104. expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
  105. expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
  106. // An old acknowledgement means materially revised copy: welcome returns,
  107. // while the already-configured provider step remains complete.
  108. await scaffold.ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
  109. op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
  110. }])
  111. const thirdReloadWarnings = tripwire.warnings.length
  112. await page.reload({ waitUntil: 'load' })
  113. acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
  114. await welcome.waitFor({ timeout: 15_000 })
  115. await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
  116. await welcome.waitFor({ state: 'detached', timeout: 15_000 })
  117. expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
  118. expect((await page.content()).includes(secret)).toBe(false)
  119. expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
  120. expect(browserConsole.some(line => line.includes(secret))).toBe(false)
  121. expect(tripwire.warnings).toEqual([])
  122. expect(tripwire.pageErrors).toEqual([])
  123. }, 60_000)
  124. it('never paints the takeover chrome on a configured reload, even with the settings join held open', async () => {
  125. onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-configured-reload'))
  126. // Regression pin for the reload flash: both steps are satisfied, yet each
  127. // must load private facts before deciding not to show. Dialog chrome lives
  128. // inside each visible branch, so the deciding window paints and blocks
  129. // nothing. Holding settings/describe widens that window from loopback
  130. // RTT scale to a deterministic hundreds of milliseconds, removing all
  131. // timing dependence from the sampler assertions below.
  132. //
  133. // The sampler init script persists across this shared page's later
  134. // navigations (init scripts re-run per navigation); that stays harmless
  135. // because no later scenario in this file legitimately shows the
  136. // takeover, and only this test reads __takeoverSightings.
  137. await page.addInitScript(() => {
  138. const sightings: string[] = []
  139. ;(window as unknown as { __takeoverSightings: string[] }).__takeoverSightings = sightings
  140. setInterval(() => {
  141. if (document.querySelector(
  142. '[role="dialog"][aria-label="内测声明"], '
  143. + '[role="dialog"][aria-label="添加一个 API Key 开始使用"]',
  144. ) !== null) {
  145. sightings.push('chrome')
  146. }
  147. if (document.getElementById('root')?.inert === true) sightings.push('inert')
  148. }, 8)
  149. })
  150. // EVERY settings/describe issued before the release is held — not just
  151. // the first — so the pin cannot silently collapse back to loopback
  152. // timing if a second boot-time consumer of the join ever appears.
  153. let released = false
  154. const heldRoutes: Array<() => void> = []
  155. const releaseDescribe = (): void => {
  156. released = true
  157. for (const resolve of heldRoutes.splice(0)) resolve()
  158. }
  159. await page.route('**/api/settings/describe', async (route) => {
  160. if (!released) await new Promise<void>((resolve) => { heldRoutes.push(resolve) })
  161. await route.continue()
  162. })
  163. const warningsBefore = tripwire.warnings.length
  164. await page.reload({ waitUntil: 'commit' })
  165. await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
  166. // The app is painted and interactive while the steps are still deciding.
  167. await page.waitForTimeout(600)
  168. releaseDescribe()
  169. await page.waitForTimeout(400)
  170. await page.unroute('**/api/settings/describe')
  171. acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
  172. expect(await page.evaluate(() =>
  173. (window as unknown as { __takeoverSightings: string[] }).__takeoverSightings)).toEqual([])
  174. expect(await page.getByRole('dialog', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
  175. expect(await page.getByRole('dialog', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
  176. expect(tripwire.pageErrors).toEqual([])
  177. }, 60_000)
  178. it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => {
  179. onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
  180. // Opened here rather than inherited: the credential test reloads the page
  181. // after configuring the key, so nothing carries an open dialog across.
  182. await page.getByRole('button', { name: '设置', exact: true }).click()
  183. const settings = page.getByRole('dialog', { name: '设置' })
  184. await settings.waitFor({ timeout: 10_000 })
  185. await settings.getByRole('button', { name: '模型' }).click()
  186. const deepSeek = settings.getByText('DeepSeek', { exact: true }).first()
  187. await deepSeek.waitFor({ timeout: 10_000 })
  188. await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
  189. await settings.getByText('自定义设置').click()
  190. expect(await settings.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash')
  191. expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V41-Flash')
  192. expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-pro')
  193. expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(2)
  194. await settings.getByRole('button', { name: '模型选项 1' }).click()
  195. expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
  196. await assertModelInputLayout(page, settings)
  197. const defaultModels = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  198. await compareOrRefreshGolden(DEFAULT_MODELS_EXPECTED, defaultModels, MODE)
  199. await settings.getByLabel('显示名称 1').fill('Configured Flash')
  200. await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).uncheck()
  201. await settings.getByRole('button', { name: '保存', exact: true }).click()
  202. await settings.getByLabel('模型 ID 1').waitFor({ state: 'detached', timeout: 15_000 })
  203. const savedDefaults = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  204. expect(savedDefaults).toContain('id: deepseek-flash')
  205. expect(savedDefaults).toContain('inputModalities:')
  206. expect(savedDefaults).toContain('- text')
  207. expect(savedDefaults).toContain('systemPromptUpdate: in-history')
  208. await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({
  209. name: 'Configured Flash', inputModalities: ['text'], systemPromptUpdate: 'in-history',
  210. })
  211. await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-pro')).resolves.toMatchObject({
  212. name: 'DeepSeek-V4-Pro', inputModalities: ['text'],
  213. })
  214. await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
  215. await settings.getByText('自定义设置').click()
  216. for (let index = 0; index < 2; index++) {
  217. await settings.getByRole('button', { name: /删除模型/ }).first().click()
  218. }
  219. await settings.getByRole('button', { name: '添加模型' }).click()
  220. const customModelId = settings.getByLabel('模型 ID 1')
  221. await customModelId.fill('private-preview')
  222. await settings.getByLabel('显示名称 1').fill('Private Preview')
  223. await settings.getByRole('button', { name: '模型选项 1' }).click()
  224. await settings.getByLabel('上下文窗口 1').fill('131072')
  225. await settings.getByLabel('最大输出 token 数 1').fill('64K')
  226. expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
  227. await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
  228. await expect.poll(
  229. () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'),
  230. { timeout: 10_000 },
  231. ).toBe('已配置——输入新值可替换')
  232. const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  233. await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
  234. await settings.getByRole('button', { name: '保存', exact: true }).click()
  235. await customModelId.waitFor({ state: 'detached', timeout: 15_000 })
  236. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  237. expect(document).toContain('id: private-preview')
  238. expect(document).toContain('name: Private Preview')
  239. expect(document).toContain('contextWindow: 131072')
  240. expect(document).toContain('maxTokens: 64000')
  241. expect(document).not.toContain('id: deepseek-flash')
  242. await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'private-preview')).resolves.toMatchObject({
  243. inputModalities: ['text', 'image'],
  244. })
  245. await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
  246. await settings.getByText('自定义设置').click()
  247. await settings.getByRole('button', { name: '模型选项 1' }).click()
  248. expect(await settings.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(true)
  249. await settings.getByRole('button', { name: '取消', exact: true }).click()
  250. await page.keyboard.press('Escape')
  251. // A connected Workspace is what puts a live composer — and its model
  252. // trigger — on the page; the scaffold boots without one.
  253. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e')
  254. const modelTrigger = page.getByRole('button', { name: /^选择模型/ })
  255. await modelTrigger.waitFor({ timeout: 10_000 })
  256. await modelTrigger.click()
  257. await page.getByRole('menuitem', { name: /模型/ }).click()
  258. expect(await page.getByText('Configured Flash', { exact: true }).count()).toBe(0)
  259. await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 })
  260. expect(tripwire.warnings).toEqual([])
  261. expect(tripwire.pageErrors).toEqual([])
  262. }, 60_000)
  263. it('keeps the fixture inventory closed', async () => {
  264. await assertFixtureInventory(
  265. SNAPSHOT_DIR,
  266. ['welcome.expected.md', 'missing.expected.md', 'models.expected.md', 'default-models.expected.md'],
  267. )
  268. })
  269. })