Merge remote-tracking branch 'origin/master' into worktree/composer-caret-binding
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: 360ab26ec3dfecf2841a012fda8947d6a84fdfec
|
||||
README.zh.md: 3ffa5d7726a798b784c67fdb8c4154fddbdea7a4
|
||||
README.md: 6fdca68eed11dffe46bf2fbde9a7899359690dca
|
||||
README.zh.md: d8d7122729df1dd8aaed8207ddfb0a0470778b01
|
||||
|
||||
@@ -49,6 +49,8 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
|
||||
|
||||
`dsh -p "task"` uses the same base and Web composition with the startup personal config, starts its Web host on an OS-assigned port, runs one fresh persisted session, prints the final answer, and exits. It accepts neither `--config` nor raw config-dump flags.
|
||||
|
||||
Web and headless process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If headless normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
|
||||
|
||||
Both modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Web watches valid personal config edits; headless reads the file once at startup. The [app-boot personal-config contract](../../packages/ui/app-boot/README.md#personal-config) owns layer precedence, credential storage, live-update failure behavior, and `$DSH_HOME` resolution.
|
||||
|
||||
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
|
||||
|
||||
@@ -49,6 +49,8 @@ dsh web --dump-config
|
||||
|
||||
`dsh -p "task"` 使用相同的 base 与 Web 组合及启动时个人配置,在由操作系统分配的端口上启动 Web 宿主,运行一个全新的持久会话,打印最终答案后退出。它不接受 `--config` 或原始配置输出标志。
|
||||
|
||||
Web 与 headless 的进程关闭流程最多给插件树 5 秒执行 dispose。第一次 `SIGINT`/`SIGTERM` 会启动这次优雅排空;第二次信号会立即强制退出。如果 headless 的正常完成流程已经卡在 dispose 中,第一次 `Ctrl+C` 就会触发强制退出:进程立即结束,该信号不再被吞掉。
|
||||
|
||||
两种模式都以调用目录作为默认 workspace 根目录,加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,渲染预算为 65,536 字节,并使用内存 SQLite 会话内容索引。Web 会持续应用有效的个人配置编辑;headless 只在启动时读取该文件一次。层次优先级、凭据存储、实时更新失败行为与 `$DSH_HOME` 解析均由 [app-boot 个人配置契约](../../packages/ui/app-boot/README.md#personal-config) 统一定义。
|
||||
|
||||
新会话默认使用 `workspace-write` 权限 preset。Bash 和文件系统写操作受限于会话 workspace 与平台临时根目录;读取、网络访问与进程可见性不受限制。`DSH_PERMISSION_MODE` 会改变进程回退值。已存储的常规设置权限会影响之后的 Web 会话,不会更改已打开的会话。
|
||||
|
||||
@@ -111,17 +111,19 @@
|
||||
# process out (the launchers patch the row disabled; config cannot disable
|
||||
# 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
|
||||
# (both explicit) makes the drain a single batch, and exportTimeoutMillis
|
||||
# is the processor's own cap on that one export cycle — the second bound
|
||||
# when the exporter's clock alone does not fire. Every CLI exit path drains it
|
||||
# by disposing the root on SIGINT/SIGTERM.
|
||||
# user.id. The exporter/processor values normally 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), while maxExportBatchSize == maxQueueSize
|
||||
# (both explicit) makes the drain a single batch. The SDK awaits
|
||||
# exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
|
||||
# shutdownTimeoutMillis is the load-bearing outer bound when a transport
|
||||
# promise never settles. Every CLI exit path drains it by disposing the root
|
||||
# on SIGINT/SIGTERM.
|
||||
- id: telemetry-otel
|
||||
name: '@deepseek-ai/dsh-session-telemetry-otel'
|
||||
config:
|
||||
shutdownTimeoutMillis: 3000
|
||||
exporter:
|
||||
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
|
||||
compression: gzip
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
import { createProcessShutdown } from './process-shutdown.ts'
|
||||
|
||||
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
|
||||
interface TurnOutcome {
|
||||
@@ -21,12 +22,12 @@ interface TurnOutcome {
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (dispose first). */
|
||||
async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
|
||||
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */
|
||||
async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>): Promise<T> {
|
||||
if (response.result.ok) return response.result.value
|
||||
const { code, message } = response.result.error
|
||||
process.stderr.write(`dsh: ${code}: ${message}\n`)
|
||||
await dispose()
|
||||
await shutdown()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -82,23 +83,16 @@ export async function runHeadless(task: string): Promise<void> {
|
||||
port: 0,
|
||||
})
|
||||
const { ctx, port } = await entry.run()
|
||||
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
|
||||
// Signal exits must still dispose the tree: the composition mounts
|
||||
// exit-drained plugins (telemetry's queued tail and shutdown marker would
|
||||
// otherwise be lost), and Node's default signal exit skips disposal.
|
||||
let signalled = false
|
||||
const disposeAndExit = (code: number): void => {
|
||||
if (signalled) return
|
||||
signalled = true
|
||||
void dispose().finally(() => { process.exit(code) })
|
||||
}
|
||||
process.on('SIGTERM', () => { disposeAndExit(143) })
|
||||
process.on('SIGINT', () => { disposeAndExit(130) })
|
||||
// Normal completion and signals share one bounded drain. A signal received
|
||||
// during that drain escalates immediately instead of becoming a no-op.
|
||||
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
|
||||
process.on('SIGTERM', () => { shutdown.interrupt(143) })
|
||||
process.on('SIGINT', () => { shutdown.interrupt(130) })
|
||||
// The headless session is web-observable while it runs (same composition).
|
||||
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
|
||||
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
|
||||
|
||||
const created = await unwrap(await api.sessions.create({}), dispose)
|
||||
const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1))
|
||||
|
||||
// Open the stream before prompting so no frame is lost — kept in this order
|
||||
// even though in-process delivery has no race, so the code survives a move
|
||||
@@ -111,11 +105,10 @@ export async function runHeadless(task: string): Promise<void> {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: task }],
|
||||
}), dispose)
|
||||
}), () => shutdown.shutdown(1))
|
||||
|
||||
const outcome = await done
|
||||
process.stdout.write(outcome.text + '\n')
|
||||
abort.abort()
|
||||
await dispose()
|
||||
process.exit(outcome.reason === 'completed' ? 0 : 1)
|
||||
await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1)
|
||||
}
|
||||
|
||||
58
apps/cli/src/process-shutdown.ts
Normal file
58
apps/cli/src/process-shutdown.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** Bounded, escalating process shutdown for the long-lived CLI surfaces. */
|
||||
|
||||
/** Maximum grace allowed for the application tree to dispose before process exit. */
|
||||
export const PROCESS_SHUTDOWN_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Process-exit controller shared by normal completion and Unix signal handlers. */
|
||||
export interface ProcessShutdown {
|
||||
/** Start or join graceful disposal before exiting with `code`. */
|
||||
shutdown(code: number): Promise<void>
|
||||
/** Start graceful disposal, or force exit when a shutdown is already running. */
|
||||
interrupt(code: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one process-exit controller around an application disposer.
|
||||
* @param dispose - Whole-application teardown that resolves at quiescence.
|
||||
* @param exit - Process exit boundary, replaceable by tests.
|
||||
* @param timeoutMs - Grace before forced exit, replaceable by tests.
|
||||
* @returns A controller whose normal calls coalesce and whose repeated signal call escalates.
|
||||
*/
|
||||
export function createProcessShutdown(
|
||||
dispose: () => Promise<void>,
|
||||
exit: (code: number) => void = (code) => { process.exit(code) },
|
||||
timeoutMs = PROCESS_SHUTDOWN_TIMEOUT_MS,
|
||||
): ProcessShutdown {
|
||||
let pending: Promise<void> | undefined
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let exited = false
|
||||
|
||||
const exitOnce = (code: number): void => {
|
||||
if (exited) return
|
||||
exited = true
|
||||
/* v8 ignore else -- shutdown() arms the timer before any asynchronous exit path can run. */
|
||||
if (timeout !== undefined) clearTimeout(timeout)
|
||||
exit(code)
|
||||
}
|
||||
|
||||
const shutdown = (code: number): Promise<void> => {
|
||||
if (pending !== undefined) return pending
|
||||
timeout = setTimeout(() => { exitOnce(code) }, timeoutMs)
|
||||
pending = Promise.resolve().then(dispose).then(
|
||||
() => { exitOnce(code) },
|
||||
() => { exitOnce(code) },
|
||||
)
|
||||
return pending
|
||||
}
|
||||
|
||||
return {
|
||||
shutdown,
|
||||
interrupt(code) {
|
||||
if (pending !== undefined) {
|
||||
exitOnce(code)
|
||||
return
|
||||
}
|
||||
void shutdown(code)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
import { createProcessShutdown } from './process-shutdown.ts'
|
||||
|
||||
// The shipped base plus the Web application's overlay.
|
||||
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
|
||||
@@ -118,17 +119,12 @@ export async function runWeb(
|
||||
const { ctx, port: boundPort } = await entry.run()
|
||||
const resolvedLocalWebUrl = localWebUrl(ctx)
|
||||
|
||||
let exiting = false
|
||||
const shutdown = (code: number): void => {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
|
||||
}
|
||||
const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
|
||||
|
||||
// Install shutdown handling before publishing readiness: supervisors may
|
||||
// send a signal as soon as they observe the URL line.
|
||||
process.on('SIGTERM', () => { shutdown(0) })
|
||||
process.on('SIGINT', () => { shutdown(130) })
|
||||
process.on('SIGTERM', () => { shutdown.interrupt(0) })
|
||||
process.on('SIGINT', () => { shutdown.interrupt(130) })
|
||||
|
||||
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
|
||||
// must name an address the /api trust fence was configured with.
|
||||
|
||||
18
apps/cli/tests/fixtures/never-dispose.mjs
vendored
Normal file
18
apps/cli/tests/fixtures/never-dispose.mjs
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Test-only Cordis plugin whose disposer announces entry and never settles. */
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
/**
|
||||
* Register a disposer that keeps process shutdown pending until it is forced.
|
||||
* @param {import('cordis').Context} ctx - loader-mounted test plugin context.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const keepAlive = setInterval(() => {}, 60_000)
|
||||
ctx.effect(() => async () => {
|
||||
clearInterval(keepAlive)
|
||||
const armFile = process.env.DSH_TEST_SHUTDOWN_ARM_FILE
|
||||
if (armFile === undefined || !existsSync(armFile)) return
|
||||
process.stderr.write('dsh-test: never-dispose started\n')
|
||||
await new Promise(() => {})
|
||||
})
|
||||
}
|
||||
121
apps/cli/tests/headless-shutdown.e2e.ts
Normal file
121
apps/cli/tests/headless-shutdown.e2e.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
|
||||
|
||||
const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
|
||||
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
|
||||
const neverDisposePlugin = pathToFileURL(
|
||||
fileURLToPath(new URL('./fixtures/never-dispose.mjs', import.meta.url)),
|
||||
).href
|
||||
|
||||
const POSIX_HEADLESS_PTY_DRIVER = String.raw`
|
||||
import errno, json, os, pty, select, signal, sys, time
|
||||
node, launch_args_json, launch_env_json, cwd, timeout_seconds = sys.argv[1:]
|
||||
env = os.environ.copy()
|
||||
env.update(json.loads(launch_env_json))
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe(node, [node, *json.loads(launch_args_json)], env)
|
||||
|
||||
markers = [b"dsh: observing at ", b"dsh-test: never-dispose started"]
|
||||
output = bytearray()
|
||||
marker_index = 0
|
||||
deadline = time.monotonic() + float(timeout_seconds)
|
||||
status = None
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||
if ready:
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EIO:
|
||||
raise
|
||||
chunk = b""
|
||||
if chunk:
|
||||
output.extend(chunk)
|
||||
while marker_index < len(markers) and markers[marker_index] in output:
|
||||
if marker_index == 0:
|
||||
open(os.path.join(cwd, "shutdown-armed"), "w").close()
|
||||
os.write(fd, b"\x03")
|
||||
marker_index += 1
|
||||
waited, candidate = os.waitpid(pid, os.WNOHANG)
|
||||
if waited == pid:
|
||||
status = candidate
|
||||
break
|
||||
|
||||
if status is None:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
_, status = os.waitpid(pid, 0)
|
||||
sys.stdout.buffer.write(output)
|
||||
if marker_index != len(markers):
|
||||
sys.stderr.write(f"completed {marker_index}/{len(markers)} PTY actions before timeout\n")
|
||||
sys.exit(124)
|
||||
actual_exit = os.waitstatus_to_exitcode(status)
|
||||
if actual_exit != 130:
|
||||
sys.stderr.write(f"expected exit 130, got {actual_exit}\n")
|
||||
sys.exit(125)
|
||||
`
|
||||
|
||||
async function runHeadlessPtySmoke(): Promise<string> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-shutdown-'))
|
||||
try {
|
||||
const home = join(cwd, '.dsh')
|
||||
await mkdir(home, { recursive: true })
|
||||
await writeFile(join(home, 'config.yaml'), [
|
||||
'- insert:',
|
||||
' - id: never-dispose',
|
||||
` name: '${neverDisposePlugin}'`,
|
||||
'',
|
||||
].join('\n'))
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: dshBinScript,
|
||||
configArgs: ['-p', 'never complete'],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
DSH_HOME: home,
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
DEEPSEEK_API_KEY: 'keyless-shutdown-no-call',
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TEST_SHUTDOWN_ARM_FILE: join(cwd, 'shutdown-armed'),
|
||||
},
|
||||
})
|
||||
const timeoutMs = 15_000
|
||||
const result = await execa('python3', [
|
||||
'-c',
|
||||
POSIX_HEADLESS_PTY_DRIVER,
|
||||
launch.command,
|
||||
JSON.stringify(launch.args),
|
||||
JSON.stringify(launch.env),
|
||||
cwd,
|
||||
String(timeoutMs / 1_000),
|
||||
], {
|
||||
stdin: 'ignore',
|
||||
timeout: timeoutMs + 5_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
stripFinalNewline: false,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh headless PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
if (result.failed) {
|
||||
throw new Error(`dsh headless PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
return result.stdout
|
||||
} finally {
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('headless process shutdown (real Loader tree in a PTY)', () => {
|
||||
it('lets a second Ctrl+C force exit while the first signal is draining', async () => {
|
||||
const output = await runHeadlessPtySmoke()
|
||||
expect(output).toContain('dsh: observing at ')
|
||||
expect(output).toContain('dsh-test: never-dispose started')
|
||||
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
131
apps/cli/tests/process-shutdown.spec.ts
Normal file
131
apps/cli/tests/process-shutdown.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createProcessShutdown,
|
||||
PROCESS_SHUTDOWN_TIMEOUT_MS,
|
||||
} from '../src/process-shutdown.ts'
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
|
||||
let resolve!: () => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<void>((accept, fail) => {
|
||||
resolve = accept
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('process shutdown', () => {
|
||||
it('exits once after graceful disposal resolves or rejects', async () => {
|
||||
const resolvedExit = vi.fn()
|
||||
const resolved = createProcessShutdown(() => Promise.resolve(), resolvedExit)
|
||||
await resolved.shutdown(0)
|
||||
expect(resolvedExit).toHaveBeenCalledOnce()
|
||||
expect(resolvedExit).toHaveBeenCalledWith(0)
|
||||
|
||||
const rejectedExit = vi.fn()
|
||||
const rejected = createProcessShutdown(() => Promise.reject(new Error('dispose failed')), rejectedExit)
|
||||
await rejected.shutdown(1)
|
||||
expect(rejectedExit).toHaveBeenCalledOnce()
|
||||
expect(rejectedExit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('uses process.exit as the default process boundary', async () => {
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(_code => undefined as never)
|
||||
const shutdown = createProcessShutdown(() => Promise.resolve())
|
||||
|
||||
await shutdown.shutdown(7)
|
||||
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('forces exit when graceful disposal reaches its bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(PROCESS_SHUTDOWN_TIMEOUT_MS - 1)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(0)
|
||||
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('honors a caller-supplied grace period', async () => {
|
||||
vi.useFakeTimers()
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit, 25)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(24)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
|
||||
disposal.resolve()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('lets Ctrl+C force a normal shutdown already stuck in disposal', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
const pending = shutdown.shutdown(0)
|
||||
|
||||
shutdown.interrupt(130)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(130)
|
||||
|
||||
disposal.resolve()
|
||||
await pending
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('drains on the first signal and forces on the second signal', async () => {
|
||||
const disposal = deferred()
|
||||
const dispose = vi.fn(() => disposal.promise)
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(dispose, exit)
|
||||
|
||||
shutdown.interrupt(143)
|
||||
await Promise.resolve()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
|
||||
shutdown.interrupt(130)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(130)
|
||||
|
||||
disposal.resolve()
|
||||
await shutdown.shutdown(0)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('coalesces normal shutdown calls without treating them as escalation', async () => {
|
||||
const disposal = deferred()
|
||||
const exit = vi.fn()
|
||||
const shutdown = createProcessShutdown(() => disposal.promise, exit)
|
||||
|
||||
const first = shutdown.shutdown(0)
|
||||
const second = shutdown.shutdown(1)
|
||||
expect(second).toBe(first)
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
|
||||
disposal.resolve()
|
||||
await first
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(exit).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
||||
153
apps/web/stress-tests/reasoning-chunks.stress.ts
Normal file
153
apps/web/stress-tests/reasoning-chunks.stress.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Opt-in browser stress reproduction for reasoning-stream renderer stalls.
|
||||
* The fixture emits 100,000 individual chunks through the normal async
|
||||
* carrier; the test measures event-loop and scheduled-interaction delay while
|
||||
* the assembled React surface keeps a collapsed Think row live.
|
||||
*/
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it, onTestFailed } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from '../tests/support.ts'
|
||||
|
||||
const CHUNK_COUNT = 100_000
|
||||
const CHUNKS_PER_INTERVAL = 128
|
||||
const CHUNK_INTERVAL_MS = 16
|
||||
const MAIN_THREAD_DELAY_BUDGET_MS = 250
|
||||
|
||||
interface ReasoningChunkStormState {
|
||||
sessionId: string
|
||||
chunkCount: number
|
||||
chunksPerInterval: number
|
||||
intervalMs: number
|
||||
emitted: number
|
||||
marker: string
|
||||
emitting: boolean
|
||||
}
|
||||
|
||||
interface StressProbe {
|
||||
intervalId: number
|
||||
intervalMs: number
|
||||
lastTickAt: number
|
||||
maxDelayMs: number
|
||||
samples: number
|
||||
interactionDueAt: number
|
||||
interactionHandledAt: number | null
|
||||
}
|
||||
|
||||
interface StressWindow extends Window {
|
||||
__fxTiming?: {
|
||||
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
|
||||
reasoningChunkStormState(): ReasoningChunkStormState | null
|
||||
}
|
||||
__reasoningStressProbe?: StressProbe
|
||||
}
|
||||
|
||||
it('keeps the browser responsive while rendering 100,000 reasoning chunks', async () => {
|
||||
let scaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
let page: Page | undefined
|
||||
try {
|
||||
scaffold = await launchWebScaffold()
|
||||
browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' })
|
||||
page = await newEnglishPage(browser)
|
||||
const activePage = page
|
||||
await activePage.addInitScript(() => {
|
||||
localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' }))
|
||||
})
|
||||
const tripwire = watchConsole(activePage)
|
||||
onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks'))
|
||||
await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
|
||||
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fixture settings deliberately reject writes, so its welcome notice
|
||||
// cannot acknowledge. Hide only that test overlay; the assembled chat
|
||||
// tree beneath it remains mounted and exercises the production renderer.
|
||||
await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' })
|
||||
await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 })
|
||||
|
||||
await activePage.evaluate(() => {
|
||||
const intervalMs = 50
|
||||
const now = performance.now()
|
||||
const probe: StressProbe = {
|
||||
intervalId: 0,
|
||||
intervalMs,
|
||||
lastTickAt: now,
|
||||
maxDelayMs: 0,
|
||||
samples: 0,
|
||||
interactionDueAt: now + 1_000,
|
||||
interactionHandledAt: null,
|
||||
}
|
||||
probe.intervalId = window.setInterval(() => {
|
||||
const tickAt = performance.now()
|
||||
probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs)
|
||||
probe.lastTickAt = tickAt
|
||||
probe.samples++
|
||||
}, intervalMs)
|
||||
document.body.addEventListener('reasoning-stress-interaction', () => {
|
||||
probe.interactionHandledAt = performance.now()
|
||||
}, { once: true })
|
||||
window.setTimeout(() => {
|
||||
document.body.dispatchEvent(new CustomEvent('reasoning-stress-interaction'))
|
||||
}, 1_000)
|
||||
;(window as StressWindow).__reasoningStressProbe = probe
|
||||
})
|
||||
|
||||
const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => {
|
||||
const hooks = (window as StressWindow).__fxTiming
|
||||
if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable')
|
||||
return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs)
|
||||
}, {
|
||||
chunkCount: CHUNK_COUNT,
|
||||
chunksPerInterval: CHUNKS_PER_INTERVAL,
|
||||
intervalMs: CHUNK_INTERVAL_MS,
|
||||
})
|
||||
|
||||
const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last()
|
||||
await liveThink.waitFor({ timeout: 60_000 })
|
||||
await expect.poll(async () => await activePage.evaluate(() => {
|
||||
const hooks = (window as StressWindow).__fxTiming
|
||||
return hooks?.reasoningChunkStormState()?.emitted ?? 0
|
||||
}), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT)
|
||||
await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker)
|
||||
|
||||
const report = await activePage.evaluate(() => {
|
||||
const win = window as StressWindow
|
||||
const probe = win.__reasoningStressProbe
|
||||
const state = win.__fxTiming?.reasoningChunkStormState()
|
||||
if (probe === undefined || state === undefined || state === null) {
|
||||
throw new Error('reasoning stress metrics unavailable')
|
||||
}
|
||||
window.clearInterval(probe.intervalId)
|
||||
const interactionDelayMs = probe.interactionHandledAt === null
|
||||
? null
|
||||
: probe.interactionHandledAt - probe.interactionDueAt
|
||||
return {
|
||||
chunkCount: state.chunkCount,
|
||||
chunksPerInterval: state.chunksPerInterval,
|
||||
intervalMs: state.intervalMs,
|
||||
emitted: state.emitted,
|
||||
maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
|
||||
interactionDelayMs,
|
||||
heartbeatSamples: probe.samples,
|
||||
}
|
||||
})
|
||||
process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`)
|
||||
|
||||
expect(report).toMatchObject({
|
||||
chunkCount: CHUNK_COUNT,
|
||||
chunksPerInterval: CHUNKS_PER_INTERVAL,
|
||||
intervalMs: CHUNK_INTERVAL_MS,
|
||||
emitted: CHUNK_COUNT,
|
||||
})
|
||||
expect(report.heartbeatSamples).toBeGreaterThan(0)
|
||||
const interactionDelayMs = report.interactionDelayMs
|
||||
if (interactionDelayMs === null) throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`)
|
||||
expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
|
||||
expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
}
|
||||
}, 600_000)
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
|
||||
// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
|
||||
// ModuleLoader path (fetchBundle/executeBundle) and proves the boot graph
|
||||
// ModuleLoader path (loadBundle) and proves the boot graph
|
||||
// assembles — staged activation across the immediately tier and the inject
|
||||
// layers, per-plugin CSS injection, and a rendered journey reaching chat
|
||||
// content from the keyless FixtureApiClient transport.
|
||||
@@ -91,11 +91,11 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
loadBundle: async (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
if (code === undefined) throw new Error(`missing built bundle ${url}`)
|
||||
;(0, eval)(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
|
||||
328
apps/web/tests/chat-continuous-conversation.e2e.ts
Normal file
328
apps/web/tests/chat-continuous-conversation.e2e.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
// Web e2e contract for a conversation grown through the real composer rather
|
||||
// than pre-seeded history. Twelve deterministic replay turns exercise repeated
|
||||
// send/settle/render cycles, including two real bash executions and one long,
|
||||
// multi-chunk final turn. Assertions stay semantic: no host timing, heap, or
|
||||
// mounted-row cardinality is treated as a correctness contract.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
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 { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const TURN_COUNT = 12
|
||||
const TOOL_TURNS = [4, 9] as const
|
||||
const STREAM_PACE_MS = 10
|
||||
|
||||
interface TurnSpec {
|
||||
readonly index: number
|
||||
readonly prompt: string
|
||||
readonly userMarker: string
|
||||
readonly firstMarker: string
|
||||
readonly doneMarker: string
|
||||
readonly deltas: readonly string[]
|
||||
readonly callId?: ReturnType<typeof CallId>
|
||||
readonly toolResultMarker?: string
|
||||
}
|
||||
|
||||
function suffix(index: number): string {
|
||||
return String(index).padStart(3, '0')
|
||||
}
|
||||
|
||||
function longFinalPrompt(userMarker: string): string {
|
||||
return [
|
||||
`${userMarker} Reconcile this accumulated conversation without losing earlier turn ownership.`,
|
||||
...Array.from(
|
||||
{ length: 36 },
|
||||
(_, index) => `Context ${String(index + 1).padStart(2, '0')}: preserve token-${String(index)} and verify ${'payload '.repeat(12).trimEnd()}.`,
|
||||
),
|
||||
'Return one continuous response and finish with the requested completion marker.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function turnSpec(index: number): TurnSpec {
|
||||
const id = suffix(index)
|
||||
const userMarker = `CONTINUOUS_CHAT_USER_${id}`
|
||||
const firstMarker = `CONTINUOUS_CHAT_FIRST_${id}`
|
||||
const doneMarker = `CONTINUOUS_CHAT_DONE_${id}`
|
||||
const deltaCount = index === TURN_COUNT ? 36 : 8
|
||||
const deltas = Array.from({ length: deltaCount }, (_, chunkIndex) => {
|
||||
if (chunkIndex === 0) return `${firstMarker} `
|
||||
if (chunkIndex === deltaCount - 1) return `${doneMarker}.`
|
||||
return `turn-${id}-chunk-${String(chunkIndex).padStart(2, '0')} keeps semantic ownership stable. `
|
||||
})
|
||||
if (!TOOL_TURNS.includes(index as (typeof TOOL_TURNS)[number])) {
|
||||
return {
|
||||
index,
|
||||
prompt: index === TURN_COUNT
|
||||
? longFinalPrompt(userMarker)
|
||||
: `${userMarker} Continue this same conversation through turn ${String(index)}.`,
|
||||
userMarker,
|
||||
firstMarker,
|
||||
doneMarker,
|
||||
deltas,
|
||||
}
|
||||
}
|
||||
return {
|
||||
index,
|
||||
prompt: `${userMarker} Run the requested deterministic tool for turn ${String(index)}, then continue.`,
|
||||
userMarker,
|
||||
firstMarker,
|
||||
doneMarker,
|
||||
deltas,
|
||||
callId: CallId(`continuous-chat-tool-${id}`),
|
||||
toolResultMarker: `CONTINUOUS_CHAT_TOOL_RESULT_${id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function textStream(spec: TurnSpec): StreamChunk[] {
|
||||
const response = spec.deltas.join('')
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...spec.deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
|
||||
{
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: Math.ceil(spec.prompt.length / 4),
|
||||
outputTokens: Math.ceil(response.length / 4),
|
||||
},
|
||||
},
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
function toolStream(spec: TurnSpec): StreamChunk[] {
|
||||
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
|
||||
throw new Error(`turn ${String(spec.index)} has no tool identity`)
|
||||
}
|
||||
const args = JSON.stringify({
|
||||
command: `printf '${spec.toolResultMarker}\\n'`,
|
||||
description: spec.toolResultMarker,
|
||||
})
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{
|
||||
type: 'tool-call-delta',
|
||||
index: 0,
|
||||
id: spec.callId,
|
||||
name: 'bash',
|
||||
argumentsDelta: args,
|
||||
},
|
||||
{
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'tool-call', id: spec.callId, name: 'bash', arguments: args },
|
||||
},
|
||||
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 24 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
}
|
||||
|
||||
function replayScript(specs: readonly TurnSpec[]): ReplayOverrideDoc {
|
||||
return specs.flatMap((spec): ReplayEntry[] => {
|
||||
const final: ReplayEntry = { kind: 'chunks', chunks: textStream(spec) }
|
||||
return spec.callId === undefined
|
||||
? [final]
|
||||
: [{ kind: 'chunks', chunks: toolStream(spec) }, final]
|
||||
})
|
||||
}
|
||||
|
||||
function userText(event: Extract<SessionEvent, { type: 'user/message' }>): string {
|
||||
return event.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
|
||||
return event.data.message.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>): string {
|
||||
return event.data.message.content[0].content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let replayDir: string
|
||||
let scaffold: WebScaffold
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const consoleWarnings: string[] = []
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
const specs = Array.from({ length: TURN_COUNT }, (_, offset) => turnSpec(offset + 1))
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-continuous-chat-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayOverride, JSON.stringify(replayScript(specs)))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
replayContextWindow: 10_000_000,
|
||||
paceMs: STREAM_PACE_MS,
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
|
||||
sessionEvents.push(event)
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, 900)
|
||||
tripwire = watchConsole(page)
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'warning') consoleWarnings.push(message.text())
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e')
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'continuous Chat e2e cleanup failed')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation'))
|
||||
const composer = page.locator('textarea:enabled').last()
|
||||
await composer.waitFor({ timeout: 15_000 })
|
||||
let sessionId: SessionId | undefined
|
||||
|
||||
for (const spec of specs) {
|
||||
const eventStart = sessionEvents.length
|
||||
expect(await composer.inputValue()).toBe('')
|
||||
expect(await composer.isEnabled()).toBe(true)
|
||||
await composer.fill(spec.prompt)
|
||||
expect(await composer.inputValue()).toBe(spec.prompt)
|
||||
|
||||
const settled = scaffold.whenTurnSettled(60_000)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
const echoedUser = sessionEvents.slice(eventStart).find(
|
||||
(event): event is SessionEvent<'user/message'> => (
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& userText(event).includes(spec.userMarker)
|
||||
),
|
||||
)
|
||||
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
|
||||
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await userRow.textContent()).toContain(spec.userMarker)
|
||||
await page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
const settledSessionId = await settled
|
||||
if (sessionId === undefined) {
|
||||
sessionId = settledSessionId
|
||||
} else {
|
||||
expect(settledSessionId).toBe(sessionId)
|
||||
}
|
||||
|
||||
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => composer.inputValue(), { timeout: 10_000 }).toBe('')
|
||||
await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
const turnEvents = sessionEvents.slice(eventStart)
|
||||
const turnStarts = turnEvents.filter((event): event is SessionEvent<'turn/start'> => (
|
||||
event.type === 'turn/start'
|
||||
))
|
||||
const users = turnEvents.filter((event): event is SessionEvent<'user/message'> => (
|
||||
event.type === 'user/message' && event.data.source.kind === 'user'
|
||||
))
|
||||
const assistants = turnEvents.filter((event): event is SessionEvent<'assistant/message'> => (
|
||||
event.type === 'assistant/message'
|
||||
))
|
||||
const finalAssistants = assistants.filter(event => assistantText(event).includes(spec.doneMarker))
|
||||
const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => (
|
||||
event.type === 'turn/end'
|
||||
))
|
||||
const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
|
||||
|
||||
expect(turnStarts).toHaveLength(1)
|
||||
expect(turnStarts[0]?.data.turn).toBe(spec.index)
|
||||
expect(users).toHaveLength(1)
|
||||
expect(users[0]?.seq).toBe(echoedUser.seq)
|
||||
expect(userText(users[0]!)).toBe(spec.prompt)
|
||||
expect(finalAssistants).toHaveLength(1)
|
||||
expect(assistants).toHaveLength(spec.callId === undefined ? 1 : 2)
|
||||
expect(turnEnds).toHaveLength(1)
|
||||
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
|
||||
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
|
||||
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
|
||||
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
|
||||
|
||||
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
|
||||
const results = turnEvents.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
|
||||
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(results).toHaveLength(0)
|
||||
continue
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(results).toHaveLength(1)
|
||||
expect(calls[0]?.data).toMatchObject({
|
||||
turn: spec.index,
|
||||
callId: spec.callId,
|
||||
name: 'bash',
|
||||
})
|
||||
expect(results[0]?.data.turn).toBe(spec.index)
|
||||
expect(results[0]?.data.message.source.callId).toBe(spec.callId)
|
||||
expect(results[0]?.data.message.content[0].isError).toBe(false)
|
||||
expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`)
|
||||
|
||||
const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
|
||||
await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
|
||||
const disclosure = toolRow.locator('[data-sample="bash"]')
|
||||
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
|
||||
// The collapsed summary deliberately repeats the result marker; the
|
||||
// last exact match is the expanded terminal output owned by this call.
|
||||
await toolRow.getByText(spec.toolResultMarker, { exact: true }).last().waitFor({ timeout: 10_000 })
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
|
||||
}
|
||||
|
||||
if (sessionId === undefined) throw new Error('continuous conversation completed no turn')
|
||||
expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
|
||||
event.type === 'turn/end' && event.data.reason.kind === 'completed'
|
||||
))).toHaveLength(TURN_COUNT)
|
||||
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
|
||||
expect(sessionEvents.filter(event => (
|
||||
event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
|
||||
)).length).toBeGreaterThan(30)
|
||||
expect(consoleWarnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 180_000)
|
||||
})
|
||||
271
apps/web/tests/chat-long-interactions.e2e.ts
Normal file
271
apps/web/tests/chat-long-interactions.e2e.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// Long-history Chat behavior contract for a future virtualized renderer. Wheel
|
||||
// input only navigates to the semantic target; assertions pin content identity
|
||||
// and interaction routing rather than scroll geometry or mounted row counts.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
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 { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SESSION_ID = 'chat-long-interactions-e2e'
|
||||
const FIXTURE_TURNS = 88
|
||||
const TOOL_TURN = FIXTURE_TURNS
|
||||
const BRANCH_TURN = 80
|
||||
const TARGET_CALL_1 = 'chat-scroll-088-1'
|
||||
const TARGET_CALL_2 = 'chat-scroll-088-2'
|
||||
const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.'
|
||||
const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST'
|
||||
const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE'
|
||||
const FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'INTERACTION',
|
||||
title: 'CHAT_INTERACTION long semantic identity session',
|
||||
turns: FIXTURE_TURNS,
|
||||
})
|
||||
|
||||
function continuationChunks(): StreamChunk[] {
|
||||
const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.`
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` },
|
||||
{ type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
|
||||
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
|
||||
return { kind: 'chunks', chunks }
|
||||
}
|
||||
|
||||
function carries(event: SessionEvent, marker: string): boolean {
|
||||
return JSON.stringify(event).includes(marker)
|
||||
}
|
||||
|
||||
function textContent(content: readonly unknown[]): string {
|
||||
return content.flatMap((block) => {
|
||||
if (typeof block !== 'object' || block === null) return []
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
return candidate.type === 'text' && typeof candidate.text === 'string'
|
||||
? [candidate.text]
|
||||
: []
|
||||
}).join('')
|
||||
}
|
||||
|
||||
async function nextPaint(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { resolve() })
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async function openSeed(page: Page): Promise<void> {
|
||||
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await results.first().waitFor({ timeout: 60_000 })
|
||||
const resultCount = await results.count()
|
||||
if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
|
||||
await results.click()
|
||||
await results.click()
|
||||
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
|
||||
.last().waitFor({ timeout: 30_000 })
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
|
||||
const scrollport = page.locator('[data-conversation-scroll]')
|
||||
const box = await scrollport.boundingBox()
|
||||
if (box === null) throw new Error('conversation scrollport has no layout box')
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (await page.locator(selector).count() > 0) return
|
||||
await page.mouse.wheel(0, deltaY)
|
||||
await nextPaint(page)
|
||||
}
|
||||
throw new Error(`semantic Chat target did not mount: ${selector}`)
|
||||
}
|
||||
|
||||
function requiredEvent<T extends SessionEvent['type']>(
|
||||
events: readonly SessionEvent[],
|
||||
type: T,
|
||||
marker: string,
|
||||
): Extract<SessionEvent, { type: T }> {
|
||||
const event = events.find((candidate): candidate is Extract<SessionEvent, { type: T }> => (
|
||||
candidate.type === type && carries(candidate, marker)
|
||||
))
|
||||
if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`)
|
||||
return event
|
||||
}
|
||||
|
||||
describe('web e2e: long Chat interaction contract', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let replayDir: string
|
||||
let scaffold: WebScaffold
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())]
|
||||
await writeFile(replayOverride, JSON.stringify(replay))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
replayContextWindow: 10_000_000,
|
||||
paceMs: 18,
|
||||
})
|
||||
await seedSession(scaffold, FIXTURE.log, SESSION_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, 900)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openSeed(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'long Chat interaction cleanup failed')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
|
||||
const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
|
||||
if (source === undefined) throw new Error('seeded long-history agent is not attached')
|
||||
|
||||
const toolUserMarker = FIXTURE.markers.user(TOOL_TURN)
|
||||
const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN)
|
||||
const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1)
|
||||
const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2)
|
||||
const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker)
|
||||
const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker)
|
||||
const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN)
|
||||
const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN)
|
||||
const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker)
|
||||
const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker)
|
||||
const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
|
||||
event.type === 'turn/end' && event.data.turn === BRANCH_TURN
|
||||
))
|
||||
if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`)
|
||||
const expectedUserText = textContent(branchUserEvent.data.content)
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
|
||||
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
|
||||
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
|
||||
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
|
||||
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
|
||||
|
||||
await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await call1.count()).toBe(1)
|
||||
expect(await call2.count()).toBe(1)
|
||||
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
|
||||
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
|
||||
expect(await call1.textContent()).toContain(toolMarker1)
|
||||
expect(await call2.textContent()).toContain(toolMarker2)
|
||||
|
||||
const expectedOrder = [
|
||||
`node:${String(toolUserEvent.seq)}`,
|
||||
`call:${TARGET_CALL_1}`,
|
||||
`call:${TARGET_CALL_2}`,
|
||||
`node:${String(toolAssistantEvent.seq)}`,
|
||||
]
|
||||
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
|
||||
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
|
||||
.filter((key): key is string => key !== undefined && keys.includes(key))
|
||||
), expectedOrder)
|
||||
expect(actualOrder).toEqual(expectedOrder)
|
||||
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
|
||||
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
|
||||
))))
|
||||
expect(groupKeys[0]).not.toBeNull()
|
||||
expect(groupKeys[1]).toBe(groupKeys[0])
|
||||
|
||||
const summary1 = call1.locator('[data-sample="bash"]')
|
||||
const summary2 = call2.locator('[data-sample="bash"]')
|
||||
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await summary2.getAttribute('aria-expanded')).toBe('false')
|
||||
await summary2.focus()
|
||||
await summary2.press('Enter')
|
||||
await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
|
||||
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
|
||||
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
|
||||
expect(await userRow.textContent()).toContain(branchUserMarker)
|
||||
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await userRow.hover()
|
||||
await userRow.getByRole('button', { name: 'Copy', exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
|
||||
.toBe(expectedUserText)
|
||||
|
||||
await assistantRow.hover()
|
||||
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
|
||||
{ timeout: 15_000 },
|
||||
).toBeDefined()
|
||||
const child = scaffold.ctx.agents.list()
|
||||
.find(agent => agent.session.header.parentSession === SessionId(SESSION_ID))
|
||||
if (child === undefined) throw new Error('message branch did not create a child session')
|
||||
expect(child.session.header.seedLength).toBe(boundary.seq + 1)
|
||||
expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true)
|
||||
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
|
||||
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
|
||||
|
||||
const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
.getByRole('button').last()
|
||||
await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 })
|
||||
.toBe(`${FIXTURE.title} (1)`)
|
||||
await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
const settled = scaffold.whenTurnSettled(60_000)
|
||||
const composer = page.locator('textarea:enabled').last()
|
||||
await composer.fill(CONTINUE_PROMPT)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(await settled).toBe(child.session.id)
|
||||
await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
expect(await composer.inputValue()).toBe('')
|
||||
expect(await composer.isEnabled()).toBe(true)
|
||||
expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
|
||||
expect(child.session.events.filter(event => carries(event, CONTINUE_PROMPT))).toHaveLength(1)
|
||||
const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => (
|
||||
event.type === 'turn/end'
|
||||
))
|
||||
expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 180_000)
|
||||
})
|
||||
683
apps/web/tests/chat-scroll-contract.e2e.ts
Normal file
683
apps/web/tests/chat-scroll-contract.e2e.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
// Browser geometry contracts for a long Chat transcript. These scenarios are
|
||||
// deliberately virtualizer-neutral: they assert semantic-row position,
|
||||
// bottom ownership, interaction state, and the real outer scroll host rather
|
||||
// than DOM cardinality or implementation-specific spacer markup.
|
||||
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
|
||||
const TOOL_SESSION_ID = 'chat-scroll-tool-e2e'
|
||||
const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e'
|
||||
const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e'
|
||||
const REPLAY_CONTEXT_WINDOW = 10_000_000
|
||||
const STREAM_PACE_MS = 24
|
||||
const GEOMETRY_TOLERANCE = 2
|
||||
const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.'
|
||||
const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST'
|
||||
const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE'
|
||||
const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.'
|
||||
const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call')
|
||||
const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT'
|
||||
const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
|
||||
const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
|
||||
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
|
||||
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
|
||||
|
||||
const HISTORY_FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'HISTORY',
|
||||
title: 'CHAT_SCROLL_HISTORY long paging session',
|
||||
})
|
||||
const TOOL_FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'TOOL',
|
||||
title: 'CHAT_SCROLL_TOOL live tool session',
|
||||
})
|
||||
const RESTORE_FIXTURE_A = createChatScrollFixture({
|
||||
markerPrefix: 'RESTORE_A',
|
||||
title: 'CHAT_SCROLL_RESTORE_A long session',
|
||||
})
|
||||
const RESTORE_FIXTURE_B = createChatScrollFixture({
|
||||
markerPrefix: 'RESTORE_B',
|
||||
title: 'CHAT_SCROLL_RESTORE_B comparison session',
|
||||
turns: 32,
|
||||
})
|
||||
|
||||
interface ScrollGeometry {
|
||||
readonly distanceFromBottom: number
|
||||
readonly scrollTop: number
|
||||
}
|
||||
|
||||
interface FlowAnchor {
|
||||
readonly key: string
|
||||
readonly top: number
|
||||
}
|
||||
|
||||
interface ScrollWorld {
|
||||
readonly events: SessionEvent[]
|
||||
readonly page: Page
|
||||
readonly replayDir?: string
|
||||
readonly scaffold: WebScaffold
|
||||
readonly tripwire: ReturnType<typeof watchConsole>
|
||||
}
|
||||
|
||||
interface ScrollWorldOptions {
|
||||
readonly failureShot: string
|
||||
readonly replay?: ReplayOverrideDoc
|
||||
readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
|
||||
}
|
||||
|
||||
function textStream(first: string, done: string, deltaCount: number): StreamChunk[] {
|
||||
const deltas = Array.from({ length: deltaCount }, (_, index) => {
|
||||
if (index === 0) return `${first} `
|
||||
if (index === deltaCount - 1) return `${done}.`
|
||||
return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}`
|
||||
})
|
||||
const response = deltas.join('')
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
|
||||
{
|
||||
type: 'usage',
|
||||
usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) },
|
||||
},
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
function toolStream(): StreamChunk[] {
|
||||
const command = [
|
||||
`: > ${TOOL_READY_FILE}`,
|
||||
`while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`,
|
||||
'line=1',
|
||||
`while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`,
|
||||
].join('; ')
|
||||
const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT })
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{
|
||||
type: 'tool-call-delta',
|
||||
index: 0,
|
||||
id: LIVE_TOOL_CALL_ID,
|
||||
name: 'bash',
|
||||
argumentsDelta: args,
|
||||
},
|
||||
{
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args },
|
||||
},
|
||||
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
}
|
||||
|
||||
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
|
||||
return { kind: 'chunks', chunks }
|
||||
}
|
||||
|
||||
async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWorld> {
|
||||
let replayDir: string | undefined
|
||||
let scaffold: WebScaffold | undefined
|
||||
let page: Page | undefined
|
||||
try {
|
||||
if (options.replay !== undefined) {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayOverride, JSON.stringify(options.replay))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
paceMs: STREAM_PACE_MS,
|
||||
replayContextWindow: REPLAY_CONTEXT_WINDOW,
|
||||
})
|
||||
} else {
|
||||
scaffold = await launchWebScaffold({})
|
||||
}
|
||||
for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id)
|
||||
const events: SessionEvent[] = []
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
||||
page = await newEnglishPage(browser, 900)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Session-list bootstrap can replace the controlled search state. Wait
|
||||
// for the seeded baseline before openSeed starts the lazy content query.
|
||||
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
|
||||
return {
|
||||
events,
|
||||
page,
|
||||
scaffold,
|
||||
tripwire,
|
||||
...(replayDir === undefined ? {} : { replayDir }),
|
||||
}
|
||||
} catch (error) {
|
||||
const failures: unknown[] = [error]
|
||||
if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError))
|
||||
if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
|
||||
}
|
||||
if (failures.length === 1) throw error
|
||||
throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete')
|
||||
}
|
||||
}
|
||||
|
||||
async function closeScrollWorld(world: ScrollWorld): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
// newEnglishPage/browser.newPage owns an isolated context. Close the whole
|
||||
// context so its SSE connection and cache cannot leak into the next world
|
||||
// in this file's shared Chromium process.
|
||||
await world.page.context().close().catch((error: unknown) => failures.push(error))
|
||||
await world.scaffold.close().catch((error: unknown) => failures.push(error))
|
||||
if (world.replayDir !== undefined) {
|
||||
await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed')
|
||||
}
|
||||
|
||||
async function withScrollWorld(
|
||||
options: ScrollWorldOptions,
|
||||
run: (world: ScrollWorld) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const world = await launchScrollWorld(options)
|
||||
let runFailure: unknown
|
||||
try {
|
||||
await run(world)
|
||||
} catch (error) {
|
||||
runFailure = error
|
||||
try {
|
||||
await saveFailureShot(world.page, options.failureShot)
|
||||
} catch {
|
||||
// Best-effort evidence must never prevent cleanup of the owned world.
|
||||
}
|
||||
}
|
||||
let cleanupFailure: unknown
|
||||
try {
|
||||
await closeScrollWorld(world)
|
||||
} catch (error) {
|
||||
cleanupFailure = error
|
||||
}
|
||||
if (runFailure !== undefined && cleanupFailure !== undefined) {
|
||||
throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed')
|
||||
}
|
||||
if (runFailure !== undefined) throw runFailure
|
||||
if (cleanupFailure !== undefined) throw cleanupFailure
|
||||
}
|
||||
|
||||
async function nextPaint(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { resolve() })
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function scrollGeometry(page: Page): Promise<ScrollGeometry> {
|
||||
return page.locator('[data-conversation-scroll]').evaluate(host => ({
|
||||
distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop,
|
||||
scrollTop: host.scrollTop,
|
||||
}))
|
||||
}
|
||||
|
||||
async function conversationTurns(page: Page): Promise<number> {
|
||||
const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
|
||||
await stats.waitFor({ timeout: 15_000 })
|
||||
const value = await stats.textContent()
|
||||
const match = value?.match(/^(\d+) turns · \d+ steps$/)
|
||||
if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
// Cold summaries initially show the temporary workspace basename, so the
|
||||
// persisted first-message marker is the stable user-facing identity. The
|
||||
// query itself triggers lazy content-index reconciliation; no transient
|
||||
// empty-state paint is used as a barrier.
|
||||
await search.fill(fixture.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
||||
await results.click()
|
||||
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
|
||||
if (tailMarker !== undefined) {
|
||||
await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 })
|
||||
}
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
|
||||
const box = await page.locator('[data-conversation-scroll]').boundingBox()
|
||||
if (box === null) throw new Error('conversation scrollport has no layout box')
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
|
||||
await page.mouse.wheel(0, deltaY)
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelToHistoryStart(page: Page): Promise<void> {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if ((await scrollGeometry(page)).scrollTop <= 1) break
|
||||
await wheelTranscript(page, -2_400)
|
||||
}
|
||||
await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
if (await page.locator(selector).count() > 0) return
|
||||
await wheelTranscript(page, deltaY)
|
||||
}
|
||||
throw new Error(`selector did not mount during transcript wheel: ${selector}`)
|
||||
}
|
||||
|
||||
async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
|
||||
const target = page.locator(selector)
|
||||
for (let attempt = 0; attempt < 32; attempt += 1) {
|
||||
if (await target.count() > 0 && await target.evaluate((row) => {
|
||||
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
|
||||
if (host === null) return false
|
||||
const viewport = host.getBoundingClientRect()
|
||||
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
|
||||
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.bottom > viewport.top && rect.top < visibleBottom
|
||||
})) return
|
||||
await wheelTranscript(page, deltaY)
|
||||
}
|
||||
throw new Error(`selector did not become visible during transcript wheel: ${selector}`)
|
||||
}
|
||||
|
||||
function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
|
||||
return page.locator('[data-conversation-scroll]').evaluate((host) => {
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
|
||||
const viewport = host.getBoundingClientRect()
|
||||
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
|
||||
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
|
||||
const visible = rows.filter((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect()
|
||||
return rect.bottom > viewport.top && rect.top < visibleBottom
|
||||
})
|
||||
const row = visible[0]
|
||||
if (row?.dataset.chatAnchorKey === undefined) {
|
||||
throw new Error(`no visible settled Chat row: ${JSON.stringify({
|
||||
composerTop: visibleBottom,
|
||||
host: { bottom: viewport.bottom, top: viewport.top },
|
||||
rows: rows.slice(0, 4).map(candidate => ({
|
||||
callId: candidate.dataset.chatCallId,
|
||||
key: candidate.dataset.chatAnchorKey,
|
||||
rect: {
|
||||
bottom: candidate.getBoundingClientRect().bottom,
|
||||
top: candidate.getBoundingClientRect().top,
|
||||
},
|
||||
})),
|
||||
totalRows: rows.length,
|
||||
})}`)
|
||||
}
|
||||
return {
|
||||
key: row.dataset.chatAnchorKey,
|
||||
top: row.getBoundingClientRect().top - viewport.top,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function flowTop(page: Page, key: string): Promise<number> {
|
||||
return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => {
|
||||
const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey)
|
||||
if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`)
|
||||
const host = row.closest('[data-conversation-scroll]')
|
||||
if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport')
|
||||
return row.getBoundingClientRect().top - host.getBoundingClientRect().top
|
||||
}, key)
|
||||
}
|
||||
|
||||
async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise<void> {
|
||||
await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), {
|
||||
timeout: 10_000,
|
||||
message: `flow row ${anchor.key} moved relative to the transcript viewport`,
|
||||
}).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
}
|
||||
|
||||
async function expectBottom(page: Page): Promise<void> {
|
||||
await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), {
|
||||
timeout: 10_000,
|
||||
}).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function expectMarkerAboveComposer(page: Page, marker: string): Promise<void> {
|
||||
const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => {
|
||||
const row = node.closest('[data-chat-flow-key], [data-streaming]')
|
||||
const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]')
|
||||
if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) {
|
||||
throw new Error('latest marker or composer geometry is unavailable')
|
||||
}
|
||||
return {
|
||||
composerTop: composer.getBoundingClientRect().top,
|
||||
rowBottom: row.getBoundingClientRect().bottom,
|
||||
}
|
||||
})
|
||||
expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
|
||||
}
|
||||
|
||||
async function loadEarlierWithAnchor(page: Page): Promise<void> {
|
||||
await wheelToHistoryStart(page)
|
||||
const older = page.getByRole('button', { name: 'Load earlier', exact: true })
|
||||
await older.waitFor({ timeout: 10_000 })
|
||||
const anchor = await visibleFlowAnchor(page)
|
||||
const before = await conversationTurns(page)
|
||||
await older.click()
|
||||
await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
|
||||
await nextPaint(page)
|
||||
await expectSameFlowTop(page, anchor)
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function eventCarries(event: SessionEvent, marker: string): boolean {
|
||||
return JSON.stringify(event).includes(marker)
|
||||
}
|
||||
|
||||
function assertClean(world: ScrollWorld): void {
|
||||
expect(world.tripwire.pageErrors).toEqual([])
|
||||
expect(world.tripwire.warnings).toEqual([])
|
||||
}
|
||||
|
||||
let browser: Browser
|
||||
|
||||
describe('web e2e: long Chat scroll contract', () => {
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-history-stream',
|
||||
replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))],
|
||||
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
await openSeed(
|
||||
world.page,
|
||||
HISTORY_FIXTURE,
|
||||
HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns),
|
||||
)
|
||||
await expectBottom(world.page)
|
||||
|
||||
let releaseHistory = (): void => {}
|
||||
let held = false
|
||||
let releaseGate: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { releaseGate = resolve })
|
||||
releaseHistory = () => { releaseGate?.() }
|
||||
await world.page.route('**/api/session.history', async (route) => {
|
||||
const request = route.request().postDataJSON() as {
|
||||
method?: string
|
||||
payload?: { beforeSeq?: number }
|
||||
}
|
||||
if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
|
||||
held = true
|
||||
await gate
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
try {
|
||||
const composer = world.page.locator('textarea:enabled').last()
|
||||
await composer.fill(LIVE_TEXT_PROMPT)
|
||||
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await wheelToHistoryStart(world.page)
|
||||
const beforeTurns = await conversationTurns(world.page)
|
||||
await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
|
||||
await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
|
||||
|
||||
await wheelTranscript(world.page, 420)
|
||||
const readerAnchor = await visibleFlowAnchor(world.page)
|
||||
const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(chunksAfterAnchor + 5)
|
||||
|
||||
releaseHistory()
|
||||
await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
|
||||
await nextPaint(world.page)
|
||||
await expectSameFlowTop(world.page, readerAnchor)
|
||||
} finally {
|
||||
releaseHistory()
|
||||
}
|
||||
|
||||
await settled
|
||||
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await world.page.unroute('**/api/session.history')
|
||||
|
||||
let additionalPages = 0
|
||||
while (additionalPages < 8) {
|
||||
await wheelToHistoryStart(world.page)
|
||||
if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
|
||||
await loadEarlierWithAnchor(world.page)
|
||||
additionalPages += 1
|
||||
}
|
||||
expect(additionalPages).toBeGreaterThan(0)
|
||||
expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
|
||||
expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-live-tool',
|
||||
replay: [
|
||||
replayEntry(toolStream()),
|
||||
replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)),
|
||||
],
|
||||
seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
|
||||
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
|
||||
await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
let released = false
|
||||
try {
|
||||
const composer = world.page.locator('textarea:enabled').last()
|
||||
await composer.fill(LIVE_TOOL_PROMPT)
|
||||
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
|
||||
const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
|
||||
await liveRow.waitFor({ timeout: 15_000 })
|
||||
expect(await liveRow.getAttribute('data-state')).toBe('running')
|
||||
await expectBottom(world.page)
|
||||
|
||||
await wheelTranscript(world.page, -1_200)
|
||||
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
|
||||
const awayAnchor = await visibleFlowAnchor(world.page)
|
||||
const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await writeFile(releasePath, 'release\n')
|
||||
released = true
|
||||
await expect.poll(
|
||||
() => world.events.some(event => event.type === 'tool/result'),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(true)
|
||||
await expect.poll(
|
||||
() => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(true)
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(chunksBeforeRelease + 5)
|
||||
await expectSameFlowTop(world.page, awayAnchor)
|
||||
|
||||
const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
||||
await expectBottom(world.page)
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(chunksAtRepin + 5)
|
||||
await expectBottom(world.page)
|
||||
} finally {
|
||||
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
|
||||
}
|
||||
|
||||
await settled
|
||||
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expectBottom(world.page)
|
||||
await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE)
|
||||
|
||||
const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
|
||||
const liveRow = world.page.locator(liveRowSelector)
|
||||
await wheelUntilVisible(world.page, liveRowSelector, -300)
|
||||
const toolAnchor = await liveRow.evaluate((row) => {
|
||||
const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
|
||||
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
|
||||
if (flow?.dataset.chatAnchorKey === undefined || host === null) {
|
||||
throw new Error('live tool row has no settled flow identity')
|
||||
}
|
||||
return {
|
||||
key: flow.dataset.chatAnchorKey,
|
||||
top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top,
|
||||
}
|
||||
})
|
||||
await liveRow.click()
|
||||
await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
|
||||
await expectSameFlowTop(world.page, toolAnchor)
|
||||
await wheelToHistoryStart(world.page)
|
||||
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
||||
await expectBottom(world.page)
|
||||
await wheelUntilMounted(world.page, liveRowSelector, -1_100)
|
||||
const restoredRow = world.page.locator(liveRowSelector)
|
||||
await restoredRow.waitFor({ timeout: 10_000 })
|
||||
expect(await restoredRow.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-restore-composer',
|
||||
seeds: [
|
||||
{ fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID },
|
||||
{ fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID },
|
||||
],
|
||||
}, async (world) => {
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_A,
|
||||
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
|
||||
)
|
||||
await loadEarlierWithAnchor(world.page)
|
||||
await loadEarlierWithAnchor(world.page)
|
||||
await wheelToHistoryStart(world.page)
|
||||
await wheelTranscript(world.page, 1_300)
|
||||
const sessionAnchor = await visibleFlowAnchor(world.page)
|
||||
|
||||
await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
|
||||
await world.page.setViewportSize({ width: 700, height: 900 })
|
||||
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
|
||||
await nextPaint(world.page)
|
||||
await expectSameFlowTop(world.page, sessionAnchor)
|
||||
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_B,
|
||||
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
|
||||
)
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_A,
|
||||
)
|
||||
await expectSameFlowTop(world.page, sessionAnchor)
|
||||
|
||||
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
await backToBottom.evaluate((button) => {
|
||||
if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
|
||||
button.click()
|
||||
const trajectory = [...document.querySelectorAll<HTMLElement>('[role="tab"]')]
|
||||
.find(tab => tab.textContent?.trim() === 'Trajectory')
|
||||
if (!(trajectory instanceof HTMLElement)) {
|
||||
throw new Error('Trajectory tab is unavailable during pinned remount')
|
||||
}
|
||||
trajectory.click()
|
||||
})
|
||||
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
|
||||
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
|
||||
await expectBottom(world.page)
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_B,
|
||||
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
|
||||
)
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_A,
|
||||
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
|
||||
)
|
||||
await expectBottom(world.page)
|
||||
const composer = world.page.locator('textarea:enabled').last()
|
||||
const longDraft = Array.from(
|
||||
{ length: 18 },
|
||||
(_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
|
||||
).join('\n')
|
||||
await composer.fill(longDraft)
|
||||
await nextPaint(world.page)
|
||||
await expectBottom(world.page)
|
||||
await expectMarkerAboveComposer(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
|
||||
)
|
||||
|
||||
await composer.fill('short draft')
|
||||
await nextPaint(world.page)
|
||||
await wheelTranscript(world.page, -900)
|
||||
const resizeAnchor = await visibleFlowAnchor(world.page)
|
||||
await composer.fill(longDraft)
|
||||
await nextPaint(world.page)
|
||||
await expectSameFlowTop(world.page, resizeAnchor)
|
||||
await composer.fill('short draft')
|
||||
await nextPaint(world.page)
|
||||
await expectSameFlowTop(world.page, resizeAnchor)
|
||||
|
||||
const beforeChain = await scrollGeometry(world.page)
|
||||
await composer.hover()
|
||||
await world.page.mouse.wheel(0, -320)
|
||||
await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeLessThan(beforeChain.scrollTop)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
})
|
||||
235
apps/web/tests/chat-scroll-fixture.ts
Normal file
235
apps/web/tests/chat-scroll-fixture.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
// Synthetic long-chat history for browser behavior contracts. The fixture is
|
||||
// generated through Session so pagination exercises the same event shapes as
|
||||
// persisted conversations, while unique markers let tests identify semantic
|
||||
// rows without depending on CSS-module names or the eventual virtualizer DOM.
|
||||
import {
|
||||
CallId,
|
||||
createAssistantMessage,
|
||||
createToolResultMessage,
|
||||
createUserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
// Carries the session/title event declaration into this fixture builder.
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Options for one deterministic long-chat fixture. */
|
||||
export interface ChatScrollFixtureOptions {
|
||||
/** Marker namespace, used when two sessions share one browser world. */
|
||||
readonly markerPrefix: string
|
||||
/** Searchable title projected into the sidebar. */
|
||||
readonly title: string
|
||||
/** Number of closed turns to generate. */
|
||||
readonly turns?: number
|
||||
}
|
||||
|
||||
/** Semantic marker helpers returned with a generated fixture. */
|
||||
interface ChatScrollMarkers {
|
||||
/** Marker painted in the human message for a turn. */
|
||||
user(turn: number): string
|
||||
/** Marker painted in the final assistant message for a turn. */
|
||||
assistant(turn: number): string
|
||||
/** Marker painted in one seeded bash call and result. */
|
||||
tool(turn: number, index: number): string
|
||||
}
|
||||
|
||||
/** Generated JSONL plus the stable facts browser scenarios assert. */
|
||||
export interface ChatScrollFixture {
|
||||
readonly log: string
|
||||
readonly markers: ChatScrollMarkers
|
||||
readonly title: string
|
||||
readonly turns: number
|
||||
}
|
||||
|
||||
const DEFAULT_TURNS = 88
|
||||
const TOOL_INTERVAL = 8
|
||||
const CODE_INTERVAL = 11
|
||||
|
||||
function text(value: string): { type: 'text'; text: string }[] {
|
||||
return [{ type: 'text', text: value }]
|
||||
}
|
||||
|
||||
function suffix(turn: number): string {
|
||||
return String(turn).padStart(3, '0')
|
||||
}
|
||||
|
||||
function markerHelpers(prefix: string): ChatScrollMarkers {
|
||||
return {
|
||||
user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`,
|
||||
assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`,
|
||||
tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`,
|
||||
}
|
||||
}
|
||||
|
||||
function appendRequestHeader(session: Session, turn: number, step: number): void {
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
|
||||
},
|
||||
reason: turn === 1 && step === 1 ? 'initial' : 'change',
|
||||
})
|
||||
}
|
||||
|
||||
function appendAssistant(session: Session, turn: number, step: number, body: string): void {
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
message: createAssistantMessage({
|
||||
content: text(body),
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
usage: {
|
||||
inputTokens: 2_000 + turn * 7,
|
||||
outputTokens: 180 + step * 20,
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function codeBlock(turn: number): string {
|
||||
if (turn % CODE_INTERVAL !== 0) return ''
|
||||
const lines = Array.from(
|
||||
{ length: 30 },
|
||||
(_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`,
|
||||
)
|
||||
return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
|
||||
}
|
||||
|
||||
function appendToolStep(
|
||||
session: Session,
|
||||
markers: ChatScrollMarkers,
|
||||
turn: number,
|
||||
): void {
|
||||
const calls = [1, 2].map((index) => {
|
||||
const marker = markers.tool(turn, index)
|
||||
const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`)
|
||||
const args = JSON.stringify({
|
||||
command: `printf '${marker}\\n'`,
|
||||
description: marker,
|
||||
})
|
||||
return { args, callId, marker }
|
||||
})
|
||||
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` },
|
||||
...calls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
id: call.callId,
|
||||
name: 'bash',
|
||||
arguments: call.args,
|
||||
})),
|
||||
],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
for (const call of calls) {
|
||||
const source = session.append('tool/call', {
|
||||
turn,
|
||||
step: 1,
|
||||
callId: call.callId,
|
||||
name: 'bash',
|
||||
arguments: call.args,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: call.callId,
|
||||
content: text(Array.from(
|
||||
{ length: 12 },
|
||||
(_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`,
|
||||
).join('\n')),
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureLog(session: Session): string {
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: Date.now() - 60_000,
|
||||
cwd: '{{cwd}}',
|
||||
delegationDepth: 0,
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multi-page conversation with prose, fenced code, and paired bash
|
||||
* calls/results. Every turn is closed, so cold resume cannot repair or mutate
|
||||
* the seed before the browser observes it.
|
||||
* @param options - Fixture identity and optional turn count.
|
||||
* @returns Canonical JSONL and semantic marker helpers.
|
||||
*/
|
||||
export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
|
||||
const turns = options.turns ?? DEFAULT_TURNS
|
||||
const markers = markerHelpers(options.markerPrefix)
|
||||
const session = new Session(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
|
||||
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: text(
|
||||
`${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
|
||||
+ 'Keep the visible message stable while history, tools, and new output change around it.',
|
||||
),
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
if (turn === 1) {
|
||||
session.append('session/title', {
|
||||
title: options.title,
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
}
|
||||
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
appendRequestHeader(session, turn, 1)
|
||||
if (turn % TOOL_INTERVAL === 0) {
|
||||
appendToolStep(session, markers, turn)
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('step/start', { turn, step: 2 })
|
||||
appendRequestHeader(session, turn, 2)
|
||||
appendAssistant(
|
||||
session,
|
||||
turn,
|
||||
2,
|
||||
`${markers.assistant(turn)} Both tool results are accounted for. `
|
||||
+ `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`,
|
||||
)
|
||||
session.append('step/end', { turn, step: 2 })
|
||||
} else {
|
||||
appendAssistant(
|
||||
session,
|
||||
turn,
|
||||
1,
|
||||
`${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n`
|
||||
+ `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. `
|
||||
+ 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n'
|
||||
+ `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`,
|
||||
)
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
}
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
return { log: fixtureLog(session), markers, title: options.title, turns }
|
||||
}
|
||||
1434
apps/web/tests/complex-history.perf.ts
Normal file
1434
apps/web/tests/complex-history.perf.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
const REPLAY_PACE_MS = 100
|
||||
|
||||
describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
|
||||
let scaffold: WebScaffold
|
||||
@@ -42,7 +43,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Page, Response } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
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'
|
||||
import {
|
||||
@@ -34,12 +34,46 @@ const SEED_ID = 'navigation-panes-web-e2e'
|
||||
const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.'
|
||||
const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.'
|
||||
|
||||
async function baselineResponse(
|
||||
page: Page,
|
||||
method: 'session.list' | 'workspace.list',
|
||||
): Promise<Response> {
|
||||
return page.waitForResponse(response => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname === `/api/${method}`
|
||||
), { timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function assertBaselineSucceeded(response: Response, method: string): Promise<void> {
|
||||
expect(response.ok(), `${method} baseline HTTP response`).toBe(true)
|
||||
const body = await response.json() as { result?: { ok?: unknown } }
|
||||
expect(body.result?.ok, `${method} baseline RPC result`).toBe(true)
|
||||
}
|
||||
|
||||
async function ensureSeedOpen(page: Page): Promise<void> {
|
||||
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
if (await chat.count() === 0) {
|
||||
await search.fill('WATERFALL')
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
|
||||
await result.click()
|
||||
await chat.waitFor({ timeout: 15_000 })
|
||||
}
|
||||
await chat.click()
|
||||
await page.getByText('FIRST_DONE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
if (await search.inputValue() !== '') {
|
||||
await search.fill('')
|
||||
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let slotErrors: string[]
|
||||
let tripwire: ReturnType<typeof watchConsole> = { warnings: [], pageErrors: [] }
|
||||
let slotErrors: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
@@ -57,6 +91,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
}, 120_000)
|
||||
|
||||
beforeEach(async () => {
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
slotErrors = []
|
||||
@@ -65,7 +102,19 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
slotErrors.push(message.text())
|
||||
}
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
// Initial navigation and list ownership settle only after both independent
|
||||
// RPC baselines succeed; arm before navigation so neither response is missed.
|
||||
const sessionBaseline = baselineResponse(page, 'session.list')
|
||||
const workspaceBaseline = baselineResponse(page, 'workspace.list')
|
||||
const [, sessionResponse, workspaceResponse] = await Promise.all([
|
||||
page.goto(scaffold.baseUrl, { waitUntil: 'load' }),
|
||||
sessionBaseline,
|
||||
workspaceBaseline,
|
||||
])
|
||||
await Promise.all([
|
||||
assertBaselineSucceeded(sessionResponse, 'session.list'),
|
||||
assertBaselineSucceeded(workspaceResponse, 'workspace.list'),
|
||||
])
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The frame mounts before the asynchronous session-list baseline lands.
|
||||
// Search must target the settled seeded row, not the startup input that
|
||||
@@ -73,9 +122,32 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterEach(async () => {
|
||||
const failures: unknown[] = []
|
||||
try {
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
slotErrors,
|
||||
warnings: tripwire.warnings,
|
||||
}).toEqual({
|
||||
pageErrors: [],
|
||||
slotErrors: [],
|
||||
warnings: [],
|
||||
})
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
await page?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'navigation case cleanup failed')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'navigation e2e cleanup failed')
|
||||
})
|
||||
|
||||
it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
|
||||
@@ -102,6 +174,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
|
||||
// The API baselines can settle before React commits their projection. The
|
||||
// seeded count is the final user-visible barrier before editing search.
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
// The cold row has not been opened, so only the persisted log can satisfy
|
||||
// this query. First search lazily reconciles the SQLite content index.
|
||||
@@ -136,6 +211,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||||
await ensureSeedOpen(page)
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
const overlayLayout = await page.getByRole('table').evaluate((table) => {
|
||||
@@ -200,7 +276,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
||||
await ensureSeedOpen(page)
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
|
||||
await plot.waitFor({ timeout: 15_000 })
|
||||
const before = await page.locator('tr[data-kind]').count()
|
||||
const box = await plot.boundingBox()
|
||||
if (box === null) throw new Error('trajectory timeline plot has no layout box')
|
||||
@@ -217,7 +296,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
|
||||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||||
await ensureSeedOpen(page)
|
||||
const bashRow = page.locator('[data-sample="bash"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
@@ -239,9 +318,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
|
||||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||||
await ensureSeedOpen(page)
|
||||
// The card is expand-gated behind the whole-row toggle (the unified
|
||||
// tool-row interaction): open it if a previous case left it collapsed.
|
||||
// tool-row interaction): open it if this fresh view leaves it collapsed.
|
||||
// Expanded, the recorded command's own output sits in the message flow,
|
||||
// derived from the logged call/result presentations alone.
|
||||
const bashRow = page.locator('[data-sample="bash"]').first()
|
||||
@@ -322,10 +401,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(slotErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md',
|
||||
|
||||
@@ -85,6 +85,14 @@ const REPLAY_PROVIDERS = [{
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
|
||||
}]
|
||||
|
||||
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
|
||||
if (contextWindow === undefined) return REPLAY_PROVIDERS
|
||||
return REPLAY_PROVIDERS.map(provider => ({
|
||||
...provider,
|
||||
models: provider.models.map(model => ({ ...model, contextWindow })),
|
||||
}))
|
||||
}
|
||||
|
||||
/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
|
||||
export interface WebScaffold {
|
||||
/** The active snapshot mode this scaffold booted under. */
|
||||
@@ -134,6 +142,8 @@ export interface LaunchOptions {
|
||||
replayOverride?: string
|
||||
/** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
|
||||
paceMs?: number
|
||||
/** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
|
||||
replayContextWindow?: number
|
||||
/**
|
||||
* Tool presentation mode patched onto the shipped `tools` row (`code`
|
||||
* collapses the wire to run_code + the SDK prompt section). Omit for the
|
||||
@@ -344,7 +354,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
if (mode !== 'record' && options.replayFixture !== undefined) {
|
||||
replayHandle = installLlmReplay(ctx, {
|
||||
file: options.replayFixture,
|
||||
providers: REPLAY_PROVIDERS,
|
||||
providers: replayProviders(options.replayContextWindow),
|
||||
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
|
||||
@@ -128,11 +128,11 @@ describe('assembled search card', () => {
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
loadBundle: async (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
if (code === undefined) throw new Error(`missing built bundle ${url}`)
|
||||
;(0, eval)(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
|
||||
@@ -106,6 +106,10 @@ interface ListMetrics {
|
||||
overflows: boolean
|
||||
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
|
||||
band: number
|
||||
/** Distance from the scrollbar's right edge to the sidebar edge. */
|
||||
scrollbarEdgeOffset: number
|
||||
/** Distance from the first row background's right edge to the sidebar edge. */
|
||||
rowEdgeInset: number
|
||||
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
|
||||
clientRight: number
|
||||
/** Border-box right edge in viewport coordinates. */
|
||||
@@ -133,6 +137,8 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
if (list === null) throw new Error('sidebar session list not in the DOM')
|
||||
const time = list.querySelector<HTMLElement>('[class*="time"]')
|
||||
if (time === null) throw new Error('no row relative-time element in the sidebar list')
|
||||
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
|
||||
if (row === null) throw new Error('no row in the sidebar list')
|
||||
// Each indirection variable is resolved through its own throwaway probe
|
||||
// appended to the list: `var()` substitution then happens where the list
|
||||
// sits in the cascade, which is the claim, and `color` normalizes whatever
|
||||
@@ -167,6 +173,9 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
const style = getComputedStyle(list)
|
||||
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
|
||||
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
|
||||
const listRect = list.getBoundingClientRect()
|
||||
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
|
||||
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
|
||||
return {
|
||||
gutter: style.scrollbarGutter,
|
||||
width: pseudoWidth,
|
||||
@@ -177,9 +186,11 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
token: resolve('--dsh-scrollbar-thumb'),
|
||||
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
|
||||
overflows: list.scrollHeight > list.clientHeight,
|
||||
band: list.getBoundingClientRect().width - list.clientWidth,
|
||||
clientRight: list.getBoundingClientRect().left + list.clientWidth,
|
||||
borderRight: list.getBoundingClientRect().right,
|
||||
band: listRect.width - list.clientWidth,
|
||||
scrollbarEdgeOffset: sidebarEdge - listRect.right,
|
||||
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
|
||||
clientRight: listRect.left + list.clientWidth,
|
||||
borderRight: listRect.right,
|
||||
timeRight: time.getBoundingClientRect().right,
|
||||
// The bar is drawn in the rightmost `barWidth` of the border box, whether
|
||||
// or not that space was reserved. Its width comes from the sheet where the
|
||||
@@ -188,7 +199,28 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
// absent. Taking the UA width as the fallback is what keeps the assertion
|
||||
// honest: assuming 0 there would report no occlusion precisely in the
|
||||
// state that has it.
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)),
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure only overflow and row inset, which remain observable when every
|
||||
* session is hidden under a collapsed workspace group.
|
||||
* @param page - the page under test.
|
||||
* @returns the list overflow state and first row's trailing inset.
|
||||
*/
|
||||
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
|
||||
return page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
|
||||
if (list === null) throw new Error('sidebar session list not in the DOM')
|
||||
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
|
||||
if (row === null) throw new Error('no row in the sidebar list')
|
||||
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
|
||||
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
|
||||
return {
|
||||
overflows: list.scrollHeight > list.clientHeight,
|
||||
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -222,6 +254,8 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
|
||||
`- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
|
||||
`- list overflows: ${String(metrics.overflows)}`,
|
||||
`- reserved band: ${String(metrics.band)}px`,
|
||||
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
|
||||
`- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
|
||||
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
|
||||
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
|
||||
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
|
||||
@@ -299,6 +333,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
// drawn over it. Removing the declaration makes it exactly 0. The value
|
||||
// itself is not pinned — it tracks `scrollbar-width` and the platform.
|
||||
expect(metrics.band).toBeGreaterThan(0)
|
||||
expect(metrics.scrollbarEdgeOffset).toBe(2)
|
||||
expect(metrics.rowEdgeInset).toBe(12)
|
||||
// The reported symptom, stated directly: no part of the row's relative time
|
||||
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
|
||||
// covered part. Unlike the client-edge comparison below it does not go
|
||||
@@ -317,6 +353,20 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the row background inset when overflow disappears', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
|
||||
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
|
||||
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
await bucket.click()
|
||||
try {
|
||||
await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
|
||||
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
|
||||
} finally {
|
||||
await expandSeededSessions(page)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('renders the themed thumb through the WebKit path in both palettes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
|
||||
const light = await measureList(page)
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
- --dsh-scrollbar-thumb-hover: rgb(212, 212, 212)
|
||||
- list overflows: true
|
||||
- reserved band: 8px
|
||||
- scrollbar inset from the sidebar edge: 2px
|
||||
- row background inset from the sidebar edge: 12px
|
||||
- relative time covered by the bar: 0px
|
||||
- relative time ends inside the content area: true
|
||||
- content area ends before the border box: true
|
||||
@@ -28,6 +30,8 @@
|
||||
- --dsh-scrollbar-thumb-hover: rgb(84, 85, 87)
|
||||
- list overflows: true
|
||||
- reserved band: 8px
|
||||
- scrollbar inset from the sidebar edge: 2px
|
||||
- row background inset from the sidebar edge: 12px
|
||||
- relative time covered by the bar: 0px
|
||||
- relative time ends inside the content area: true
|
||||
- content area ends before the border box: true
|
||||
|
||||
@@ -56,7 +56,12 @@
|
||||
"tests/goal-bar.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/subagent-conversation.e2e.ts",
|
||||
"tests/bash-abort-row.e2e.ts"
|
||||
"tests/bash-abort-row.e2e.ts",
|
||||
"tests/chat-scroll-fixture.ts",
|
||||
"tests/chat-scroll-contract.e2e.ts",
|
||||
"tests/chat-long-interactions.e2e.ts",
|
||||
"tests/chat-continuous-conversation.e2e.ts",
|
||||
"tests/complex-history.perf.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -20,6 +20,9 @@ function rejectStandaloneServe(): Plugin {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [rejectStandaloneServe(), react()],
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
resolve: {
|
||||
// Workspace packages resolve to SOURCE: package.json exports point at lib
|
||||
// for Node/type consumers, but the browser bundle must compile src directly
|
||||
|
||||
Reference in New Issue
Block a user