support.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. // Shared plumbing for the web smoke tests (dist location, free port, failure shots).
  2. import { existsSync, mkdirSync } from 'node:fs'
  3. import { createServer } from 'node:net'
  4. import { createRequire } from 'node:module'
  5. import { join } from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import type { Browser, Locator, Page } from 'playwright'
  8. /** The built page under test; `pnpm run test:web` rebuilds it before running. */
  9. export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
  10. export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
  11. const installationRequire = createRequire(join(REPO_ROOT, 'apps/cli/package.json'))
  12. /**
  13. * The built copy of a workspace package, as the dsh installation resolves it.
  14. * The Host plugins a scaffold profile loads run from built packages through
  15. * Node's own loader; a scaffold call that must share their module state
  16. * (app-boot keeps the root Include it mounted per context) has to run that
  17. * same copy, not the source a bare import gets through the tsconfig paths,
  18. * and not the test runner's own inlined copy of the built file either.
  19. * `require` of an ES module goes through Node's loader and shares its
  20. * module map with the plugins' imports; it needs a graph without top-level
  21. * await, which the built Host packages keep.
  22. * @param name - the workspace package name.
  23. * @returns the package's built module namespace, for the caller to type as the package's own.
  24. */
  25. export function requireBuilt(name: string): unknown {
  26. return installationRequire(name)
  27. }
  28. /**
  29. * Browser language a page must advertise to boot into the product's Chinese
  30. * surface: with no stored preference the client derives its initial locale
  31. * from the browser, and Playwright's default browser asks for English.
  32. */
  33. export const ZH_BROWSER_LOCALE = 'zh-CN'
  34. /**
  35. * Open the standard browser-test page advertising English before client boot.
  36. * This keeps role locators and goldens deterministic while leaving the Host
  37. * settings document free to override the provisional browser-derived locale;
  38. * scenarios asserting the Chinese surface advertise
  39. * {@link ZH_BROWSER_LOCALE} instead. The context uses Asia/Shanghai to preserve
  40. * the recorded Web user-source timezone independently of the host timezone.
  41. * @param browser - Playwright browser owning the page.
  42. * @param height - Viewport height; width is fixed to the lane baseline.
  43. * @returns the initialized page.
  44. */
  45. export async function newEnglishPage(browser: Browser, height = 1000): Promise<Page> {
  46. return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
  47. }
  48. /**
  49. * Expand every currently eligible Turn-process group so a Tool-focused
  50. * scenario can exercise the original row contract beneath product-default
  51. * compact Chat presentation.
  52. * @param page - page containing the Chat view.
  53. */
  54. export async function expandTurnProcesses(page: Page): Promise<void> {
  55. const controls = page.locator('[data-turn-process]')
  56. await controls.first().waitFor({ state: 'visible', timeout: 10_000 })
  57. const count = await controls.count()
  58. for (let index = 0; index < count; index++) {
  59. const control = controls.nth(index)
  60. if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
  61. }
  62. }
  63. /**
  64. * Expand the Turn-process group containing one possibly hidden descendant.
  65. * @param page - page containing the Chat view.
  66. * @param target - descendant whose owning Turn process should open.
  67. */
  68. export async function expandOwningTurnProcess(page: Page, target: Locator): Promise<void> {
  69. const turn = await target.evaluate(element => element.closest<HTMLElement>('[data-chat-turn]')?.dataset.chatTurn)
  70. if (turn === undefined || await target.isVisible()) return
  71. const control = page.locator(`[data-turn-process="${turn}"]`)
  72. await control.waitFor({ state: 'visible', timeout: 10_000 })
  73. if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
  74. }
  75. /** Fail loud on a stale checkout instead of testing yesterday's bundle. */
  76. export function requireDist(): void {
  77. if (!existsSync(DIST_INDEX)) {
  78. throw new Error('web app dist not built — run `pnpm run build` from the repository root (`pnpm run test:web` does this first)')
  79. }
  80. }
  81. /** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */
  82. export function probeFreePort(): Promise<number> {
  83. return new Promise((resolvePort, reject) => {
  84. const probe = createServer()
  85. probe.once('error', reject)
  86. probe.listen(0, '127.0.0.1', () => {
  87. const address = probe.address()
  88. if (address === null || typeof address === 'string') {
  89. probe.close(() => { reject(new Error('port probe returned no address')) })
  90. return
  91. }
  92. probe.close(() => { resolvePort(address.port) })
  93. })
  94. })
  95. }
  96. /**
  97. * Drive the hero's workspace picker through the composed directory dialog
  98. * until the live composer unlocks. A fresh world has no Workspace, so the boot
  99. * lands in the Workspace-trigger view state (startup auto-selection has nothing to
  100. * select); every scenario that types into the composer must connect one
  101. * first. With nothing to list, activating the composer surface raises the dialog directly —
  102. * adding a workspace is the picker's only entry. The directory is staged here
  103. * and adopted through the path editor, which is idempotent across the repeated
  104. * connects a scenario may make; creating a folder from inside the dialog (the
  105. * product's other half of the same route) is covered by
  106. * workspace-management.e2e.ts. The default name 'workspace' keeps the session
  107. * header cwd at <root>/workspace, the materialization proof several scenarios
  108. * assert.
  109. * @param page - the page under test.
  110. * @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`).
  111. * @param name - folder name staged and adopted as the workspace.
  112. */
  113. export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
  114. mkdirSync(join(root, name), { recursive: true })
  115. await page.getByRole('textbox', { name: 'Choose workspace' }).click()
  116. const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
  117. await dialog.waitFor({ timeout: 10_000 })
  118. await dialog.getByRole('button', { name: 'Edit path' }).click()
  119. const pathInput = dialog.getByRole('textbox', { name: 'Edit path' })
  120. await pathInput.fill(join(root, name))
  121. await pathInput.press('Enter')
  122. await dialog.getByRole('button', { name: 'Open', exact: true }).click()
  123. // The pick connected the workspace: the blank session's live composer
  124. // replaces the locked placeholder and enables.
  125. await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
  126. .waitFor({ timeout: 15_000 })
  127. }
  128. /**
  129. * {@link connectFreshWorkspace} over a page that advertises
  130. * {@link ZH_BROWSER_LOCALE}: the English helper's anchors assume the locale
  131. * most other scenarios boot, so a scenario that deliberately keeps zh needs
  132. * the localized picker copy.
  133. * @param page - the browser page under test.
  134. * @param root - workspace parent directory.
  135. * @param name - directory created under `root` and connected.
  136. */
  137. export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
  138. mkdirSync(join(root, name), { recursive: true })
  139. await page.getByRole('textbox', { name: '选择工作区' }).click()
  140. const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
  141. await dialog.waitFor({ timeout: 10_000 })
  142. await dialog.getByRole('button', { name: '编辑路径' }).click()
  143. const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
  144. await pathInput.fill(join(root, name))
  145. await pathInput.press('Enter')
  146. await dialog.getByRole('button', { name: '打开', exact: true }).click()
  147. await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]')
  148. .waitFor({ timeout: 15_000 })
  149. }
  150. /**
  151. * Replace the composer draft through per-key gestures. `fill()` issues
  152. * select-all and insertText inside one task; directly after a trigger-menu or
  153. * chip interaction Lexical's internal selection has not yet absorbed the DOM
  154. * selection, and the batched edit lands on a null selection and is silently
  155. * dropped, leaving the previous draft in place. Real keystrokes leave room for
  156. * `selectionchange` between keys, which is also what a user's typing does.
  157. *
  158. * Waits for the surface to be editable first. While the input machine is
  159. * adjudicating or submitting a send — and in every locked state (removed
  160. * session, no workspace, an owner block) — the composer renders read-only
  161. * with `contenteditable="false"` on the same element. `fill()` throws
  162. * immediately on that element, and `isEnabled()` reports `true` for a
  163. * `<div>` regardless of the attribute — so a gesture directly after a
  164. * submit must gate on the attribute, not on enablement. A running turn by
  165. * itself keeps the composer editable (that is what queueing types into).
  166. * @param page - the page under test.
  167. * @param input - the `[data-composer-input]` surface locator.
  168. * @param text - the replacement draft; `''` clears the draft. Must not
  169. * contain a newline: typed Enter submits the composer.
  170. */
  171. export async function writeComposerDraft(
  172. page: Page,
  173. input: ReturnType<Page['locator']>,
  174. text: string,
  175. ): Promise<void> {
  176. await input.and(page.locator('[contenteditable="true"]')).waitFor({ timeout: 15_000 })
  177. await input.click()
  178. await page.keyboard.press('ControlOrMeta+A')
  179. if (text === '') await page.keyboard.press('Backspace')
  180. else await page.keyboard.type(text)
  181. }
  182. /** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
  183. export async function saveFailureShot(page: Page, name: string): Promise<void> {
  184. const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
  185. mkdirSync(dir, { recursive: true })
  186. try {
  187. await page.screenshot({ path: `${dir}/${name}.png`, fullPage: true })
  188. } catch {
  189. // Best-effort evidence: a dead page/browser at failure time must not mask the real assertion error.
  190. }
  191. }
  192. /**
  193. * The conversation engine's Context key format, restated here rather than
  194. * imported: these specs live in the Host compiler aggregate, which must not
  195. * reach the Client plane. The engine's own copy is
  196. * `conversationContextKey` in ui-conversation; a drift between them makes
  197. * the key miss its rendered node, so the assertion fails loudly.
  198. * @param kind - Definition kind.
  199. * @param id - Definition-local business identity.
  200. * @returns the engine-owned Context key.
  201. */
  202. export function conversationContextKey(kind: string, id: string): string {
  203. return `${kind.length}:${kind}${id}`
  204. }