support.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  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 { fileURLToPath } from 'node:url'
  5. import type { Browser, Page } from 'playwright'
  6. /** The built page under test; `pnpm run test:web` rebuilds it before running. */
  7. export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
  8. export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
  9. /**
  10. * Browser language a page must advertise to boot into the product's Chinese
  11. * surface: with no stored preference the client derives its initial locale
  12. * from the browser, and Playwright's default browser asks for English.
  13. */
  14. export const ZH_BROWSER_LOCALE = 'zh-CN'
  15. /**
  16. * Open the standard browser-test page with English selected before client
  17. * boot. This keeps role locators and goldens deterministic across localized
  18. * component migrations; the scenarios asserting the Chinese surface bypass
  19. * this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
  20. * @param browser - Playwright browser owning the page.
  21. * @param height - Viewport height; width is fixed to the lane baseline.
  22. * @returns the initialized page.
  23. */
  24. export async function newEnglishPage(browser: Browser, height = 1000): Promise<Page> {
  25. const page = await browser.newPage({ viewport: { width: 1680, height } })
  26. await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
  27. return page
  28. }
  29. /** Fail loud on a stale checkout instead of testing yesterday's bundle. */
  30. export function requireDist(): void {
  31. if (!existsSync(DIST_INDEX)) {
  32. throw new Error('web app dist not built — run `pnpm --filter @deepseek-ai/dsh-frontend build` (pnpm run test:web does this first)')
  33. }
  34. }
  35. /** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */
  36. export function probeFreePort(): Promise<number> {
  37. return new Promise((resolvePort, reject) => {
  38. const probe = createServer()
  39. probe.once('error', reject)
  40. probe.listen(0, '127.0.0.1', () => {
  41. const address = probe.address()
  42. if (address === null || typeof address === 'string') {
  43. probe.close(() => { reject(new Error('port probe returned no address')) })
  44. return
  45. }
  46. probe.close(() => { resolvePort(address.port) })
  47. })
  48. })
  49. }
  50. /**
  51. * Drive the hero's workspace picker through its create-by-name dialog until
  52. * the live composer unlocks. A fresh world has no Workspace, so the boot
  53. * lands in the locked view state (startup auto-selection has nothing to
  54. * select); every scenario that types into the composer must connect one
  55. * first. The default name 'workspace' keeps the session header cwd at
  56. * <workspaceRoot>/workspace — the materialization proof several scenarios
  57. * assert.
  58. * @param page - the page under test.
  59. * @param name - workspace name typed into the create dialog.
  60. */
  61. export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise<void> {
  62. await page.getByRole('button', { name: 'Choose workspace' }).click()
  63. await page.getByRole('menuitem', { name: 'Create a new workspace' }).click()
  64. const dialog = page.getByRole('dialog', { name: 'Create a new workspace' })
  65. await dialog.waitFor({ timeout: 10_000 })
  66. await dialog.getByLabel('New workspace name').fill(name)
  67. await dialog.getByRole('button', { name: 'Create workspace' }).click()
  68. // The pick connected the workspace: the blank session's live composer
  69. // replaces the locked placeholder and enables.
  70. await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
  71. .waitFor({ timeout: 15_000 })
  72. }
  73. /** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
  74. export async function saveFailureShot(page: Page, name: string): Promise<void> {
  75. const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
  76. mkdirSync(dir, { recursive: true })
  77. try {
  78. await page.screenshot({ path: `${dir}/${name}.png`, fullPage: true })
  79. } catch {
  80. // Best-effort evidence: a dead page/browser at failure time must not mask the real assertion error.
  81. }
  82. }