declared-reasoning.e2e.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. // Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the
  2. // composer's effort pane — the levels a settings profile declares are exactly
  3. // what the picker offers, and picking one records it with the Agent default.
  4. // Zero model calls: declaring, describing, and switching are settings/llm
  5. // traffic only, so there is no fixture and a stray stream would fail loud.
  6. import { readFile } from 'node:fs/promises'
  7. import { fileURLToPath } from 'node:url'
  8. import { join } from 'node:path'
  9. import type { Browser, Page } from 'playwright'
  10. import { chromium } from 'playwright'
  11. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  12. import {
  13. assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  14. launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
  15. } from './scaffold.ts'
  16. import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
  17. /** Starts the shipped default on this scenario's declared reasoning model. */
  18. const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url))
  19. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/declared-reasoning', import.meta.url))
  20. const UI_EXPECTED = fileURLToPath(new URL('./expected/declared-reasoning/ui.expected.md', import.meta.url))
  21. const MODE = webSnapshotMode()
  22. describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => {
  23. let scaffold: WebScaffold
  24. let browser: Browser
  25. let page: Page
  26. let tripwire: ReturnType<typeof watchConsole>
  27. beforeAll(async () => {
  28. scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
  29. // The whole reasoning offer is the profile: key = selectable level, value
  30. // = the wire spelling dispatch would send (`max: ultra` renames; the
  31. // valueless `off` means "supported, send nothing"). The route sets no
  32. // deployment default, so the pane leads with the provider-default entry.
  33. await scaffold.ctx.settings.update('llm-pi-ai', {
  34. providers: {
  35. 'acme-gateway': {
  36. displayName: 'Acme Gateway',
  37. api: 'openai-completions',
  38. baseURL: 'https://gateway.acme.example/v1',
  39. models: [{
  40. id: 'acme-think',
  41. name: 'Acme Think',
  42. reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
  43. }],
  44. },
  45. },
  46. })
  47. browser = await chromium.launch()
  48. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  49. tripwire = watchConsole(page)
  50. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  51. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  52. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
  53. }, 120_000)
  54. afterAll(async () => {
  55. await browser?.close()
  56. await scaffold?.close()
  57. })
  58. it('offers exactly the declared levels and records the picked one', async () => {
  59. onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning'))
  60. const trigger = page.getByRole('button', { name: /^选择模型/ })
  61. await trigger.waitFor({ timeout: 15_000 })
  62. await trigger.click()
  63. await page.getByRole('menuitem', { name: /推理等级/ }).click()
  64. // Declared levels, nothing else: the provider-default entry (the route
  65. // configures no `reasoning`), then Off/High/Max — minimal, low, medium,
  66. // and xhigh were not declared and must not be offered.
  67. const levels = page.getByRole('menuitemradio')
  68. await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 })
  69. .toEqual(['Default', 'Off', 'High', 'Max'])
  70. const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
  71. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  72. // Keyboard: the clicked cell unmounts with its pane, so the drilled pane's
  73. // checked row takes the focus it left behind. ↑↓ walk the rows from there
  74. // and Tab settles the focused one exactly as Enter would.
  75. await expect.poll(
  76. () => levels.nth(0).evaluate(element => element === document.activeElement),
  77. { timeout: 10_000 },
  78. ).toBe(true)
  79. await page.keyboard.press('ArrowDown')
  80. await expect.poll(
  81. () => levels.nth(1).evaluate(element => element === document.activeElement),
  82. { timeout: 10_000 },
  83. ).toBe(true)
  84. await page.keyboard.press('ArrowDown')
  85. await expect.poll(
  86. () => levels.nth(2).evaluate(element => element === document.activeElement),
  87. { timeout: 10_000 },
  88. ).toBe(true)
  89. // Settling with Tab is the same gesture that saves the default selection, so
  90. // the effort lands in the Agent default Settings section beside provider/model.
  91. await page.keyboard.press('Tab')
  92. await expect.poll(() => levels.count(), { timeout: 10_000 }).toBe(0)
  93. await expect.poll(
  94. async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
  95. { timeout: 10_000 },
  96. ).toContain('reasoningEffort: high')
  97. await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 })
  98. .toBe('选择模型,当前 Acme Think,推理等级 High')
  99. // Reopening the drilled pane parks the keyboard on the level in use, and
  100. // Shift+Tab walks back out like Escape: to the drilled cell, then closed.
  101. await trigger.click()
  102. await page.getByRole('menuitem', { name: /推理等级/ }).click()
  103. const high = page.getByRole('menuitemradio', { name: 'High' })
  104. await expect.poll(
  105. () => high.evaluate(element => element === document.activeElement),
  106. { timeout: 10_000 },
  107. ).toBe(true)
  108. await page.keyboard.press('Shift+Tab')
  109. await expect.poll(
  110. () => page.getByRole('menuitem', { name: /推理等级/ })
  111. .evaluate(element => element === document.activeElement),
  112. { timeout: 10_000 },
  113. ).toBe(true)
  114. await page.keyboard.press('Shift+Tab')
  115. await expect.poll(() => page.getByRole('menu').count(), { timeout: 10_000 }).toBe(0)
  116. expect(tripwire.pageErrors).toEqual([])
  117. }, 60_000)
  118. it('keeps its snapshot inventory closed', async () => {
  119. await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
  120. })
  121. })