models-settings.e2e.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. // Web e2e scenario: the Models settings page end to end through the real
  2. // wire — the add card offers the dormant pi-ai catalog, a blank key saves a
  3. // reference-free profile for provider-native auth, and typing an API key later
  4. // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
  5. // while the settings document records only that reference. Each saved row
  6. // appears after route topology invalidation without presenting liveness as
  7. // provider status. The customized-settings fold writes the curated
  8. // reasoning field as a merge patch. Zero model calls: configuration is pure
  9. // settings/credentials/llm-domain traffic, so there is no fixture and a
  10. // stray stream would fail loud on the open seam. The provider under test is
  11. // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
  12. // never shadow the derived reference. The deletion dialog distinguishes a
  13. // reference-free profile from a page-managed key before the credential and
  14. // settings unsets reach the wire.
  15. import { readFile } from 'node:fs/promises'
  16. import { fileURLToPath } from 'node:url'
  17. import { join } from 'node:path'
  18. import type { Browser, Page } from 'playwright'
  19. import { chromium } from 'playwright'
  20. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  21. import {
  22. assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  23. launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
  24. } from './scaffold.ts'
  25. import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
  26. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
  27. const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
  28. const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
  29. const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
  30. const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
  31. const MODE = webSnapshotMode()
  32. describe('web e2e: Models settings page configures a dormant provider', () => {
  33. let scaffold: WebScaffold
  34. let browser: Browser
  35. let page: Page
  36. let tripwire: ReturnType<typeof watchConsole>
  37. beforeAll(async () => {
  38. scaffold = await launchWebScaffold({})
  39. browser = await chromium.launch()
  40. // The scenario asserts the shipped Chinese copy, so the browser asks for it.
  41. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  42. tripwire = watchConsole(page)
  43. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  44. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  45. }, 120_000)
  46. afterAll(async () => {
  47. await browser?.close()
  48. await scaffold?.close()
  49. })
  50. it('opens the add card over the dormant directory vocabulary', async () => {
  51. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
  52. await page.getByRole('button', { name: '设置', exact: true }).click()
  53. const dialog = page.getByRole('dialog', { name: '设置' })
  54. await dialog.waitFor({ timeout: 10_000 })
  55. await dialog.getByRole('button', { name: '模型' }).click()
  56. await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
  57. // The dormant pi-ai adapter contributes its whole installed catalog; no
  58. // provider is configured yet, so the page is one add button.
  59. const add = dialog.getByRole('button', { name: '添加提供方' })
  60. await add.waitFor({ timeout: 10_000 })
  61. // The button enables once the dormant catalog lands in the join.
  62. await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
  63. await add.click()
  64. const pick = dialog.getByLabel('提供方')
  65. await pick.waitFor({ timeout: 10_000 })
  66. await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
  67. const options = await pick.locator('option').allTextContents()
  68. expect(options).toContain('anthropic')
  69. expect(options).toContain('minimax-cn')
  70. await pick.selectOption('minimax-cn')
  71. await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 })
  72. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  73. await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
  74. }, 60_000)
  75. it('refuses a key no HTTP header can carry before anything is written', async () => {
  76. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key'))
  77. const dialog = page.getByRole('dialog', { name: '设置' })
  78. const key = dialog.getByLabel('API 密钥')
  79. const save = dialog.getByRole('button', { name: '保存', exact: true })
  80. // The paste that used to save cleanly and then fail the first turn with a
  81. // ByteString TypeError now names the field that holds it.
  82. await key.fill('sk-\u{1F600}minimax')
  83. await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
  84. await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
  85. // Clearing it restores submit: an empty field means "keep what is stored",
  86. // never a refusal, or editing any other setting would demand the key.
  87. await key.fill('')
  88. await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
  89. expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
  90. }, 60_000)
  91. it('saves a blank key as a reference-free provider-native profile', async () => {
  92. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth'))
  93. const dialog = page.getByRole('dialog', { name: '设置' })
  94. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  95. const row = dialog.getByText('minimax-cn', { exact: true }).first()
  96. await row.waitFor({ timeout: 10_000 })
  97. await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
  98. expect(await dialog.getByRole('img', { name: 'API 密钥已配置' }).count()).toBe(0)
  99. expect(await dialog.getByRole('img', { name: 'API 密钥缺失' }).count()).toBe(0)
  100. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  101. expect(document).toContain('minimax-cn: {}')
  102. expect(document).not.toContain('MINIMAX_CN_API_KEY')
  103. }, 60_000)
  104. it('describes reference-free deletion without claiming a credential exists', async () => {
  105. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-delete'))
  106. const settingsDialog = page.getByRole('dialog', { name: '设置' })
  107. await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
  108. const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
  109. await deleteDialog.waitFor({ timeout: 10_000 })
  110. const snapshot = await captureStableAria(
  111. page,
  112. '[role="dialog"][aria-label="删除 minimax-cn?"]',
  113. scaffold.workspaceCwd,
  114. )
  115. await compareOrRefreshGolden(NATIVE_DELETE_EXPECTED, snapshot, MODE)
  116. await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
  117. }, 60_000)
  118. it('stores the key under the derived reference and keeps the route live', async () => {
  119. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
  120. const dialog = page.getByRole('dialog', { name: '设置' })
  121. await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
  122. await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
  123. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  124. // The profile lands in settings.yaml with only the derived reference, the
  125. // key value lands in the harness home's .env, the dormant route
  126. // registers, and the topology frame invalidates the page into the row.
  127. await expect.poll(
  128. async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
  129. { timeout: 10_000 },
  130. ).toBe(0)
  131. await dialog.getByRole('img', { name: 'API 密钥已配置' }).waitFor({ timeout: 10_000 })
  132. await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
  133. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  134. expect(document).toContain('minimax-cn:')
  135. expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
  136. expect(document).not.toContain('sk-e2e-minimax')
  137. const credentialFile = join(scaffold.harnessHome, '.env')
  138. await expect.poll(
  139. async () => readFile(credentialFile, 'utf8').catch(() => ''),
  140. { timeout: 10_000 },
  141. ).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
  142. expect(await page.content()).not.toContain('sk-e2e-minimax')
  143. }, 60_000)
  144. it('applies a customized-settings field as a merge patch', async () => {
  145. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
  146. const dialog = page.getByRole('dialog', { name: '设置' })
  147. await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
  148. await dialog.getByText('自定义设置').click()
  149. const effort = dialog.getByLabel('推理强度')
  150. await effort.waitFor({ timeout: 10_000 })
  151. await effort.selectOption('high')
  152. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  153. // The editor closes back to the row; the fold's write merged into the
  154. // stored profile beside the reference.
  155. await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
  156. await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
  157. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  158. expect(document).toContain('reasoning: high')
  159. expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
  160. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  161. await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
  162. expect(tripwire.pageErrors).toEqual([])
  163. }, 60_000)
  164. it('confirms an identified provider deletion before removing its profile and key', async () => {
  165. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
  166. const settingsDialog = page.getByRole('dialog', { name: '设置' })
  167. await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
  168. const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
  169. await deleteDialog.waitFor({ timeout: 10_000 })
  170. const snapshot = await captureStableAria(
  171. page,
  172. '[role="dialog"][aria-label="删除 minimax-cn?"]',
  173. scaffold.workspaceCwd,
  174. )
  175. await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
  176. await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
  177. expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
  178. await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
  179. await page.getByRole('dialog', { name: '删除 minimax-cn?' })
  180. .getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
  181. await expect.poll(
  182. async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
  183. { timeout: 10_000 },
  184. ).not.toContain('minimax-cn:')
  185. expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
  186. .not.toContain('MINIMAX_CN_API_KEY')
  187. await expect.poll(
  188. async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(),
  189. { timeout: 10_000 },
  190. ).toBe(0)
  191. await page.keyboard.press('Escape')
  192. expect(tripwire.pageErrors).toEqual([])
  193. }, 60_000)
  194. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  195. await assertFixtureInventory(SNAPSHOT_DIR, [
  196. 'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
  197. ])
  198. })
  199. })