101 lines
4.7 KiB
TypeScript
101 lines
4.7 KiB
TypeScript
// Shared plumbing for the web smoke tests (dist location, free port, failure shots).
|
|
import { existsSync, mkdirSync } from 'node:fs'
|
|
import { createServer } from 'node:net'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import type { Browser, Page } from 'playwright'
|
|
|
|
/** The built page under test; `pnpm run test:web` rebuilds it before running. */
|
|
export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
|
|
|
|
export const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
|
|
|
/**
|
|
* Browser language a page must advertise to boot into the product's Chinese
|
|
* surface: with no stored preference the client derives its initial locale
|
|
* from the browser, and Playwright's default browser asks for English.
|
|
*/
|
|
export const ZH_BROWSER_LOCALE = 'zh-CN'
|
|
|
|
/**
|
|
* Open the standard browser-test page with English selected before client
|
|
* boot. This keeps role locators and goldens deterministic across localized
|
|
* component migrations; the scenarios asserting the Chinese surface bypass
|
|
* this helper and advertise {@link ZH_BROWSER_LOCALE} instead.
|
|
* @param browser - Playwright browser owning the page.
|
|
* @param height - Viewport height; width is fixed to the lane baseline.
|
|
* @returns the initialized page.
|
|
*/
|
|
export async function newEnglishPage(browser: Browser, height = 1000): Promise<Page> {
|
|
const page = await browser.newPage({ viewport: { width: 1680, height } })
|
|
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
|
|
return page
|
|
}
|
|
|
|
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
|
|
export function requireDist(): void {
|
|
if (!existsSync(DIST_INDEX)) {
|
|
throw new Error('web app dist not built — run `pnpm --filter @deepseek-ai/dsh-frontend build` (pnpm run test:web does this first)')
|
|
}
|
|
}
|
|
|
|
/** OS-assigned free port, released before use (the spawned `dsh web` needs a concrete --port). */
|
|
export function probeFreePort(): Promise<number> {
|
|
return new Promise((resolvePort, reject) => {
|
|
const probe = createServer()
|
|
probe.once('error', reject)
|
|
probe.listen(0, '127.0.0.1', () => {
|
|
const address = probe.address()
|
|
if (address === null || typeof address === 'string') {
|
|
probe.close(() => { reject(new Error('port probe returned no address')) })
|
|
return
|
|
}
|
|
probe.close(() => { resolvePort(address.port) })
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Drive the hero's workspace picker through the composed directory dialog
|
|
* until the live composer unlocks. A fresh world has no Workspace, so the boot
|
|
* lands in the locked view state (startup auto-selection has nothing to
|
|
* select); every scenario that types into the composer must connect one
|
|
* first. With nothing to list, the chip gesture raises the dialog directly —
|
|
* adding a workspace is the picker's only entry. The directory is staged here
|
|
* and adopted through the path editor, which is idempotent across the repeated
|
|
* connects a scenario may make; creating a folder from inside the dialog (the
|
|
* product's other half of the same route) is covered by
|
|
* workspace-management.e2e.ts. The default name 'workspace' keeps the session
|
|
* header cwd at <root>/workspace, the materialization proof several scenarios
|
|
* assert.
|
|
* @param page - the page under test.
|
|
* @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`).
|
|
* @param name - folder name staged and adopted as the workspace.
|
|
*/
|
|
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
|
|
mkdirSync(join(root, name), { recursive: true })
|
|
await page.getByRole('button', { name: 'Choose workspace' }).click()
|
|
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
|
|
await dialog.waitFor({ timeout: 10_000 })
|
|
await dialog.getByRole('button', { name: 'Edit path' }).click()
|
|
const pathInput = dialog.getByRole('textbox', { name: 'Edit path' })
|
|
await pathInput.fill(join(root, name))
|
|
await pathInput.press('Enter')
|
|
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
|
|
// The pick connected the workspace: the blank session's live composer
|
|
// replaces the locked placeholder and enables.
|
|
await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
|
|
.waitFor({ timeout: 15_000 })
|
|
}
|
|
|
|
/** Failure evidence goes to the gitignored .artifacts/ (repo convention). */
|
|
export async function saveFailureShot(page: Page, name: string): Promise<void> {
|
|
const dir = fileURLToPath(new URL('../../../.artifacts', import.meta.url))
|
|
mkdirSync(dir, { recursive: true })
|
|
try {
|
|
await page.screenshot({ path: `${dir}/${name}.png`, fullPage: true })
|
|
} catch {
|
|
// Best-effort evidence: a dead page/browser at failure time must not mask the real assertion error.
|
|
}
|
|
}
|