Merge pinned master into status bar token metrics
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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/cli/README.md
|
||||
README.md: e4b34c11d5deb722caed199d6350f7931092a636
|
||||
README.zh.md: 5701bc8b6d99f00e68db572a58a0b6d520d67f08
|
||||
README.md: c36a75fc61fd7118f48c9b68be3144177df19534
|
||||
README.zh.md: e926fa99c4e483351f52ca4e76b668e26b34d02f
|
||||
|
||||
@@ -17,8 +17,7 @@ The TUI surface:
|
||||
|
||||
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
|
||||
|
||||
|
||||
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config <path>` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
|
||||
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@ TUI 界面:
|
||||
|
||||
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
|
||||
|
||||
|
||||
Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
|
||||
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
|
||||
|
||||
|
||||
@@ -105,7 +105,9 @@
|
||||
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
|
||||
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
|
||||
# process out (the launchers patch the row disabled; config cannot disable
|
||||
# a row). The exporter/processor values bound the shutdown drain to ~1s
|
||||
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
|
||||
# random UUID; delete the file to reset the identity) as the Resource's
|
||||
# user.id. The exporter/processor values bound the shutdown drain to ~1s
|
||||
# against an unreachable collector: exporter.timeoutMillis is both the
|
||||
# per-attempt socket timeout and the retry deadline (1s effectively
|
||||
# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
|
||||
|
||||
104
apps/web/tests/access-confirmation.e2e.ts
Normal file
104
apps/web/tests/access-confirmation.e2e.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Web e2e scenario: every visible permission picker gates Full access behind
|
||||
// the same locale-aware, in-page risk confirmation. Zero model calls: the
|
||||
// scenario boots the shipped Web composition and exercises the real
|
||||
// permission projection, client command path, HTTP RPC, and pushed update.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
|
||||
/**
|
||||
* connectFreshWorkspace twin over the product default Chinese locale (the
|
||||
* shared helper's anchors assume the English page every other scenario
|
||||
* boots; this scenario deliberately keeps zh, so the localized picker
|
||||
* copy is the anchor set).
|
||||
*/
|
||||
async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise<void> {
|
||||
await page.getByRole('button', { name: '选择工作区' }).click()
|
||||
await page.getByRole('menuitem', { name: '新建工作区' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '新建工作区' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByLabel('新工作区名称').fill(name)
|
||||
await dialog.getByRole('button', { name: '创建工作区' }).click()
|
||||
await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
|
||||
.waitFor({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: Full access confirmation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// CI uses Playwright's pinned browser. A developer may point this one
|
||||
// scenario at an installed Chromium when the matching browser download
|
||||
// is temporarily unavailable.
|
||||
const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
|
||||
browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
|
||||
// Keep the product default Chinese locale: the golden pins the actual
|
||||
// registered dictionary rather than a test-local translation callback.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspaceZh(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('requires acknowledgement before the composer picker can enable Full access', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
|
||||
const access = page.locator('button[aria-label^="访问模式"]').first()
|
||||
await access.waitFor({ timeout: 10_000 })
|
||||
|
||||
// Normalize the starting preset through the real command path. The
|
||||
// shipped web config may already start at Full access.
|
||||
if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
|
||||
await access.click()
|
||||
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
|
||||
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('访问模式,当前:Workspace Write')
|
||||
}
|
||||
|
||||
await access.click()
|
||||
await page.getByRole('menuitem', { name: 'Full access' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const enable = dialog.getByRole('button', { name: '启用 Full access' })
|
||||
expect(await enable.isDisabled()).toBe(true)
|
||||
|
||||
// The modal is in this page's body (not a native/new window) and escapes
|
||||
// the sticky composer's stacking context.
|
||||
expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
|
||||
expect(await enable.isEnabled()).toBe(true)
|
||||
await enable.click()
|
||||
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('访问模式,当前:Full access')
|
||||
expect(await dialog.count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -111,6 +111,29 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// The write/edit turns render a real diff card through the assembled graph
|
||||
// (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
|
||||
// The write turn's `hello fixture\n` proves the terminator rule end to end: a
|
||||
// trailing newline terminates its line, so the footer reads `+1` (not a
|
||||
// phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
|
||||
// it is absent from textContent — assert on the line body and the footer.
|
||||
const diffCards = [...document.querySelectorAll('[data-diff]')]
|
||||
expect(diffCards.length).toBeGreaterThan(0)
|
||||
const footers = diffCards.map(card => card.textContent ?? '')
|
||||
expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
|
||||
|
||||
// The web render intent reaches the assembled boot graph: the fixture's
|
||||
// web_search / web_fetch turns render their keyed WebRow cards, proving the
|
||||
// registration, wire projection, and card rendering survive the real bundle
|
||||
// path (not just the per-package src benches). The selector pins the KEYED
|
||||
// WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
|
||||
// WebBlock draws — the generic fallback renders the same WebBlock, so a silent
|
||||
// keyed-registration failure would still satisfy a bare `[data-web]` check.
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
|
||||
expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
|
||||
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
|
||||
.map(style => style.getAttribute('data-plugin'))
|
||||
|
||||
397
apps/web/tests/composer-draft-scroll.e2e.ts
Normal file
397
apps/web/tests/composer-draft-scroll.e2e.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
|
||||
// GLYPHS, not just its caret.
|
||||
//
|
||||
// The composer paints its text in two stacked layers (see
|
||||
// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
|
||||
// `<textarea>` carries the value, the selection and the caret but renders its
|
||||
// own glyphs `color: transparent`, and every visible character is painted by the
|
||||
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
|
||||
// highlight, the chips and the ghost hint. The backdrop is `position: absolute;
|
||||
// inset: 0; overflow: hidden` — it is CLIPPED, not scrolled, and nothing in the
|
||||
// browser links its scroll offset to the textarea's.
|
||||
//
|
||||
// So past the cap the textarea scrolled and the words did not: the caret walked
|
||||
// off the bottom of a block of text frozen at line 1, and no gesture — wheel,
|
||||
// drag, arrow key — moved it. `InputBar` now mirrors the offset onto the
|
||||
// backdrop on every textarea `scroll`, which is the one event every way of
|
||||
// moving the box ends in.
|
||||
//
|
||||
// Mirroring an offset is only correct while both layers can reach it, so the
|
||||
// geometry underneath is asserted here alongside the visible outcome: the
|
||||
// backdrop's trailing-line sentinel (a textarea reserves a line box for the
|
||||
// caret after a final newline; `pre-wrap` collapses one), and one wrap width
|
||||
// across all three layers (only the textarea scrolls, so only it can lose
|
||||
// width to a scrollbar that consumes layout space). Either breaks the extent
|
||||
// equality, and an unreachable offset clamps the glyphs below the caret.
|
||||
//
|
||||
// Only a real engine can show this. Scrolling is layout: jsdom reports
|
||||
// `scrollHeight === clientHeight` for every element and never scrolls one, so
|
||||
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx has
|
||||
// to stub both offsets and can only prove the mirroring code path runs. What is
|
||||
// asserted here instead is the user-visible fact that path exists for — after
|
||||
// scrolling to the end of a long draft, the LAST line is the one on screen —
|
||||
// measured with a DOM Range over the backdrop's own text.
|
||||
//
|
||||
// Zero model calls: a fresh workspace's blank session already carries a live
|
||||
// composer, and the scenario only types into it. A stray stream would fail loud
|
||||
// with NO_ADAPTER.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
|
||||
/**
|
||||
* Committed golden of the composer's two-layer scroll geometry. The change
|
||||
* alters no DOM and no accessible name, so the aria goldens the other scenarios
|
||||
* commit are byte-identical with and without it; this records the relations
|
||||
* instead, which makes a shift in the cap or in the layer coupling a reviewable
|
||||
* diff rather than an assertion someone has to reconstruct.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
/** Marks the first and last line so a Range can find them in the backdrop's text. */
|
||||
const FIRST_MARKER = 'FIRST-LINE-MARKER'
|
||||
const LAST_MARKER = 'LAST-LINE-MARKER'
|
||||
/** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */
|
||||
const DRAFT_LINES = 40
|
||||
const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
|
||||
if (index === 0) return FIRST_MARKER
|
||||
if (index === DRAFT_LINES - 1) return LAST_MARKER
|
||||
return `draft line ${String(index + 1).padStart(2, '0')}`
|
||||
}).join('\n')
|
||||
|
||||
/**
|
||||
* A draft ending in a newline: the shape whose layer extents diverge without
|
||||
* the backdrop's trailing-line sentinel. A textarea reserves a line box for the
|
||||
* caret after a final newline; `white-space: pre-wrap` collapses a text node's
|
||||
* trailing newline and generates none, so the backdrop would come out exactly
|
||||
* one line shorter and the mirrored offset would clamp a line above the caret.
|
||||
*/
|
||||
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
|
||||
|
||||
/** The composer's two text layers as the browser lays them out. */
|
||||
interface ComposerMetrics {
|
||||
/** True when the draft is taller than the capped box — the situation under test. */
|
||||
overflows: boolean
|
||||
/** Visible height of the textarea's content box: the cap in pixels. */
|
||||
clientHeight: number
|
||||
/** Whole lines that fit in the visible box, at the composer's own line-height. */
|
||||
visibleLines: number
|
||||
/** The textarea's scroll offset, which the caret and the selection follow. */
|
||||
inputScrollTop: number
|
||||
/** The backdrop's scroll offset, which every visible glyph follows. */
|
||||
backdropScrollTop: number
|
||||
/** True when the two layers agree — the coupling this scenario exists for. */
|
||||
layersAgree: boolean
|
||||
/**
|
||||
* Top of the LAST draft line relative to the visible box's top, in pixels: at
|
||||
* most `clientHeight` when that line is on screen. This is the reported
|
||||
* symptom as a number — with the layers uncoupled the backdrop stays at offset
|
||||
* 0, so the last line sits a full draft-height below the box.
|
||||
*/
|
||||
lastLineOffset: number
|
||||
/** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
|
||||
firstLineOffset: number
|
||||
/** Furthest the textarea can scroll. */
|
||||
inputMax: number
|
||||
/** Furthest the backdrop can scroll — equal to `inputMax`, or the mirror clamps below the caret. */
|
||||
backdropMax: number
|
||||
/** Content width the textarea wraps at. */
|
||||
inputWrapWidth: number
|
||||
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
|
||||
backdropWrapWidth: number
|
||||
/** Content width the hidden auto-grow mirror wraps at — it decides the box's height. */
|
||||
mirrorWrapWidth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure both composer layers in the page.
|
||||
* @param page - the page under test.
|
||||
* @returns the two layers' offsets and where the draft's first and last lines sit.
|
||||
*/
|
||||
function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
return page.evaluate(({ first, last }) => {
|
||||
const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
|
||||
if (input === null) throw new Error('no live composer textarea in the DOM')
|
||||
const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
|
||||
if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
|
||||
// The hidden auto-grow mirror: the textarea's next sibling, and the layer
|
||||
// that decides the box's height, so its wrap width matters as much as the
|
||||
// two that carry glyphs.
|
||||
const mirror = input.nextElementSibling
|
||||
if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
|
||||
const box = input.getBoundingClientRect()
|
||||
// The draft carries no chips or claim token, so the decoration walk emits it
|
||||
// as one text node — the backdrop's first, ahead of the trailing-line
|
||||
// sentinel React renders as a second one. Both markers live in that first
|
||||
// node, which is what the Range below needs.
|
||||
const text = backdrop.firstChild
|
||||
if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
|
||||
const offsetOf = (marker: string): number => {
|
||||
const at = text.data.indexOf(marker)
|
||||
if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
|
||||
const range = document.createRange()
|
||||
range.setStart(text, at)
|
||||
range.setEnd(text, at + marker.length)
|
||||
return range.getBoundingClientRect().top - box.top
|
||||
}
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
|
||||
// Each layer's own maximum, probed by asking for an impossible offset and
|
||||
// reading back what it clamped to, then restored. Reading scrollHeight -
|
||||
// clientHeight instead would compute the maximum rather than observe it.
|
||||
const restore = input.scrollTop
|
||||
const restoreBackdrop = backdrop.scrollTop
|
||||
input.scrollTop = 1e7
|
||||
backdrop.scrollTop = 1e7
|
||||
const inputMax = input.scrollTop
|
||||
const backdropMax = backdrop.scrollTop
|
||||
input.scrollTop = restore
|
||||
backdrop.scrollTop = restoreBackdrop
|
||||
return {
|
||||
inputMax,
|
||||
backdropMax,
|
||||
inputWrapWidth: input.clientWidth,
|
||||
backdropWrapWidth: backdrop.clientWidth,
|
||||
mirrorWrapWidth: mirror.clientWidth,
|
||||
overflows: input.scrollHeight > input.clientHeight,
|
||||
clientHeight: input.clientHeight,
|
||||
visibleLines: Math.floor(input.clientHeight / lineHeight),
|
||||
inputScrollTop: input.scrollTop,
|
||||
backdropScrollTop: backdrop.scrollTop,
|
||||
layersAgree: input.scrollTop === backdrop.scrollTop,
|
||||
lastLineOffset: offsetOf(last),
|
||||
firstLineOffset: offsetOf(first),
|
||||
}
|
||||
}, { first: FIRST_MARKER, last: LAST_MARKER })
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the golden body.
|
||||
*
|
||||
* Absolute glyph coordinates are deliberately absent: they depend on font
|
||||
* metrics and would make the fixture fail on a machine that measures text
|
||||
* differently — a golden that needs re-recording per platform documents the
|
||||
* platform, not the change. What is recorded is the cap, the layer agreement,
|
||||
* and which lines are on screen, each a comparison that survives any layout
|
||||
* keeping the coupling.
|
||||
* @param top - metrics with the draft scrolled to its start.
|
||||
* @param bottom - metrics with the draft scrolled to its end.
|
||||
* @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics): string {
|
||||
return [
|
||||
'# Composer draft scrolling (14-line cap, two text layers)',
|
||||
'',
|
||||
'## At the start of the draft',
|
||||
'',
|
||||
`- draft overflows the capped box: ${String(top.overflows)}`,
|
||||
`- visible lines: ${String(top.visibleLines)}`,
|
||||
`- both layers share one scroll extent: ${String(top.inputMax === top.backdropMax)}`,
|
||||
`- all three layers wrap at one width: ${String(
|
||||
top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
|
||||
)}`,
|
||||
`- textarea scroll offset: ${String(top.inputScrollTop)}px`,
|
||||
`- glyph layer tracks it: ${String(top.layersAgree)}`,
|
||||
`- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
|
||||
`- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
|
||||
'',
|
||||
'## Scrolled to the end of the draft',
|
||||
'',
|
||||
`- textarea moved: ${String(bottom.inputScrollTop > 0)}`,
|
||||
`- glyph layer tracks it: ${String(bottom.layersAgree)}`,
|
||||
`- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
|
||||
`- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
|
||||
'',
|
||||
'## Draft ending in a newline, scrolled to the end',
|
||||
'',
|
||||
`- both layers share one scroll extent: ${String(trailingNewline.inputMax === trailingNewline.backdropMax)}`,
|
||||
`- glyph layer tracks the caret: ${String(trailingNewline.layersAgree)}`,
|
||||
`- last draft line is on screen: ${String(trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight)}`,
|
||||
].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
describe('web e2e: composer draft scrolling', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
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, 'composer-draft-scroll')
|
||||
await page.locator('textarea:enabled').first().fill(DRAFT)
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('caps the draft box and keeps both text layers at the start', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
|
||||
// Vacuity guard: without an overflowing draft there is nothing to scroll and
|
||||
// every assertion below holds trivially.
|
||||
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
// Typing the draft left the caret — and the box — at its end, so reach the
|
||||
// start by the same gesture a user would, and leave it there for the wheel
|
||||
// case below.
|
||||
await page.locator('textarea:enabled').first().hover()
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
|
||||
const metrics = await measureComposer(page)
|
||||
// The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
|
||||
// 14 x 24px lines). The count, not the pixels: it is the figma constant and
|
||||
// survives a device-pixel-ratio change.
|
||||
expect(metrics.visibleLines).toBe(14)
|
||||
// Resting state: the draft's head is what a 40-line draft shows, and its
|
||||
// tail is far below the box. Both layers sit at the origin, which is why the
|
||||
// uncoupled build looks correct until something scrolls.
|
||||
expect(metrics.inputScrollTop).toBe(0)
|
||||
expect(metrics.layersAgree).toBe(true)
|
||||
expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('lays out all three text layers at one wrap width', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
|
||||
// The premise under the mirror, asserted rather than assumed. Only .input
|
||||
// scrolls, so only .input can lose content width to a scrollbar that
|
||||
// consumes layout space; a narrower .input wraps a long draft onto more
|
||||
// lines, ends up taller, and its larger maximum makes the mirrored offset
|
||||
// clamp below the caret. Measured on a standalone harness, an 8px width
|
||||
// difference is worth 2 to 5 lines on a wrap-sensitive draft.
|
||||
//
|
||||
// This holds on the lane's engine and is what a regression would break —
|
||||
// it is NOT vacuous: measured on the same app, WebKit reports 768 against
|
||||
// 776 here, which is the divergence the Agent Note records as a
|
||||
// pre-existing, engine-specific limitation. The mirror is unaffected there
|
||||
// today because the extents still agree; this assertion is what would
|
||||
// notice if the lane's engine ever moved into the same state.
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
|
||||
// The mirror decides the box height, so it belongs in the same equality —
|
||||
// were it alone to wrap wider, the box would be measured too short and
|
||||
// clip content before the 14-line cap, with every other assertion green.
|
||||
expect(metrics.mirrorWrapWidth).toBe(metrics.inputWrapWidth)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.hover()
|
||||
// One delta past the whole draft: the textarea clamps at its own end, and
|
||||
// the wheel-chaining handler leaves it native because the box is not yet at
|
||||
// its edge when the gesture starts (the chaining itself is owned by the
|
||||
// unit spec).
|
||||
await page.mouse.wheel(0, 2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0)
|
||||
const metrics = await measureComposer(page)
|
||||
// The coupling, stated directly.
|
||||
expect(metrics.layersAgree).toBe(true)
|
||||
// The reported symptom, stated as what the user sees: the end of the draft
|
||||
// is on screen and its beginning is not. On the uncoupled build the glyph
|
||||
// layer stays at offset 0, so `lastLineOffset` is still a full draft below
|
||||
// the box and `firstLineOffset` is still 0 — the text never moved.
|
||||
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(metrics.firstLineOffset).toBeLessThan(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('typing at the end of a scrolled draft keeps the layers together', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
|
||||
// The other way the box moves. Typing at the caret — parked at the draft's
|
||||
// end by the wheel gesture — scrolls it into view, which is a `scroll` like
|
||||
// any other; this pins that an edit is not a separate case needing its own
|
||||
// mirror, which is why one listener is the whole implementation.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.press('End')
|
||||
await input.pressSequentially(' tail')
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.layersAgree).toBe(true)
|
||||
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
|
||||
// The layers reserve a final line box on different terms, so this shape is
|
||||
// the one that separates equal extents from a mirror that clamps early.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.fill(DRAFT_TRAILING_NEWLINE)
|
||||
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
const extents = await measureComposer(page)
|
||||
// The invariant the sentinel exists for. Without it the textarea measured
|
||||
// 652 against the backdrop's 628 — one 24px line apart.
|
||||
expect(extents.backdropMax).toBe(extents.inputMax)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, 4000)
|
||||
await expect.poll(async () => {
|
||||
const m = await measureComposer(page)
|
||||
return m.inputScrollTop === m.inputMax
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
const bottom = await measureComposer(page)
|
||||
// At the very bottom the glyphs are level with the caret, not a line behind.
|
||||
expect(bottom.layersAgree).toBe(true)
|
||||
expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('matches the committed composer scroll geometry golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
// Restore the pristine draft (the edit case appended to it) and return to
|
||||
// its start, both through ordinary gestures.
|
||||
await input.fill(DRAFT)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 }).toBe(0)
|
||||
const top = await measureComposer(page)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, 2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).inputScrollTop, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0)
|
||||
const bottom = await measureComposer(page)
|
||||
await input.fill(DRAFT_TRAILING_NEWLINE)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, 4000)
|
||||
await expect.poll(async () => {
|
||||
const m = await measureComposer(page)
|
||||
return m.inputScrollTop === m.inputMax
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
const trailingNewline = await measureComposer(page)
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('commits exactly the fixtures it reads', async () => {
|
||||
// Zero model calls, so the scenario records no session fixture: the geometry
|
||||
// golden is the whole inventory.
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -27,11 +27,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
// One golden per interactive end-state: what the user is left looking at
|
||||
// after cancel, after a non-retryable failure (pins the FIXME(web-error-surface)
|
||||
// gap as a reviewable artifact: NO error copy in the tree), and after retry
|
||||
// recovery — three genuinely different terminal surfaces of one fixture.
|
||||
// One golden pins the stable mid-turn loading state; the other three capture
|
||||
// what the user is left looking at after cancel, after a non-retryable failure
|
||||
// (pins the FIXME(web-error-surface) gap as a reviewable artifact: NO error
|
||||
// copy in the tree), and after retry recovery.
|
||||
const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
|
||||
const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
|
||||
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
|
||||
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -133,6 +134,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
// The marker IS the synchronization: the stream is provably parked in the
|
||||
// hang (prefix chunks delivered to the loop) before the stop click.
|
||||
await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
|
||||
await expect.poll(
|
||||
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(true)
|
||||
const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
await settled
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
|
||||
@@ -221,8 +228,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
// only on change, so attempt count is invisible there).
|
||||
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
// Golden of the recovered end-state: indistinguishable from a clean
|
||||
// completion — retries are deliberately invisible in the transcript.
|
||||
// Golden of the recovered end-state: the discarded partial stays absent,
|
||||
// while the settled retry row remains as durable recovery context.
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
@@ -231,7 +238,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md',
|
||||
'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// Web e2e scenario: the Models settings page end to end through the real
|
||||
// wire — the add card offers the dormant pi-ai catalog, typing an API key
|
||||
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
|
||||
// while the settings document records only that reference, and the saved
|
||||
// route registers live (the row's 已启用 badge is the topology invalidation
|
||||
// landing). The customized-settings fold writes the curated reasoning field
|
||||
// as a merge patch. Zero model calls: configuration is pure
|
||||
// while the settings document records only that reference; the saved row
|
||||
// appears after the route topology invalidation without presenting liveness
|
||||
// as provider status. The customized-settings fold writes the curated
|
||||
// reasoning field as a merge patch. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud on the open seam. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
// never shadow the derived reference.
|
||||
// never shadow the derived reference. Removing that row is guarded by the
|
||||
// localized provider-confirmation dialog before the unset reaches the wire.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -24,6 +25,7 @@ import { saveFailureShot } from './support.ts'
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
|
||||
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
@@ -82,7 +84,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
// registers, and the topology frame invalidates the page into the row.
|
||||
const row = dialog.getByText('minimax-cn', { exact: true }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('已启用').waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('minimax-cn:')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
@@ -109,11 +110,42 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('confirms provider deletion before removing its settings profile', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
|
||||
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
||||
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
|
||||
const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
|
||||
await deleteDialog.waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(
|
||||
page,
|
||||
'[role="dialog"][aria-label="删除模型提供方?"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
|
||||
|
||||
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
|
||||
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await page.getByRole('dialog', { name: '删除模型提供方?' })
|
||||
.getByRole('button', { name: '删除提供方', exact: true }).click()
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).not.toContain('minimax-cn:')
|
||||
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
|
||||
.toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
|
||||
await expect.poll(
|
||||
async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,12 +9,18 @@ import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { 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')
|
||||
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
@@ -26,7 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
const browserConsole: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 } })
|
||||
tripwire = watchConsole(page)
|
||||
@@ -42,16 +48,61 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
|
||||
it('stores a key write-only and observes configured state without restarting', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
|
||||
const dialog = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
|
||||
await dialog.waitFor({ timeout: 15_000 })
|
||||
expect(await dialog.getByRole('textbox').count()).toBe(0)
|
||||
const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
||||
const welcomeAria = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
|
||||
expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel])
|
||||
expect(await welcome.locator('button').count()).toBe(1)
|
||||
|
||||
const mask = page.locator('[class*="onboardingMask"]')
|
||||
expect(await mask.count()).toBe(1)
|
||||
const maskStyles = await mask.evaluate((mask) => {
|
||||
const style = getComputedStyle(mask)
|
||||
const rect = mask.getBoundingClientRect()
|
||||
return {
|
||||
position: style.position,
|
||||
left: style.left,
|
||||
right: style.right,
|
||||
top: style.top,
|
||||
bottom: style.bottom,
|
||||
background: style.backgroundColor,
|
||||
backdropFilter: style.backdropFilter,
|
||||
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
||||
}
|
||||
})
|
||||
expect(maskStyles).toEqual({
|
||||
position: 'absolute',
|
||||
left: '0px',
|
||||
right: '0px',
|
||||
top: '80px',
|
||||
bottom: '0px',
|
||||
background: 'rgba(0, 0, 0, 0.24)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
rect: { left: 0, top: 80, right: 1440, bottom: 960 },
|
||||
})
|
||||
|
||||
// Closing the process/page before acknowledgement writes nothing, so the
|
||||
// same durable profile presents the notice again after reload.
|
||||
const firstReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
|
||||
await credentialStep.waitFor({ timeout: 15_000 })
|
||||
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
|
||||
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
|
||||
|
||||
await dialog.getByRole('button', { name: '前往配置' }).click()
|
||||
await dialog.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
await credentialStep.getByRole('button', { name: '前往配置' }).click()
|
||||
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
|
||||
const keyInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await keyInput.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -78,6 +129,29 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
{ timeout: 10_000 },
|
||||
).toBe('已配置——输入新值可替换')
|
||||
|
||||
const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
|
||||
|
||||
const secondReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
||||
expect(await page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
|
||||
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
||||
|
||||
// A different stored copy version represents an intentional version bump:
|
||||
// the welcome step returns even though the credential is already ready.
|
||||
await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
|
||||
}])
|
||||
const thirdReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
||||
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
@@ -86,6 +160,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -96,6 +96,26 @@ describe('web e2e: queue row actions', () => {
|
||||
{ timeout: 10_000 },
|
||||
).toBe(2)
|
||||
|
||||
await page.setViewportSize({ width: 640, height: 1000 })
|
||||
const queueBox = await page.locator('[data-queue-dock]').boundingBox()
|
||||
const composerBox = await page.locator('[data-composer-card]').boundingBox()
|
||||
expect(queueBox).not.toBeNull()
|
||||
expect(composerBox).not.toBeNull()
|
||||
expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x)
|
||||
expect(queueBox!.x + queueBox!.width)
|
||||
.toBeLessThanOrEqual(composerBox!.x + composerBox!.width)
|
||||
const queueLeftInset = queueBox!.x - composerBox!.x
|
||||
const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width
|
||||
const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')),
|
||||
}
|
||||
})
|
||||
expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1)
|
||||
expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1)
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
|
||||
const editRow = page.getByText(EDIT, { exact: true }).locator('..')
|
||||
await editRow.getByRole('button', { name: 'Edit queued message' }).click()
|
||||
const editor = page.getByRole('textbox', { name: 'Edit queued message' })
|
||||
|
||||
@@ -32,6 +32,10 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, {
|
||||
@@ -135,6 +139,8 @@ export interface LaunchOptions {
|
||||
* keyless first-run configuration lane; the default disables the adapter.
|
||||
*/
|
||||
deepSeekMissingCredential?: boolean
|
||||
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
|
||||
welcomeNoticePending?: boolean
|
||||
}
|
||||
|
||||
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
|
||||
@@ -266,6 +272,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, 'web e2e scaffold')
|
||||
if (options.welcomeNoticePending !== true) {
|
||||
await ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
|
||||
}])
|
||||
}
|
||||
const boundPort = ctx.get('httpServer')?.port
|
||||
if (boundPort === undefined) {
|
||||
throw new Error('web e2e scaffold: httpServer service missing after settled boot')
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
// where neither half repeats the other (the dispatched `/` and its
|
||||
// argument stay out of the title, and the settlement text never restates
|
||||
// the command's own name).
|
||||
await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click()
|
||||
await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
|
||||
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
|
||||
// Scoped to the row itself, so unrelated page text that happens to read
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
// section switching, both close paths), the Appearance preference row (the
|
||||
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
|
||||
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
|
||||
// and the Language row (settings-scoped localization + persisted dsh.locale).
|
||||
// and the Language row (settings-scoped localization + persisted dsh.locale),
|
||||
// plus Permission as the persisted default for subsequently created sessions.
|
||||
// Zero model calls: everything is pure client + persistence state on a blank
|
||||
// frame, so there is no fixture and a stray stream would fail loud on the
|
||||
// open llm seam.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
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 { join } from 'node:path'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
@@ -21,7 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import
|
||||
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
describe('web e2e: settings modal and General preferences', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -50,9 +53,9 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
// General is the active section by default; its skeleton rows plus the
|
||||
// functional Language and Appearance rows render.
|
||||
// General is active by default; Permission, Language and Appearance are functional.
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
|
||||
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
// Golden of the freshly opened dialog (default zh, General active).
|
||||
@@ -73,6 +76,55 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('stores Permission as the default for future sessions without changing an existing session', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
|
||||
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'danger-full-access' })
|
||||
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const selector = dialog.getByRole('button', { name: 'Full access' })
|
||||
await selector.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
|
||||
await selector.click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('permission:')
|
||||
expect(document).toContain('defaultPreset: read-only')
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'danger-full-access' })
|
||||
|
||||
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
|
||||
expect(created.events.map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'read-only' }],
|
||||
['sandbox/mode', { mode: 'read-only' }],
|
||||
['approval/policy', { policy: 'ask' }],
|
||||
])
|
||||
|
||||
await dialog.getByRole('button', { name: 'Read Only' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Full access' }).click()
|
||||
const confirmation = page.getByRole('dialog', { name: '确认启用 Full access?' })
|
||||
const enable = confirmation.getByRole('button', { name: '启用 Full access' })
|
||||
expect(await enable.isDisabled()).toBe(true)
|
||||
await confirmation.getByRole('checkbox').click()
|
||||
await enable.click()
|
||||
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
|
||||
const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
|
||||
const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
|
||||
expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['sandbox/mode', { mode: 'danger-full-access' }],
|
||||
['approval/policy', { policy: 'never' }],
|
||||
])
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('flips the theme through the Appearance cubes and persists across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
|
||||
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
|
||||
|
||||
@@ -272,6 +272,98 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a partial transport failure through the shipped Web composition', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-'))
|
||||
const promptMarker = 'WEB_RETRY_REQUEST'
|
||||
const recoveredMarker = 'WEB_RETRY_RECOVERED'
|
||||
let mainAttempts = 0
|
||||
const provider = createServer((request, response) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] }
|
||||
const titleRequest = parsed.max_tokens === 64
|
||||
const mainRequest = !titleRequest && body.includes(promptMarker)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
if (!mainRequest) {
|
||||
response.end([
|
||||
'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n\n'))
|
||||
return
|
||||
}
|
||||
mainAttempts++
|
||||
if (mainAttempts === 1) {
|
||||
response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
|
||||
setTimeout(() => { response.destroy() }, 20)
|
||||
return
|
||||
}
|
||||
response.end([
|
||||
`data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
|
||||
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n\n'))
|
||||
})
|
||||
})
|
||||
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
|
||||
const address = provider.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
|
||||
{
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-retry',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_HOME: join(workspace, '.dsh'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
try {
|
||||
const baseUrl = await waitForReadyLine(child)
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: promptMarker }],
|
||||
})
|
||||
let page: HistoryPage | undefined
|
||||
await expect.poll(async () => {
|
||||
page = await history(baseUrl, created.sessionId)
|
||||
return hasAssistantMarker(page, recoveredMarker)
|
||||
}, { timeout: 20_000 }).toBe(true)
|
||||
if (page === undefined) throw new Error('retry history was not observed')
|
||||
const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
|
||||
expect(mainAttempts).toBe(2)
|
||||
expect(retry?.data).toMatchObject({
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
failure: { code: 'TRANSPORT' },
|
||||
})
|
||||
expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
: Promise.resolve()
|
||||
if (child.exitCode === null) child.kill('SIGTERM')
|
||||
await closed
|
||||
await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-'))
|
||||
|
||||
10
apps/web/tests/snapshots/access-confirmation/ui.expected.md
Normal file
10
apps/web/tests/snapshots/access-confirmation/ui.expected.md
Normal file
@@ -0,0 +1,10 @@
|
||||
- dialog "确认启用 Full access?":
|
||||
- heading "确认启用 Full access?" [level=2]
|
||||
- button "Close":
|
||||
- img
|
||||
- img
|
||||
- paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
|
||||
- checkbox "我已了解风险,并愿意继续"
|
||||
- text: 我已了解风险,并愿意继续
|
||||
- button "取消"
|
||||
- button "启用 Full access" [disabled]
|
||||
@@ -37,7 +37,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Composer draft scrolling (14-line cap, two text layers)
|
||||
|
||||
## At the start of the draft
|
||||
|
||||
- draft overflows the capped box: true
|
||||
- visible lines: 14
|
||||
- both layers share one scroll extent: true
|
||||
- all three layers wrap at one width: true
|
||||
- textarea scroll offset: 0px
|
||||
- glyph layer tracks it: true
|
||||
- first draft line is on screen: true
|
||||
- last draft line is on screen: false
|
||||
|
||||
## Scrolled to the end of the draft
|
||||
|
||||
- textarea moved: true
|
||||
- glyph layer tracks it: true
|
||||
- first draft line has scrolled out above: true
|
||||
- last draft line is on screen: true
|
||||
|
||||
## Draft ending in a newline, scrolled to the end
|
||||
|
||||
- both layers share one scroll extent: true
|
||||
- glyph layer tracks the caret: true
|
||||
- last draft line is on screen: true
|
||||
@@ -52,7 +52,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- treeitem "New Session now" [selected]
|
||||
- treeitem "New Session" [selected]
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
@@ -28,7 +28,7 @@
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- treeitem "New Session now" [selected]
|
||||
- treeitem "New Session" [selected]
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
@@ -28,7 +28,7 @@
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Stop generating"
|
||||
@@ -11,6 +11,8 @@
|
||||
- img
|
||||
- button "Edit":
|
||||
- img
|
||||
- group:
|
||||
- status: Retried model request (1/2) · {{duration}}
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
@@ -24,7 +26,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn 已启用
|
||||
- text: minimax-cn
|
||||
- button "编辑"
|
||||
- button "删除"
|
||||
- button "+ 添加提供方"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
- dialog "删除模型提供方?":
|
||||
- heading "删除模型提供方?" [level=2]
|
||||
- button "关闭":
|
||||
- img
|
||||
- paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。
|
||||
- button "取消"
|
||||
- button "删除提供方"
|
||||
@@ -1,6 +1,5 @@
|
||||
- dialog "添加一个 API Key 开始使用":
|
||||
- region "添加一个 API Key 开始使用":
|
||||
- heading "添加一个 API Key 开始使用" [level=2]
|
||||
- button "稍后配置":
|
||||
- img
|
||||
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
|
||||
- button "稍后配置"
|
||||
- button "前往配置"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
- region "内测声明":
|
||||
- heading "内测声明" [level=2]
|
||||
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。
|
||||
- paragraph: 目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
|
||||
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
|
||||
- paragraph:
|
||||
- text: 我们尤其希望听见那些失败、困惑与不顺手的时刻——
|
||||
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
|
||||
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
|
||||
- button "继续"
|
||||
@@ -37,7 +37,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
- button "Edit":
|
||||
- img
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- button "2 queued messages"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- button "Edit":
|
||||
- img
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- button "2 queued messages" [disabled] [expanded]
|
||||
- list:
|
||||
- listitem:
|
||||
@@ -29,7 +30,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- button "Edit":
|
||||
- img
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Edited queue item
|
||||
@@ -22,7 +23,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- img
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- text: 权限 选择默认权限模式
|
||||
- button "Read only" [disabled]:
|
||||
- text: Read only
|
||||
- text: 权限 选择新会话的默认权限模式
|
||||
- button "Full access":
|
||||
- text: Full access
|
||||
- img
|
||||
- text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言
|
||||
- text: 语言
|
||||
- button "中文":
|
||||
- text: 中文
|
||||
- img
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
- img
|
||||
- img
|
||||
- text: Ask question waiting
|
||||
- status: Deep diving...
|
||||
- region "Ready to continue?":
|
||||
- text: Checkpoint
|
||||
- heading "Ready to continue?" [level=2]
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- 'button "Access mode, current: Full access"': Full access
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// Web e2e scenarios: workspace management — the create-by-name dialog, the
|
||||
// rename round trip over the real wire (workspace.rename RPC + durable
|
||||
// registry), duplicate-name pre-check, the flat "In one list" view with its
|
||||
// persisted group-by preference, and the session hover card. Zero model
|
||||
// calls: workspace.create/rename are host RPCs with no model involvement,
|
||||
// and the one session row the flat/hover scenarios need comes from a seeded
|
||||
// fixture (the seeded-history seed reused verbatim — no new recording).
|
||||
// persisted group-by preference, the session hover card, and the session
|
||||
// archive round trip (row menu → workspace.archiveSession RPC → durable
|
||||
// global set → row hidden across reload). Zero model calls:
|
||||
// workspace.create/rename/archiveSession are host RPCs with no model
|
||||
// involvement, and the one session row the flat/hover/archive scenarios need
|
||||
// comes from a seeded fixture (the seeded-history seed reused verbatim — no
|
||||
// new recording).
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -413,6 +416,55 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
|
||||
// The seeded session lives under Ungrouped (expanded by the hover-card
|
||||
// test's gesture; converge again for order independence).
|
||||
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
const ungroupedSection = ungroupedRow.locator('..')
|
||||
await expect.poll(async () => {
|
||||
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
|
||||
await page.getByText('Ungrouped', { exact: true }).click()
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
return await ungroupedRow.getAttribute('aria-expanded')
|
||||
}, { timeout: 5_000 }).toBe('true')
|
||||
// Anchor on session rows (the rows carrying a session actions button),
|
||||
// not a positional index, and assert the single-stray assumption loudly
|
||||
// so a fixture gaining a second stray fails here instead of archiving
|
||||
// the wrong row. CSS attribute match, not getByRole: the button is
|
||||
// display:none until its row hovers, and role queries skip hidden nodes.
|
||||
const sessionRows = ungroupedSection.locator('[role="treeitem"]')
|
||||
.filter({ has: page.locator('button[aria-label^="Session actions for "]') })
|
||||
await expect.poll(() => sessionRows.count(), { timeout: 10_000 }).toBe(1)
|
||||
const sessionRow = sessionRows.first()
|
||||
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
|
||||
// Row menu: hover reveals the actions button; Archive session commits
|
||||
// without a confirmation dialog (non-destructive: log + accounting stay).
|
||||
await sessionRow.hover()
|
||||
await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click()
|
||||
await page.getByRole('menuitem', { name: 'Archive session' }).click()
|
||||
// The row disappears on the archive-set echo; with no other visible
|
||||
// stray, the whole Ungrouped bucket withdraws.
|
||||
await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
|
||||
// Durable on the host: the registry-global set carries the id while the
|
||||
// session log itself stays in persistence untouched.
|
||||
expect([...scaffold.ctx.workspace.archivedSessionIds]).toEqual([SessionId(SEED_ID)])
|
||||
expect((await scaffold.ctx.sessionPersistence.list()).map(header => header.id)).toContain(SessionId(SEED_ID))
|
||||
// Reload: the hidden state is rebuilt from the workspace.list baseline.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
// The archived row must not resurface (the Ungrouped bucket itself may
|
||||
// reappear if selection restore lands on another stray — not this test's
|
||||
// concern).
|
||||
expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
// The directory-browser aria golden is this spec's one owned artifact;
|
||||
|
||||
@@ -40,10 +40,12 @@
|
||||
"tests/seeded-history.e2e.ts",
|
||||
"tests/sidebar-scrollbar.e2e.ts",
|
||||
"tests/code-mode-round.e2e.ts",
|
||||
"tests/composer-draft-scroll.e2e.ts",
|
||||
"tests/cordis-tool-round.e2e.ts",
|
||||
"tests/message-actions.e2e.ts",
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts"
|
||||
"tests/skill-invocation-policy.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user