models-settings.e2e.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  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, typing an API key
  3. // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
  4. // while the settings document records only that reference, and the saved
  5. // route registers live (the row's 已启用 badge is the topology invalidation
  6. // landing). The customized-settings fold writes the curated reasoning field
  7. // as a merge patch. Zero model calls: configuration is pure
  8. // settings/credentials/llm-domain traffic, so there is no fixture and a
  9. // stray stream would fail loud on the open seam. The provider under test is
  10. // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
  11. // never shadow the derived reference.
  12. import { readFile } from 'node:fs/promises'
  13. import { fileURLToPath } from 'node:url'
  14. import { join } from 'node:path'
  15. import type { Browser, Page } from 'playwright'
  16. import { chromium } from 'playwright'
  17. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  18. import {
  19. assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  20. launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
  21. } from './scaffold.ts'
  22. import { saveFailureShot } from './support.ts'
  23. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
  24. const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
  25. const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
  26. const MODE = webSnapshotMode()
  27. describe('web e2e: Models settings page configures a dormant provider', () => {
  28. let scaffold: WebScaffold
  29. let browser: Browser
  30. let page: Page
  31. let tripwire: ReturnType<typeof watchConsole>
  32. beforeAll(async () => {
  33. scaffold = await launchWebScaffold({})
  34. browser = await chromium.launch()
  35. page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
  36. tripwire = watchConsole(page)
  37. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  38. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  39. }, 120_000)
  40. afterAll(async () => {
  41. await browser?.close()
  42. await scaffold?.close()
  43. })
  44. it('opens the add card over the dormant directory vocabulary', async () => {
  45. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
  46. await page.getByRole('button', { name: '设置', exact: true }).click()
  47. const dialog = page.getByRole('dialog', { name: '设置' })
  48. await dialog.waitFor({ timeout: 10_000 })
  49. await dialog.getByRole('button', { name: '模型' }).click()
  50. await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
  51. // The dormant pi-ai adapter contributes its whole installed catalog; no
  52. // provider is configured yet, so the page is one add button.
  53. const add = dialog.getByRole('button', { name: '+ 添加提供方' })
  54. await add.waitFor({ timeout: 10_000 })
  55. // The button enables once the dormant catalog lands in the join.
  56. await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
  57. await add.click()
  58. const pick = dialog.getByLabel('提供方')
  59. await pick.waitFor({ timeout: 10_000 })
  60. await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
  61. const options = await pick.locator('option').allTextContents()
  62. expect(options).toContain('anthropic')
  63. expect(options).toContain('minimax-cn')
  64. await pick.selectOption('minimax-cn')
  65. await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 })
  66. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  67. await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
  68. }, 60_000)
  69. it('stores the key under the derived reference and the route registers live', async () => {
  70. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
  71. const dialog = page.getByRole('dialog', { name: '设置' })
  72. await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax')
  73. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  74. // The profile lands in settings.yaml with only the derived reference, the
  75. // key value lands in the harness home's .env, the dormant route
  76. // registers, and the topology frame invalidates the page into the row.
  77. const row = dialog.getByText('minimax-cn', { exact: true }).first()
  78. await row.waitFor({ timeout: 10_000 })
  79. await dialog.getByText('已启用').waitFor({ timeout: 10_000 })
  80. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  81. expect(document).toContain('minimax-cn:')
  82. expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
  83. expect(document).not.toContain('sk-e2e-minimax')
  84. const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
  85. expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
  86. expect(await page.content()).not.toContain('sk-e2e-minimax')
  87. }, 60_000)
  88. it('applies a customized-settings field as a merge patch', async () => {
  89. onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
  90. const dialog = page.getByRole('dialog', { name: '设置' })
  91. await dialog.getByRole('button', { name: '编辑' }).click()
  92. await dialog.getByText('自定义设置').click()
  93. const effort = dialog.getByLabel('推理强度')
  94. await effort.waitFor({ timeout: 10_000 })
  95. await effort.selectOption('high')
  96. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  97. // The editor closes back to the row; the fold's write merged into the
  98. // stored profile beside the reference.
  99. await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
  100. const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
  101. expect(document).toContain('reasoning: high')
  102. expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
  103. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  104. await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
  105. await page.keyboard.press('Escape')
  106. expect(tripwire.pageErrors).toEqual([])
  107. }, 60_000)
  108. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  109. await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md'])
  110. })
  111. })