fix(tui): stabilize Windows terminal snapshots

Treat an absolute path.relative() result as a cross-volume path instead of incorrectly abbreviating it beneath the user's home directory.

Allow embeddings to project a logical footer cwd without changing the operational session cwd. The recorded-session harness now uses a POSIX-shaped display alias for both the footer and filesystem result paths, preserving the existing pre-normalization layout width on every host.

Keep runtime-provided labels behind terminal-control escaping, cover that boundary, and document the embedding contract.
This commit is contained in:
Tianyi Cui
2026-07-19 13:20:59 +08:00
parent bdba206640
commit 0f8d0082e4
5 changed files with 68 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
@@ -106,6 +106,13 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
const observedScenarios = new Set<string>()
function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string {
const rel = relative(cwd, displayPath)
if (rel === '') return displayCwd
if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) return displayPath
return `${displayCwd}/${rel.split(sep).join('/')}`
}
function scenarioDir(scenario: Scenario): string {
return join(SNAPSHOTS_DIR, scenario.name)
}
@@ -136,9 +143,10 @@ function rawSessionLog(session: Session): string {
].join('\n')
}
function normalizeTerminalSnapshot(snapshot: string, cwd: string): string {
function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string {
return snapshot
.split(`/private${cwd}`).join('/workspace/project')
.split(displayCwd).join('/workspace/project')
.split(cwd).join('/workspace/project')
.replace(UUID_RE, '{{uuid}}')
}
@@ -157,9 +165,20 @@ async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
async function mountScenarioContext(
scenario: Scenario,
cwd: string,
displayCwd: string,
fixtureFile: string,
childFiles: string[],
): Promise<Context> {
class SnapshotLocalFileSystem extends LocalFileSystem {
override async resolve(
path: string,
opts?: { cwd?: string; signal?: AbortSignal },
): Promise<Awaited<ReturnType<LocalFileSystem['resolve']>>> {
const target = await super.resolve(path, opts)
return { ...target, displayPath: snapshotDisplayPath(target.displayPath, cwd, displayCwd) }
}
}
const ctx = new Context()
await ctx.plugin(AgentCore, {
agents: [],
@@ -169,7 +188,7 @@ async function mountScenarioContext(
skills: { local: { agentsHome: join(cwd, '.agents') } },
})
await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
await ctx.plugin(LocalFileSystem, { cwd: '/' })
await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
await ctx.plugin(UserInteractionService)
@@ -207,6 +226,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0)
const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
const displayCwd = `/tmp/${basename(cwd)}`
let ctx: Context | undefined
let controller: ReturnType<typeof createTuiChat> | undefined
const terminal = new HeadlessTerminal(100, 36)
@@ -215,7 +235,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const source = join(scenarioDir(scenario), 'workspace')
await cp(source, cwd, { recursive: true })
}
ctx = await mountScenarioContext(scenario, cwd, fixtureFile, childFiles)
ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
const disposedSessions: Session[] = []
ctx.on('session/disposed', (session) => { disposedSessions.push(session) })
const workflowEvents: string[] = []
@@ -235,7 +255,11 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
title: 'DSH TUI snapshot',
welcome: `Recorded replay: ${scenario.name}`,
maxToolOutputLines: 8,
}, { terminal, exit: () => {} })
}, {
terminal,
exit: () => {},
formatCwd: () => displayCwd,
})
await settleTerminal(terminal)
for (const prompt of prompts) {
@@ -266,6 +290,7 @@ async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
const snapshot = normalizeTerminalSnapshot(
await terminal.snapshot({ includeScrollback: true }),
cwd,
displayCwd,
)
await handle.dispose()
const children = disposedSessions