Merge origin/master into worktree/web-plugin-config
Three seams: the tsconfig path map gained a mapping on each side and keeps both; the event-producer matrix is generated, so it was regenerated rather than hand-merged row by row.
This commit is contained in:
@@ -44,6 +44,7 @@
|
||||
"playwright": "^1.49.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.8",
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="50.000000" height="50.000000" viewBox="0 0 50 50" fill="none">
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #fff; }
|
||||
}
|
||||
</style>
|
||||
<path id="path" d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#000" fill-opacity="1.000000" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -196,18 +196,18 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
const dialog = settingsDialog()
|
||||
await dialog.getByRole('button', { name: '通用设置' }).click()
|
||||
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 })
|
||||
await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 })
|
||||
|
||||
const snapshot = withPresetRoot(
|
||||
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
|
||||
await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE)
|
||||
// Both damage shapes surface as marked, unselectable, uncopyable cards
|
||||
// that still carry their metadata and the discovery-reported reason.
|
||||
expect(snapshot).toContain('已损坏: broken-yaml')
|
||||
expect(snapshot).toContain('已损坏: 幽灵预设')
|
||||
expect(snapshot).toContain('加载失败: broken-yaml')
|
||||
expect(snapshot).toContain('加载失败: 幽灵预设')
|
||||
expect(snapshot).toContain('not valid YAML')
|
||||
expect(snapshot).toContain('agent.cordis.yml is missing')
|
||||
expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
|
||||
// A broken card offers no "set default" affordance at all — the aria name
|
||||
// IS the broken marking, so the picking name must not exist.
|
||||
|
||||
133
apps/web/tests/background-task-list.e2e.ts
Normal file
133
apps/web/tests/background-task-list.e2e.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// Web e2e scenario: the session-header background-task list over the real
|
||||
// host. No model call is involved — a genuine `run_in_background` bash call
|
||||
// registers with `ctx.tasks`, and the assertion chain is the whole delivery
|
||||
// path: registry change feed → api-proxy `session/tasks` frame → the client's
|
||||
// `tasksBySession` mirror → the header action.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
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 type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/background-task-list', import.meta.url))
|
||||
const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
|
||||
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'background-task-list-web-e2e'
|
||||
// Long enough that the running assertions never race the process exiting on
|
||||
// their own; the test kills it explicitly to reach the settled state.
|
||||
const COMMAND = 'sleep 45'
|
||||
|
||||
/**
|
||||
* Wait for the Host to publish the live Agent that opening a session resumes.
|
||||
* @param scaffold - the booted web scaffold.
|
||||
* @param sessionId - the opened session's identity.
|
||||
* @returns the registered Agent instance.
|
||||
*/
|
||||
async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise<Agent> {
|
||||
const deadline = Date.now() + 30_000
|
||||
for (;;) {
|
||||
const found = scaffold.ctx.agents.get(sessionId)
|
||||
if (found !== undefined) return found
|
||||
if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: background task list', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let agent: Agent
|
||||
let taskId: TaskId
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)
|
||||
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 })
|
||||
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
|
||||
// Opening the session drives the Host's ordinary Agent resolution; the
|
||||
// task owner must be that exact live instance, never a second one.
|
||||
// `expect.poll` is test-scoped, so this hook polls by hand.
|
||||
agent = await liveAgent(scaffold, SessionId(SEED_ID))
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('shows a running background task in the session header without a refresh', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-running'))
|
||||
// Point assertion, not a poll: `expect.poll` retries until a predicate
|
||||
// holds, so polling for zero passes at t=0 and proves nothing. The
|
||||
// "renders nothing without a task" branch is owned by the component suite.
|
||||
const trigger = page.getByRole('button', { name: '1 background task running' })
|
||||
expect(await trigger.count()).toBe(0)
|
||||
|
||||
const started = await scaffold.ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: CallId('background-task-list-e2e'),
|
||||
name: 'bash',
|
||||
arguments: { command: COMMAND, description: 'Hold a background slot open', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
|
||||
const matched = /\bbash-\d+\b/.exec(reported)
|
||||
if (matched === null) throw new Error(`background bash reported no task id: ${reported}`)
|
||||
taskId = TaskId(matched[0])
|
||||
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
const row = page.getByRole('list', { name: 'Background tasks' }).getByRole('listitem').first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => row.textContent()).toContain(COMMAND)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('flips the open list to the cancelled outcome when the registry settles it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-settled'))
|
||||
expect(scaffold.ctx.tasks.kill(taskId, agent, 'web e2e cancellation')).toBe('requested')
|
||||
|
||||
// The trigger drops its live count once the task leaves running/stopping,
|
||||
// which is also the proof that settlement reached the browser unprompted.
|
||||
const idle = page.getByRole('button', { name: '1 background task' })
|
||||
await idle.waitFor({ timeout: 20_000 })
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md'])
|
||||
})
|
||||
})
|
||||
123
apps/web/tests/goal-command-presentation.e2e.ts
Normal file
123
apps/web/tests/goal-command-presentation.e2e.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Web e2e: /goal opts its command input into the human transcript while the
|
||||
// command remains log-only. The shipped composition runs with no model adapter,
|
||||
// so an accidental turn fails loud in addition to the event-level assertions.
|
||||
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 { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria,
|
||||
compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-command-presentation', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL(
|
||||
'./snapshots/goal-command-presentation/ui.expected.md', import.meta.url,
|
||||
))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: /goal human transcript presentation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const events: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
||||
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 bare input and result from a fresh session without a model turn', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation'))
|
||||
await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), {
|
||||
timeout: 15_000,
|
||||
}).toBe(1)
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/goal')
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => input.inputValue()).toBe('/goal ')
|
||||
await input.press('Enter')
|
||||
|
||||
const commandInput = page.locator('[data-command-input]')
|
||||
await commandInput.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => commandInput.textContent()).toBe('/goal')
|
||||
expect(await commandInput.getAttribute('role')).toBe('group')
|
||||
expect(await commandInput.getAttribute('aria-label')).toBe('Command input')
|
||||
expect(await commandInput.getByRole('button').count()).toBe(0)
|
||||
const typography = await commandInput.evaluate((element) => {
|
||||
const bubble = element.firstElementChild?.firstElementChild
|
||||
if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing')
|
||||
const rootStyle = getComputedStyle(element)
|
||||
const bubbleStyle = getComputedStyle(bubble)
|
||||
return {
|
||||
fontFamily: bubbleStyle.fontFamily,
|
||||
parentFontFamily: rootStyle.fontFamily,
|
||||
fontSize: bubbleStyle.fontSize,
|
||||
lineHeight: bubbleStyle.lineHeight,
|
||||
}
|
||||
})
|
||||
expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' })
|
||||
expect(typography.fontFamily).not.toBe(typography.parentFontFamily)
|
||||
const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
|
||||
await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1)
|
||||
await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
|
||||
expect(await page.getByText('Into the Unknown', { exact: false }).count()).toBe(0)
|
||||
|
||||
const run = events.find(event => event.type === 'command/run')
|
||||
expect(run).toMatchObject({
|
||||
type: 'command/run',
|
||||
data: { name: 'goal', args: ' ', source: { kind: 'user' } },
|
||||
})
|
||||
expect(events.some(event => event.type === 'command/done')).toBe(true)
|
||||
expect(events.some(event => event.type === 'user/message')).toBe(false)
|
||||
expect(events.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(events.some(event => event.type === 'step/start')).toBe(false)
|
||||
expect(events.some(event => event.type === 'request/header')).toBe(false)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('reloads the same bubble and result from the persisted command lifecycle', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation-reload'))
|
||||
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.locator('[data-command-input]').textContent(), { timeout: 15_000 }).toBe('/goal')
|
||||
const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' })
|
||||
await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1)
|
||||
|
||||
const sessions = scaffold.ctx.sessions.list()
|
||||
expect(sessions).toHaveLength(1)
|
||||
const persisted = sessions[0]?.events ?? []
|
||||
expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done')
|
||||
.map(event => event.type)).toEqual(['command/run', 'command/done'])
|
||||
expect(persisted.some(event => event.type === 'user/message')).toBe(false)
|
||||
expect(persisted.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(persisted.some(event => event.type === 'step/start')).toBe(false)
|
||||
expect(persisted.some(event => event.type === 'request/header')).toBe(false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -119,6 +119,10 @@ describe('web e2e: settled Markdown math rendering', () => {
|
||||
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
|
||||
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
|
||||
expect(await page.locator('.katex-error').count()).toBe(0)
|
||||
await expect.poll(
|
||||
() => page.getByText('Input 0 tok · Output 0 tok', { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
|
||||
115
apps/web/tests/message-feedback-protocol.snapshot.ts
Normal file
115
apps/web/tests/message-feedback-protocol.snapshot.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url))
|
||||
const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json')
|
||||
const SESSION_ID = 'message-feedback-protocol'
|
||||
const MESSAGE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
interface ProtocolExchange {
|
||||
readonly endpoint: string
|
||||
readonly request: unknown
|
||||
readonly status: number
|
||||
readonly response: unknown
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */
|
||||
function createdVersion(response: unknown): string {
|
||||
if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true
|
||||
|| !isRecord(response.result.value) || response.result.value.ok !== true
|
||||
|| !isRecord(response.result.value.value)
|
||||
|| typeof response.result.value.value.version !== 'string') {
|
||||
throw new Error('messageFeedback.put did not return a successful versioned item')
|
||||
}
|
||||
return response.result.value.value.version
|
||||
}
|
||||
|
||||
/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */
|
||||
function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string {
|
||||
return JSON.stringify(exchanges, (key, value: unknown) => {
|
||||
if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}'
|
||||
if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}'
|
||||
return value
|
||||
}, 2)
|
||||
}
|
||||
|
||||
describe('message feedback Host Remote protocol', () => {
|
||||
let scaffold: WebScaffold
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => {
|
||||
const exchanges: ProtocolExchange[] = []
|
||||
const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise<unknown> => {
|
||||
const payload = { args: { request } }
|
||||
const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId,
|
||||
method: endpoint,
|
||||
payload,
|
||||
}),
|
||||
})
|
||||
const body: unknown = await response.json()
|
||||
exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body })
|
||||
return body
|
||||
}
|
||||
|
||||
await invoke('feedback-invalid', 'messageFeedback/put', {
|
||||
sessionId: SESSION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
rating: 'invalid-rating',
|
||||
ifVersion: null,
|
||||
})
|
||||
await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID })
|
||||
const created = await invoke('feedback-put', 'messageFeedback/put', {
|
||||
sessionId: SESSION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
rating: 'positive',
|
||||
note: 'Useful answer',
|
||||
ifVersion: null,
|
||||
})
|
||||
const version = createdVersion(created)
|
||||
expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
||||
await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID })
|
||||
await invoke('feedback-conflict', 'messageFeedback/put', {
|
||||
sessionId: SESSION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
rating: 'negative',
|
||||
ifVersion: null,
|
||||
})
|
||||
await invoke('feedback-delete', 'messageFeedback/delete', {
|
||||
sessionId: SESSION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
ifVersion: version,
|
||||
})
|
||||
await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID })
|
||||
|
||||
expect(exchanges.every(exchange => exchange.status === 200)).toBe(true)
|
||||
await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode)
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl'])
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,9 @@
|
||||
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
|
||||
// while the settings document records only that reference. Each saved row
|
||||
// appears after 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
|
||||
// provider status. The customized-settings fold writes its curated fields —
|
||||
// the endpoint, and a declared route's own name and protocol — as merge
|
||||
// patches against the stored profile. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud because the adapter registry is empty. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
@@ -28,6 +29,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
|
||||
const DECLARED_EXPECTED = join(SNAPSHOT_DIR, 'declared.expected.md')
|
||||
const DECLARED_EDIT_EXPECTED = join(SNAPSHOT_DIR, 'declared-edit.expected.md')
|
||||
const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md')
|
||||
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -209,6 +211,40 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('reopens the name and protocol a declared route was created with', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declared-identity'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '编辑 Acme Gateway (acme-gateway)' }).click()
|
||||
await dialog.getByText('自定义设置').click()
|
||||
// The create card asked this route for a name and a protocol because
|
||||
// nothing can default them; the editor reaches the same two fields rather
|
||||
// than sending the user to settings.yaml for what only this route names.
|
||||
const protocol = dialog.getByLabel('API 协议')
|
||||
await protocol.waitFor({ timeout: 10_000 })
|
||||
expect(await protocol.inputValue()).toBe('openai-completions')
|
||||
const name = dialog.getByLabel('显示名称', { exact: true })
|
||||
expect(await name.inputValue()).toBe('Acme Gateway')
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(DECLARED_EDIT_EXPECTED, snapshot, MODE)
|
||||
|
||||
await protocol.selectOption('anthropic-messages')
|
||||
await name.fill('Acme 网关')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await expect.poll(async () => dialog.getByLabel('API 协议').count(), { timeout: 10_000 }).toBe(0)
|
||||
// The adapter re-resolved the route under the new protocol and re-registered
|
||||
// it under the new name: an unserviceable profile would have been refused
|
||||
// at the write instead, and a rename that did not re-register would leave
|
||||
// the old label on the row.
|
||||
await dialog.getByText('Acme 网关', { exact: true }).first().waitFor({ timeout: 10_000 })
|
||||
// The status line names the route as the refreshed directory reports it;
|
||||
// the target captured when the card opened still carries the old name.
|
||||
await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('api: anthropic-messages')
|
||||
expect(document).toContain('displayName: Acme 网关')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('confirms an identified provider deletion before removing its profile and key', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
|
||||
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
||||
@@ -243,8 +279,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'configured.expected.md', 'declared.expected.md', 'delete.expected.md',
|
||||
'empty.expected.md', 'native-delete.expected.md',
|
||||
'configured.expected.md', 'declared-edit.expected.md', 'declared.expected.md',
|
||||
'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page, Response } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { strFromU8, unzipSync } from 'fflate'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -274,6 +275,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await details.getByRole('button', { name: 'Close details' }).click()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('downloads the session-log ZIP from the trajectory toolbar', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
|
||||
await ensureSeedOpen(page)
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 })
|
||||
await page.getByRole('button', { name: 'Export session log' }).click()
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/)
|
||||
// The real host streamed the ZIP; its root entry is the persisted log
|
||||
// text verbatim (the assembled seam: real route, real persistence read).
|
||||
const files = unzipSync(await readFile(await download.path()))
|
||||
expect(Object.keys(files)).toEqual(['session.jsonl'])
|
||||
const content = strFromU8(files['session.jsonl'] as Uint8Array)
|
||||
expect(content.split('\n')[0]).toContain(SEED_ID)
|
||||
expect(content).toContain('FIRST_DONE')
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
||||
await ensureSeedOpen(page)
|
||||
|
||||
@@ -25,3 +25,11 @@ it('ships install metadata with the built web application', async () => {
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('ships a favicon that switches to a light mark under dark color scheme', async () => {
|
||||
const favicon = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8')
|
||||
// The light fill must live inside the dark-scheme media query, so the icon
|
||||
// stays black in light mode and only turns white under a dark scheme.
|
||||
expect(favicon).toMatch(/@media \(prefers-color-scheme: dark\)\s*{\s*path\s*{[^}]*fill:\s*#fff/i)
|
||||
expect(favicon).toContain('fill="#000"')
|
||||
})
|
||||
|
||||
@@ -18,7 +18,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import { join } from 'node:path'
|
||||
@@ -195,21 +194,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
if (MODE !== 'record') {
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
|
||||
// The meter belongs to an agent's preset, not to the process — token
|
||||
// accounting is per session. It is used here as a pure pricing function
|
||||
// over fixture content, so a throwaway composition is enough to reach one.
|
||||
const priced = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('seeded-history-pricing'),
|
||||
setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined),
|
||||
})
|
||||
let realizedWithCompaction: string
|
||||
try {
|
||||
const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter')
|
||||
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
|
||||
realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
|
||||
} finally {
|
||||
await priced.dispose()
|
||||
}
|
||||
// The meter is host-plane — it takes no configuration and keys every
|
||||
// fold by Session — so pricing fixture content needs no agent at all.
|
||||
const meter = scaffold.ctx.get('tokenMeter')
|
||||
if (meter === undefined) throw new Error('seeded-history requires the host token meter')
|
||||
const realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
|
||||
await seedSession(scaffold, realizedWithCompaction, SEED_ID)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
|
||||
@@ -152,6 +152,67 @@ describe('web e2e: settings modal and General preferences', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('uses the persisted dark preference while plugins are still loading', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-boot-theme'))
|
||||
await page.emulateMedia({ colorScheme: 'light' })
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const initialDialog = page.getByRole('dialog', { name: '设置' })
|
||||
const darkCube = initialDialog.getByRole('button', { name: '深色' })
|
||||
await darkCube.click()
|
||||
await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
|
||||
.toMatch(/ui-theme:\n\s+preference: dark/)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Hold real plugin bundles so the shell-owned loading page remains observable.
|
||||
const pluginPattern = '**/plugins/**'
|
||||
let releaseBundles = (): void => {}
|
||||
const bundlesReleased = new Promise<void>((resolve) => { releaseBundles = resolve })
|
||||
await page.route(pluginPattern, async (route) => {
|
||||
await bundlesReleased
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
let reload: ReturnType<Page['reload']> | undefined
|
||||
try {
|
||||
reload = page.reload({ waitUntil: 'domcontentloaded' })
|
||||
const loading = page.getByText('Loading plugins…', { exact: true })
|
||||
await loading.waitFor({ timeout: 10_000 })
|
||||
const state = await loading.evaluate((element) => {
|
||||
const boot = element.parentElement?.parentElement
|
||||
if (boot === undefined || boot === null) throw new Error('loading hint is detached from the boot page')
|
||||
return {
|
||||
attr: document.body.hasAttribute('data-ds-dark-theme'),
|
||||
background: getComputedStyle(boot).backgroundColor,
|
||||
colorScheme: document.documentElement.style.colorScheme,
|
||||
}
|
||||
})
|
||||
expect(state).toEqual({
|
||||
attr: true,
|
||||
background: 'rgb(21, 21, 23)',
|
||||
colorScheme: 'dark',
|
||||
})
|
||||
} finally {
|
||||
releaseBundles()
|
||||
await reload
|
||||
await page.unroute(pluginPattern)
|
||||
}
|
||||
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const restoredDialog = page.getByRole('dialog', { name: '设置' })
|
||||
const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' })
|
||||
await systemCube.click()
|
||||
await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), {
|
||||
timeout: 5_000,
|
||||
}).toBe(false)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
|
||||
interface ThemeState {
|
||||
|
||||
@@ -36,6 +36,7 @@ const EXPECTED_TOOLS = [
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
'read_image',
|
||||
'send_message',
|
||||
'skill',
|
||||
'subagent',
|
||||
@@ -144,7 +145,7 @@ it('lets a preset producer reach the background-task registry', async () => {
|
||||
content: [{ type: 'text', text: 'started background task bash-1' }],
|
||||
})
|
||||
|
||||
// The control surface reads what the producer started: same registry, one
|
||||
// The controller reads what the producer started: same registry, one
|
||||
// owner. A per-preset registry would list nothing here even on success.
|
||||
const listed = await ctx.tools.execute({
|
||||
signal,
|
||||
|
||||
@@ -478,7 +478,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
requireDist()
|
||||
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
|
||||
const port = await probeFreePort()
|
||||
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
|
||||
// tsx boot mirrors the runtime half of the root dsh script. Isolate
|
||||
// the host-level Harness and shared-agent homes inside the temp world; tsx
|
||||
// also needs the repo's loader and tsconfig paths pointed at explicitly.
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
|
||||
@@ -64,8 +64,8 @@
|
||||
- heading "自定义" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "已损坏: broken-yaml" [disabled]':
|
||||
- text: broken-yaml 已损坏 自定义 暂无描述。
|
||||
- 'button "加载失败: broken-yaml" [disabled]':
|
||||
- text: broken-yaml 加载失败 自定义 暂无描述。
|
||||
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
|
||||
- code: broken-yaml
|
||||
- 'button "查看路径: broken-yaml"':
|
||||
@@ -73,13 +73,13 @@
|
||||
- text: 查看路径
|
||||
- 'button "复制: broken-yaml" [disabled]':
|
||||
- img
|
||||
- text: 预设已损坏,无法复制
|
||||
- text: 预设加载失败,不能复制
|
||||
- 'button "删除: broken-yaml"':
|
||||
- img
|
||||
- text: 删除
|
||||
- listitem:
|
||||
- 'button "已损坏: 幽灵预设" [disabled]':
|
||||
- text: 幽灵预设 已损坏 自定义 composition 已被手动删除。
|
||||
- 'button "加载失败: 幽灵预设" [disabled]':
|
||||
- text: 幽灵预设 加载失败 自定义 composition 已被手动删除。
|
||||
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
|
||||
- code: ghost
|
||||
- 'button "查看路径: 幽灵预设"':
|
||||
@@ -87,7 +87,7 @@
|
||||
- text: 查看路径
|
||||
- 'button "复制: 幽灵预设" [disabled]':
|
||||
- img
|
||||
- text: 预设已损坏,无法复制
|
||||
- text: 预设加载失败,不能复制
|
||||
- 'button "删除: 幽灵预设"':
|
||||
- img
|
||||
- text: 删除
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- list "Background tasks":
|
||||
- listitem: bash sleep 45 running {{duration}}
|
||||
@@ -0,0 +1,2 @@
|
||||
- list "Background tasks":
|
||||
- listitem: "bash sleep 45 signal: SIGTERM {{duration}}"
|
||||
@@ -0,0 +1,21 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- group "Command input": /goal
|
||||
- 'button "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"':
|
||||
- img
|
||||
- img
|
||||
- text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
@@ -6,6 +6,7 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的
|
||||
- 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
[
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/put",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol",
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "invalid-rating",
|
||||
"ifVersion": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-invalid",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "internal",
|
||||
"message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/list",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-list-empty",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"items": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/put",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol",
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "positive",
|
||||
"note": "Useful answer",
|
||||
"ifVersion": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-put",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "positive",
|
||||
"note": "Useful answer",
|
||||
"version": "{{version}}",
|
||||
"createdAt": "{{timestamp}}",
|
||||
"updatedAt": "{{timestamp}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/list",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-list-created",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"items": [
|
||||
{
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "positive",
|
||||
"note": "Useful answer",
|
||||
"version": "{{version}}",
|
||||
"createdAt": "{{timestamp}}",
|
||||
"updatedAt": "{{timestamp}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/put",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol",
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "negative",
|
||||
"ifVersion": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-conflict",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "version-conflict",
|
||||
"current": {
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "positive",
|
||||
"note": "Useful answer",
|
||||
"version": "{{version}}",
|
||||
"createdAt": "{{timestamp}}",
|
||||
"updatedAt": "{{timestamp}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/delete",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol",
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"ifVersion": "{{version}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-delete",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"absent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/list",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-list-deleted",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"items": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"}
|
||||
{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}}
|
||||
{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}}
|
||||
{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,65 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "Agent 预设":
|
||||
- img
|
||||
- text: Agent 预设
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
- img "API 密钥已配置"
|
||||
- button "编辑 minimax-cn": 编辑
|
||||
- button "删除 minimax-cn": 删除
|
||||
- listitem:
|
||||
- text: Acme Gateway 自定义
|
||||
- button "编辑 Acme Gateway (acme-gateway)": 编辑
|
||||
- button "删除 Acme Gateway (acme-gateway)": 删除
|
||||
- text: Acme Gateway acme-gateway API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥,或留空使用环境认证
|
||||
- group:
|
||||
- text: 自定义设置 显示名称
|
||||
- textbox "显示名称":
|
||||
- /placeholder: acme-gateway
|
||||
- text: Acme Gateway
|
||||
- text: API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: https://gateway.acme.example/v1
|
||||
- text: https://gateway.acme.example/v1
|
||||
- text: API 协议
|
||||
- combobox "API 协议":
|
||||
- option "openai-completions" [selected]
|
||||
- option "openai-responses"
|
||||
- option "anthropic-messages"
|
||||
- region "模型目录":
|
||||
- text: 模型目录 已自定义模型目录
|
||||
- button "恢复默认模型"
|
||||
- button "获取可用模型"
|
||||
- textbox "模型 ID 1":
|
||||
- /placeholder: 模型 ID
|
||||
- text: acme-large
|
||||
- textbox "显示名称 1":
|
||||
- /placeholder: 显示名称
|
||||
- button "容量 1"
|
||||
- button "删除模型 1"
|
||||
- button "添加模型"
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
- button "添加自定义提供方":
|
||||
- img
|
||||
- text: 添加自定义提供方
|
||||
@@ -2,6 +2,7 @@
|
||||
- button "Use actual duration": Duration
|
||||
- button "Collapse turns": Turns
|
||||
- button "Collapse calls": Calls
|
||||
- button "Export session log": Export
|
||||
- img
|
||||
- searchbox "Search trajectory"
|
||||
- region "Trajectory timeline":
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- group "Command input": /goal Keep the composer context panels aligned
|
||||
- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
|
||||
- img
|
||||
- img
|
||||
|
||||
@@ -57,9 +57,9 @@ export function probeFreePort(): Promise<number> {
|
||||
/**
|
||||
* Drive the hero's workspace picker through the composed directory dialog
|
||||
* until the live composer unlocks. A fresh world has no Workspace, so the boot
|
||||
* lands in the locked view state (startup auto-selection has nothing to
|
||||
* lands in the Workspace-trigger view state (startup auto-selection has nothing to
|
||||
* select); every scenario that types into the composer must connect one
|
||||
* first. With nothing to list, the chip gesture raises the dialog directly —
|
||||
* first. With nothing to list, activating the textarea raises the dialog directly —
|
||||
* adding a workspace is the picker's only entry. The directory is staged here
|
||||
* and adopted through the path editor, which is idempotent across the repeated
|
||||
* connects a scenario may make; creating a folder from inside the dialog (the
|
||||
@@ -73,7 +73,7 @@ export function probeFreePort(): Promise<number> {
|
||||
*/
|
||||
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
|
||||
mkdirSync(join(root, name), { recursive: true })
|
||||
await page.getByRole('button', { name: 'Choose workspace' }).click()
|
||||
await page.getByRole('textbox', { name: 'Choose workspace' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Edit path' }).click()
|
||||
@@ -97,7 +97,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
|
||||
*/
|
||||
export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
|
||||
mkdirSync(join(root, name), { recursive: true })
|
||||
await page.getByRole('button', { name: '选择工作区' }).click()
|
||||
await page.getByRole('textbox', { name: '选择工作区' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '编辑路径' }).click()
|
||||
|
||||
@@ -99,6 +99,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal and click a row action, re-hovering if a projection update replaces
|
||||
* the row before its hover-only button becomes visible.
|
||||
*/
|
||||
async function clickHoverAction(row: Locator, name: string): Promise<void> {
|
||||
const button = row.getByRole('button', { name })
|
||||
await expect.poll(async () => {
|
||||
await row.hover()
|
||||
return await button.isVisible()
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
await button.click()
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// Seed one cold session (Ungrouped bucket) for the flat view + hover card.
|
||||
@@ -137,10 +150,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
|
||||
it('renames a workspace over the wire with a duplicate-name pre-check', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-rename'))
|
||||
// The actions button is display:none until its row hovers — hover the
|
||||
// group row first, then the revealed button becomes actionable.
|
||||
await page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first().hover()
|
||||
await page.getByRole('button', { name: 'Workspace actions for alpha-ws' }).click()
|
||||
const alphaRow = page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first()
|
||||
await clickHoverAction(alphaRow, 'Workspace actions for alpha-ws')
|
||||
await page.getByRole('menuitem', { name: 'Rename' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Rename workspace' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
@@ -214,17 +225,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
// The header row is wrapped by its HoverCard anchor span, so the section
|
||||
// is the nearest groupSection ancestor, not the immediate parent.
|
||||
const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
|
||||
if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click()
|
||||
await expect.poll(
|
||||
() => groupSection.locator('[role="treeitem"]').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
await expect.poll(async () => {
|
||||
const count = await groupSection.locator('[role="treeitem"]').count()
|
||||
if (count < 2 && await groupRow.getAttribute('aria-expanded') !== 'true') {
|
||||
await groupRow.click()
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
return await groupSection.locator('[role="treeitem"]').count()
|
||||
}, { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
const seededRow = groupSection.locator('[role="treeitem"]').nth(1)
|
||||
await seededRow.click()
|
||||
await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
|
||||
|
||||
await groupRow.hover()
|
||||
await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click()
|
||||
await clickHoverAction(groupRow, `Workspace actions for ${workspace.title}`)
|
||||
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete workspace' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
@@ -338,8 +351,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered')
|
||||
|
||||
const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first()
|
||||
await oldRow.hover()
|
||||
await page.getByRole('button', { name: `Workspace actions for ${title}` }).click()
|
||||
await clickHoverAction(oldRow, `Workspace actions for ${title}`)
|
||||
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
|
||||
await page.getByRole('dialog', { name: 'Delete workspace' })
|
||||
.getByRole('button', { name: 'Delete workspace' }).click()
|
||||
@@ -509,9 +521,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
|
||||
const sessionRow = await seededSessionRow()
|
||||
// The trigger is display:none until its row hovers.
|
||||
await sessionRow.hover()
|
||||
const trigger = sessionRow.locator('button[aria-label^="Session actions for "]')
|
||||
await trigger.click()
|
||||
const triggerName = await trigger.getAttribute('aria-label')
|
||||
if (triggerName === null) throw new Error('seeded Session row has no actions label')
|
||||
await clickHoverAction(sessionRow, triggerName)
|
||||
const item = page.getByRole('menuitem', { name: 'Rename' })
|
||||
await item.waitFor({ timeout: 5_000 })
|
||||
// Into the list, then back up to the trigger across the 4px gap below it:
|
||||
@@ -559,8 +572,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
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 clickHoverAction(sessionRow, `Session actions for ${rowTitle}`)
|
||||
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.
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"tests/scaffold.ts",
|
||||
"tests/scaffold-hermetic.e2e.ts",
|
||||
"tests/minimal-preset.snapshot.ts",
|
||||
"tests/message-feedback-protocol.snapshot.ts",
|
||||
"tests/live-interactions.e2e.ts",
|
||||
"tests/question-composer.e2e.ts",
|
||||
"tests/approval-composer.e2e.ts",
|
||||
@@ -70,10 +71,12 @@
|
||||
"tests/produced-files.e2e.ts",
|
||||
"tests/produced-file-mentions.e2e.ts",
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/goal-command-presentation.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts",
|
||||
"tests/subagent-interrupt.e2e.ts",
|
||||
"tests/subagent-interrupt-ui.e2e.ts",
|
||||
"tests/sidebar-subagent-activity.e2e.ts",
|
||||
"tests/background-task-list.e2e.ts",
|
||||
"tests/bash-abort-row.e2e.ts",
|
||||
"tests/skill-tool-row.e2e.ts",
|
||||
"tests/turn-tail-actions.e2e.ts",
|
||||
|
||||
@@ -5,8 +5,8 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
|
||||
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
|
||||
+ 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). '
|
||||
+ 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.'
|
||||
+ 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
|
||||
+ 'For client-plugin HMR, run `pnpm dsh web --dev` together with `pnpm run dev:web`.'
|
||||
|
||||
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
|
||||
function rejectStandaloneServe(): Plugin {
|
||||
@@ -130,7 +130,7 @@ export default defineConfig({
|
||||
// Workspace packages resolve to SOURCE: package.json exports point at lib
|
||||
// for Node/type consumers, but the browser bundle must compile src directly
|
||||
// so CSS rides vite's pipeline instead of the CSS-externalized lib bundle.
|
||||
// Only the shell's normal-package surface is aliased — plugin packages are
|
||||
// Only the shell's normal package entry is aliased — plugin packages are
|
||||
// NEVER bundled here (shell self-sufficiency — see
|
||||
// packages/client/web/README.md); they arrive as runtime
|
||||
// bundles through the client module system. Order matters — subpath
|
||||
|
||||
Reference in New Issue
Block a user