f0410d592d
The web host now composes the sandboxed product path (sandbox-local + sandbox-policy behind bash-sandbox/fs-sandbox, with user-approval and permission on top); BootHostOptions.sandbox carries the deployment defaults (workspace-write + ask). createApiProxy owns the approval pending registry: a ctx.approval ask becomes an answerable approval/requested mux frame with a stable rpcId, replayed verbatim on every mux open until settled; respond routes by the echoed rpcId, validates the ApprovalResponsePayload audit correlation, and broadcasts approval/resolved; the ask's abort signal withdraws the question as cancelled. session.permissions / session.setPermission project ctx.permission into a protocol-owned PermissionOption select; idle switches are held last-write-wins and flushed into the next prompted turn (the ACP bridge's anchoring pattern). The shared hasOpenTurn fold moved to dsh-session, deduplicating the private copies in user-approval, the ACP bridge, and the proxy. Client, per the designer draft: a pending approval takes over the composer (ApprovalPanel replaces the InputBar — amber strip, justification headline, paired command, one-shot refuse/allow, keyed by rpcId so a queued second approval remounts live; the resolved frame restores the composer); the sidebar session row shows an amber waiting-approval dot that outranks the running ring (manager-tracked approvalId set, idempotent under mux-open replays, cleared per connection generation, lit for uninstantiated sessions too); the permission selector is a composer bottom-row chip over an invisible native select, with a presentation-only title-case transform (workspace-write renders as Workspace Write; wire names untouched). Question placeholders stay in the message flow. The connection fixture mirrors the host behavior for keyless browser acceptance.
328 lines
17 KiB
TypeScript
328 lines
17 KiB
TypeScript
// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
|
|
// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
|
|
// chromium. First describe: graph injection + the fail-loud half. Second
|
|
// describe: the settled success pass — all nine REAL tsdown bundles load
|
|
// through the module system + vendored Loader chain in ?fixture mode (the
|
|
// infrastructure four ride the immediately prefetch tier, the UI rows fetch
|
|
// on demand), the three-column frame appears in one flip, and the resident
|
|
// question completes through the real UI stack. The full model 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 { WebBootEntry, WebBootGraph } 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))
|
|
|
|
const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout'
|
|
const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar'
|
|
|
|
/** id ↔ bundle table for the success pass (the complete Web UI assembly). */
|
|
const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [
|
|
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', 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', immediately: true },
|
|
{ id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true },
|
|
{ id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
|
{ id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
|
|
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
|
|
{ id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
|
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
|
]
|
|
|
|
const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)]))
|
|
|
|
const row = (id: string, extra?: Partial<WebBootEntry>): WebBootEntry =>
|
|
({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra })
|
|
|
|
const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, {
|
|
...(p.inject !== undefined ? { inject: p.inject } : {}),
|
|
...(p.immediately === true ? { immediately: true } : {}),
|
|
}))
|
|
|
|
/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */
|
|
const FAIL_GRAPH: WebBootGraph = {
|
|
rev: 'e2e-fail',
|
|
entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')],
|
|
}
|
|
|
|
/** Graph for the success pass: the complete assembly. */
|
|
const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows }
|
|
|
|
/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */
|
|
function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap<string, string>) {
|
|
return {
|
|
graph: () => graph,
|
|
clientPath: (id: string) => byId.get(id),
|
|
onRebuilt: () => () => undefined,
|
|
}
|
|
}
|
|
|
|
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({
|
|
host: '127.0.0.1',
|
|
port,
|
|
distIndex: DIST_INDEX,
|
|
apiHandler,
|
|
webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS),
|
|
}, (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 entry graph verbatim', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest'))
|
|
const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__)
|
|
expect(boot).toEqual(FAIL_GRAPH)
|
|
})
|
|
|
|
it('serves a real bundle through the plugins endpoint', async () => {
|
|
const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`)
|
|
expect(res.status()).toBe(200)
|
|
expect(await res.text()).toContain('window.__ModuleLoader__.load')
|
|
})
|
|
|
|
it('boots to the loading page and fail-louds the absent entry', 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, nine real bundles, ?fixture)', () => {
|
|
let server: Awaited<ReturnType<typeof startWebServer>>
|
|
let browser: Browser
|
|
let page: Page
|
|
const pageErrors: string[] = []
|
|
|
|
beforeAll(async () => {
|
|
requireDist()
|
|
const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir)))
|
|
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()
|
|
// ?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({
|
|
host: '127.0.0.1',
|
|
port,
|
|
distIndex: DIST_INDEX,
|
|
apiHandler,
|
|
webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS),
|
|
}, (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(LAYOUT_ID)
|
|
expect(owners).toContain(SIDEBAR_ID)
|
|
})
|
|
|
|
it('collapsed sidebar animates to a 56px rail with the four controls', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'smoke-boot-collapsed-rail'))
|
|
const frame = page.locator('[class*="frame"]')
|
|
const firstTrack = async (): Promise<string> => (await frame.evaluate(
|
|
el => getComputedStyle(el).gridTemplateColumns)).split(' ')[0]!
|
|
// The tracks transition on the deepsuite curve; assert the animated
|
|
// settle rather than an instant jump.
|
|
const settledTrack = async (px: string): Promise<void> => {
|
|
await expect.poll(firstTrack, { timeout: 2000 }).toBe(px)
|
|
}
|
|
// The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome.
|
|
const brand = () => page.locator('[class*="brand"]').count()
|
|
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
|
// Mid-collapse the wide chrome is still mounted, fading — not swapped out.
|
|
expect(await brand()).toBe(1)
|
|
await settledTrack('56px')
|
|
await expect.poll(brand, { timeout: 2000 }).toBe(0)
|
|
for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) {
|
|
await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true)
|
|
}
|
|
await page.getByRole('button', { name: 'Open sidebar' }).click()
|
|
await settledTrack('280px')
|
|
await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true)
|
|
// Rail search: collapse again, the search control expands and lands in the box.
|
|
await page.getByRole('button', { name: 'Collapse sidebar' }).click()
|
|
await settledTrack('56px')
|
|
await page.getByRole('button', { name: 'Search sessions' }).click()
|
|
await settledTrack('280px')
|
|
// Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it.
|
|
await expect.poll(() => page.evaluate(() =>
|
|
(document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search')
|
|
})
|
|
|
|
it('renders file tool rows and expands fixture reasoning from either click target', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'smoke-think-disclosure'))
|
|
await page.locator('[role="treeitem"]').first().click()
|
|
await page.locator('[role="treeitem"][aria-selected]').first().click()
|
|
|
|
const thinkRoot = page.locator('[data-variant="think"]').first()
|
|
const think = thinkRoot.getByRole('button')
|
|
await think.waitFor({ state: 'visible', timeout: 10_000 })
|
|
expect(await think.getAttribute('aria-expanded')).toBe('false')
|
|
|
|
await thinkRoot.getByText(/^思考过程 .*reasoning 内容。$/).click()
|
|
expect(await think.getAttribute('aria-expanded')).toBe('true')
|
|
expect(await thinkRoot.locator(':scope > div').count()).toBe(2)
|
|
|
|
await think.getByText('Think', { exact: true }).click()
|
|
expect(await think.getAttribute('aria-expanded')).toBe('false')
|
|
|
|
const editRoot = page.locator('[data-variant="edit"]').first()
|
|
await editRoot.waitFor({ state: 'visible', timeout: 10_000 })
|
|
expect(await editRoot.getByText('Edit', { exact: true }).count()).toBe(1)
|
|
expect(await editRoot.getByText('notes/demo.txt', { exact: true }).count()).toBe(1)
|
|
|
|
const writeRoot = page.locator('[data-variant="write"]').first()
|
|
await writeRoot.waitFor({ state: 'visible', timeout: 10_000 })
|
|
expect(await writeRoot.getByText('Write', { exact: true }).count()).toBe(1)
|
|
expect(await writeRoot.getByText('notes/new-demo.txt', { exact: true }).count()).toBe(1)
|
|
})
|
|
|
|
it('keeps Markdown semantic while a fixture reply streams and finalizes', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'smoke-markdown-stream'))
|
|
await page.getByRole('button', { name: 'New session', exact: true }).click()
|
|
const input = page.locator('textarea[placeholder]')
|
|
await input.waitFor({ timeout: 15_000 })
|
|
await input.fill('render markdown')
|
|
await page.getByRole('button', { name: '发送' }).click()
|
|
|
|
const streaming = page.locator('[data-streaming="true"]')
|
|
await streaming.getByRole('heading', { name: 'Markdown fixture' }).waitFor({ timeout: 15_000 })
|
|
await streaming.waitFor({ state: 'detached', timeout: 15_000 })
|
|
|
|
const finalHeading = page.getByRole('heading', { name: 'Markdown fixture' })
|
|
expect(await finalHeading.evaluate(element => element.tagName)).toBe('H1')
|
|
expect(await page.locator('pre code').filter({ hasText: 'const markdown = true' }).count()).toBe(1)
|
|
const external = page.getByRole('link', { name: 'DeepSeek' })
|
|
expect(await external.getAttribute('target')).toBe('_blank')
|
|
expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
|
|
})
|
|
|
|
it('renders and completes the resident question through the composer slot', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
|
|
const sessionTree = page.getByRole('tree', { name: 'Sessions' })
|
|
const projectRow = sessionTree.getByRole('treeitem').filter({ hasText: '3 sessions' })
|
|
if (await projectRow.getAttribute('aria-expanded') === 'false') await projectRow.click()
|
|
await sessionTree.getByText('Fixture 历史会话', { exact: true }).click()
|
|
const composer = page.locator('[data-question-key]')
|
|
await composer.waitFor({ timeout: 15_000 })
|
|
expect({
|
|
question: await composer.getByRole('heading').innerText(),
|
|
progress: await composer.getByText('1 / 3', { exact: true }).innerText(),
|
|
options: await composer.getByRole('radio').allTextContents(),
|
|
custom: await composer.getByRole('button', { name: '其他,请填写自定义答案' }).innerText(),
|
|
}).toMatchInlineSnapshot(`
|
|
{
|
|
"custom": "其他,请填写自定义答案",
|
|
"options": [
|
|
"1工程落地型推荐更看重能直接做 runtime、tool executor、sandbox、trace 和线上问题排查。",
|
|
"2研究潜力型更看重 Agent 理解、训练评测思路和长期成长空间。",
|
|
"3均衡型同时要求工程能力和 Agent 认知,但可能筛选门槛更高。",
|
|
],
|
|
"progress": "1 / 3",
|
|
"question": "你现在更想招哪类 Agent/Harness 候选人?",
|
|
}
|
|
`)
|
|
|
|
await composer.getByRole('radio', { name: '工程落地型' }).click()
|
|
await composer.getByText('2 / 3', { exact: true }).waitFor()
|
|
await composer.getByRole('button', { name: '跳过本题', exact: true }).click()
|
|
await composer.getByRole('checkbox', { name: '系统设计' }).click()
|
|
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).click()
|
|
await composer.getByRole('checkbox', { name: 'Agent 产品判断' }).press('Enter')
|
|
|
|
await composer.waitFor({ state: 'detached' })
|
|
// The composer does not fall back to the InputBar yet: fx-alpha's resident
|
|
// approval is still pending, so the approval takeover elects next (the
|
|
// approval scenario below answers it and watches the InputBar return).
|
|
await page.locator('[data-approval-key]').waitFor({ state: 'visible', timeout: 10_000 })
|
|
})
|
|
|
|
it('answers the composer-takeover approval panel; the InputBar returns on the resolved frame', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'smoke-approval-answer'))
|
|
// fx-alpha carries the resident pending approval; the session is already
|
|
// selected by the previous scenario. With the question settled, the
|
|
// approval owns the composer slot — no textarea is mounted.
|
|
const panel = page.locator('[data-approval-key]')
|
|
await panel.waitFor({ state: 'visible', timeout: 10_000 })
|
|
expect(await panel.getByText('等待审批').count()).toBe(1)
|
|
expect(await page.locator('textarea').count()).toBe(0)
|
|
// Sidebar mirrors the blocked state: the session row shows the amber
|
|
// warning dot in place of the running ring.
|
|
expect(await page.locator('[role="treeitem"] [data-state="warning"]').count()).toBeGreaterThan(0)
|
|
await panel.getByRole('button', { name: '允许一次' }).click()
|
|
// The broadcast resolved frame removes the panel and restores the
|
|
// composer; the sidebar dot clears with it.
|
|
await panel.waitFor({ state: 'detached', timeout: 5000 })
|
|
const restoredInput = page.locator('textarea[placeholder]')
|
|
await restoredInput.waitFor({ state: 'visible', timeout: 5000 })
|
|
expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入')
|
|
await expect.poll(() => page.locator('[role="treeitem"] [data-state="warning"]').count(), { timeout: 5000 }).toBe(0)
|
|
})
|
|
|
|
it('switches the permission preset through the composer select', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'smoke-permission-select'))
|
|
const select = page.locator('select').filter({ hasText: 'Workspace Write' }).first()
|
|
await select.waitFor({ state: 'visible', timeout: 10_000 })
|
|
expect(await select.inputValue()).toBe('workspace-write')
|
|
await select.selectOption('danger-full-access')
|
|
// The control disables during the round trip and adopts the confirmed
|
|
// value; host-side persistence across re-reads is covered by the fixture
|
|
// unit suite (fixture.spec.ts permissions/setPermission).
|
|
await expect.poll(() => select.inputValue(), { timeout: 5000 }).toBe('danger-full-access')
|
|
await expect.poll(() => select.isDisabled(), { timeout: 5000 }).toBe(false)
|
|
})
|
|
|
|
it('stayed clean: no page errors across the whole load chain', () => {
|
|
expect(pageErrors).toEqual([])
|
|
})
|
|
})
|