Merge latest master into tui-staging-merge-1

This commit is contained in:
Tianyi Cui
2026-07-22 20:45:24 +08:00
392 changed files with 33687 additions and 446 deletions

View 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([])
})
})

View 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
View 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.
}
}