Merge remote-tracking branch 'origin/master' into feat/loader-entry-disabled-interpolation

This commit is contained in:
Huanqi Cao
2026-08-11 20:00:54 +08:00
428 changed files with 8913 additions and 2452 deletions

View File

@@ -27,7 +27,6 @@
"@deepseek-ai/dsh-agent-tool-mode": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-base": "workspace:^",
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
"@deepseek-ai/dsh-command-compact": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",

View File

@@ -70,51 +70,6 @@
},
{
"path": "../../packages/bash/tool-bash"
},
{
"path": "../../packages/client/connection"
},
{
"path": "../../packages/client/hmr"
},
{
"path": "../../packages/client/runtime"
},
{
"path": "../../packages/client/ui-theme"
},
{
"path": "../../packages/client/ui-settings"
},
{
"path": "../../packages/client/ui-settings-general"
},
{
"path": "../../packages/client/ui-models"
},
{
"path": "../../packages/client/ui-permission"
},
{
"path": "../../packages/client/locale"
},
{
"path": "../../packages/client/ui-layout"
},
{
"path": "../../packages/client/ui-sidebar"
},
{
"path": "../../packages/client/ui-conversation"
},
{
"path": "../../packages/client/ui-plan"
},
{
"path": "../../packages/client/ui-trajectory"
},
{
"path": "../../packages/client/ui-question"
}
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
README.md: 68e5db5af5f816cc982bacb7989d996c859be204
README.zh.md: f366c28024dab89d0243a60d93a706f220fa8fb8

46
apps/web/tests/README.md Normal file
View File

@@ -0,0 +1,46 @@
# apps/web browser e2e
English | [中文](README.zh.md)
These tests boot the real web composition in-process and drive it with a real
Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and
the deliberate composition divergences from `dsh web` — are documented in
[`scaffold.ts`](scaffold.ts) and the
[browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
## These are Host-face tests
They type-check in the root `tsconfig.host.json`, not in the Client aggregate,
because they read Host services directly: `ctx.apiProxy`, the Host
`SessionStore`, `ctx.sessionProjectionCache`. Driving a browser at runtime does
not make a file part of the Client program — the two faces merge cordis
`Context` under the same keys with different services, so one program cannot see
both. Moving these files into the Client aggregate makes every Host-service
access fail to compile.
## Do not import `@deepseek-ai/dsh-client-*` here
Importing a Client package — a value or a type — pulls its whole TypeScript
project, and every project it references, into the **Host build graph**. That has
bitten this lane once already: four Client consumer packages reference
`api/remotes`' Client face, which cannot compile until Host tsdown has generated
`@deepseek-ai/dsh-goal/remote`, so the Host build phase ended up waiting on an
artifact it produces itself.
When a scenario needs a Client-owned constant or pure function, mirror it here
instead, next to the commented-out import that names the source module. A drift
then surfaces as a missed selector or an unsuppressed notice — a loud failure,
never a silent pass. `scaffold.ts` holds the mirrored welcome-notice values and
exports them for the scenarios that assert on them.
Two kinds of Client import stand. `assembled-boot.ts` drives the shell itself, so
it imports `AppWebEntry` from `@deepseek-ai/dsh-client-web` and the boot-manifest
type from `@deepseek-ai/dsh-client-modules/client`: booting the real shell is what
that harness is for, and both packages are already in the Host graph. Separately,
the chat scenarios import `conversationContextKey` from
`@deepseek-ai/dsh-client-runtime/client` because `client/runtime` is reachable
through the unsplit `directory-picker` packages and pulls nothing further in.
That reachability is incidental, not a guarantee — if it ever leaves the graph,
mirror the helper like the rest.
Nothing mechanically enforces this rule; keep it in review.

View File

@@ -0,0 +1,37 @@
# apps/web 浏览器 e2e
[English](README.md) | 中文
这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane
的运行机制——模式、fixture、golden以及与 `dsh web` 之间刻意保留的组合差异——记录在
[`scaffold.ts`](scaffold.ts) 和
[浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)中。
## 这些是 Host 面的测试
它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取
Host 服务:`ctx.apiProxy`、Host 侧 `SessionStore``ctx.sessionProjectionCache`。运行时驱动
浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 cordis
`Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处
Host 服务访问都无法编译。
## 不要在此 import `@deepseek-ai/dsh-client-*`
import 一个 Client 包——无论值还是类型——都会把它整个 TypeScript 工程、以及它引用的每个工程
拉进 **Host 构建图**。这已经坑过本 lane 一次:四个 Client 消费方包引用了 `api/remotes`
Client face而该 face 必须等 Host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 之后才能编译,
于是 Host 构建阶段变成在等一个由它自己产出的产物。
当某个场景需要 Client 持有的常量或纯函数时,改为在此处镜像一份,并紧挨着一条注释掉的
import 点明源模块。这样漂移会表现为选择器未命中或提示未被抑制——是响亮的失败,绝不会是静默
通过。`scaffold.ts` 持有镜像的 welcome-notice 取值,并导出给断言它们的场景使用。
有两类 Client import 是长期成立的。`assembled-boot.ts` 驱动 shell 本身,因此它从
`@deepseek-ai/dsh-client-web` import `AppWebEntry`、从
`@deepseek-ai/dsh-client-modules/client` import boot manifest 类型:启动真实 shell 正是该
harness 的用途,且这两个包本来就在 Host 图中。另外chat 场景从
`@deepseek-ai/dsh-client-runtime/client` import `conversationContextKey`,因为
`client/runtime` 经未拆分的 `directory-picker` 包可达,且不会再牵入别的东西。这种可达性是
偶然而非保证——一旦它离开该图,就像其余情形那样镜像该 helper。
没有任何机制强制这条规则;靠 review 守住它。

View File

@@ -20,13 +20,18 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [
{ id: '@deepseek-ai/dsh-client-connection', bundlePath: 'packages/client/connection/lib/client.js', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-api-gateway', bundlePath: 'packages/api/gateway/lib/client.js', url: '/plugins/api-gateway.js', rev: 'fx', inject: ['@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-client-connection'], immediately: true },
{ id: '@deepseek-ai/dsh-api-remotes', bundlePath: 'packages/api/remotes/lib/client.js', url: '/plugins/api-remotes.js', rev: 'fx', inject: ['@deepseek-ai/dsh-api-gateway'], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
// The settings domain base: the only provider of ctx.settingsScope, which the
// locale and ui-theme rows below inject for their preference rows. Without it
// both stay pending and ui-layout never activates, so nothing renders.
{ id: '@deepseek-ai/dsh-client-ui-settings', bundlePath: 'packages/client/ui-settings/lib/client.js', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-api-remotes'], immediately: true },
{ id: '@deepseek-ai/dsh-client-runtime', bundlePath: 'packages/client/runtime/lib/client.js', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-gateway'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-theme', bundlePath: 'packages/client/ui-theme/lib/client.js', url: '/plugins/ui-theme.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
{ id: '@deepseek-ai/dsh-client-locale', bundlePath: 'packages/client/locale/lib/client.js', url: '/plugins/locale.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-api-remotes'], immediately: true },
{ id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
{ id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
{ id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] },
{ id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
{
id: '@deepseek-ai/dsh-client-ui-workspace',
bundlePath: 'packages/client/ui-workspace/lib/client.js',

View File

@@ -4,7 +4,7 @@
// Opens the fixture history session whose turn 72 carries an image in BOTH a
// user message and an assistant message, and pins the product surfaces: the
// history ImageGallery loading real fixture bytes through the authorized
// sessions.attachment route, the double-click ImageLightbox, and the composer
// sessions.attachment route, the single-click ImageLightbox, and the composer
// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove).
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, it } from 'vitest'
@@ -66,10 +66,10 @@ it('renders the history image pair through the authorized attachment route and o
`)
const userImage = document.querySelector<HTMLElement>('[data-align="end"] img')!
// Double-click opens the original-size lightbox; Escape/close dismisses it.
// A single click opens the original-size lightbox; Escape/close dismisses it.
const frame = userImage.closest('button')
if (frame === null) throw new Error('image frame button missing')
fireEvent.doubleClick(frame)
fireEvent.click(frame)
const lightbox = await screen.findByRole('dialog')
expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob')
fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ }))
@@ -133,4 +133,18 @@ it('accepts pasted images into the composer rail in order and removes them', asy
await waitFor(() => {
expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull()
})
// An unsupported file announces a transient toast (the inline strip is
// gone) and the banner dismisses itself after its hold-and-fade lifetime.
fireEvent.paste(textarea, {
clipboardData: {
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }],
getData: () => '',
},
})
const toast = await screen.findByRole('alert')
expect(toast.textContent).toContain('Unsupported image format: text/plain')
await waitFor(() => {
expect(screen.queryByRole('alert')).toBeNull()
}, { timeout: 6_000 })
})

View File

@@ -10,14 +10,12 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_ACK_FIELD,
WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')

View File

@@ -5,10 +5,10 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
WELCOME_NOTICE_COPY,
type WebScaffold,
} from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general'
const MODE = webSnapshotMode()

View File

@@ -41,9 +41,19 @@ import {
loadOverlayPatches,
} from '@deepseek-ai/dsh-app-boot'
import { dshHomePath } from '@deepseek-ai/dsh-paths'
import {
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
} from '@deepseek-ai/dsh-client-ui-settings-general'
// Client packages must not be imported here: these e2e type-check in the Host
// aggregate, so a Client import pulls that package's whole project — and every
// project it references — into the Host build graph. Mirrored from
// packages/client/ui-settings-general/src/onboarding-copy.ts; a drift makes the
// pre-acknowledgement stop suppressing the notice, which fails loudly.
// import {
// WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
// } from '@deepseek-ai/dsh-client-ui-settings-general'
export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
export const WELCOME_NOTICE_VERSION = '2026-07-30.7'
export const WELCOME_NOTICE_COPY = { zh: { title: '内测声明', continueLabel: '继续' } } as const
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type {

View File

@@ -0,0 +1,32 @@
- text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}"
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:":
- img
- img
- text: "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:"
- button "Tool call workflow ·":
- img
- img
- text: Tool call workflow ·
- button "snapshot-flow 1 member Completed" [expanded]:
- img
- text: snapshot-flow 1 member Completed
- button "Run 1 member Completed 1" [expanded]:
- img
- text: Run 1 member Completed 1
- text: Reply with exactly the word WF_CHILD_OK and not… Completed
- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.":
- img
- img
- text: Think The workflow returned successfully with the reply "WF_CHILD_OK". Now I need to reply with exactly "WORKFLOW_DONE" and stop.
- paragraph: WORKFLOW_DONE
- button "Copy":
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s

View File

@@ -0,0 +1,181 @@
// Keyless shipped-Web acceptance for the durable workflow Conversation Node.
// Reuses the existing recorded workflow parent/child model fixtures; the real
// workflow tool, worker, subagent provider, Session log, browser plugin graph,
// and navigation all execute during replay.
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
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 type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import {
connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot,
} from './support.ts'
const MODE = webSnapshotMode()
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workflow-run', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const PARENT_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.jsonl')
const CHILD_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl')
const CHILD_PROMPT = 'Reply with exactly the word WF_CHILD_OK and nothing else.'
describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let prompt: string
const waitForParentSettlement = (): Promise<SessionId> => new Promise((resolve, reject) => {
let dispose = (): void => {}
dispose = scaffold.ctx.on('session/event', (session: Session, event: SessionEvent) => {
if (event.type !== 'turn/end' || session.header.origin === 'subagent') return
dispose()
void (async () => {
await scaffold.ctx.agents.get(session.id)?.whenIdle()
await scaffold.ctx.sessions.flush(session)
resolve(session.id)
})().catch(reject)
})
})
beforeAll(async () => {
const prompts = fixtureUserPrompts(await readFile(PARENT_FIXTURE, 'utf8'))
expect(prompts).toHaveLength(1)
prompt = prompts[0]!
scaffold = await launchWebScaffold({
replayFixture: PARENT_FIXTURE,
replayChildFixtures: [CHILD_FIXTURE],
paceMs: 25,
})
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live'))
const settled = waitForParentSettlement()
const input = page.locator('textarea').first()
await input.fill(prompt)
await input.press('Enter')
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
await workflow.waitFor({ timeout: 30_000 })
expect(await workflow.getAttribute('aria-expanded')).toBe('true')
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor({ timeout: 15_000 })
await phase.click()
const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ })
await member.waitFor({ timeout: 15_000 })
await member.focus()
const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color)
await page.setViewportSize({ width: 560, height: 800 })
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const darkNarrow = await page.locator('[data-workflow-run]').evaluate((element) => {
const panel = element as HTMLElement
panel.style.width = '356px'
const label = element.querySelector('[data-member-label]')
const labelWrap = element.querySelector('[data-member-label-wrap]')
const status = element.querySelector('[data-member-status-text]')
const disclosures = element.querySelectorAll('[data-disclosure-row]')
const runHeader = disclosures[0]
const phaseHeader = disclosures[1]
const phaseTitle = phaseHeader?.children.item(1) as HTMLElement | null
const phaseStatus = element.querySelector('[data-phase-status-text]')
const originalPhaseTitle = phaseTitle?.textContent ?? ''
if (phaseTitle !== null) phaseTitle.textContent = 'A phase name long enough to require ellipsis in the narrow layout'
const phaseTitleRight = phaseTitle?.getBoundingClientRect().right ?? 0
const phaseStatusLeft = phaseStatus?.getBoundingClientRect().left ?? 0
if (phaseTitle !== null) phaseTitle.textContent = originalPhaseTitle
return {
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
color: label === null ? '' : getComputedStyle(label).color,
decoration: label === null ? '' : getComputedStyle(label).textDecorationLine,
focusWidth: labelWrap === null ? '' : getComputedStyle(labelWrap).outlineWidth,
statusWidth: status?.getBoundingClientRect().width ?? 0,
statusFontSize: status === null ? '' : getComputedStyle(status).fontSize,
runHeight: runHeader?.getBoundingClientRect().height ?? 0,
phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0,
phaseTitleRight,
phaseStatusLeft,
}
})
expect(darkNarrow.clientWidth).toBe(356)
expect(darkNarrow.scrollWidth).toBeLessThanOrEqual(darkNarrow.clientWidth)
expect(darkNarrow.color).not.toBe(lightColor)
expect(darkNarrow.decoration).toContain('underline')
expect(Number.parseFloat(darkNarrow.focusWidth)).toBeGreaterThanOrEqual(2)
expect(darkNarrow.statusWidth).toBe(64)
expect(darkNarrow.statusFontSize).toBe('13px')
expect(darkNarrow.runHeight).toBe(32)
expect(darkNarrow.phaseHeight).toBe(32)
expect(darkNarrow.phaseTitleRight).toBeLessThanOrEqual(darkNarrow.phaseStatusLeft)
await page.locator('[data-workflow-run]').evaluate((element) => {
(element as HTMLElement).style.removeProperty('width')
document.body.removeAttribute('data-ds-dark-theme')
})
await page.setViewportSize({ width: 1280, height: 800 })
await member.click()
await page.getByText(CHILD_PROMPT, { exact: true }).waitFor({ timeout: 15_000 })
const sessions = page.getByRole('tree', { name: 'Sessions' })
await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
await settled
expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1)
const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ })
await terminalWorkflow.waitFor()
if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click()
const terminalPhase = page.getByRole('button', { name: /^Run/ })
await terminalPhase.waitFor()
if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
await expect.poll(
() => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(),
{ timeout: 10_000 },
).toBe(0)
}, 90_000)
it('rebuilds the terminal record from history after reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history'))
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
await workflow.waitFor({ timeout: 15_000 })
expect(await workflow.getAttribute('aria-expanded')).toBe('false')
await workflow.click()
const phase = page.getByRole('button', { name: /^Run/ })
await phase.waitFor()
await phase.click()
await page.getByText(CHILD_PROMPT, { exact: false }).waitFor()
expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
}, 60_000)
it('stays clean and owns only its one golden', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})

View File

@@ -86,7 +86,8 @@
"tests/chat-continuous-conversation.e2e.ts",
"tests/composer-tab-geometry.e2e.ts",
"tests/complex-history.perf.ts",
"tests/pwsh-terminal.e2e.ts"
"tests/pwsh-terminal.e2e.ts",
"tests/workflow-run.e2e.ts"
],
"references": [
{

View File

@@ -143,6 +143,7 @@ export default defineConfig({
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-attachment$/, replacement: src('../../packages/client/ui-attachment/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
],