models-settings.e2e.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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('saves a blank key as a reference-free provider-native profile', async () => {
  76. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth'))
  77. const dialog = page.getByRole('dialog', { name: '设置' })
  78. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  79. const row = dialog.getByText('minimax-cn', { exact: true }).first()
  80. await row.waitFor({ timeout: 10_000 })
  81. await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
  82. expect(await dialog.getByRole('img', { name: 'API 密钥已配置' }).count()).toBe(0)
  83. expect(await dialog.getByRole('img', { name: 'API 密钥缺失' }).count()).toBe(0)
  84. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  85. expect(document).toContain('minimax-cn: {}')
  86. expect(document).not.toContain('MINIMAX_CN_API_KEY')
  87. }, 60_000)
  88. it('describes reference-free deletion without claiming a credential exists', async () => {
  89. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-delete'))
  90. const settingsDialog = page.getByRole('dialog', { name: '设置' })
  91. await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
  92. const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
  93. await deleteDialog.waitFor({ timeout: 10_000 })
  94. const snapshot = await captureStableAria(
  95. page,
  96. '[role="dialog"][aria-label="删除 minimax-cn?"]',
  97. scaffold.workspaceCwd,
  98. )
  99. await compareOrRefreshGolden(NATIVE_DELETE_EXPECTED, snapshot, MODE)
  100. await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
  101. }, 60_000)
  102. it('stores the key under the derived reference and keeps the route live', async () => {
  103. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
  104. const dialog = page.getByRole('dialog', { name: '设置' })
  105. await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
  106. await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax')
  107. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  108. // The profile lands in settings.yaml with only the derived reference, the
  109. // key value lands in the harness home's .env, the dormant route
  110. // registers, and the topology frame invalidates the page into the row.
  111. await expect.poll(
  112. async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
  113. { timeout: 10_000 },
  114. ).toBe(0)
  115. await dialog.getByRole('img', { name: 'API 密钥已配置' }).waitFor({ timeout: 10_000 })
  116. await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
  117. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  118. expect(document).toContain('minimax-cn:')
  119. expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
  120. expect(document).not.toContain('sk-e2e-minimax')
  121. const credentialFile = join(scaffold.harnessHome, '.env')
  122. await expect.poll(
  123. async () => readFile(credentialFile, 'utf8').catch(() => ''),
  124. { timeout: 10_000 },
  125. ).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
  126. expect(await page.content()).not.toContain('sk-e2e-minimax')
  127. }, 60_000)
  128. it('applies a customized-settings field as a merge patch', async () => {
  129. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
  130. const dialog = page.getByRole('dialog', { name: '设置' })
  131. await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
  132. await dialog.getByText('自定义设置').click()
  133. const effort = dialog.getByLabel('推理强度')
  134. await effort.waitFor({ timeout: 10_000 })
  135. await effort.selectOption('high')
  136. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  137. // The editor closes back to the row; the fold's write merged into the
  138. // stored profile beside the reference.
  139. await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
  140. await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
  141. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  142. expect(document).toContain('reasoning: high')
  143. expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
  144. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  145. await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
  146. expect(tripwire.pageErrors).toEqual([])
  147. }, 60_000)
  148. it('confirms an identified provider deletion before removing its profile and key', async () => {
  149. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
  150. const settingsDialog = page.getByRole('dialog', { name: '设置' })
  151. await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
  152. const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' })
  153. await deleteDialog.waitFor({ timeout: 10_000 })
  154. const snapshot = await captureStableAria(
  155. page,
  156. '[role="dialog"][aria-label="删除 minimax-cn?"]',
  157. scaffold.workspaceCwd,
  158. )
  159. await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
  160. await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
  161. expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
  162. await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
  163. await page.getByRole('dialog', { name: '删除 minimax-cn?' })
  164. .getByRole('button', { name: '删除 minimax-cn', exact: true }).click()
  165. await expect.poll(
  166. async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
  167. { timeout: 10_000 },
  168. ).not.toContain('minimax-cn:')
  169. expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
  170. .not.toContain('MINIMAX_CN_API_KEY')
  171. await expect.poll(
  172. async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(),
  173. { timeout: 10_000 },
  174. ).toBe(0)
  175. await page.keyboard.press('Escape')
  176. expect(tripwire.pageErrors).toEqual([])
  177. }, 60_000)
  178. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  179. await assertFixtureInventory(SNAPSHOT_DIR, [
  180. 'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
  181. ])
  182. })
  183. })