support.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  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 { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import type { Browser, Page } from 'playwright'
  7. /** The built page under test; `pnpm run test:web` rebuilds it before running. */
  8. export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
  9. export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
  10. /**
  11. * Browser language a page must advertise to boot into the product's Chinese
  12. * surface: with no stored preference the client derives its initial locale
  13. * from the browser, and Playwright's default browser asks for English.
  14. */
  15. export const ZH_BROWSER_LOCALE = 'zh-CN'
  16. /**
  17. * Open the standard browser-test page with English selected before client
  18. * boot. This keeps role locators and goldens deterministic across localized
  19. * component migrations; the scenarios asserting the Chinese surface bypass
  20. * this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
  21. * @param browser - Playwright browser owning the page.
  22. * @param height - Viewport height; width is fixed to the lane baseline.
  23. * @returns the initialized page.
  24. */
  25. export async function newEnglishPage(browser: Browser, height = 1000): Promise<Page> {
  26. const page = await browser.newPage({ viewport: { width: 1680, height } })
  27. await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
  28. return page
  29. }
  30. /** Fail loud on a stale checkout instead of testing yesterday's bundle. */
  31. export function requireDist(): void {
  32. if (!existsSync(DIST_INDEX)) {
  33. throw new Error('web app dist not built — run `pnpm run build` from the repository root (`pnpm run test:web` does this first)')
  34. }
  35. }
  36. /** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */
  37. export function probeFreePort(): Promise<number> {
  38. return new Promise((resolvePort, reject) => {
  39. const probe = createServer()
  40. probe.once('error', reject)
  41. probe.listen(0, '127.0.0.1', () => {
  42. const address = probe.address()
  43. if (address === null || typeof address === 'string') {
  44. probe.close(() => { reject(new Error('port probe returned no address')) })
  45. return
  46. }
  47. probe.close(() => { resolvePort(address.port) })
  48. })
  49. })
  50. }
  51. /**
  52. * Drive the hero's workspace picker through the composed directory dialog
  53. * until the live composer unlocks. A fresh world has no Workspace, so the boot
  54. * lands in the locked view state (startup auto-selection has nothing to
  55. * select); every scenario that types into the composer must connect one
  56. * first. With nothing to list, the chip gesture raises the dialog directly —
  57. * adding a workspace is the picker's only entry. The directory is staged here
  58. * and adopted through the path editor, which is idempotent across the repeated
  59. * connects a scenario may make; creating a folder from inside the dialog (the
  60. * product's other half of the same route) is covered by
  61. * workspace-management.e2e.ts. The default name 'workspace' keeps the session
  62. * header cwd at <root>/workspace, the materialization proof several scenarios
  63. * assert.
  64. * @param page - the page under test.
  65. * @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`).
  66. * @param name - folder name staged and adopted as the workspace.
  67. */
  68. export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
  69. mkdirSync(join(root, name), { recursive: true })
  70. await page.getByRole('button', { name: 'Choose workspace' }).click()
  71. const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
  72. await dialog.waitFor({ timeout: 10_000 })
  73. await dialog.getByRole('button', { name: 'Edit path' }).click()
  74. const pathInput = dialog.getByRole('textbox', { name: 'Edit path' })
  75. await pathInput.fill(join(root, name))
  76. await pathInput.press('Enter')
  77. await dialog.getByRole('button', { name: 'Open', exact: true }).click()
  78. // The pick connected the workspace: the blank session's live composer
  79. // replaces the locked placeholder and enables.
  80. await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
  81. .waitFor({ timeout: 15_000 })
  82. }
  83. /**
  84. * {@link connectFreshWorkspace} over the product default Chinese locale: the
  85. * English helper's anchors assume the locale every other scenario boots, so a
  86. * scenario that deliberately keeps zh needs the localized picker copy.
  87. * @param page - the browser page under test.
  88. * @param root - workspace parent directory.
  89. * @param name - directory created under `root` and connected.
  90. */
  91. export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
  92. mkdirSync(join(root, name), { recursive: true })
  93. await page.getByRole('button', { name: '选择工作区' }).click()
  94. const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
  95. await dialog.waitFor({ timeout: 10_000 })
  96. await dialog.getByRole('button', { name: '编辑路径' }).click()
  97. const pathInput = dialog.getByRole('textbox', { name: '编辑路径' })
  98. await pathInput.fill(join(root, name))
  99. await pathInput.press('Enter')
  100. await dialog.getByRole('button', { name: '打开', exact: true }).click()
  101. await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
  102. .waitFor({ timeout: 15_000 })
  103. }
  104. /** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
  105. export async function saveFailureShot(page: Page, name: string): Promise<void> {
  106. const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
  107. mkdirSync(dir, { recursive: true })
  108. try {
  109. await page.screenshot({ path: `${dir}/${name}.png`, fullPage: true })
  110. } catch {
  111. // Best-effort evidence: a dead page/browser at failure time must not mask the real assertion error.
  112. }
  113. }