plan-control-row.e2e.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. // Web e2e scenario: at the 800×720 viewport the plan chip and the model
  2. // trigger keep disjoint click areas, and clicking the chip at its center
  3. // leaves plan mode through the real command channel. This is the browser
  4. // regression the external report asked for (dsh-external/issues#107 →
  5. // deepseek-harness#1406): "increase an 800×720 browser regression test and
  6. // assert that the plan center hits the plan button".
  7. //
  8. // Plan mode is entered through the real /plan command with no argument:
  9. // the command handler commits plan/mode active on the live agent without a
  10. // model round (the lifecycle-chrome precedent), so the test needs no model
  11. // call in any mode and no API key in replay/refresh; a providers-only
  12. // fixture mounts the model catalog without a script to consume. Plan state
  13. // folds from the session log (`plan/mode`, last one wins); the chip executes
  14. // /plan off through commands.execute, which needs the live agent
  15. // connectFreshWorkspace keeps.
  16. //
  17. // The geometry golden records stable facts — viewport membership on both
  18. // axes for the chip and the trigger, and disjoint click areas — never
  19. // absolute coordinates, whose pixel values depend on installed fonts and
  20. // differ between macOS and Linux. The center hit-test is Playwright's
  21. // actionability check: clicking the chip fails in a real engine when the
  22. // element center does not receive pointer events. jsdom resolves no layout,
  23. // so only a real engine can answer any of these facts.
  24. import { fileURLToPath } from 'node:url'
  25. import { join } from 'node:path'
  26. import type { Browser, Page } from 'playwright'
  27. import { chromium } from 'playwright'
  28. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  29. // Type-only: pulls the plan/mode SessionEventMap merge so the discriminant
  30. // filter below types as the plan-mode event in the host aggregate.
  31. import type {} from '@deepseek-ai/dsh-plan-mode'
  32. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  33. import {
  34. assertFixtureInventory, compareOrRefreshGolden,
  35. launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
  36. } from './scaffold.ts'
  37. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  38. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/plan-narrow-viewport', import.meta.url))
  39. const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
  40. const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
  41. const MODE = webSnapshotMode()
  42. /** The reported viewport: 800×720, where the composer card is 448px wide at 0.0.1. */
  43. const VIEWPORT = { width: 800, height: 720 } as const
  44. /** Chip aria-label on the English page; the seat renders only while plan is the effective target. */
  45. const CHIP_ARIA = 'Plan mode on, press to turn off'
  46. describe('web e2e: plan chip click area at the narrow viewport', () => {
  47. let scaffold: WebScaffold
  48. let browser: Browser
  49. let page: Page
  50. let tripwire: ReturnType<typeof watchConsole>
  51. const sessionEvents: SessionEvent[] = []
  52. beforeAll(async () => {
  53. // replayProvidersOnly mounts the provider catalog without any recorded
  54. // script to consume (no model call happens — the /plan command never
  55. // steers a message), so the model trigger renders its real long label,
  56. // which is what made the reported overlap measurable.
  57. scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayProvidersOnly: true })
  58. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  59. browser = await chromium.launch()
  60. page = await newEnglishPage(browser, VIEWPORT.height)
  61. tripwire = watchConsole(page)
  62. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  63. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  64. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  65. await page.setViewportSize(VIEWPORT)
  66. }, 120_000)
  67. afterAll(async () => {
  68. await browser?.close()
  69. await scaffold?.close()
  70. })
  71. it('keeps the plan chip and model trigger disjoint and exits plan mode by click', async () => {
  72. onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-narrow-viewport'))
  73. const input = page.locator('[data-composer-input]').first()
  74. await input.waitFor({ timeout: 10_000 })
  75. await input.fill('/plan ')
  76. await input.press('Enter')
  77. // The command handler commits plan/mode active immediately (no model
  78. // round), so the chip renders and the composer control row — the surface
  79. // under test — is the one visible.
  80. const chip = page.getByRole('button', { name: CHIP_ARIA })
  81. const trigger = page.getByRole('button', { name: /Select model/ })
  82. await chip.waitFor({ timeout: 30_000 })
  83. await trigger.waitFor({ timeout: 10_000 })
  84. // The regression depends on the real model label width: a bare fallback
  85. // trigger would fit beside the chip even on the pre-fix layout. The
  86. // directory loads asynchronously, so poll for the real label.
  87. await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 }).toContain('DeepSeek-V4-Flash')
  88. const chipBox = await chip.boundingBox()
  89. const triggerBox = await trigger.boundingBox()
  90. expect(chipBox).not.toBeNull()
  91. expect(triggerBox).not.toBeNull()
  92. // The reported acceptance as numbers: both controls in viewport and
  93. // disjoint click areas (a non-zero overlap would fail), and — in the
  94. // click below — the chip center receiving the pointer.
  95. const chipInViewport = chipBox!.x >= 0 && chipBox!.x + chipBox!.width <= VIEWPORT.width
  96. && chipBox!.y >= 0 && chipBox!.y + chipBox!.height <= VIEWPORT.height
  97. const triggerInViewport = triggerBox!.x >= 0 && triggerBox!.x + triggerBox!.width <= VIEWPORT.width
  98. && triggerBox!.y >= 0 && triggerBox!.y + triggerBox!.height <= VIEWPORT.height
  99. const overlapLeft = Math.max(chipBox!.x, triggerBox!.x)
  100. const overlapTop = Math.max(chipBox!.y, triggerBox!.y)
  101. const overlapRight = Math.min(chipBox!.x + chipBox!.width, triggerBox!.x + triggerBox!.width)
  102. const overlapBottom = Math.min(chipBox!.y + chipBox!.height, triggerBox!.y + triggerBox!.height)
  103. const overlapArea = Math.max(0, overlapRight - overlapLeft) * Math.max(0, overlapBottom - overlapTop)
  104. const golden = [
  105. '# Plan chip and model trigger at the 800×720 viewport',
  106. '',
  107. '- Plan chip fully in viewport: ' + (chipInViewport ? 'true' : 'false'),
  108. '- Model trigger fully in viewport: ' + (triggerInViewport ? 'true' : 'false'),
  109. '- Click areas disjoint: ' + (overlapArea === 0 ? 'true' : 'false'),
  110. ].join('\n').trimEnd()
  111. await compareOrRefreshGolden(LAYOUT_EXPECTED, golden, MODE)
  112. expect(overlapArea).toBe(0)
  113. expect(chipInViewport).toBe(true)
  114. expect(triggerInViewport).toBe(true)
  115. // Exit through the real command channel: the click at the chip's center
  116. // executes /plan off and the folded projection flips inactive, so the chip
  117. // unmounts. Playwright's click() targets the element center by default and
  118. // its actionability check fails the click when that point is covered by
  119. // the model trigger — the reported bug as a failing click rather than a
  120. // coordinate probe.
  121. await chip.click()
  122. await expect.poll(() => page.getByRole('button', { name: CHIP_ARIA }).count(), { timeout: 15_000 }).toBe(0)
  123. // The click must have committed the exit: the last plan/mode event flips
  124. // inactive (the /plan command's entry event stays active:true earlier in
  125. // the log, so the pair proves the exit and not just the entry).
  126. const planModes = sessionEvents.filter(
  127. (event): event is SessionEvent<'plan/mode'> => event.type === 'plan/mode',
  128. )
  129. expect(planModes.at(-1)?.data.active).toBe(false)
  130. expect(tripwire.pageErrors).toEqual([])
  131. expect(tripwire.warnings).toEqual([])
  132. }, 200_000)
  133. it('keeps the snapshot inventory closed', async () => {
  134. await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'layout.expected.md'])
  135. })
  136. })