models-settings-recovery.e2e.ts 5.3 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. /** Stored catalog drift remains repairable through the assembled Models settings page. */
  2. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { chromium, type Browser, type Page } from 'playwright'
  7. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  8. import {
  9. captureStableAria, compareOrRefreshGolden, launchWebScaffold,
  10. watchConsole, webSnapshotMode, type WebScaffold,
  11. } from './scaffold.ts'
  12. import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
  13. const EXPECTED = fileURLToPath(new URL('./expected/models-settings-recovery/stored-error.expected.md', import.meta.url))
  14. const FAILURE = 'llm-pi-ai: provider "openrouter" model "111" needs an api; '
  15. + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks'
  16. const CUSTOM_FAILURE = 'llm-pi-ai: provider "acme-gateway" model "custom-model" needs an api; '
  17. + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks'
  18. describe('web e2e: repairs a stored provider after catalog drift', () => {
  19. let home: string
  20. let scaffold: WebScaffold
  21. let browser: Browser
  22. let page: Page
  23. let tripwire: ReturnType<typeof watchConsole>
  24. beforeAll(async () => {
  25. home = await mkdtemp(join(tmpdir(), 'dsh-models-recovery-'))
  26. await writeFile(join(home, 'settings.yaml'), [
  27. 'llm-pi-ai:', ' providers:', ' openrouter:', ' models:',
  28. ' - id: "111"', ' zai: {}', ' acme-gateway:',
  29. ' baseURL: https://gateway.example/v1', ' models:', ' - id: "custom-model"', '',
  30. ].join('\n'))
  31. scaffold = await launchWebScaffold({ harnessHome: home })
  32. browser = await chromium.launch()
  33. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  34. tripwire = watchConsole(page)
  35. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  36. await page.getByRole('button', { name: '设置', exact: true }).click()
  37. const dialog = page.getByRole('dialog', { name: '设置' })
  38. await dialog.getByRole('button', { name: '模型', exact: true }).click()
  39. await dialog.getByText(FAILURE, { exact: true }).waitFor()
  40. }, 120_000)
  41. afterAll(async () => {
  42. try {
  43. await browser?.close()
  44. } finally {
  45. try {
  46. await scaffold?.close()
  47. } finally {
  48. if (home !== undefined) await rm(home, { recursive: true, force: true })
  49. }
  50. }
  51. })
  52. it('shows the failed provider beside healthy providers and keeps both add actions usable', async () => {
  53. onTestFailed(() => saveFailureShot(page, 'models-settings-recovery'))
  54. const dialog = page.getByRole('dialog', { name: '设置' })
  55. expect(await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).count()).toBe(1)
  56. expect(await dialog.getByRole('button', { name: '编辑 zai', exact: true }).count()).toBe(1)
  57. expect(await dialog.getByRole('button', { name: '编辑 acme-gateway', exact: true }).count()).toBe(1)
  58. expect(await dialog.getByText(CUSTOM_FAILURE, { exact: true }).count()).toBe(1)
  59. expect(await dialog.getByRole('button', { name: '添加提供方', exact: true }).isEnabled()).toBe(true)
  60. expect(await dialog.getByRole('button', { name: '添加自定义提供方', exact: true }).isEnabled()).toBe(true)
  61. await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
  62. await dialog.getByRole('button', { name: '添加提供方', exact: true }).click()
  63. await dialog.getByLabel('提供方', { exact: true }).selectOption('minimax-cn')
  64. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  65. await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor()
  66. expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toContain('minimax-cn: {}')
  67. expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(1)
  68. })
  69. it('rejects an invalid edit without persisting and accepts removal of the obsolete model', async () => {
  70. const dialog = page.getByRole('dialog', { name: '设置' })
  71. await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).click()
  72. await dialog.getByText('自定义设置', { exact: true }).click()
  73. await dialog.getByLabel('API 地址', { exact: true }).fill('https://gateway.example/v1')
  74. const before = await readFile(join(home, 'settings.yaml'), 'utf8')
  75. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  76. await expect.poll(() => dialog.getByText(FAILURE, { exact: true }).count()).toBe(2)
  77. expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toBe(before)
  78. await dialog.getByRole('button', { name: '删除模型 1', exact: true }).click()
  79. await dialog.getByRole('button', { name: '保存', exact: true }).click()
  80. await dialog.getByText('已保存 openrouter。', { exact: true }).waitFor()
  81. expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(0)
  82. const repaired = await readFile(join(home, 'settings.yaml'), 'utf8')
  83. expect(repaired).not.toContain('111')
  84. expect(repaired).toContain('baseURL: https://gateway.example/v1')
  85. expect(tripwire.pageErrors).toEqual([])
  86. })
  87. })