Merge latest master into tui-staging-merge-1
This commit is contained in:
147
apps/web/tests/smoke-fixture.e2e.ts
Normal file
147
apps/web/tests/smoke-fixture.e2e.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins
|
||||
// registry surface + __DSH_BOOT__ injection + built shell dist in a real
|
||||
// chromium. First describe: manifest injection + fail-loud half. Second
|
||||
// describe: the settled success pass — five REAL tsdown bundles (the
|
||||
// infrastructure four + layout) load through the DI chain in ?fixture mode
|
||||
// and the three-column frame appears in one flip. The full conversation
|
||||
// round lands in smoke-real under the W5 real-host standard.
|
||||
import { existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { startWebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import type { WebPluginBootEntry } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
const bundlePath = (dir: string): string =>
|
||||
fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url))
|
||||
|
||||
/** id ↔ bundle table for the success pass (immediately four + layout). */
|
||||
const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
]
|
||||
|
||||
/** Manifest served by the fake registry: one live bundle row, one missing row. */
|
||||
const ROWS: WebPluginBootEntry[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] },
|
||||
{ id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] },
|
||||
]
|
||||
const LAYOUT_BUNDLE = bundlePath('ui-layout')
|
||||
|
||||
describe('web boot chain (keyless, real carrier)', () => {
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
const pageErrors: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
requireDist()
|
||||
const port = await probeFreePort()
|
||||
const apiHandler = { fetch: () => Promise.resolve(new Response('boot smoke must not call /api', { status: 500 })) }
|
||||
server = await startWebServer({
|
||||
port,
|
||||
distIndex: DIST_INDEX,
|
||||
apiHandler,
|
||||
webPlugins: {
|
||||
snapshot: () => ROWS,
|
||||
clientPath: id => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined),
|
||||
},
|
||||
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage()
|
||||
page.on('pageerror', e => pageErrors.push(String(e)))
|
||||
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'load' })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('GET / injects the manifest verbatim', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
|
||||
const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
|
||||
expect(boot).toEqual({ plugins: ROWS })
|
||||
})
|
||||
|
||||
it('serves a real bundle through the plugins endpoint', async () => {
|
||||
const res = await page.request.get(`${new URL(page.url()).origin}${ROWS[0]!.url}`)
|
||||
expect(res.status()).toBe(200)
|
||||
expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin')
|
||||
})
|
||||
|
||||
it('boots to the loading page and fail-louds the absent plugin', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud'))
|
||||
await page.waitForSelector('text=HARNESS', { timeout: 10_000 })
|
||||
await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 })
|
||||
await page.waitForSelector('text=@probe/absent', { timeout: 2000 })
|
||||
// The real UI must not have flipped in: the gate opens only on settled().
|
||||
expect(await page.locator('[class*="frame"]').count()).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the token sheets before any plugin CSS', async () => {
|
||||
const family = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--dsw-font-family'))
|
||||
expect(family.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('web boot chain success pass (keyless, five real bundles, ?fixture)', () => {
|
||||
const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
const pageErrors: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
requireDist()
|
||||
if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter <pkg> bundle): ${missing.map(m => m.dir).join(', ')}`)
|
||||
const port = await probeFreePort()
|
||||
const rows: WebPluginBootEntry[] = REAL_PLUGINS.map((p) => {
|
||||
const row: WebPluginBootEntry = { id: p.id, url: `/plugins/${p.id}/client.js`, inject: p.inject }
|
||||
if (p.immediately === true) row.immediately = true
|
||||
return row
|
||||
})
|
||||
const byId = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
|
||||
// ?fixture never opens HTTP streams; /api is a tripwire like the first describe.
|
||||
const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }
|
||||
server = await startWebServer({
|
||||
port,
|
||||
distIndex: DIST_INDEX,
|
||||
apiHandler,
|
||||
webPlugins: { snapshot: () => rows, clientPath: id => byId.get(id) },
|
||||
}, (err) => { pageErrors.push(`server: ${String(err)}`) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage()
|
||||
page.on('pageerror', e => pageErrors.push(String(e)))
|
||||
await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('settles and flips to the three-column frame in one pass', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-boot-settled'))
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
||||
// Loading page is gone; the grid carries the three tracks.
|
||||
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
|
||||
const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
|
||||
expect(template.split(' ').length).toBe(3)
|
||||
})
|
||||
|
||||
it('every plugin CSS landed with its ownership tag', async () => {
|
||||
const owners = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin']))
|
||||
expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout')
|
||||
})
|
||||
|
||||
it('stayed clean: no page errors across the whole load chain', () => {
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
235
apps/web/tests/smoke-real.e2e.ts
Normal file
235
apps/web/tests/smoke-real.e2e.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
// W5 real-host smoke: spawn `dsh web` with a real key, walk the full W5 flow
|
||||
// list in a real chromium, screenshot every screen into .artifacts/ for the
|
||||
// figma comparison pass. Self-skips without DEEPSEEK_API_KEY (repo e2e
|
||||
// convention); the runner loads the repo-root .env explicitly because the CLI
|
||||
// only auto-loads .env from its cwd (a temp dir here, so sessions never land
|
||||
// in the repo's .sessions).
|
||||
//
|
||||
// Selector convention: CSS Modules hash as [hash]_[local], so class-substring
|
||||
// selectors are unreliable — anchor on data-* attributes (data-variant /
|
||||
// data-clickable / data-sample) or visible text. The one [class*=] use below
|
||||
// (frame/handle) rides local names that survive hashing as suffixes; prefer
|
||||
// data-* for anything new.
|
||||
//
|
||||
// Flow order matters: chat rounds first (5 depends on 3's session), geometry
|
||||
// and theme after, reload recovery last. Tests run sequentially in-file.
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { REPO_ROOT, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Repo-root .env → process.env (never overrides an already-set variable). */
|
||||
function loadRootEnv(): void {
|
||||
const envPath = join(REPO_ROOT, '.env')
|
||||
if (!existsSync(envPath)) return
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim())
|
||||
if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2]
|
||||
}
|
||||
}
|
||||
loadRootEnv()
|
||||
|
||||
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
let out = ''
|
||||
const timer = setTimeout(() => { reject(new Error(`dsh web not ready in 90s; output:\n${out}`)) }, 90_000)
|
||||
const onData = (chunk: Buffer): void => {
|
||||
out += chunk.toString()
|
||||
const match = /dsh web: (http:\/\/[^\s]+)/.exec(out)
|
||||
if (match?.[1] !== undefined) {
|
||||
clearTimeout(timer)
|
||||
resolveReady(match[1])
|
||||
}
|
||||
}
|
||||
child.stdout?.on('data', onData)
|
||||
child.stderr?.on('data', onData)
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error(`dsh web exited early (code ${code}); output:\n${out}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** W5 screenshot: evidence for the figma comparison, not a failure artifact. */
|
||||
async function screen(page: Page, name: string): Promise<void> {
|
||||
await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
|
||||
}
|
||||
|
||||
/** First column track (px string) of the frame grid. */
|
||||
async function firstTrack(page: Page): Promise<string> {
|
||||
return (await page.locator('[class*="frame"]').evaluate(
|
||||
el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
|
||||
}
|
||||
|
||||
/** Last column track (details) as a number of pixels. */
|
||||
async function detailsTrack(page: Page): Promise<number> {
|
||||
const cols = await page.locator('[class*="frame"]').evaluate(
|
||||
el => getComputedStyle(el).gridTemplateColumns)
|
||||
return Number(cols.split(' ').pop()!.replace('px', ''))
|
||||
}
|
||||
|
||||
// Readiness gate: `dsh web` serves ALL eight manifest plugins; until every UI
|
||||
// plugin's client bundle exists and exports apply, the loader fail-louds and
|
||||
// the frame never appears.
|
||||
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-trajectory']
|
||||
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
|
||||
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
|
||||
return !existsSync(bundle) || !readFileSync(bundle, 'utf8').includes('exports.apply')
|
||||
})
|
||||
if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`)
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
|
||||
let child: ChildProcess
|
||||
let sessionsDir: string
|
||||
let baseUrl: string
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
const pageErrors: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
requireDist()
|
||||
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
|
||||
const port = await probeFreePort()
|
||||
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. cwd is a
|
||||
// temp dir (persistenceRoot is cwd-relative), so tsx needs the repo's loader
|
||||
// and tsconfig paths pointed at explicitly.
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
env: { ...process.env, TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json') },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1')
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page.on('pageerror', e => pageErrors.push(String(e)))
|
||||
await page.goto(baseUrl, { waitUntil: 'load' })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
if (child !== undefined && child.exitCode === null) {
|
||||
const gone = new Promise<void>(resolveExit => child.once('exit', () => { resolveExit() }))
|
||||
child.kill('SIGTERM')
|
||||
await Promise.race([gone, new Promise(r => setTimeout(r, 10_000).unref())])
|
||||
if (child.exitCode === null) child.kill('SIGKILL')
|
||||
}
|
||||
if (sessionsDir !== undefined) rmSync(sessionsDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('1 cold start: loading page settles into the three-column frame', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-cold-start'))
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await page.locator('text=Failed to load plugins').count()).toBe(0)
|
||||
const template = await page.locator('[class*="frame"]').evaluate(el => getComputedStyle(el).gridTemplateColumns)
|
||||
expect(template.split(' ').length).toBe(3)
|
||||
await screen(page, '01-cold-start')
|
||||
})
|
||||
|
||||
it('2+3 empty-state first send completes a real model round', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
await screen(page, '02-empty-state')
|
||||
await input.fill('请简单介绍事件溯源,两句话即可,最后以「介绍完毕」结尾')
|
||||
await input.press('Enter')
|
||||
// startSession chain: session mounts, composer moves to the bottom.
|
||||
// Regression pin (P0, 585671106): this send used to white-screen the tree
|
||||
// (scope tag lost to a duplicate inlined runtime instance) — body going
|
||||
// near-empty here means that class of bug is back.
|
||||
await page.waitForFunction(() => document.body.innerText.length > 50, undefined, { timeout: 15_000 })
|
||||
expect(pageErrors).toEqual([])
|
||||
await page.waitForFunction(() => document.body.innerText.includes('介绍完毕'), undefined, { timeout: 120_000 })
|
||||
await screen(page, '04-round-complete')
|
||||
}, 150_000)
|
||||
|
||||
it('4 view tabs: Chat / Trajectory / Waterfall all switch', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tabs'))
|
||||
await page.locator('button', { hasText: /Trajectory/i }).first().click()
|
||||
await screen(page, '05-trajectory-tab')
|
||||
await page.locator('button', { hasText: /Waterfall/i }).first().click()
|
||||
await screen(page, '06-waterfall-tab')
|
||||
await page.locator('button', { hasText: /^Chat$/i }).first().click()
|
||||
await screen(page, '07-back-to-chat')
|
||||
})
|
||||
|
||||
it('5 bash differential rendering: tool row click opens the details column', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
|
||||
await input.press('Enter')
|
||||
// Wait for the tool ROW, not response text (the reply echoes any marker).
|
||||
// bash renders through the third-party sample registration (data-sample) —
|
||||
// that IS the differential-rendering acceptance; the generic path renders
|
||||
// data-variant rows with the handler on the data-clickable inner row.
|
||||
const toolRow = page.locator('[data-sample], [data-variant] [data-clickable]').first()
|
||||
await toolRow.waitFor({ timeout: 120_000 })
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await toolRow.click()
|
||||
// Selection channel: click writes selection + layout.openDetails.
|
||||
await page.waitForFunction(() => {
|
||||
const frame = document.querySelector('[class*="frame"]')
|
||||
if (frame === null) return false
|
||||
return Number(getComputedStyle(frame).gridTemplateColumns.split(' ').pop()!.replace('px', '')) > 0
|
||||
}, undefined, { timeout: 10_000 })
|
||||
await screen(page, '09-details-open')
|
||||
}, 150_000)
|
||||
|
||||
it('6 sidebar drag widens the column and persists across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-drag'))
|
||||
const before = await firstTrack(page)
|
||||
const handle = page.locator('[class*="handle"]').first()
|
||||
const box = await handle.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + 300)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box!.x + 70, box!.y + 300, { steps: 6 })
|
||||
await page.mouse.up()
|
||||
const after = await firstTrack(page)
|
||||
expect(after).not.toBe(before)
|
||||
await screen(page, '10-sidebar-dragged')
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await firstTrack(page)).toBe(after)
|
||||
})
|
||||
|
||||
it('7 dark mode: the body attribute cascades the token sheets', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-dark'))
|
||||
// theme.apply === toggling this attribute (v3 §8); no switcher UI owns it
|
||||
// in P-I, so the acceptance drives the documented mechanism directly.
|
||||
const dark = await page.evaluate(() => {
|
||||
document.body.setAttribute('data-ds-dark-theme', '')
|
||||
return getComputedStyle(document.body).backgroundColor
|
||||
})
|
||||
await screen(page, '11-dark-mode')
|
||||
const light = await page.evaluate(() => {
|
||||
document.body.removeAttribute('data-ds-dark-theme')
|
||||
return getComputedStyle(document.body).backgroundColor
|
||||
})
|
||||
expect(dark).not.toBe(light)
|
||||
})
|
||||
|
||||
it('8 reload recovery: history replays after a fresh boot', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-reload'))
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await page.waitForFunction(() => document.body.innerText.includes('介绍完毕'), undefined, { timeout: 30_000 })
|
||||
await screen(page, '12-reload-recovery')
|
||||
})
|
||||
|
||||
it('stayed clean: no page errors across every flow', () => {
|
||||
expect(pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
47
apps/web/tests/support.ts
Normal file
47
apps/web/tests/support.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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 { fileURLToPath } from 'node:url'
|
||||
import type { 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))
|
||||
|
||||
/** 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. startWebServer echoes
|
||||
* options.port instead of the bound one, so passing 0 directly is unusable.
|
||||
*/
|
||||
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) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user