feat(pwsh): render pwsh calls as bash-shaped terminal cards in the Web UI
A completed pwsh foreground call now presents as the bash tool's terminal card with the parsed exit-status pill instead of a generic console fence, and the collapsed row classifies as the shell family (Pwsh title). The marker-to-exit-status parse moves from dsh-tool-bash's private render module into the @deepseek-ai/dsh-bash seam so both shell tools share one inverse of the marker contract (the bash-env precedent). The Web UI needs no per-tool code: the terminal-card bridge maps any card:'terminal' view. Coverage: dsh-bash owns the parse edge cases; the tool-pwsh presenter suite mirrors tool-bash's; the client row-model suite pins the Pwsh shell row; the new keyless pwsh-terminal web lane seeds an authored session, presents it through the real tool on replay, and pins the card golden. TUI is out of scope: the TUI package was removed (ed30088adb), so the Web surface is the only UI the gap affected; the roadmap proposal's stage 2 is updated accordingly.
This commit is contained in:
99
apps/web/tests/pwsh-terminal.e2e.ts
Normal file
99
apps/web/tests/pwsh-terminal.e2e.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// Keyless browser regression for pwsh UI parity with bash: a seeded session
|
||||
// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the
|
||||
// api-proxy recomputes presentation views from logged args/result content)
|
||||
// must render as a bash-shaped terminal card with the parsed exit-status
|
||||
// pill — not the generic console-fenced card the pwsh presenter used to
|
||||
// emit. The seed is authored, not recorded: its header line carries no `cwd`
|
||||
// field (seedSession writes the session cwd itself, and a Windows temp path
|
||||
// substituted into the header would not round-trip through its JSON parse),
|
||||
// and no event references the workspace, so the lane replays on any host
|
||||
// with a usable `pwsh` — the lane mounts the pwsh stack through an overlay
|
||||
// (the shipped tree keeps the bash stack).
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
fixtureUserPrompts, launchWebScaffold, seedSession, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/pwsh-terminal', import.meta.url))
|
||||
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
|
||||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const OVERLAY = fileURLToPath(new URL('./pwsh-terminal.overlay.yml', import.meta.url))
|
||||
const PROMPT = 'Run a PowerShell command that fails, then stop.'
|
||||
const SEED_ID = 'pwsh-terminal-web-e2e'
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The overlay swaps the shipped bash executor for @deepseek-ai/dsh-pwsh-local;
|
||||
// a host without a usable `pwsh` cannot boot it, so the lane self-skips,
|
||||
// mirroring the pwshOnly ACP scenarios.
|
||||
const HAS_PWSH = spawnSync(
|
||||
'pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'exit 0'],
|
||||
{ stdio: 'ignore' },
|
||||
).status === 0
|
||||
|
||||
describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as bash-shaped terminal cards', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture), 'seed fixture must carry the single drive prompt').toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders the seeded pwsh call as a terminal card with the parsed exit pill', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal'))
|
||||
// Open the seeded session through content search: the sidebar groups
|
||||
// sessions by workspace and its row order is world-dependent, while the
|
||||
// search index covers the seeded log deterministically.
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
await search.fill('Run a PowerShell command')
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
|
||||
await result.click()
|
||||
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 15_000 })
|
||||
// The tool row is expand-gated: the settled bash-shaped row carries the
|
||||
// shell-family variant, and the terminal card lives in the expanded body.
|
||||
const row = page.locator('[data-tool="pwsh"]').first()
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
if (await row.getAttribute('aria-expanded') !== 'true') await row.click()
|
||||
const card = page.locator('[data-terminal]').first()
|
||||
await card.waitFor({ timeout: 15_000 })
|
||||
// The parsed exit pill replaces the `[exit code: 1]` marker in the output
|
||||
// body — the bash tool's terminal presentation, not the generic fence.
|
||||
const text = await card.textContent()
|
||||
expect(text).toContain('exit code 1')
|
||||
expect(text).toContain('Get-Item : Cannot find path')
|
||||
expect(text).not.toContain('[exit code: 1]')
|
||||
const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
|
||||
// normalizeAria collapses the workspace basename with a '/' split, which
|
||||
// misses Windows temp paths; collapse it here too (a no-op on POSIX) so
|
||||
// the golden is platform-independent.
|
||||
.split(scaffold.workspaceCwd.split(/[\\/]/).pop()!).join('{{workspace}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('guards the lane fixture inventory', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'terminal-card.expected.md'])
|
||||
})
|
||||
})
|
||||
20
apps/web/tests/pwsh-terminal.overlay.yml
Normal file
20
apps/web/tests/pwsh-terminal.overlay.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# The pwsh terminal-card lane swaps the shipped bash stack for the PowerShell
|
||||
# twin: the bash executor row is disabled (patches cannot rename a row — `name`
|
||||
# is a guard) and the pwsh executor + tool are inserted. The permission service
|
||||
# refuses an unconfined executor by design (presets bundle a sandbox mode), so
|
||||
# its row is disabled too — this lane renders a seeded session, never a
|
||||
# permission decision. The seeded scenario renders the logged pwsh call/result
|
||||
# through the real tools on replay; no command executes, but the composition
|
||||
# must boot the pwsh executor, so the lane skips on hosts without a usable
|
||||
# `pwsh`.
|
||||
- id: bash-sandbox
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
disabled: true
|
||||
- id: permission
|
||||
name: '@deepseek-ai/dsh-permission'
|
||||
disabled: true
|
||||
- insert:
|
||||
- id: pwsh-local
|
||||
name: '@deepseek-ai/dsh-pwsh-local'
|
||||
- id: tool-pwsh
|
||||
name: '@deepseek-ai/dsh-tool-pwsh'
|
||||
19
apps/web/tests/snapshots/pwsh-terminal/seed.jsonl
Normal file
19
apps/web/tests/snapshots/pwsh-terminal/seed.jsonl
Normal file
@@ -0,0 +1,19 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747}
|
||||
{"type":"turn/start","seq":0,"time":1784974200000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784974200001,"data":{"content":[{"type":"text","text":"Run a PowerShell command that fails, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784974200002,"data":{"title":"Run a PowerShell command","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784974200010,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784974200011,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1784974200200,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Run the failing pwsh command."}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Run the failing pwsh command."}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1784974200300,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_pwsh_fail_0001","name":"pwsh","argumentsDelta":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":13,"time":1784974200310,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Run the failing pwsh command."},{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":14,"time":1784974200311,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}
|
||||
{"type":"tool/result","seq":15,"time":1784974200500,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","content":[{"type":"text","text":"[stderr]\nGet-Item : Cannot find path 'missing.txt' because it does not exist.\n[exit code: 1]"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":16,"time":1784974200501,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":17,"time":1784974200501,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,3 @@
|
||||
- text: Failed {{workspace}} Get-Item missing.txt exit code 1
|
||||
- button "Copy"
|
||||
- text: "[stderr] Get-Item : Cannot find path 'missing.txt' because it does not exist."
|
||||
@@ -61,7 +61,8 @@
|
||||
"tests/chat-scroll-contract.e2e.ts",
|
||||
"tests/chat-long-interactions.e2e.ts",
|
||||
"tests/chat-continuous-conversation.e2e.ts",
|
||||
"tests/complex-history.perf.ts"
|
||||
"tests/complex-history.perf.ts",
|
||||
"tests/pwsh-terminal.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user