sidebar-right.e2e.ts 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108
  1. // Keyless assembled-browser coverage for the shipped right Sidebar: the official
  2. // roster row, the real plugin graph, and one Chromium. No overlay is applied —
  3. // this scenario proves the surface is in the product's own composition.
  4. //
  5. // The frame owns the right column as a track; the Sidebar anchors its panel to
  6. // the column's edge and slides it in and out. Which of the two presentations
  7. // draws the panel is a recorded, reversible choice, so this file asserts against
  8. // the frame's track as much as against the panel itself. The way back in while
  9. // collapsed is not in the column at all: it is one button in the conversation
  10. // header, and it leaves when the panel opens.
  11. //
  12. // Ordering is the product's own: the hero comes before any session, so the
  13. // empty right edge is asserted first and the session-bound cases follow in a
  14. // nested block that seeds one turn.
  15. //
  16. // Copy is asserted in English because this page advertises English, which is
  17. // itself the point: every string in this column now comes from the dictionary,
  18. // so an English page renders English. The Chinese draft the product ships is
  19. // asserted, and captured for review, on its own page at the end.
  20. import { mkdirSync, writeFileSync } from 'node:fs'
  21. import { join } from 'node:path'
  22. import { fileURLToPath } from 'node:url'
  23. import type { Browser, ConsoleMessage, Locator, Page } from 'playwright'
  24. import { chromium } from 'playwright'
  25. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  26. import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  27. import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
  28. import {
  29. connectFreshWorkspace, newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE,
  30. } from './support.ts'
  31. /** The produced file the seeded turn writes, and what the preview should show. */
  32. const SAMPLE_NAME = 'notes.txt'
  33. const SAMPLE_TEXT = 'produced by the seeded turn\nsecond line\n'
  34. /** Where this batch's accepted product forms are archived. */
  35. const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0907-sidebar-rules', import.meta.url))
  36. /** Archive one accepted product form; the batch receipt cites these by name. */
  37. async function shot(page: Page, name: string): Promise<void> {
  38. mkdirSync(SHOT_DIR, { recursive: true })
  39. await page.screenshot({ path: `${SHOT_DIR}/${name}.png`, fullPage: true })
  40. }
  41. /** Centre of a rendered element, in viewport coordinates. */
  42. async function centre(locator: Locator): Promise<{ x: number; y: number }> {
  43. const box = await locator.boundingBox()
  44. if (box === null) throw new Error('element is not rendered')
  45. return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
  46. }
  47. /**
  48. * Press a tab chip and release it over a point.
  49. *
  50. * The press stays left of the chip's nested close control. An optional preview
  51. * verifies the browser recognized the drop target before the release.
  52. */
  53. async function dragTo(page: Page, tab: Locator, to: { x: number; y: number }, preview?: Locator): Promise<void> {
  54. const box = await tab.boundingBox()
  55. if (box === null) throw new Error('tab is not rendered')
  56. const from = { x: box.x + 6, y: box.y + box.height / 2 }
  57. await page.mouse.move(from.x, from.y)
  58. await page.mouse.down()
  59. try {
  60. await page.mouse.move(to.x, to.y, { steps: 8 })
  61. await preview?.waitFor({ state: 'visible' })
  62. } finally {
  63. await page.mouse.up()
  64. }
  65. }
  66. /** Press an element itself and release over a point (no tab-title indirection). */
  67. async function dragElement(page: Page, handle: Locator, to: { x: number; y: number }): Promise<void> {
  68. const from = await centre(handle)
  69. await page.mouse.move(from.x, from.y)
  70. await page.mouse.down()
  71. await page.mouse.move(to.x, to.y, { steps: 8 })
  72. await page.mouse.up()
  73. }
  74. /** A point inside `locator`, offset by fractions of its own box. */
  75. async function pointIn(locator: Locator, fx: number, fy: number): Promise<{ x: number; y: number }> {
  76. const box = await locator.boundingBox()
  77. if (box === null) throw new Error('element is not rendered')
  78. return { x: box.x + box.width * fx, y: box.y + box.height * fy }
  79. }
  80. /** Pause the panel's next real transform transition after observing its initial frame geometry. */
  81. async function holdPanelSlide(panel: Locator) {
  82. return await panel.evaluateHandle((node) => {
  83. const controller = new AbortController()
  84. const state = { animation: null as Animation | null, columnsAtStart: '', dispose: () => { controller.abort() } }
  85. node.addEventListener('transitionrun', (event) => {
  86. if (event.target !== node || (event as TransitionEvent).propertyName !== 'transform') return
  87. const frame = node.closest('[style*="grid-template-columns"]')
  88. if (frame === null) throw new Error('panel frame is unavailable')
  89. state.columnsAtStart = getComputedStyle(frame).gridTemplateColumns
  90. const slide = node.getAnimations().find(animation =>
  91. 'transitionProperty' in animation && animation.transitionProperty === 'transform')
  92. if (slide === undefined || slide.effect === null) throw new Error('panel transform transition is unavailable')
  93. slide.pause()
  94. slide.currentTime = Number(slide.effect.getComputedTiming().endTime) / 2
  95. state.animation = slide
  96. controller.abort()
  97. }, { signal: controller.signal })
  98. return state
  99. })
  100. }
  101. /** The expand button in the conversation header, present only while collapsed. */
  102. function expandOf(page: Page): Locator {
  103. return page.locator('[data-sidebar-right-expand]')
  104. }
  105. /**
  106. * Make sure the panel is open.
  107. *
  108. * These cases share one page and run in order, so an earlier one may have left
  109. * the panel collapsed; a case that needs tabs says so rather than inheriting
  110. * whatever the previous one happened to leave. The way in while collapsed is the
  111. * header's expand button, which lives in the conversation, not the column.
  112. */
  113. async function ensureExpanded(page: Page, column: Locator): Promise<void> {
  114. if (await column.locator('[data-sidebar-right-open]').count() > 0) return
  115. await expandOf(page).click()
  116. await column.locator('[data-sidebar-right-open]').waitFor({ timeout: 10_000 })
  117. }
  118. /** Clear only this fixture's saved layouts before an independent gesture case. */
  119. async function resetSidebar(page: Page): Promise<Locator> {
  120. await page.evaluate(() => {
  121. for (const key of Object.keys(localStorage)) {
  122. if (key.startsWith('dsh.sidebar-right.v1.')) localStorage.removeItem(key)
  123. }
  124. })
  125. await page.reload({ waitUntil: 'load' })
  126. const column = page.locator('[data-rightbar-col]')
  127. await expandOf(page).waitFor({ timeout: 15_000 })
  128. await ensureExpanded(page, column)
  129. await column.locator('[data-sidebar-right-guide-entry="files"]').click()
  130. await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
  131. await width(column)
  132. return column
  133. }
  134. /** Whether the element at the centre of `locator` is the locator's own element or a descendant. */
  135. async function hitsItself(locator: Locator): Promise<boolean> {
  136. const point = await centre(locator)
  137. return await locator.evaluate((node, at) => {
  138. const hit = document.elementFromPoint(at.x, at.y)
  139. return hit !== null && node.contains(hit)
  140. }, point)
  141. }
  142. /**
  143. * Float a docked tab the way a user does: drag its chip clear of the docked
  144. * surface and release over the conversation.
  145. */
  146. async function floatByDrag(page: Page, tab: Locator): Promise<void> {
  147. const surface = page.locator('[data-dockkit-surface]').first()
  148. const box = await surface.boundingBox()
  149. if (box === null) throw new Error('surface is not rendered')
  150. await dragTo(page, tab, { x: box.x - 240, y: box.y + box.height / 2 })
  151. }
  152. /**
  153. * Drag the frame's rightbar handle until the panel is `target` px wide (the
  154. * frame clamps to its own range). The handle sits on the panel's left edge, so
  155. * widening is a drag to the left.
  156. */
  157. async function setPanelWidth(page: Page, target: number): Promise<void> {
  158. const panel = page.locator('[data-sidebar-right-panel]')
  159. const handle = page.locator('[data-side="rightbar"]').first()
  160. for (let attempt = 0; attempt < 3; attempt += 1) {
  161. const box = await panel.boundingBox()
  162. if (box === null) throw new Error('panel is not rendered')
  163. const delta = target - box.width
  164. if (Math.abs(delta) < 2) return
  165. const grip = await centre(handle)
  166. await page.mouse.move(grip.x, grip.y)
  167. await page.mouse.down()
  168. await page.mouse.move(grip.x - delta, grip.y, { steps: 8 })
  169. await page.mouse.up()
  170. await page.waitForTimeout(300)
  171. }
  172. }
  173. /** Tab titles inside one container, in strip order. */
  174. async function tabTitles(root: Locator): Promise<string[]> {
  175. return await root.locator('[data-dockkit-tab-title]').allInnerTexts()
  176. }
  177. /**
  178. * A rendered width, read once the frame's track transition has settled.
  179. *
  180. * The frame eases its grid tracks, so a single sample taken right after a
  181. * gesture reports a frame of the animation. Column arithmetic is only exact at
  182. * rest, so this samples until three consecutive readings agree.
  183. */
  184. async function width(locator: Locator): Promise<number> {
  185. let last = Number.NaN
  186. let steady = 0
  187. for (let attempt = 0; attempt < 80; attempt += 1) {
  188. // Layout width, not the visible box: a zero-width track is still an answer.
  189. const now = Math.round(await locator.evaluate(node => node.getBoundingClientRect().width))
  190. steady = now === last ? steady + 1 : 0
  191. if (steady === 2) return now
  192. last = now
  193. await locator.page().waitForTimeout(50)
  194. }
  195. throw new Error(`width never settled (last ${last}px)`)
  196. }
  197. describe('web e2e: shipped right Sidebar', () => {
  198. let scaffold: WebScaffold
  199. let browser: Browser
  200. let page: Page
  201. let tripwire: ReturnType<typeof watchConsole>
  202. beforeAll(async () => {
  203. scaffold = await launchWebScaffold()
  204. browser = await chromium.launch()
  205. page = await newEnglishPage(browser)
  206. tripwire = watchConsole(page)
  207. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  208. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  209. }, 180_000)
  210. afterAll(async () => {
  211. await browser?.close()
  212. await scaffold?.close()
  213. })
  214. it('shows nothing on the right while no session keys a surface', async () => {
  215. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-hero'))
  216. const frame = page.locator('[class*="frame"]').first()
  217. const column = page.locator('[data-rightbar-col]')
  218. await column.waitFor({ state: 'attached', timeout: 15_000 })
  219. // With no session there is no surface: no panel in the column, no expand
  220. // button in the header, and no track — the conversation reaches the frame's edge.
  221. expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
  222. expect(await column.locator('[data-sidebar-right-panel]').count()).toBe(0)
  223. expect(await expandOf(page).count()).toBe(0)
  224. expect(await width(column)).toBe(0)
  225. await shot(page, '01-hero-no-sidebar')
  226. expect(tripwire.pageErrors).toEqual([])
  227. expect(tripwire.warnings).toEqual([])
  228. })
  229. describe('with a settled session', () => {
  230. beforeAll(async () => {
  231. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  232. // A settled session is what keys the surface; seed one turn through the
  233. // real append path so the Chat surface is live before the Sidebar is driven.
  234. const agent = scaffold.ctx.agents.list()[0]
  235. if (agent === undefined) throw new Error('connected workspace did not create an Agent')
  236. // The wire parameter is `agentId`; the client passes a session id. If the
  237. // scaffold's Agent and Session carry different ids, that mismatch is the
  238. // silent lookup failure.
  239. expect(String(agent.id)).toBe(String(agent.session.id))
  240. agent.session.append('turn/start', { turn: 1 })
  241. agent.session.append('user/message', createUserMessage({
  242. content: [{ type: 'text', text: 'Show the right sidebar.' }],
  243. source: { kind: 'user' },
  244. }), { surfaceOp: 'append' })
  245. agent.session.append('step/start', { turn: 1, step: 1 })
  246. // A successful mutation is what makes the turn tail offer a produced-file
  247. // chip — the product's own way into the Sidebar. The file is written for
  248. // real because the preview reads it through the workspace endpoint.
  249. //
  250. // It goes in the SESSION's cwd, not the scaffold's: the endpoint resolves
  251. // relative paths against the header-derived workspace root. Writing
  252. // anywhere else makes the read fail with workspace-file/not-found, which
  253. // is the endpoint being right.
  254. writeFileSync(join(agent.session.header.cwd ?? scaffold.workspaceCwd, SAMPLE_NAME), SAMPLE_TEXT, 'utf8')
  255. agent.session.append('tool/call', {
  256. turn: 1,
  257. step: 1,
  258. callId: 'call-write-1',
  259. name: 'write',
  260. arguments: JSON.stringify({ file_path: SAMPLE_NAME, content: SAMPLE_TEXT }),
  261. } as never)
  262. agent.session.append('tool/result', {
  263. turn: 1,
  264. step: 1,
  265. message: {
  266. id: 'result-call-write-1',
  267. role: 'user',
  268. source: { kind: 'tool', callId: 'call-write-1' },
  269. content: [{ type: 'tool-result', toolCallId: 'call-write-1', content: [{ type: 'text', text: 'ok' }] }],
  270. },
  271. } as never, { surfaceOp: 'append' })
  272. agent.session.append('assistant/message', {
  273. stream: [],
  274. turn: 1,
  275. step: 1,
  276. message: createMessage({
  277. role: 'assistant',
  278. content: [{ type: 'text', text: 'Ready.' }],
  279. source: { kind: 'model', provider: 'fixture', model: 'fixture' },
  280. }),
  281. }, { surfaceOp: 'append' })
  282. agent.session.append('step/end', { turn: 1, step: 1 })
  283. agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  284. await scaffold.ctx.sessions.flush(agent.session)
  285. await page.getByText('Ready.').waitFor({ timeout: 10_000 })
  286. }, 120_000)
  287. it('CONTROL: the old fileReferences namespace answers over the same wire', async () => {
  288. const composer = page.locator('textarea, [contenteditable="true"]').first()
  289. await composer.click()
  290. await composer.pressSequentially('@')
  291. // Any candidate list means the agent-scoped lookup and the gateway route
  292. // both work in this scaffold; nothing rendered means the wire is the fault.
  293. const answered = await page.locator('[data-input-trigger], [role="listbox"], [data-reference-list]')
  294. .first().waitFor({ timeout: 10_000 }).then(() => true, () => false)
  295. await page.keyboard.press('Escape')
  296. // Leave the composer as it was found: these cases share one page, and a
  297. // stray '@' rides into every later assertion and screenshot. `fill('')`
  298. // does NOT clear this editor — it reported success while the character
  299. // stayed — so the reset is a real keystroke, and it is asserted rather
  300. // than assumed.
  301. await composer.click()
  302. await page.keyboard.press('ControlOrMeta+a')
  303. await page.keyboard.press('Backspace')
  304. await expect.poll(async () => (await composer.innerText()).trim()).toBe('')
  305. expect(answered).toBe(true)
  306. })
  307. it('starts collapsed behind a header button and squeezes the conversation when opened', async () => {
  308. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right'))
  309. const frame = page.locator('[class*="frame"]').first()
  310. const column = page.locator('[data-rightbar-col]')
  311. const conversation = page.locator('[class*="centerCol"]').first()
  312. const expand = expandOf(page)
  313. // Collapsed default: no track, the panel sits off the frame's edge, and
  314. // the only way in is the header button — on the same row as the other
  315. // header utilities, at its far right.
  316. await expand.waitFor({ timeout: 15_000 })
  317. expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
  318. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  319. const utilities = page.locator('[class*="headerUtilities"]')
  320. const expandBox = await expand.boundingBox()
  321. const rowBox = await utilities.boundingBox()
  322. if (expandBox === null || rowBox === null) throw new Error('header utilities are not rendered')
  323. expect(Math.round(expandBox.y + expandBox.height / 2)).toBe(Math.round(rowBox.y + rowBox.height / 2))
  324. // Its own corner seat, past the utilities' right edge — not a utility.
  325. expect(expandBox.x).toBeGreaterThan(rowBox.x + rowBox.width)
  326. const conversationBoxBefore = await conversation.boundingBox()
  327. if (conversationBoxBefore === null) throw new Error('conversation is not rendered')
  328. // How far the utilities' right edge sits from the conversation's own.
  329. const gapBefore = (conversationBoxBefore.x + conversationBoxBefore.width) - (rowBox.x + rowBox.width)
  330. const centerBefore = await width(conversation)
  331. await shot(page, '02a-collapsed-header-button')
  332. // Opening squeezes by default: the column takes a track of the panel's
  333. // width, the conversation gives up exactly that much room, and the header
  334. // button leaves with the panel's arrival.
  335. await expand.click()
  336. await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe(null)
  337. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(1)
  338. const panelWidth = await width(column)
  339. expect(panelWidth).toBeGreaterThan(0)
  340. expect(await width(conversation)).toBe(centerBefore - panelWidth)
  341. await expect.poll(async () => await expand.count()).toBe(0)
  342. // The corner seat collapses with its button, so the utilities' right edge
  343. // moves out toward the conversation's own.
  344. const utilitiesAfter = await utilities.boundingBox()
  345. const conversationAfter = await conversation.boundingBox()
  346. if (utilitiesAfter === null || conversationAfter === null) throw new Error('header is not rendered')
  347. const gapAfter = (conversationAfter.x + conversationAfter.width) - (utilitiesAfter.x + utilitiesAfter.width)
  348. expect(gapAfter).toBeLessThan(gapBefore)
  349. // The panel is in the column, not over it, and carries the seeded tab —
  350. // whose body arrives through the Files type's keyed registration, not from
  351. // any dispatch inside the seat. Its two controls sit at the end of the
  352. // top-right pane's strip: the panel has no header row of its own.
  353. expect(await column.locator('[data-sidebar-right-panel="push"]').count()).toBe(1)
  354. const chrome = column.locator('[data-dockkit-strip-chrome]')
  355. expect(await chrome.count()).toBe(1)
  356. expect(await chrome.locator('[data-sidebar-right-mode]').count()).toBe(1)
  357. expect(await chrome.locator('[data-sidebar-right-toggle]').count()).toBe(1)
  358. // One centre line across the strip: chip text, split, and the two panel
  359. // controls all sit at the same height. The add control joins the check below.
  360. const centreY = async (selector: string): Promise<number> => {
  361. const box = await column.locator(selector).first().boundingBox()
  362. if (box === null) throw new Error(`${selector} is not rendered`)
  363. return Math.round(box.y + box.height / 2)
  364. }
  365. const textLine = await centreY('[data-dockkit-tab-title]')
  366. for (const selector of ['[data-dockkit-split-button]', '[data-sidebar-right-mode]', '[data-sidebar-right-toggle]']) {
  367. expect(await centreY(selector), selector).toBe(textLine)
  368. }
  369. await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
  370. await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2)
  371. await column.locator('[data-sidebar-right-guide-entry="files"]').click()
  372. // A manual guide is closable beside Files and suppresses another add
  373. // control in its pane until it is closed.
  374. const addTab = column.locator('[data-dockkit-add-tab]')
  375. const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
  376. await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
  377. await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
  378. expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
  379. await expect.poll(async () => await addTab.count()).toBe(1)
  380. expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine)
  381. await addTab.click()
  382. await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
  383. await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
  384. await expect.poll(async () => await addTab.count()).toBe(0)
  385. expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
  386. const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
  387. expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
  388. // Back to the seeded shape the cases below start from.
  389. await guideTab.hover()
  390. await guideTab.locator('[data-dockkit-tab-close]').click()
  391. await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
  392. await expect.poll(async () => await addTab.count()).toBe(1)
  393. await shot(page, '02-squeezed-panel')
  394. expect(tripwire.pageErrors).toEqual([])
  395. expect(tripwire.warnings).toEqual([])
  396. })
  397. it('covers the viewport in fullscreen without changing the underlying columns', async () => {
  398. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-mode'))
  399. const frame = page.locator('[class*="frame"]').first()
  400. const column = page.locator('[data-rightbar-col]')
  401. const panel = column.locator('[data-sidebar-right-panel]')
  402. const conversation = page.locator('[class*="centerCol"]').first()
  403. const squeezed = await width(conversation)
  404. const before = await panel.boundingBox()
  405. const trackWidth = await width(column)
  406. await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
  407. await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
  408. expect(await frame.getAttribute('data-rightbar-collapsed')).toBe(null)
  409. expect(await width(conversation)).toBe(squeezed)
  410. expect(await width(column)).toBe(trackWidth)
  411. const viewport = page.viewportSize()
  412. if (viewport === null) throw new Error('expected a fixed viewport')
  413. await expect.poll(async () => await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
  414. expect(await expandOf(page).count()).toBe(0)
  415. expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0)
  416. await shot(page, '03-fullscreen-panel')
  417. await column.locator('[data-sidebar-right-mode="push"]').click()
  418. await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
  419. expect(await width(conversation)).toBe(squeezed)
  420. expect(await panel.boundingBox()).toEqual(before)
  421. expect(tripwire.pageErrors).toEqual([])
  422. expect(tripwire.warnings).toEqual([])
  423. })
  424. it('keeps the conversation still during fullscreen entry and installs the hidden track without animation', async () => {
  425. onTestFailed(() => saveFailureShot(page, 'screenshots/0907-sidebar-rules/sidebar-right-fullscreen-entry'))
  426. mkdirSync(SHOT_DIR, { recursive: true })
  427. const column = await resetSidebar(page)
  428. const frame = page.locator('[class*="frame"]').first()
  429. const panel = column.locator('[data-sidebar-right-panel]')
  430. const viewport = page.viewportSize()
  431. if (viewport === null) throw new Error('expected a fixed viewport')
  432. const geometry = () => frame.evaluate(node => ({
  433. columns: getComputedStyle(node).gridTemplateColumns,
  434. transition: getComputedStyle(node).transitionProperty,
  435. handles: [...node.querySelectorAll('[data-side="sidebar"], [data-side="rightbar"]')]
  436. .map(handle => getComputedStyle(handle).transitionProperty),
  437. animatingGrid: node.getAnimations().some(animation =>
  438. 'transitionProperty' in animation && animation.transitionProperty === 'grid-template-columns'
  439. && animation.playState !== 'finished' && animation.playState !== 'idle'),
  440. }))
  441. await frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) })
  442. const normalColumns = (await geometry()).columns
  443. await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
  444. await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
  445. await column.locator('[data-sidebar-right-toggle]').click()
  446. await Promise.all([
  447. panel.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }),
  448. frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }),
  449. ])
  450. const closedColumns = (await geometry()).columns
  451. expect(closedColumns).not.toBe(normalColumns)
  452. await page.emulateMedia({ reducedMotion: 'no-preference' })
  453. // Pause the real CSS transition at its midpoint so host scheduling cannot
  454. // skip the partly covered frame whose underlying width is under test.
  455. const held = await holdPanelSlide(panel)
  456. try {
  457. await expandOf(page).click()
  458. await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused')
  459. const entering = await panel.boundingBox()
  460. if (entering === null) throw new Error('entering panel is not rendered')
  461. expect(entering.x).toBeGreaterThan(0)
  462. expect(entering.x).toBeLessThan(viewport.width)
  463. expect((await geometry()).columns).toBe(closedColumns)
  464. expect((await geometry()).animatingGrid).toBe(false)
  465. expect(await frame.getAttribute('data-rightbar-fullscreen')).toBeNull()
  466. await held.evaluate((state) => { (state.animation as Animation).finish() })
  467. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
  468. expect(await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
  469. expect(await geometry()).toEqual({ columns: normalColumns, transition: 'none', handles: ['none'], animatingGrid: false })
  470. const exit = await holdPanelSlide(panel)
  471. try {
  472. await column.locator('[data-sidebar-right-toggle]').click()
  473. await expect.poll(() => exit.evaluate(state => state.animation?.playState)).toBe('paused')
  474. expect(await exit.evaluate(state => state.columnsAtStart)).toBe(closedColumns)
  475. const leaving = await panel.boundingBox()
  476. if (leaving === null) throw new Error('leaving panel is not rendered')
  477. expect(leaving.x).toBeGreaterThan(0)
  478. expect(leaving.x).toBeLessThan(viewport.width)
  479. expect(await geometry()).toEqual({ columns: closedColumns, transition: 'none', handles: ['none'], animatingGrid: false })
  480. await exit.evaluate((state) => { (state.animation as Animation).finish() })
  481. expect((await geometry()).columns).toBe(closedColumns)
  482. } finally {
  483. await exit.evaluate((state) => {
  484. state.dispose()
  485. if (state.animation?.playState === 'paused') state.animation.finish()
  486. })
  487. await exit.dispose()
  488. }
  489. await expandOf(page).click()
  490. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
  491. await column.locator('[data-sidebar-right-mode="push"]').click()
  492. expect((await geometry()).columns).toBe(normalColumns)
  493. expect((await geometry()).animatingGrid).toBe(false)
  494. await page.emulateMedia({ reducedMotion: 'reduce' })
  495. await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
  496. await column.locator('[data-sidebar-right-toggle]').click()
  497. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBeNull()
  498. await expandOf(page).click()
  499. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
  500. expect(await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
  501. expect((await geometry()).columns).toBe(normalColumns)
  502. expect((await geometry()).animatingGrid).toBe(false)
  503. } finally {
  504. await held.evaluate((state) => {
  505. state.dispose()
  506. if (state.animation?.playState === 'paused') state.animation.finish()
  507. })
  508. await held.dispose()
  509. try {
  510. if (await panel.getAttribute('data-sidebar-right-panel') === 'fullscreen'
  511. && await panel.getAttribute('data-sidebar-right-open') !== null) {
  512. await column.locator('[data-sidebar-right-mode="push"]').click()
  513. }
  514. } finally {
  515. await page.emulateMedia({ reducedMotion: null })
  516. }
  517. }
  518. expect(tripwire.pageErrors).toEqual([])
  519. expect(tripwire.warnings).toEqual([])
  520. })
  521. it('keeps a capacity-closed panel closed after widening and uses fullscreen on a narrow viewport', async () => {
  522. const viewport = page.viewportSize()
  523. if (viewport === null) throw new Error('expected a fixed viewport')
  524. const frame = page.locator('[class*="frame"]').first()
  525. const column = page.locator('[data-rightbar-col]')
  526. const sidebar = page.locator('[class*="sidebarCol"]').first()
  527. const panel = column.locator('[data-sidebar-right-panel]')
  528. try {
  529. await page.setViewportSize({ width: 1024, height: viewport.height })
  530. const leftGrip = frame.locator('[data-side="sidebar"]')
  531. const grip = await centre(leftGrip)
  532. await dragElement(page, leftGrip, { x: 420, y: grip.y })
  533. await expect.poll(async () => await width(sidebar)).toBe(420)
  534. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  535. await page.setViewportSize(viewport)
  536. await expect.poll(async () => await width(sidebar)).toBe(420)
  537. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  538. await expandOf(page).click()
  539. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(1)
  540. await page.setViewportSize({ width: 767, height: viewport.height })
  541. await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
  542. await expect.poll(async () => await width(panel)).toBe(767)
  543. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
  544. expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0)
  545. await column.locator('[data-sidebar-right-mode="push"]').click()
  546. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  547. await page.setViewportSize(viewport)
  548. await expect.poll(async () => await width(sidebar)).toBe(420)
  549. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  550. } finally {
  551. await page.setViewportSize(viewport)
  552. const grip = await centre(frame.locator('[data-side="sidebar"]'))
  553. await dragElement(page, frame.locator('[data-side="sidebar"]'), { x: 280, y: grip.y })
  554. await expect.poll(async () => await width(sidebar)).toBe(280)
  555. await ensureExpanded(page, column)
  556. }
  557. expect(tripwire.pageErrors).toEqual([])
  558. expect(tripwire.warnings).toEqual([])
  559. })
  560. it('survives grip drags past both clamps on a squeezed viewport', async () => {
  561. // Regression: an overshoot drag swept the panel through widths where a
  562. // width-blocked split control hid itself, which changed the very strip
  563. // measurement that had blocked it — a layout-effect feedback loop that
  564. // crashed the pane (React error #185) and unmounted the rightbar slot
  565. // entry while the column still believed it was expanded, so neither the
  566. // panel nor the header's expand button remained. The crash surfaces only
  567. // as a console error, which the scaffold tripwire does not watch, so this
  568. // case collects console errors itself.
  569. const viewport = page.viewportSize()
  570. if (viewport === null) throw new Error('expected a fixed viewport')
  571. const column = await resetSidebar(page)
  572. const frame = page.locator('[class*="frame"]').first()
  573. const panel = column.locator('[data-sidebar-right-panel]')
  574. const consoleErrors: string[] = []
  575. const collect = (message: ConsoleMessage): void => {
  576. if (message.type() === 'error') consoleErrors.push(message.text().slice(0, 600))
  577. }
  578. page.on('console', collect)
  579. try {
  580. await page.setViewportSize({ width: 1000, height: viewport.height })
  581. await ensureExpanded(page, column)
  582. await width(column)
  583. const grip = frame.locator('[data-side="rightbar"]')
  584. // The frame reads the new viewport through a throttled ResizeObserver,
  585. // a couple of frames after the resize; until then the grip sits at the
  586. // old frame's coordinates. Press only a grip aligned with the panel's
  587. // left edge (the handle is 8px wide, centred on the seam).
  588. await expect.poll(async () => {
  589. const gripBox = await grip.boundingBox()
  590. const panelBox = await panel.boundingBox()
  591. if (gripBox === null || panelBox === null) return Number.NaN
  592. return Math.abs(gripBox.x + 4 - panelBox.x)
  593. }).toBeLessThanOrEqual(1)
  594. // Narrow with overshoot: drag the grip far right past the clamp floor.
  595. const from = await centre(grip)
  596. await page.mouse.move(from.x, from.y)
  597. await page.mouse.down()
  598. await page.mouse.move(980, from.y, { steps: 30 })
  599. await page.mouse.up()
  600. // The panel holds its floor, still open, with its grip still rendered.
  601. await expect.poll(async () => await width(panel)).toBeLessThanOrEqual(302)
  602. expect(await width(panel)).toBeGreaterThanOrEqual(300)
  603. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
  604. expect(await grip.count()).toBe(1)
  605. // Widen with overshoot to the far left: clamped by the frame's range.
  606. const back = await centre(grip)
  607. await page.mouse.move(back.x, back.y)
  608. await page.mouse.down()
  609. await page.mouse.move(20, back.y, { steps: 30 })
  610. await page.mouse.up()
  611. const widened = await width(panel)
  612. expect(widened).toBeGreaterThan(302)
  613. expect(widened).toBeLessThan(1000)
  614. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
  615. expect(await grip.count()).toBe(1)
  616. expect(consoleErrors).toEqual([])
  617. expect(tripwire.pageErrors).toEqual([])
  618. expect(tripwire.warnings).toEqual([])
  619. } finally {
  620. page.off('console', collect)
  621. await page.setViewportSize(viewport)
  622. await ensureExpanded(page, column)
  623. await setPanelWidth(page, Math.round(viewport.width * 0.45))
  624. }
  625. }, 60_000)
  626. it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => {
  627. const files = (scaffold.ctx as unknown as {
  628. get(name: string): {
  629. read(
  630. scope: { sessionId: string; workspaceRoot: string },
  631. path: string,
  632. range: object,
  633. signal: AbortSignal,
  634. ): Promise<{ text: string; eof: boolean }>
  635. } | undefined
  636. }).get('workspaceFiles')
  637. if (files === undefined) throw new Error('host endpoint is not provided')
  638. const agent = scaffold.ctx.agents.list()[0]
  639. if (agent === undefined) throw new Error('no Agent to read for')
  640. const scope = {
  641. sessionId: agent.session.id,
  642. workspaceRoot: agent.session.header.cwd ?? scaffold.workspaceCwd,
  643. }
  644. // Raced against a timer so a hang reports a verdict instead of stalling
  645. // the suite: this case exists to tell host logic apart from the wire.
  646. // A page is the file's lines joined by `\n`, without the final terminator.
  647. const verdict = await Promise.race([
  648. files.read(scope, SAMPLE_NAME, {}, new AbortController().signal)
  649. .then(value => ({ kind: 'settled' as const, text: value.text, eof: value.eof }))
  650. .catch((error: unknown) => ({ kind: 'threw' as const, text: String(error), eof: false })),
  651. new Promise<{ kind: 'hung'; text: string; eof: boolean }>((resolve) => {
  652. setTimeout(() => { resolve({ kind: 'hung', text: 'no settlement in 10s', eof: false }) }, 10_000)
  653. }),
  654. ])
  655. expect(verdict).toEqual({ kind: 'settled', text: SAMPLE_TEXT.replace(/\n$/u, ''), eof: true })
  656. }, 30_000)
  657. it('opens content once, splits, and floats it outside the column', async () => {
  658. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-content'))
  659. const column = page.locator('[data-rightbar-col]')
  660. const panes = column.locator('[data-dockkit-pane]')
  661. const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]')
  662. // Observation before action: does the read ever leave the browser? The
  663. // assertion states the healthy answer so a failure prints the real one.
  664. //
  665. // CAVEAT: `sent` is trustworthy — a request frame carries the endpoint
  666. // name. `received` is NOT: a response frame carries only its rpc id, so a
  667. // zero here means "my filter saw nothing", not "the host never answered".
  668. // Correlate by rpc id before drawing any conclusion from it.
  669. const wire = { sent: 0, received: 0 }
  670. const watch = (payload: string): void => {
  671. if (!payload.includes('workspaceFiles')) return
  672. wire.sent += 1
  673. }
  674. page.on('websocket', (ws) => {
  675. ws.on('framesent', (frame) => { watch(String(frame.payload)) })
  676. ws.on('framereceived', (frame) => {
  677. if (String(frame.payload).includes('workspaceFiles')) wire.received += 1
  678. })
  679. })
  680. page.on('request', (request) => {
  681. if (request.url().includes('workspaceFiles')) wire.sent += 1
  682. })
  683. // The product's own entry point: the turn tail's produced-file chip. It
  684. // reaches the Sidebar through openFile → ctx.sidebarRight.openResource, and the
  685. // text type claims the address.
  686. const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME}` })
  687. await chip.click()
  688. await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
  689. // Opening the same content again focuses rather than duplicating.
  690. await panes.first().locator('[data-dockkit-tab]').first().click()
  691. await chip.click()
  692. await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
  693. // The body arrives through the text type's keyed registration, and its
  694. // content came over the wire from the real file.
  695. // A real Remote round-trip settles well after the default poll window.
  696. await column.locator('[data-textpreview-state="text"]')
  697. .waitFor({ timeout: 15_000 })
  698. .catch(() => { throw new Error(`preview never settled; wire=${JSON.stringify(wire)}`) })
  699. expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
  700. // The whole batch-E chain in one frame: a produced-file chip in the
  701. // conversation, the tab it opened, and the file's real content read over
  702. // the workspace endpoint.
  703. await shot(page, '06-produced-chip-to-preview')
  704. // The directory scenario's V1 behaviour, asserted in the shipped product:
  705. // there is no folder affordance at all. `openFile('.')` would name a
  706. // directory, which a text preview correctly refuses, and the native opener
  707. // it used to reach is gone — so the row offers nothing rather than a
  708. // button that always fails.
  709. expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
  710. // Split, then dock-drag: the kit's gestures drive the store's actions.
  711. await panes.first().locator('[data-dockkit-split-button]').click()
  712. await expect.poll(async () => await panes.count()).toBe(2)
  713. await dragTo(
  714. page,
  715. column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first(),
  716. await pointIn(panes.nth(1), 0.5, 0.94),
  717. )
  718. await expect.poll(async () => await panes.count()).toBe(2)
  719. const splitGuide = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
  720. expect(await splitGuide.locator('[data-dockkit-tab-close]').count()).toBe(1)
  721. await dragTo(page, splitGuide, await pointIn(panes.first(), 0.5, 0.5))
  722. await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
  723. const movedGuide = panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
  724. await movedGuide.hover()
  725. await movedGuide.locator('[data-dockkit-tab-close]').click()
  726. // Neither pane holds a guide, so both offer an add control.
  727. const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) })
  728. await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1)
  729. expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(2)
  730. // Floating leaves the column entirely, and survives collapsing it. The
  731. // pane the tab was alone in goes with it: an emptied pane never stays.
  732. const tab = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first()
  733. await floatByDrag(page, tab)
  734. await expect.poll(async () => await floats.count()).toBe(1)
  735. expect(await column.locator('[data-dockkit-float]').count()).toBe(0)
  736. await expect.poll(async () => await panes.count()).toBe(1)
  737. await shot(page, '04-split-and-float')
  738. await column.locator('[data-sidebar-right-toggle]').click()
  739. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  740. expect(await floats.count()).toBe(1)
  741. expect(tripwire.pageErrors).toEqual([])
  742. expect(tripwire.warnings).toEqual([])
  743. })
  744. // §9.2 (an explicit second copy of the same content) has no control on the
  745. // panel by product decision, and copy has no service method yet:
  746. // `duplicateTab` is a store/kit intent only, which service.client.spec.ts covers.
  747. it('keeps each session\'s surface to itself across switching and page reload', async () => {
  748. const fx = await newEnglishPage(browser)
  749. const fxTripwire = watchConsole(fx)
  750. onTestFailed(() => saveFailureShot(fx, 'web-e2e-sidebar-right-sessions'))
  751. try {
  752. await fx.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  753. const settled = fx.getByRole('treeitem', { name: /Show the right sidebar\./u }).first()
  754. await settled.click()
  755. await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true')
  756. const frame = fx.locator('[class*="frame"]').first()
  757. const column = fx.locator('[data-rightbar-col]')
  758. await ensureExpanded(fx, column)
  759. await width(column)
  760. await fx.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
  761. await column.locator('[data-textpreview-state="text"]').waitFor({ timeout: 15_000 })
  762. const wrap = column.locator('[data-textpreview-tool="wrap"]')
  763. expect(await wrap.getAttribute('aria-pressed')).toBe('true')
  764. await wrap.click()
  765. await expect.poll(async () => await wrap.getAttribute('aria-pressed')).toBe('false')
  766. await column.locator('[data-dockkit-split-button]').first().click()
  767. const panes = column.locator('[data-dockkit-pane]')
  768. await expect.poll(async () => await panes.count()).toBe(2)
  769. const records = async (): Promise<{ panes: string[]; tabs: string[]; titles: string[] }> => ({
  770. panes: await panes.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dockkit-pane')!)),
  771. tabs: await column.locator('[data-dockkit-tab]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dockkit-tab')!)),
  772. titles: await tabTitles(column),
  773. })
  774. const before = await records()
  775. // The real New Session action selects a distinct blank Session; its
  776. // collapsed surface must not inherit the settled Session's tabs.
  777. await fx.getByRole('button', { name: 'New session', exact: true }).last().click()
  778. await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('false')
  779. await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
  780. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  781. expect(await column.locator('[data-textpreview-state="text"]').count()).toBe(0)
  782. await settled.click()
  783. await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true')
  784. await expect.poll(records, { timeout: 15_000 }).toEqual(before)
  785. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
  786. expect(await wrap.getAttribute('aria-pressed')).toBe('false')
  787. expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
  788. let warningStart = fxTripwire.warnings.length
  789. await fx.reload({ waitUntil: 'load' })
  790. acknowledgeReloadConnectionLoss(fxTripwire, warningStart)
  791. await expect.poll(records, { timeout: 15_000 }).toEqual(before)
  792. await expect.poll(async () => await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
  793. await column.locator('[data-sidebar-right-toggle]').click()
  794. warningStart = fxTripwire.warnings.length
  795. await fx.reload({ waitUntil: 'load' })
  796. acknowledgeReloadConnectionLoss(fxTripwire, warningStart)
  797. await fx.locator('[data-sidebar-right-expand]').waitFor()
  798. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  799. await fx.locator('[data-sidebar-right-expand]').click()
  800. await expect.poll(records, { timeout: 15_000 }).toEqual(before)
  801. expect(await width(column)).toBeGreaterThan(0)
  802. await column.locator('[data-sidebar-right-panel]').evaluate(async (node) => {
  803. await Promise.allSettled(node.getAnimations().map(animation => animation.finished))
  804. })
  805. await shot(fx, 'session-layout-restored')
  806. expect(fxTripwire.pageErrors).toEqual([])
  807. expect(fxTripwire.warnings).toEqual([])
  808. } finally {
  809. await fx.close()
  810. }
  811. }, 120_000)
  812. it('§9.3/§9.4 runs the whole pointer chain in a real browser', async () => {
  813. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-gestures'))
  814. const column = await resetSidebar(page)
  815. const panes = column.locator('[data-dockkit-pane]')
  816. const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]')
  817. // Chromium cancels pointer capture if a render replaces the pressed
  818. // element; jsdom cannot establish that the whole gesture survives.
  819. // 1. Reorder inside one strip: drop the last tab left of its neighbours.
  820. // The first pane needs two tabs for this — and for the move below to
  821. // leave it standing, since a pane emptied by a move is dropped.
  822. const first = panes.first()
  823. const strip = first.locator('[data-dockkit-strip]')
  824. await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
  825. await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME])
  826. const order = await tabTitles(first)
  827. // The insertion index is measured against chip midpoints, not strip width.
  828. await dragTo(page, first.locator('[data-dockkit-tab]').last(),
  829. await pointIn(first.locator('[data-dockkit-tab]').first(), 0.25, 0.5),
  830. strip.locator('[data-dockkit-caret="0"]'))
  831. await expect.poll(async () => await tabTitles(first)).toEqual([...order].reverse())
  832. // 2. Cross-pane move into a second pane: the tab leaves one pane's strip
  833. // for another's.
  834. if (await panes.count() < 2) {
  835. await first.locator('[data-dockkit-split-button]').click()
  836. await expect.poll(async () => await panes.count()).toBe(2)
  837. }
  838. const moving = first.locator('[data-dockkit-tab]').first()
  839. const title = await moving.locator('[data-dockkit-tab-title]').innerText()
  840. await dragTo(page, moving, await pointIn(panes.nth(1), 0.5, 0.5))
  841. await expect.poll(async () => await tabTitles(panes.nth(1))).toContain(title)
  842. const splitButtons = column.locator('[data-dockkit-split-button]')
  843. await expect.poll(async () => await splitButtons.count()).toBe(0)
  844. expect(await panes.count()).toBe(2)
  845. await setPanelWidth(page, 560)
  846. await expect.poll(async () => await splitButtons.count()).toBe(0)
  847. const outer = column.locator('[data-dockkit-divider]').first()
  848. const before = await width(panes.last())
  849. const grip = await centre(outer)
  850. await dragElement(page, outer, { x: grip.x - 100, y: grip.y })
  851. await expect.poll(async () => await width(panes.last())).toBeGreaterThan(before)
  852. await dragElement(page, outer, { x: 0, y: grip.y })
  853. const ratio = async (): Promise<number> => {
  854. const left = await width(panes.first())
  855. const right = await width(panes.last())
  856. return left / (left + right)
  857. }
  858. await expect.poll(ratio).toBeCloseTo(0.2, 2)
  859. const surfaceBox = await column.locator('[data-dockkit-surface]').boundingBox()
  860. if (surfaceBox === null) throw new Error('surface is not rendered')
  861. await dragElement(page, outer, { x: surfaceBox.x + surfaceBox.width / 2, y: grip.y })
  862. await expect.poll(ratio).toBeCloseTo(0.5, 2)
  863. expect(await panes.count()).toBe(2)
  864. expect(await splitButtons.count()).toBe(0)
  865. // 5. The split's guide and the document float while Files stays docked.
  866. const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
  867. await floatByDrag(page, floatOne)
  868. await expect.poll(async () => await floats.count()).toBe(1)
  869. const box = await floats.first().boundingBox()
  870. if (box === null) throw new Error('float is not rendered')
  871. await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
  872. await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x)
  873. await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Start'])
  874. const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
  875. await floatByDrag(page, second)
  876. await expect.poll(async () => await floats.count()).toBe(2)
  877. // 6. Dock one back: the docked tree takes it, the other float stays. Dock
  878. // the TOPMOST float — floats render bottom-to-top, so the newest one
  879. // covers the older one's controls and would intercept the click.
  880. await floats.last().locator('[data-dockkit-float-dock]').click()
  881. await expect.poll(async () => await floats.count()).toBe(1)
  882. expect(tripwire.pageErrors).toEqual([])
  883. expect(tripwire.warnings).toEqual([])
  884. }, 90_000)
  885. it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => {
  886. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle'))
  887. const column = await resetSidebar(page)
  888. const panes = column.locator('[data-dockkit-pane]')
  889. expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1)
  890. await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
  891. await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME])
  892. await panes.first().locator('[data-dockkit-split-button]').click()
  893. await expect.poll(async () => await panes.count()).toBe(2)
  894. // Closing a pane's last tab drops the pane: there is no separate
  895. // "close pane" gesture, and none is needed.
  896. await panes.nth(1).locator('[data-dockkit-tab]').first().hover()
  897. await panes.nth(1).locator('[data-dockkit-tab-close]').first().click()
  898. await expect.poll(async () => await panes.count()).toBe(1)
  899. await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
  900. // Leave a guide as the sole docked tab.
  901. const files = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
  902. await files.hover()
  903. await files.locator('[data-dockkit-tab-close]').click()
  904. await column.locator('[data-dockkit-add-tab]').click()
  905. await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
  906. const sample = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
  907. await sample.hover()
  908. await sample.locator('[data-dockkit-tab-close]').click()
  909. await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
  910. // The guide standing as the docked surface's only tab draws no close
  911. // control, sits quiet (no capsule, no hover fill), and a secondary press
  912. // opens no menu: an empty menu never shows.
  913. expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0)
  914. expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1)
  915. await column.locator('[data-dockkit-tab]').first().click({ button: 'right' })
  916. expect(await page.locator('[data-dockkit-tab-menu]').isVisible()).toBe(false)
  917. expect(await page.getByRole('menu').count()).toBe(0)
  918. // Any other tab standing alone closes together with the column. Open the
  919. // sample file, close the guide (an ordinary close with two tabs), then
  920. // close the file: the column collapses in the same gesture, and the
  921. // settle rule reseeds the current default, so reopening shows Start.
  922. await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
  923. await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
  924. await column.locator('[data-dockkit-tab]').first().hover()
  925. await column.locator('[data-dockkit-tab-close]').first().click()
  926. await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
  927. await column.locator('[data-dockkit-tab]').first().hover()
  928. await column.locator('[data-dockkit-tab-close]').first().click()
  929. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  930. await expandOf(page).click()
  931. await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
  932. expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
  933. expect(tripwire.pageErrors).toEqual([])
  934. expect(tripwire.warnings).toEqual([])
  935. }, 90_000)
  936. it('preserves the open surface after a reload', async () => {
  937. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload'))
  938. const frame = page.locator('[class*="frame"]').first()
  939. const column = page.locator('[data-rightbar-col]')
  940. await ensureExpanded(page, column)
  941. const titles = await tabTitles(column)
  942. await page.reload({ waitUntil: 'load' })
  943. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  944. await column.waitFor({ state: 'attached', timeout: 15_000 })
  945. await expect.poll(async () => await tabTitles(column)).toEqual(titles)
  946. await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe(null)
  947. expect(await expandOf(page).count()).toBe(0)
  948. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
  949. })
  950. it('opens a context menu on right-click that the strip cannot clip', async () => {
  951. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-menu'))
  952. const column = page.locator('[data-rightbar-col]')
  953. await ensureExpanded(page, column)
  954. await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0)
  955. await column.locator('[data-sidebar-right-guide-entry="files"]').click()
  956. await column.locator('[data-dockkit-add-tab]').click()
  957. await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
  958. // No "more" control on the chip: the chip carries its close, and the menu
  959. // is the secondary press.
  960. expect(await column.locator('[data-dockkit-tab-more]').count()).toBe(0)
  961. await column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' }).click({ button: 'right' })
  962. const menu = page.locator('[data-dockkit-tab-menu]')
  963. await expect.poll(async () => await menu.count()).toBe(1)
  964. // The kit's one item; the extension seat is declared and rendered, and
  965. // with no registrant it contributes nothing, which is what "declared, not
  966. // speculative" looks like from the outside.
  967. await expect.poll(async () => await menu.getByRole('menuitem').allInnerTexts()).toEqual(['Close'])
  968. // The menu hangs below the strip that clips its overflow. Its pixels are
  969. // its own: a hit test at its centre lands on it, not on whatever the
  970. // strip would have shown through a clipped box.
  971. expect(await hitsItself(menu)).toBe(true)
  972. const box = await menu.boundingBox()
  973. const viewport = page.viewportSize()
  974. if (box === null || viewport === null) throw new Error('menu or viewport is not measurable')
  975. expect(box.x).toBeGreaterThanOrEqual(0)
  976. expect(box.x + box.width).toBeLessThanOrEqual(viewport.width)
  977. await page.keyboard.press('Escape')
  978. expect(tripwire.pageErrors).toEqual([])
  979. expect(tripwire.warnings).toEqual([])
  980. })
  981. // The product ships Chinese; the cases above advertise English so their role
  982. // locators stay stable. This is the other half of the same seam, and the
  983. // screenshot it takes is what the copy draft gets reviewed from. It lives in
  984. // this block because a settled session is its precondition too — a case that
  985. // depends on a sibling block's setup passes only in the right order.
  986. it('renders the shipped Chinese copy on a Chinese page', async () => {
  987. const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  988. const zhTripwire = watchConsole(zhPage)
  989. onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-sidebar-right-zh'))
  990. try {
  991. await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  992. await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  993. // A fresh page opens the workspace on a blank session's hero, which has
  994. // no session header and so no expand button. The settled session is the
  995. // second row of the tree; pick it the way a user would.
  996. await zhPage.getByRole('treeitem', { name: /Show the right sidebar\./u }).first().click()
  997. const column = zhPage.locator('[data-rightbar-col]')
  998. await expandOf(zhPage).waitFor({ timeout: 20_000 })
  999. await expandOf(zhPage).click()
  1000. await expect.poll(async () => await tabTitles(column)).toEqual(['开始'])
  1001. await column.locator('[data-sidebar-right-guide-entry="files"]').click()
  1002. await expect.poll(async () => await tabTitles(column)).toEqual(['文件'])
  1003. await column.locator('[data-dockkit-add-tab]').click()
  1004. const guide = column.locator('[data-sidebar-right-guide]')
  1005. await expect.poll(async () => await guide.count()).toBe(1)
  1006. // Wait for the track, not just the panel: the copy is only legible once
  1007. // the column has the width, and a screenshot taken mid-transition reads
  1008. // as a layout defect that is not there.
  1009. expect(await width(column)).toBeGreaterThan(300)
  1010. await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始'])
  1011. await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText())
  1012. .toBe('工作区文件\n浏览会话工作区的文件')
  1013. await shot(zhPage, '05-guide-copy-zh')
  1014. expect(zhTripwire.pageErrors).toEqual([])
  1015. expect(zhTripwire.warnings).toEqual([])
  1016. } finally {
  1017. await zhPage.close()
  1018. }
  1019. }, 120_000)
  1020. })
  1021. })