sidebar-right.e2e.ts 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969
  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, 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 { 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. /** Reload the session's transient sidebar state before an independent gesture case. */
  119. async function resetSidebar(page: Page): Promise<Locator> {
  120. await page.reload({ waitUntil: 'load' })
  121. const column = page.locator('[data-rightbar-col]')
  122. await expandOf(page).waitFor({ timeout: 15_000 })
  123. await ensureExpanded(page, column)
  124. await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
  125. await width(column)
  126. return column
  127. }
  128. /** Whether the element at the centre of `locator` is the locator's own element or a descendant. */
  129. async function hitsItself(locator: Locator): Promise<boolean> {
  130. const point = await centre(locator)
  131. return await locator.evaluate((node, at) => {
  132. const hit = document.elementFromPoint(at.x, at.y)
  133. return hit !== null && node.contains(hit)
  134. }, point)
  135. }
  136. /**
  137. * Float a docked tab the way a user does: drag its chip clear of the docked
  138. * surface and release over the conversation.
  139. */
  140. async function floatByDrag(page: Page, tab: Locator): Promise<void> {
  141. const surface = page.locator('[data-dockkit-surface]').first()
  142. const box = await surface.boundingBox()
  143. if (box === null) throw new Error('surface is not rendered')
  144. await dragTo(page, tab, { x: box.x - 240, y: box.y + box.height / 2 })
  145. }
  146. /**
  147. * Drag the frame's rightbar handle until the panel is `target` px wide (the
  148. * frame clamps to its own range). The handle sits on the panel's left edge, so
  149. * widening is a drag to the left.
  150. */
  151. async function setPanelWidth(page: Page, target: number): Promise<void> {
  152. const panel = page.locator('[data-sidebar-right-panel]')
  153. const handle = page.locator('[data-side="rightbar"]').first()
  154. for (let attempt = 0; attempt < 3; attempt += 1) {
  155. const box = await panel.boundingBox()
  156. if (box === null) throw new Error('panel is not rendered')
  157. const delta = target - box.width
  158. if (Math.abs(delta) < 2) return
  159. const grip = await centre(handle)
  160. await page.mouse.move(grip.x, grip.y)
  161. await page.mouse.down()
  162. await page.mouse.move(grip.x - delta, grip.y, { steps: 8 })
  163. await page.mouse.up()
  164. await page.waitForTimeout(300)
  165. }
  166. }
  167. /** Tab titles inside one container, in strip order. */
  168. async function tabTitles(root: Locator): Promise<string[]> {
  169. return await root.locator('[data-dockkit-tab-title]').allInnerTexts()
  170. }
  171. /**
  172. * A rendered width, read once the frame's track transition has settled.
  173. *
  174. * The frame eases its grid tracks, so a single sample taken right after a
  175. * gesture reports a frame of the animation. Column arithmetic is only exact at
  176. * rest, so this samples until three consecutive readings agree.
  177. */
  178. async function width(locator: Locator): Promise<number> {
  179. let last = Number.NaN
  180. let steady = 0
  181. for (let attempt = 0; attempt < 80; attempt += 1) {
  182. // Layout width, not the visible box: a zero-width track is still an answer.
  183. const now = Math.round(await locator.evaluate(node => node.getBoundingClientRect().width))
  184. steady = now === last ? steady + 1 : 0
  185. if (steady === 2) return now
  186. last = now
  187. await locator.page().waitForTimeout(50)
  188. }
  189. throw new Error(`width never settled (last ${last}px)`)
  190. }
  191. describe('web e2e: shipped right Sidebar', () => {
  192. let scaffold: WebScaffold
  193. let browser: Browser
  194. let page: Page
  195. let tripwire: ReturnType<typeof watchConsole>
  196. beforeAll(async () => {
  197. scaffold = await launchWebScaffold()
  198. browser = await chromium.launch()
  199. page = await newEnglishPage(browser)
  200. tripwire = watchConsole(page)
  201. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  202. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  203. }, 180_000)
  204. afterAll(async () => {
  205. await browser?.close()
  206. await scaffold?.close()
  207. })
  208. it('shows nothing on the right while no session keys a surface', async () => {
  209. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-hero'))
  210. const frame = page.locator('[class*="frame"]').first()
  211. const column = page.locator('[data-rightbar-col]')
  212. await column.waitFor({ state: 'attached', timeout: 15_000 })
  213. // With no session there is no surface: no panel in the column, no expand
  214. // button in the header, and no track — the conversation reaches the frame's edge.
  215. expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
  216. expect(await column.locator('[data-sidebar-right-panel]').count()).toBe(0)
  217. expect(await expandOf(page).count()).toBe(0)
  218. expect(await width(column)).toBe(0)
  219. await shot(page, '01-hero-no-sidebar')
  220. expect(tripwire.pageErrors).toEqual([])
  221. expect(tripwire.warnings).toEqual([])
  222. })
  223. describe('with a settled session', () => {
  224. beforeAll(async () => {
  225. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  226. // A settled session is what keys the surface; seed one turn through the
  227. // real append path so the Chat surface is live before the Sidebar is driven.
  228. const agent = scaffold.ctx.agents.list()[0]
  229. if (agent === undefined) throw new Error('connected workspace did not create an Agent')
  230. // The wire parameter is `agentId`; the client passes a session id. If the
  231. // scaffold's Agent and Session carry different ids, that mismatch is the
  232. // silent lookup failure.
  233. expect(String(agent.id)).toBe(String(agent.session.id))
  234. agent.session.append('turn/start', { turn: 1 })
  235. agent.session.append('user/message', createUserMessage({
  236. content: [{ type: 'text', text: 'Show the right sidebar.' }],
  237. source: { kind: 'user' },
  238. }), { surfaceOp: 'append' })
  239. agent.session.append('step/start', { turn: 1, step: 1 })
  240. // A successful mutation is what makes the turn tail offer a produced-file
  241. // chip — the product's own way into the Sidebar. The file is written for
  242. // real because the preview reads it through the workspace endpoint.
  243. //
  244. // It goes in the SESSION's cwd, not the scaffold's: the endpoint resolves
  245. // relative paths against `sandboxPolicy.resolve({session}).workspaceRoot`,
  246. // which is the session header's cwd. Writing anywhere else makes the read
  247. // fail with workspace-file/not-found, which is the endpoint being right.
  248. writeFileSync(join(agent.session.header.cwd ?? scaffold.workspaceCwd, SAMPLE_NAME), SAMPLE_TEXT, 'utf8')
  249. agent.session.append('tool/call', {
  250. turn: 1,
  251. step: 1,
  252. callId: 'call-write-1',
  253. name: 'write',
  254. arguments: JSON.stringify({ file_path: SAMPLE_NAME, content: SAMPLE_TEXT }),
  255. } as never)
  256. agent.session.append('tool/result', {
  257. turn: 1,
  258. step: 1,
  259. message: {
  260. id: 'result-call-write-1',
  261. role: 'user',
  262. source: { kind: 'tool', callId: 'call-write-1' },
  263. content: [{ type: 'tool-result', toolCallId: 'call-write-1', content: [{ type: 'text', text: 'ok' }] }],
  264. },
  265. } as never, { surfaceOp: 'append' })
  266. agent.session.append('assistant/message', {
  267. stream: [],
  268. turn: 1,
  269. step: 1,
  270. message: createMessage({
  271. role: 'assistant',
  272. content: [{ type: 'text', text: 'Ready.' }],
  273. source: { kind: 'model', provider: 'fixture', model: 'fixture' },
  274. }),
  275. }, { surfaceOp: 'append' })
  276. agent.session.append('step/end', { turn: 1, step: 1 })
  277. agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  278. await scaffold.ctx.sessions.flush(agent.session)
  279. await page.getByText('Ready.').waitFor({ timeout: 10_000 })
  280. }, 120_000)
  281. it('CONTROL: the old fileReferences namespace answers over the same wire', async () => {
  282. const composer = page.locator('textarea, [contenteditable="true"]').first()
  283. await composer.click()
  284. await composer.pressSequentially('@')
  285. // Any candidate list means the agent-scoped lookup and the gateway route
  286. // both work in this scaffold; nothing rendered means the wire is the fault.
  287. const answered = await page.locator('[data-input-trigger], [role="listbox"], [data-reference-list]')
  288. .first().waitFor({ timeout: 10_000 }).then(() => true, () => false)
  289. await page.keyboard.press('Escape')
  290. // Leave the composer as it was found: these cases share one page, and a
  291. // stray '@' rides into every later assertion and screenshot. `fill('')`
  292. // does NOT clear this editor — it reported success while the character
  293. // stayed — so the reset is a real keystroke, and it is asserted rather
  294. // than assumed.
  295. await composer.click()
  296. await page.keyboard.press('ControlOrMeta+a')
  297. await page.keyboard.press('Backspace')
  298. await expect.poll(async () => (await composer.innerText()).trim()).toBe('')
  299. expect(answered).toBe(true)
  300. })
  301. it('starts collapsed behind a header button and squeezes the conversation when opened', async () => {
  302. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right'))
  303. const frame = page.locator('[class*="frame"]').first()
  304. const column = page.locator('[data-rightbar-col]')
  305. const conversation = page.locator('[class*="centerCol"]').first()
  306. const expand = expandOf(page)
  307. // Collapsed default: no track, the panel sits off the frame's edge, and
  308. // the only way in is the header button — on the same row as the other
  309. // header utilities, at its far right.
  310. await expand.waitFor({ timeout: 15_000 })
  311. expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
  312. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  313. const utilities = page.locator('[class*="headerUtilities"]')
  314. const expandBox = await expand.boundingBox()
  315. const rowBox = await utilities.boundingBox()
  316. if (expandBox === null || rowBox === null) throw new Error('header utilities are not rendered')
  317. expect(Math.round(expandBox.y + expandBox.height / 2)).toBe(Math.round(rowBox.y + rowBox.height / 2))
  318. // Its own corner seat, past the utilities' right edge — not a utility.
  319. expect(expandBox.x).toBeGreaterThan(rowBox.x + rowBox.width)
  320. const conversationBoxBefore = await conversation.boundingBox()
  321. if (conversationBoxBefore === null) throw new Error('conversation is not rendered')
  322. // How far the utilities' right edge sits from the conversation's own.
  323. const gapBefore = (conversationBoxBefore.x + conversationBoxBefore.width) - (rowBox.x + rowBox.width)
  324. const centerBefore = await width(conversation)
  325. await shot(page, '02a-collapsed-header-button')
  326. // Opening squeezes by default: the column takes a track of the panel's
  327. // width, the conversation gives up exactly that much room, and the header
  328. // button leaves with the panel's arrival.
  329. await expand.click()
  330. await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe(null)
  331. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(1)
  332. const panelWidth = await width(column)
  333. expect(panelWidth).toBeGreaterThan(0)
  334. expect(await width(conversation)).toBe(centerBefore - panelWidth)
  335. await expect.poll(async () => await expand.count()).toBe(0)
  336. // The corner keeps its footprint, so the utilities' right edge stays where
  337. // it was relative to the conversation's own right edge.
  338. expect(await page.locator('[data-sidebar-right-expand-placeholder]').count()).toBe(1)
  339. const utilitiesAfter = await utilities.boundingBox()
  340. const conversationAfter = await conversation.boundingBox()
  341. if (utilitiesAfter === null || conversationAfter === null) throw new Error('header is not rendered')
  342. const gapAfter = (conversationAfter.x + conversationAfter.width) - (utilitiesAfter.x + utilitiesAfter.width)
  343. expect(Math.round(gapAfter)).toBe(Math.round(gapBefore))
  344. // The panel is in the column, not over it, and carries the seeded tab —
  345. // whose body arrives through the guide type's keyed registration, not from
  346. // any dispatch inside the seat. Its two controls sit at the end of the
  347. // top-right pane's strip: the panel has no header row of its own.
  348. expect(await column.locator('[data-sidebar-right-panel="push"]').count()).toBe(1)
  349. const chrome = column.locator('[data-dockkit-strip-chrome]')
  350. expect(await chrome.count()).toBe(1)
  351. expect(await chrome.locator('[data-sidebar-right-mode]').count()).toBe(1)
  352. expect(await chrome.locator('[data-sidebar-right-toggle]').count()).toBe(1)
  353. // One centre line across the strip: chip text, split, and the two panel
  354. // controls all sit at the same height. The add control joins the check
  355. // below, once the strip draws it.
  356. const centreY = async (selector: string): Promise<number> => {
  357. const box = await column.locator(selector).first().boundingBox()
  358. if (box === null) throw new Error(`${selector} is not rendered`)
  359. return Math.round(box.y + box.height / 2)
  360. }
  361. const textLine = await centreY('[data-dockkit-tab-title]')
  362. for (const selector of ['[data-dockkit-split-button]', '[data-sidebar-right-mode]', '[data-sidebar-right-toggle]']) {
  363. expect(await centreY(selector), selector).toBe(textLine)
  364. }
  365. // The guide is unique per pane, so while this pane holds one its strip
  366. // offers no add control. Closing it brings the control back, and the
  367. // control opens the guide again in that pane.
  368. const addTab = column.locator('[data-dockkit-add-tab]')
  369. expect(await addTab.count()).toBe(0)
  370. await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
  371. await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
  372. await column.locator('[data-dockkit-tab-close]').first().click()
  373. await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
  374. await expect.poll(async () => await addTab.count()).toBe(1)
  375. expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine)
  376. await addTab.click()
  377. await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
  378. await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
  379. await expect.poll(async () => await addTab.count()).toBe(0)
  380. // Back to the seeded shape the cases below start from.
  381. await column.locator('[data-dockkit-tab-close]').first().click()
  382. await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
  383. await shot(page, '02-squeezed-panel')
  384. expect(tripwire.pageErrors).toEqual([])
  385. expect(tripwire.warnings).toEqual([])
  386. })
  387. it('covers the viewport in fullscreen without changing the underlying columns', async () => {
  388. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-mode'))
  389. const frame = page.locator('[class*="frame"]').first()
  390. const column = page.locator('[data-rightbar-col]')
  391. const panel = column.locator('[data-sidebar-right-panel]')
  392. const conversation = page.locator('[class*="centerCol"]').first()
  393. const squeezed = await width(conversation)
  394. const before = await panel.boundingBox()
  395. const trackWidth = await width(column)
  396. await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
  397. await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
  398. expect(await frame.getAttribute('data-rightbar-collapsed')).toBe(null)
  399. expect(await width(conversation)).toBe(squeezed)
  400. expect(await width(column)).toBe(trackWidth)
  401. const viewport = page.viewportSize()
  402. if (viewport === null) throw new Error('expected a fixed viewport')
  403. await expect.poll(async () => await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
  404. expect(await expandOf(page).count()).toBe(0)
  405. expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0)
  406. await shot(page, '03-fullscreen-panel')
  407. await column.locator('[data-sidebar-right-mode="push"]').click()
  408. await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
  409. expect(await width(conversation)).toBe(squeezed)
  410. expect(await panel.boundingBox()).toEqual(before)
  411. expect(tripwire.pageErrors).toEqual([])
  412. expect(tripwire.warnings).toEqual([])
  413. })
  414. it('keeps the conversation still during fullscreen entry and installs the hidden track without animation', async () => {
  415. onTestFailed(() => saveFailureShot(page, 'screenshots/0907-sidebar-rules/sidebar-right-fullscreen-entry'))
  416. mkdirSync(SHOT_DIR, { recursive: true })
  417. const column = await resetSidebar(page)
  418. const frame = page.locator('[class*="frame"]').first()
  419. const panel = column.locator('[data-sidebar-right-panel]')
  420. const viewport = page.viewportSize()
  421. if (viewport === null) throw new Error('expected a fixed viewport')
  422. const geometry = () => frame.evaluate(node => ({
  423. columns: getComputedStyle(node).gridTemplateColumns,
  424. transition: getComputedStyle(node).transitionProperty,
  425. handles: [...node.querySelectorAll('[data-side="sidebar"], [data-side="rightbar"]')]
  426. .map(handle => getComputedStyle(handle).transitionProperty),
  427. animatingGrid: node.getAnimations().some(animation =>
  428. 'transitionProperty' in animation && animation.transitionProperty === 'grid-template-columns'
  429. && animation.playState !== 'finished' && animation.playState !== 'idle'),
  430. }))
  431. await frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) })
  432. const normalColumns = (await geometry()).columns
  433. await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
  434. await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
  435. await column.locator('[data-sidebar-right-toggle]').click()
  436. await Promise.all([
  437. panel.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }),
  438. frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }),
  439. ])
  440. const closedColumns = (await geometry()).columns
  441. expect(closedColumns).not.toBe(normalColumns)
  442. await page.emulateMedia({ reducedMotion: 'no-preference' })
  443. // Pause the real CSS transition at its midpoint so host scheduling cannot
  444. // skip the partly covered frame whose underlying width is under test.
  445. const held = await holdPanelSlide(panel)
  446. try {
  447. await expandOf(page).click()
  448. await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused')
  449. const entering = await panel.boundingBox()
  450. if (entering === null) throw new Error('entering panel is not rendered')
  451. expect(entering.x).toBeGreaterThan(0)
  452. expect(entering.x).toBeLessThan(viewport.width)
  453. expect((await geometry()).columns).toBe(closedColumns)
  454. expect((await geometry()).animatingGrid).toBe(false)
  455. expect(await frame.getAttribute('data-rightbar-fullscreen')).toBeNull()
  456. await held.evaluate((state) => { (state.animation as Animation).finish() })
  457. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
  458. expect(await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
  459. expect(await geometry()).toEqual({ columns: normalColumns, transition: 'none', handles: ['none'], animatingGrid: false })
  460. const exit = await holdPanelSlide(panel)
  461. try {
  462. await column.locator('[data-sidebar-right-toggle]').click()
  463. await expect.poll(() => exit.evaluate(state => state.animation?.playState)).toBe('paused')
  464. expect(await exit.evaluate(state => state.columnsAtStart)).toBe(closedColumns)
  465. const leaving = await panel.boundingBox()
  466. if (leaving === null) throw new Error('leaving panel is not rendered')
  467. expect(leaving.x).toBeGreaterThan(0)
  468. expect(leaving.x).toBeLessThan(viewport.width)
  469. expect(await geometry()).toEqual({ columns: closedColumns, transition: 'none', handles: ['none'], animatingGrid: false })
  470. await exit.evaluate((state) => { (state.animation as Animation).finish() })
  471. expect((await geometry()).columns).toBe(closedColumns)
  472. } finally {
  473. await exit.evaluate((state) => {
  474. state.dispose()
  475. if (state.animation?.playState === 'paused') state.animation.finish()
  476. })
  477. await exit.dispose()
  478. }
  479. await expandOf(page).click()
  480. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
  481. await column.locator('[data-sidebar-right-mode="push"]').click()
  482. expect((await geometry()).columns).toBe(normalColumns)
  483. expect((await geometry()).animatingGrid).toBe(false)
  484. await page.emulateMedia({ reducedMotion: 'reduce' })
  485. await column.locator('[data-sidebar-right-mode="fullscreen"]').click()
  486. await column.locator('[data-sidebar-right-toggle]').click()
  487. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBeNull()
  488. await expandOf(page).click()
  489. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
  490. expect(await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
  491. expect((await geometry()).columns).toBe(normalColumns)
  492. expect((await geometry()).animatingGrid).toBe(false)
  493. } finally {
  494. await held.evaluate((state) => {
  495. state.dispose()
  496. if (state.animation?.playState === 'paused') state.animation.finish()
  497. })
  498. await held.dispose()
  499. try {
  500. if (await panel.getAttribute('data-sidebar-right-panel') === 'fullscreen'
  501. && await panel.getAttribute('data-sidebar-right-open') !== null) {
  502. await column.locator('[data-sidebar-right-mode="push"]').click()
  503. }
  504. } finally {
  505. await page.emulateMedia({ reducedMotion: null })
  506. }
  507. }
  508. expect(tripwire.pageErrors).toEqual([])
  509. expect(tripwire.warnings).toEqual([])
  510. })
  511. it('keeps a capacity-closed panel closed after widening and uses fullscreen on a narrow viewport', async () => {
  512. const viewport = page.viewportSize()
  513. if (viewport === null) throw new Error('expected a fixed viewport')
  514. const frame = page.locator('[class*="frame"]').first()
  515. const column = page.locator('[data-rightbar-col]')
  516. const sidebar = page.locator('[class*="sidebarCol"]').first()
  517. const panel = column.locator('[data-sidebar-right-panel]')
  518. try {
  519. await page.setViewportSize({ width: 1024, height: viewport.height })
  520. const leftGrip = frame.locator('[data-side="sidebar"]')
  521. const grip = await centre(leftGrip)
  522. await dragElement(page, leftGrip, { x: 420, y: grip.y })
  523. await expect.poll(async () => await width(sidebar)).toBe(420)
  524. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  525. await page.setViewportSize(viewport)
  526. await expect.poll(async () => await width(sidebar)).toBe(420)
  527. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  528. await expandOf(page).click()
  529. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(1)
  530. await page.setViewportSize({ width: 767, height: viewport.height })
  531. await expect.poll(async () => await panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
  532. await expect.poll(async () => await width(panel)).toBe(767)
  533. await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
  534. expect(await frame.locator('[data-side="rightbar"]').count()).toBe(0)
  535. await column.locator('[data-sidebar-right-mode="push"]').click()
  536. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  537. await page.setViewportSize(viewport)
  538. await expect.poll(async () => await width(sidebar)).toBe(420)
  539. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  540. } finally {
  541. await page.setViewportSize(viewport)
  542. const grip = await centre(frame.locator('[data-side="sidebar"]'))
  543. await dragElement(page, frame.locator('[data-side="sidebar"]'), { x: 280, y: grip.y })
  544. await expect.poll(async () => await width(sidebar)).toBe(280)
  545. await ensureExpanded(page, column)
  546. }
  547. expect(tripwire.pageErrors).toEqual([])
  548. expect(tripwire.warnings).toEqual([])
  549. })
  550. it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => {
  551. const files = (scaffold.ctx as unknown as {
  552. get(name: string): {
  553. read(agent: unknown, path: string, range: object, signal: AbortSignal): Promise<{ text: string; eof: boolean }>
  554. } | undefined
  555. }).get('workspaceFiles')
  556. if (files === undefined) throw new Error('host endpoint is not provided')
  557. const agent = scaffold.ctx.agents.list()[0]
  558. if (agent === undefined) throw new Error('no Agent to read for')
  559. // Raced against a timer so a hang reports a verdict instead of stalling
  560. // the suite: this case exists to tell host logic apart from the wire.
  561. // A page is the file's lines joined by `\n`, without the final terminator.
  562. const verdict = await Promise.race([
  563. files.read(agent, SAMPLE_NAME, {}, new AbortController().signal)
  564. .then(value => ({ kind: 'settled' as const, text: value.text, eof: value.eof }))
  565. .catch((error: unknown) => ({ kind: 'threw' as const, text: String(error), eof: false })),
  566. new Promise<{ kind: 'hung'; text: string; eof: boolean }>((resolve) => {
  567. setTimeout(() => { resolve({ kind: 'hung', text: 'no settlement in 10s', eof: false }) }, 10_000)
  568. }),
  569. ])
  570. expect(verdict).toEqual({ kind: 'settled', text: SAMPLE_TEXT.replace(/\n$/u, ''), eof: true })
  571. }, 30_000)
  572. it('opens content once, splits, and floats it outside the column', async () => {
  573. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-content'))
  574. const column = page.locator('[data-rightbar-col]')
  575. const panes = column.locator('[data-dockkit-pane]')
  576. const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]')
  577. // Observation before action: does the read ever leave the browser? The
  578. // assertion states the healthy answer so a failure prints the real one.
  579. //
  580. // CAVEAT: `sent` is trustworthy — a request frame carries the endpoint
  581. // name. `received` is NOT: a response frame carries only its rpc id, so a
  582. // zero here means "my filter saw nothing", not "the host never answered".
  583. // Correlate by rpc id before drawing any conclusion from it.
  584. const wire = { sent: 0, received: 0 }
  585. const watch = (payload: string): void => {
  586. if (!payload.includes('workspaceFiles')) return
  587. wire.sent += 1
  588. }
  589. page.on('websocket', (ws) => {
  590. ws.on('framesent', (frame) => { watch(String(frame.payload)) })
  591. ws.on('framereceived', (frame) => {
  592. if (String(frame.payload).includes('workspaceFiles')) wire.received += 1
  593. })
  594. })
  595. page.on('request', (request) => {
  596. if (request.url().includes('workspaceFiles')) wire.sent += 1
  597. })
  598. // The product's own entry point: the turn tail's produced-file chip. It
  599. // reaches the Sidebar through openFile → ctx.sidebarRight.openResource, and the
  600. // text type claims the address.
  601. const chip = page.getByRole('button', { name: `Open ${SAMPLE_NAME}` })
  602. await chip.click()
  603. await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
  604. // Opening the same content again focuses rather than duplicating.
  605. await panes.first().locator('[data-dockkit-tab]').first().click()
  606. await chip.click()
  607. await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
  608. // The body arrives through the text type's keyed registration, and its
  609. // content came over the wire from the real file.
  610. // A real Remote round-trip settles well after the default poll window.
  611. await column.locator('[data-textpreview-state="text"]')
  612. .waitFor({ timeout: 15_000 })
  613. .catch(() => { throw new Error(`preview never settled; wire=${JSON.stringify(wire)}`) })
  614. expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
  615. // The whole batch-E chain in one frame: a produced-file chip in the
  616. // conversation, the tab it opened, and the file's real content read over
  617. // the workspace endpoint.
  618. await shot(page, '06-produced-chip-to-preview')
  619. // The directory scenario's V1 behaviour, asserted in the shipped product:
  620. // there is no folder affordance at all. `openFile('.')` would name a
  621. // directory, which a text preview correctly refuses, and the native opener
  622. // it used to reach is gone — so the row offers nothing rather than a
  623. // button that always fails.
  624. expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
  625. // Split, then dock-drag: the kit's gestures drive the store's actions.
  626. await panes.first().locator('[data-dockkit-split-button]').click()
  627. await expect.poll(async () => await panes.count()).toBe(2)
  628. await dragTo(
  629. page,
  630. column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first(),
  631. await pointIn(panes.nth(1), 0.5, 0.94),
  632. )
  633. await expect.poll(async () => await panes.count()).toBe(2)
  634. await panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
  635. .locator('[data-dockkit-tab-close]').click()
  636. await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
  637. // The guide is unique per pane: panes seeded with one offer no
  638. // add control; the pane holding only the file is the one that does.
  639. const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) })
  640. await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1)
  641. expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(1)
  642. // Floating leaves the column entirely, and survives collapsing it. The
  643. // pane the tab was alone in goes with it: an emptied pane never stays.
  644. const tab = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first()
  645. await floatByDrag(page, tab)
  646. await expect.poll(async () => await floats.count()).toBe(1)
  647. expect(await column.locator('[data-dockkit-float]').count()).toBe(0)
  648. await expect.poll(async () => await panes.count()).toBe(1)
  649. await shot(page, '04-split-and-float')
  650. await column.locator('[data-sidebar-right-toggle]').click()
  651. await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  652. expect(await floats.count()).toBe(1)
  653. expect(tripwire.pageErrors).toEqual([])
  654. expect(tripwire.warnings).toEqual([])
  655. })
  656. // §9.2 (an explicit second copy of the same content) has no control on the
  657. // panel by product decision, and copy has no service method yet:
  658. // `duplicateTab` is a store/kit intent only, which service.client.spec.ts covers.
  659. it('keeps each session\'s surface to itself, and restores it on return', async () => {
  660. const fx = await newEnglishPage(browser)
  661. const fxTripwire = watchConsole(fx)
  662. onTestFailed(() => saveFailureShot(fx, 'web-e2e-sidebar-right-sessions'))
  663. try {
  664. await fx.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  665. const settled = fx.getByRole('treeitem', { name: /Show the right sidebar\./u }).first()
  666. await settled.click()
  667. await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true')
  668. const frame = fx.locator('[class*="frame"]').first()
  669. const column = fx.locator('[data-rightbar-col]')
  670. await ensureExpanded(fx, column)
  671. await width(column)
  672. await fx.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
  673. await column.locator('[data-textpreview-state="text"]').waitFor({ timeout: 15_000 })
  674. const wrap = column.locator('[data-textpreview-tool="wrap"]')
  675. expect(await wrap.getAttribute('aria-pressed')).toBe('true')
  676. await wrap.click()
  677. await expect.poll(async () => await wrap.getAttribute('aria-pressed')).toBe('false')
  678. await column.locator('[data-dockkit-split-button]').first().click()
  679. const panes = column.locator('[data-dockkit-pane]')
  680. await expect.poll(async () => await panes.count()).toBe(2)
  681. const records = async (): Promise<{ panes: string[]; tabs: string[]; titles: string[] }> => ({
  682. panes: await panes.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dockkit-pane')!)),
  683. tabs: await column.locator('[data-dockkit-tab]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dockkit-tab')!)),
  684. titles: await tabTitles(column),
  685. })
  686. const before = await records()
  687. // The real New Session action selects a distinct blank Session; its
  688. // collapsed surface must not inherit the settled Session's tabs.
  689. await fx.getByRole('button', { name: 'New session', exact: true }).last().click()
  690. await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('false')
  691. await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
  692. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  693. expect(await column.locator('[data-textpreview-state="text"]').count()).toBe(0)
  694. await settled.click()
  695. await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true')
  696. await expect.poll(records, { timeout: 15_000 }).toEqual(before)
  697. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
  698. expect(await wrap.getAttribute('aria-pressed')).toBe('false')
  699. expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
  700. expect(fxTripwire.pageErrors).toEqual([])
  701. expect(fxTripwire.warnings).toEqual([])
  702. } finally {
  703. await fx.close()
  704. }
  705. }, 120_000)
  706. it('§9.3/§9.4 runs the whole pointer chain in a real browser', async () => {
  707. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-gestures'))
  708. const column = await resetSidebar(page)
  709. const panes = column.locator('[data-dockkit-pane]')
  710. const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]')
  711. // Chromium cancels pointer capture if a render replaces the pressed
  712. // element; jsdom cannot establish that the whole gesture survives.
  713. // 1. Reorder inside one strip: drop the last tab left of its neighbours.
  714. // The first pane needs two tabs for this — and for the move below to
  715. // leave it standing, since a pane emptied by a move is dropped.
  716. const first = panes.first()
  717. const strip = first.locator('[data-dockkit-strip]')
  718. await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
  719. await expect.poll(async () => await tabTitles(first)).toEqual(['Start', SAMPLE_NAME])
  720. const order = await tabTitles(first)
  721. // The insertion index is measured against chip midpoints, not strip width.
  722. await dragTo(page, first.locator('[data-dockkit-tab]').last(),
  723. await pointIn(first.locator('[data-dockkit-tab]').first(), 0.25, 0.5),
  724. strip.locator('[data-dockkit-caret="0"]'))
  725. await expect.poll(async () => await tabTitles(first)).toEqual([...order].reverse())
  726. // 2. Cross-pane move into a second pane: the tab leaves one pane's strip
  727. // for another's.
  728. if (await panes.count() < 2) {
  729. await first.locator('[data-dockkit-split-button]').click()
  730. await expect.poll(async () => await panes.count()).toBe(2)
  731. }
  732. const moving = first.locator('[data-dockkit-tab]').first()
  733. const title = await moving.locator('[data-dockkit-tab-title]').innerText()
  734. await dragTo(page, moving, await pointIn(panes.nth(1), 0.5, 0.5))
  735. await expect.poll(async () => await tabTitles(panes.nth(1))).toContain(title)
  736. const splitButtons = column.locator('[data-dockkit-split-button]')
  737. await expect.poll(async () => await splitButtons.count()).toBe(0)
  738. expect(await panes.count()).toBe(2)
  739. await setPanelWidth(page, 560)
  740. await expect.poll(async () => await splitButtons.count()).toBe(0)
  741. const outer = column.locator('[data-dockkit-divider]').first()
  742. const before = await width(panes.last())
  743. const grip = await centre(outer)
  744. await dragElement(page, outer, { x: grip.x - 100, y: grip.y })
  745. await expect.poll(async () => await width(panes.last())).toBeGreaterThan(before)
  746. await dragElement(page, outer, { x: 0, y: grip.y })
  747. const ratio = async (): Promise<number> => {
  748. const left = await width(panes.first())
  749. const right = await width(panes.last())
  750. return left / (left + right)
  751. }
  752. await expect.poll(ratio).toBeCloseTo(0.2, 2)
  753. const surfaceBox = await column.locator('[data-dockkit-surface]').boundingBox()
  754. if (surfaceBox === null) throw new Error('surface is not rendered')
  755. await dragElement(page, outer, { x: surfaceBox.x + surfaceBox.width / 2, y: grip.y })
  756. await expect.poll(ratio).toBeCloseTo(0.5, 2)
  757. expect(await panes.count()).toBe(2)
  758. expect(await splitButtons.count()).toBe(0)
  759. // 5. Two floats coexist, and one of them moves. Both leave the widest
  760. // pane; a pane emptied by the first float is merged away, so the
  761. // second one comes from whichever pane is widest by then.
  762. const floatOne = panes.last().locator('[data-dockkit-tab]').first()
  763. await floatByDrag(page, floatOne)
  764. await expect.poll(async () => await floats.count()).toBe(1)
  765. const box = await floats.first().boundingBox()
  766. if (box === null) throw new Error('float is not rendered')
  767. await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
  768. await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x)
  769. const second = panes.last().locator('[data-dockkit-tab]').first()
  770. await floatByDrag(page, second)
  771. await expect.poll(async () => await floats.count()).toBe(2)
  772. // 6. Dock one back: the docked tree takes it, the other float stays. Dock
  773. // the TOPMOST float — floats render bottom-to-top, so the newest one
  774. // covers the older one's controls and would intercept the click.
  775. await floats.last().locator('[data-dockkit-float-dock]').click()
  776. await expect.poll(async () => await floats.count()).toBe(1)
  777. expect(tripwire.pageErrors).toEqual([])
  778. expect(tripwire.warnings).toEqual([])
  779. }, 90_000)
  780. it('drops a pane whose last tab closes, and reseeds the guide when none is left', async () => {
  781. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle'))
  782. const column = await resetSidebar(page)
  783. const panes = column.locator('[data-dockkit-pane]')
  784. await panes.first().locator('[data-dockkit-split-button]').click()
  785. await expect.poll(async () => await panes.count()).toBe(2)
  786. const closeAllIn = async (pane: Locator): Promise<void> => {
  787. const tabs = await pane.locator('[data-dockkit-tab]').count()
  788. for (let i = 0; i < tabs; i += 1) {
  789. await pane.locator('[data-dockkit-tab-close]').first().click()
  790. }
  791. }
  792. // Closing a pane's last tab drops the pane: there is no separate
  793. // "close pane" gesture, and none is needed.
  794. let count = await panes.count()
  795. expect(count).toBeGreaterThan(1)
  796. while (count > 1) {
  797. await closeAllIn(panes.nth(count - 1))
  798. await expect.poll(async () => await panes.count()).toBe(count - 1)
  799. count -= 1
  800. }
  801. // The last pane cannot be dropped, so closing everything in it reseeds
  802. // the guide: the surface always has one tab to look at.
  803. await closeAllIn(panes.first())
  804. await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
  805. expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
  806. expect(tripwire.pageErrors).toEqual([])
  807. expect(tripwire.warnings).toEqual([])
  808. }, 90_000)
  809. it('§9.7 returns to the default surface after a reload', async () => {
  810. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload'))
  811. await page.reload({ waitUntil: 'load' })
  812. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  813. const frame = page.locator('[class*="frame"]').first()
  814. const column = page.locator('[data-rightbar-col]')
  815. await column.waitFor({ state: 'attached', timeout: 15_000 })
  816. // The surface is view state, not durable session data: a reload zeroes it
  817. // back to the collapsed default. Expected behaviour, not a defect.
  818. await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
  819. await expect.poll(async () => await expandOf(page).count()).toBe(1)
  820. expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
  821. })
  822. it('opens a context menu on right-click that the strip cannot clip', async () => {
  823. onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-menu'))
  824. const column = page.locator('[data-rightbar-col]')
  825. await ensureExpanded(page, column)
  826. await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0)
  827. // No "more" control on the chip: the chip carries its close, and the menu
  828. // is the secondary press.
  829. expect(await column.locator('[data-dockkit-tab-more]').count()).toBe(0)
  830. await column.locator('[data-dockkit-tab]').first().click({ button: 'right' })
  831. const menu = page.locator('[data-dockkit-tab-menu]')
  832. await expect.poll(async () => await menu.count()).toBe(1)
  833. // The kit's one item; the extension seat is declared and rendered, and
  834. // with no registrant it contributes nothing, which is what "declared, not
  835. // speculative" looks like from the outside.
  836. await expect.poll(async () => await menu.getByRole('menuitem').allInnerTexts()).toEqual(['Close'])
  837. // The menu hangs below the strip that clips its overflow. Its pixels are
  838. // its own: a hit test at its centre lands on it, not on whatever the
  839. // strip would have shown through a clipped box.
  840. expect(await hitsItself(menu)).toBe(true)
  841. const box = await menu.boundingBox()
  842. const viewport = page.viewportSize()
  843. if (box === null || viewport === null) throw new Error('menu or viewport is not measurable')
  844. expect(box.x).toBeGreaterThanOrEqual(0)
  845. expect(box.x + box.width).toBeLessThanOrEqual(viewport.width)
  846. await page.keyboard.press('Escape')
  847. expect(tripwire.pageErrors).toEqual([])
  848. expect(tripwire.warnings).toEqual([])
  849. })
  850. // The product ships Chinese; the cases above advertise English so their role
  851. // locators stay stable. This is the other half of the same seam, and the
  852. // screenshot it takes is what the copy draft gets reviewed from. It lives in
  853. // this block because a settled session is its precondition too — a case that
  854. // depends on a sibling block's setup passes only in the right order.
  855. it('renders the shipped Chinese copy on a Chinese page', async () => {
  856. const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
  857. const zhTripwire = watchConsole(zhPage)
  858. onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-sidebar-right-zh'))
  859. try {
  860. await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  861. await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  862. // A fresh page opens the workspace on a blank session's hero, which has
  863. // no session header and so no expand button. The settled session is the
  864. // second row of the tree; pick it the way a user would.
  865. await zhPage.getByRole('treeitem', { name: /Show the right sidebar\./u }).first().click()
  866. const column = zhPage.locator('[data-rightbar-col]')
  867. await expandOf(zhPage).waitFor({ timeout: 20_000 })
  868. await expandOf(zhPage).click()
  869. const guide = column.locator('[data-sidebar-right-guide]')
  870. await expect.poll(async () => await guide.count()).toBe(1)
  871. // Wait for the track, not just the panel: the copy is only legible once
  872. // the column has the width, and a screenshot taken mid-transition reads
  873. // as a layout defect that is not there.
  874. expect(await width(column)).toBeGreaterThan(300)
  875. await expect.poll(async () => await tabTitles(column)).toEqual(['开始'])
  876. await expect.poll(async () => await guide.locator('p').first().innerText())
  877. .toBe('侧栏用来放你想一直看着的东西。')
  878. await shot(zhPage, '05-guide-copy-zh')
  879. expect(zhTripwire.pageErrors).toEqual([])
  880. expect(zhTripwire.warnings).toEqual([])
  881. } finally {
  882. await zhPage.close()
  883. }
  884. }, 120_000)
  885. })
  886. })