Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- packages/app/e2e/performance/AGENTS.md +13 -0
- packages/app/e2e/performance/README.md +79 -0
- packages/app/e2e/performance/benchmark.ts +144 -0
- packages/app/e2e/performance/chrome-trace.ts +95 -0
- packages/app/e2e/performance/playwright.config.ts +20 -0
- packages/app/e2e/performance/playwright.uncapped.config.ts +13 -0
- packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts +14 -0
- packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts +16 -0
- packages/app/e2e/regression/cross-server-tab-close.spec.ts +153 -0
- packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +187 -0
- packages/app/e2e/regression/new-session-panel-corner.spec.ts +83 -0
- packages/app/e2e/regression/open-file-expand-folder.spec.ts +132 -0
- packages/app/e2e/regression/project-picker-recent-search.spec.ts +60 -0
- packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts +50 -0
- packages/app/e2e/regression/prompt-thinking-level.spec.ts +84 -0
- packages/app/e2e/regression/remote-session-settings.spec.ts +302 -0
- packages/app/e2e/regression/remote-tab-busy.spec.ts +131 -0
- packages/app/e2e/regression/review-image-flash.spec.ts +202 -0
- packages/app/e2e/regression/review-line-comment.spec.ts +169 -0
- packages/app/e2e/regression/review-open-file.spec.ts +163 -0
- packages/app/e2e/regression/review-state-persistence.spec.ts +153 -0
- packages/app/e2e/regression/review-tab-switch.spec.ts +147 -0
- packages/app/e2e/regression/review-terminal-stacked.spec.ts +306 -0
- packages/app/e2e/regression/session-list-path-loading.spec.ts +41 -0
- packages/app/e2e/regression/session-rename.spec.ts +141 -0
- packages/app/e2e/regression/session-request-docks.spec.ts +221 -0
- packages/app/e2e/regression/session-timeline-accessibility.spec.ts +22 -0
- packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +439 -0
- packages/app/e2e/regression/session-timeline-context-resize.spec.ts +374 -0
- packages/app/e2e/regression/session-timeline-context-state.spec.ts +31 -0
- packages/app/e2e/regression/session-timeline-file-projection.spec.ts +52 -0
- packages/app/e2e/regression/session-timeline-file-state.spec.ts +94 -0
- packages/app/e2e/regression/session-timeline-history-root.spec.ts +242 -0
- packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +111 -0
- packages/app/e2e/regression/session-timeline-locale-projection.spec.ts +25 -0
- packages/app/e2e/regression/session-timeline-projection.spec.ts +283 -0
- packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +93 -0
- packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts +43 -0
- packages/app/e2e/regression/session-timeline-shell-outline.spec.ts +228 -0
- packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +99 -0
- packages/app/e2e/regression/session-timeline-tool-state.spec.ts +77 -0
- packages/app/e2e/regression/session-timeline-transport.spec.ts +154 -0
- packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +190 -0
- packages/app/e2e/regression/subagent-child-navigation.spec.ts +203 -0
- packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +159 -0
- packages/app/e2e/regression/terminal-composer-focus.spec.ts +227 -0
- packages/app/e2e/regression/terminal-hidden.spec.ts +117 -0
- packages/app/e2e/regression/terminal-tab-switch.spec.ts +165 -0
- packages/app/e2e/smoke/session-timeline.fixture.ts +315 -0
- packages/app/e2e/smoke/session-timeline.spec.ts +740 -0
packages/app/e2e/performance/AGENTS.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
- Prioritize stability, then simplicity, then measurement overhead.
|
| 2 |
+
- Use Playwright for scenario control, isolation, and completion checks.
|
| 3 |
+
- Use Chrome Performance traces for generic browser profiling.
|
| 4 |
+
- Use Electron `contentTracing` for packaged multi-process profiling.
|
| 5 |
+
- Keep custom probes only for product-specific measurements.
|
| 6 |
+
- Do not duplicate measurements across the harness, probes, and traces.
|
| 7 |
+
- Run benchmarks serially to avoid cross-test contention.
|
| 8 |
+
- Run benchmarks against production builds.
|
| 9 |
+
- Keep detailed profiling opt-in when it changes workload behavior.
|
| 10 |
+
- Preserve raw diagnostic data or use lossless representations.
|
| 11 |
+
- Do not enforce machine-dependent performance thresholds.
|
| 12 |
+
- Assert scenario completion and metric collection only.
|
| 13 |
+
- Keep normal test discovery free of manual benchmarks.
|
packages/app/e2e/performance/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Manual app performance suite
|
| 2 |
+
|
| 3 |
+
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
|
| 4 |
+
|
| 5 |
+
Run the suite explicitly from `packages/app`:
|
| 6 |
+
|
| 7 |
+
```sh
|
| 8 |
+
bun run test:bench
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
PowerShell:
|
| 12 |
+
|
| 13 |
+
```powershell
|
| 14 |
+
$env:PLAYWRIGHT_WORKERS = "1"
|
| 15 |
+
bun run test:bench
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
The suite contains:
|
| 19 |
+
|
| 20 |
+
- cold and hot session-tab timing
|
| 21 |
+
- home-session click timing split between content and titlebar-tab paint
|
| 22 |
+
- single-session tab close timing through stable home restoration
|
| 23 |
+
- cached session repaint and mutation tracing
|
| 24 |
+
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
| 25 |
+
|
| 26 |
+
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
|
| 27 |
+
|
| 28 |
+
New benchmarks should look like normal Playwright tests:
|
| 29 |
+
|
| 30 |
+
```ts
|
| 31 |
+
import { benchmark, expect } from "../benchmark"
|
| 32 |
+
|
| 33 |
+
benchmark("measures one interaction", async ({ page, report }) => {
|
| 34 |
+
// Only scenario-specific setup and interaction belong here.
|
| 35 |
+
report({ durationMs: 42 })
|
| 36 |
+
})
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
|
| 40 |
+
|
| 41 |
+
```text
|
| 42 |
+
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
|
| 46 |
+
|
| 47 |
+
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
|
| 48 |
+
|
| 49 |
+
These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
|
| 50 |
+
|
| 51 |
+
CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
|
| 52 |
+
|
| 53 |
+
The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
|
| 54 |
+
|
| 55 |
+
Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
|
| 56 |
+
|
| 57 |
+
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
| 58 |
+
|
| 59 |
+
## Chrome traces
|
| 60 |
+
|
| 61 |
+
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
| 62 |
+
|
| 63 |
+
```sh
|
| 64 |
+
OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
|
| 65 |
+
bunx playwright test --config e2e/performance/playwright.config.ts \
|
| 66 |
+
timeline/session-tab-switch-benchmark.spec.ts
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
|
| 70 |
+
|
| 71 |
+
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
|
| 72 |
+
|
| 73 |
+
```sh
|
| 74 |
+
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
|
| 78 |
+
|
| 79 |
+
`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.
|
packages/app/e2e/performance/benchmark.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
|
| 2 |
+
import { startChromeTrace } from "./chrome-trace"
|
| 3 |
+
|
| 4 |
+
type BenchmarkFixtures = {
|
| 5 |
+
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
|
| 6 |
+
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
|
| 7 |
+
benchmarkResult: void
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export type PerformancePageDiagnostics = {
|
| 11 |
+
navigations: string[]
|
| 12 |
+
stop: () => Promise<string | undefined>
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
|
| 16 |
+
|
| 17 |
+
export const benchmark = base.extend<BenchmarkFixtures>({
|
| 18 |
+
reportState: async ({}, use) => use({}),
|
| 19 |
+
report: async ({ reportState }, use) => {
|
| 20 |
+
await use((metrics, context = {}) => {
|
| 21 |
+
if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
|
| 22 |
+
reportState.payload = { metrics, context }
|
| 23 |
+
})
|
| 24 |
+
},
|
| 25 |
+
benchmarkResult: [
|
| 26 |
+
async ({ reportState }, use, testInfo) => {
|
| 27 |
+
await use()
|
| 28 |
+
const missing = !reportState.payload
|
| 29 |
+
console.log(
|
| 30 |
+
`BENCHMARK ${JSON.stringify({
|
| 31 |
+
schemaVersion: 2,
|
| 32 |
+
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
|
| 33 |
+
name: benchmarkName(testInfo),
|
| 34 |
+
status: missing ? "failed" : testInfo.status,
|
| 35 |
+
expectedStatus: testInfo.expectedStatus,
|
| 36 |
+
retry: testInfo.retry,
|
| 37 |
+
repeatEachIndex: testInfo.repeatEachIndex,
|
| 38 |
+
context: {
|
| 39 |
+
project: testInfo.project.name,
|
| 40 |
+
platform: process.platform,
|
| 41 |
+
...reportState.payload?.context,
|
| 42 |
+
},
|
| 43 |
+
metrics: reportState.payload?.metrics ?? null,
|
| 44 |
+
error: missing ? "Benchmark did not report metrics" : undefined,
|
| 45 |
+
})}`,
|
| 46 |
+
)
|
| 47 |
+
if (missing && testInfo.status === testInfo.expectedStatus)
|
| 48 |
+
throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
|
| 49 |
+
},
|
| 50 |
+
{ auto: true },
|
| 51 |
+
],
|
| 52 |
+
page: async ({ page }, use, testInfo) => {
|
| 53 |
+
const name = benchmarkName(testInfo)
|
| 54 |
+
const diagnostics = await observePerformancePage(page, name)
|
| 55 |
+
try {
|
| 56 |
+
await use(page)
|
| 57 |
+
} finally {
|
| 58 |
+
try {
|
| 59 |
+
await reportPerformancePage(name, diagnostics, testInfo)
|
| 60 |
+
} finally {
|
| 61 |
+
if (testInfo.status !== testInfo.expectedStatus) {
|
| 62 |
+
await testInfo.attach("performance-navigations", {
|
| 63 |
+
body: JSON.stringify(diagnostics.navigations, null, 2),
|
| 64 |
+
contentType: "application/json",
|
| 65 |
+
})
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
function benchmarkName(testInfo: TestInfo) {
|
| 73 |
+
return testInfo.titlePath.slice(1).join(" > ")
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export { expect }
|
| 77 |
+
|
| 78 |
+
async function observePerformancePage(page: Page, name: string) {
|
| 79 |
+
const navigations: string[] = []
|
| 80 |
+
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
|
| 81 |
+
if (frame === page.mainFrame()) navigations.push(frame.url())
|
| 82 |
+
}
|
| 83 |
+
page.on("framenavigated", onNavigation)
|
| 84 |
+
const stopTrace = await startChromeTrace(page, name).catch((error) => {
|
| 85 |
+
page.off("framenavigated", onNavigation)
|
| 86 |
+
throw error
|
| 87 |
+
})
|
| 88 |
+
let stopping: Promise<string | undefined> | undefined
|
| 89 |
+
const diagnostics: PerformancePageDiagnostics = {
|
| 90 |
+
navigations,
|
| 91 |
+
stop() {
|
| 92 |
+
page.off("framenavigated", onNavigation)
|
| 93 |
+
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
|
| 94 |
+
},
|
| 95 |
+
}
|
| 96 |
+
pages.set(page, diagnostics)
|
| 97 |
+
return diagnostics
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export async function withBenchmarkPage<T>(
|
| 101 |
+
browser: Browser,
|
| 102 |
+
name: string,
|
| 103 |
+
run: (page: Page) => Promise<T>,
|
| 104 |
+
testInfo?: TestInfo,
|
| 105 |
+
) {
|
| 106 |
+
const context = await browser.newContext()
|
| 107 |
+
try {
|
| 108 |
+
const page = await context.newPage()
|
| 109 |
+
const diagnostics = await observePerformancePage(page, name)
|
| 110 |
+
try {
|
| 111 |
+
return await run(page)
|
| 112 |
+
} finally {
|
| 113 |
+
await reportPerformancePage(name, diagnostics, testInfo)
|
| 114 |
+
}
|
| 115 |
+
} finally {
|
| 116 |
+
await context.close()
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
|
| 121 |
+
const trace = await diagnostics.stop()
|
| 122 |
+
console.log(
|
| 123 |
+
`BENCHMARK_PAGE ${JSON.stringify({
|
| 124 |
+
schemaVersion: 2,
|
| 125 |
+
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
|
| 126 |
+
name,
|
| 127 |
+
test: testInfo ? benchmarkName(testInfo) : undefined,
|
| 128 |
+
retry: testInfo?.retry,
|
| 129 |
+
repeatEachIndex: testInfo?.repeatEachIndex,
|
| 130 |
+
context: {
|
| 131 |
+
platform: process.platform,
|
| 132 |
+
trace,
|
| 133 |
+
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
|
| 134 |
+
},
|
| 135 |
+
navigations: diagnostics.navigations,
|
| 136 |
+
})}`,
|
| 137 |
+
)
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
export function benchmarkDiagnostics(page: Page) {
|
| 141 |
+
const diagnostics = pages.get(page)
|
| 142 |
+
if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
|
| 143 |
+
return diagnostics
|
| 144 |
+
}
|
packages/app/e2e/performance/chrome-trace.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { CDPSession, Page } from "@playwright/test"
|
| 2 |
+
import path from "node:path"
|
| 3 |
+
import { mkdir, open, rename } from "node:fs/promises"
|
| 4 |
+
import { Buffer } from "node:buffer"
|
| 5 |
+
import { createHash, randomUUID } from "node:crypto"
|
| 6 |
+
|
| 7 |
+
const categories = [
|
| 8 |
+
"-*",
|
| 9 |
+
"devtools.timeline",
|
| 10 |
+
"v8.execute",
|
| 11 |
+
"disabled-by-default-devtools.timeline",
|
| 12 |
+
"disabled-by-default-devtools.timeline.frame",
|
| 13 |
+
"toplevel",
|
| 14 |
+
"blink.console",
|
| 15 |
+
"blink.user_timing",
|
| 16 |
+
"latencyInfo",
|
| 17 |
+
"disabled-by-default-devtools.timeline.stack",
|
| 18 |
+
"disabled-by-default-v8.cpu_profiler",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
export async function startChromeTrace(page: Page, name: string) {
|
| 22 |
+
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
|
| 23 |
+
if (!directory) return
|
| 24 |
+
|
| 25 |
+
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
|
| 26 |
+
const file = await prepareChromeTrace(directory, name, selectors)
|
| 27 |
+
const session = await page.context().newCDPSession(page)
|
| 28 |
+
try {
|
| 29 |
+
await session.send("Tracing.start", {
|
| 30 |
+
transferMode: "ReturnAsStream",
|
| 31 |
+
traceConfig: {
|
| 32 |
+
excludedCategories: categories
|
| 33 |
+
.filter((category) => category.startsWith("-"))
|
| 34 |
+
.map((category) => category.slice(1)),
|
| 35 |
+
includedCategories: [
|
| 36 |
+
...categories.filter((category) => !category.startsWith("-")),
|
| 37 |
+
...(selectors
|
| 38 |
+
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
|
| 39 |
+
: []),
|
| 40 |
+
],
|
| 41 |
+
},
|
| 42 |
+
})
|
| 43 |
+
} catch (error) {
|
| 44 |
+
await Promise.allSettled([session.detach()])
|
| 45 |
+
throw error
|
| 46 |
+
}
|
| 47 |
+
let stopping: Promise<string> | undefined
|
| 48 |
+
|
| 49 |
+
return () =>
|
| 50 |
+
(stopping ??= (async () => {
|
| 51 |
+
try {
|
| 52 |
+
const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
|
| 53 |
+
session.once("Tracing.tracingComplete", resolve),
|
| 54 |
+
)
|
| 55 |
+
await session.send("Tracing.end")
|
| 56 |
+
const result = await complete
|
| 57 |
+
if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
|
| 58 |
+
const partial = `${file}.partial`
|
| 59 |
+
await writeProtocolStream(session, result.stream, partial)
|
| 60 |
+
if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
|
| 61 |
+
await rename(partial, file)
|
| 62 |
+
return file
|
| 63 |
+
} finally {
|
| 64 |
+
await Promise.allSettled([session.detach()])
|
| 65 |
+
}
|
| 66 |
+
})())
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export async function prepareChromeTrace(
|
| 70 |
+
directory: string,
|
| 71 |
+
name: string,
|
| 72 |
+
selectors: boolean,
|
| 73 |
+
nonce = randomUUID().slice(0, 8),
|
| 74 |
+
) {
|
| 75 |
+
await mkdir(directory, { recursive: true })
|
| 76 |
+
const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
|
| 77 |
+
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
|
| 78 |
+
return path.join(
|
| 79 |
+
directory,
|
| 80 |
+
`${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
|
| 81 |
+
)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
|
| 85 |
+
const output = await open(file, "wx")
|
| 86 |
+
try {
|
| 87 |
+
while (true) {
|
| 88 |
+
const chunk = await session.send("IO.read", { handle })
|
| 89 |
+
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
|
| 90 |
+
if (chunk.eof) break
|
| 91 |
+
}
|
| 92 |
+
} finally {
|
| 93 |
+
await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
|
| 94 |
+
}
|
| 95 |
+
}
|
packages/app/e2e/performance/playwright.config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import config from "../../playwright.config"
|
| 2 |
+
|
| 3 |
+
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
| 4 |
+
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
|
| 5 |
+
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
|
| 6 |
+
|
| 7 |
+
export default {
|
| 8 |
+
...config,
|
| 9 |
+
testDir: ".",
|
| 10 |
+
testIgnore: "unit/**",
|
| 11 |
+
outputDir: "../test-results/performance",
|
| 12 |
+
fullyParallel: false,
|
| 13 |
+
workers: 1,
|
| 14 |
+
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
|
| 15 |
+
webServer: {
|
| 16 |
+
...config.webServer,
|
| 17 |
+
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
|
| 18 |
+
reuseExistingServer: false,
|
| 19 |
+
},
|
| 20 |
+
}
|
packages/app/e2e/performance/playwright.uncapped.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import config from "./playwright.config"
|
| 2 |
+
|
| 3 |
+
export default {
|
| 4 |
+
...config,
|
| 5 |
+
outputDir: "../test-results/performance-uncapped",
|
| 6 |
+
reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
|
| 7 |
+
use: {
|
| 8 |
+
...config.use,
|
| 9 |
+
launchOptions: {
|
| 10 |
+
args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
|
| 11 |
+
},
|
| 12 |
+
},
|
| 13 |
+
}
|
packages/app/e2e/performance/unit/session-timeline-stream-probe.test.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import { streamChunk } from "../timeline/session-timeline-benchmark.fixture"
|
| 3 |
+
import { streamProgress } from "../timeline/session-timeline-stream-probe"
|
| 4 |
+
|
| 5 |
+
test("classifies emitted stream markers using the fixture cycle", () => {
|
| 6 |
+
expect(streamProgress("before stream-17 after stream-18")).toEqual({ index: 18, phase: "boundary" })
|
| 7 |
+
expect(streamProgress("before stream-18 after stream-19")).toEqual({ index: 19, phase: "stream" })
|
| 8 |
+
expect(streamProgress("benchmark-complete stream-36")).toEqual({ index: 36, phase: "complete" })
|
| 9 |
+
expect(streamProgress("no marker")).toEqual({ index: -1, phase: "unknown" })
|
| 10 |
+
})
|
| 11 |
+
|
| 12 |
+
test("emits progress markers at fixture boundaries", () => {
|
| 13 |
+
expect(streamProgress(streamChunk(18, 160))).toEqual({ index: 18, phase: "boundary" })
|
| 14 |
+
})
|
packages/app/e2e/performance/unit/session-timeline-visual-tracking.test.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "bun:test"
|
| 2 |
+
import { layoutShiftValue, removeVisibleRow } from "../timeline/session-timeline-stream-probe"
|
| 3 |
+
|
| 4 |
+
test("excludes layout shifts before the probe window and recent input", () => {
|
| 5 |
+
expect(layoutShiftValue({ startTime: 9, value: 0.1 }, 10)).toBeUndefined()
|
| 6 |
+
expect(layoutShiftValue({ startTime: 10, value: 0.2, hadRecentInput: true }, 10)).toBeUndefined()
|
| 7 |
+
expect(layoutShiftValue({ startTime: 11, value: 0.3 }, 10)).toBe(0.3)
|
| 8 |
+
})
|
| 9 |
+
|
| 10 |
+
test("classifies removed rows from their last painted visibility", () => {
|
| 11 |
+
const row = {}
|
| 12 |
+
const visible = new Set([row])
|
| 13 |
+
|
| 14 |
+
expect(removeVisibleRow(visible, row)).toBe(true)
|
| 15 |
+
expect(removeVisibleRow(visible, row)).toBe(false)
|
| 16 |
+
})
|
packages/app/e2e/regression/cross-server-tab-close.spec.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page, type Route } from "@playwright/test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { currentSession } from "../utils/mock-server"
|
| 4 |
+
|
| 5 |
+
const serverA = "http://127.0.0.1:4096"
|
| 6 |
+
const serverB = "http://127.0.0.1:4097"
|
| 7 |
+
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
| 8 |
+
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
| 9 |
+
|
| 10 |
+
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
|
| 11 |
+
const requests: string[] = []
|
| 12 |
+
await mockServers(page, requests)
|
| 13 |
+
await page.addInitScript(
|
| 14 |
+
({ serverB, sessionA, sessionB }) => {
|
| 15 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 16 |
+
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
| 17 |
+
localStorage.setItem(
|
| 18 |
+
"opencode.window.browser.dat:tabs",
|
| 19 |
+
JSON.stringify([
|
| 20 |
+
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
| 21 |
+
{ type: "session", server: serverB, sessionId: sessionB },
|
| 22 |
+
]),
|
| 23 |
+
)
|
| 24 |
+
},
|
| 25 |
+
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
| 29 |
+
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
| 30 |
+
await page.goto(hrefA)
|
| 31 |
+
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
| 32 |
+
|
| 33 |
+
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
| 34 |
+
await tabA.locator('[data-slot="tab-close"] button').click()
|
| 35 |
+
|
| 36 |
+
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
| 37 |
+
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
|
| 38 |
+
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
| 39 |
+
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
| 40 |
+
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
| 41 |
+
expect(
|
| 42 |
+
requests.some((request) => {
|
| 43 |
+
const url = new URL(request)
|
| 44 |
+
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
|
| 45 |
+
}),
|
| 46 |
+
).toBe(true)
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
|
| 50 |
+
await mockServers(page, [])
|
| 51 |
+
await page.addInitScript(
|
| 52 |
+
({ serverB, sessionB }) => {
|
| 53 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 54 |
+
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
| 55 |
+
localStorage.setItem(
|
| 56 |
+
"opencode.window.browser.dat:tabs",
|
| 57 |
+
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
| 58 |
+
)
|
| 59 |
+
},
|
| 60 |
+
{ serverB, sessionB: sessionB.id },
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
| 64 |
+
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
|
| 65 |
+
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
function session(id: string, directory: string, title: string) {
|
| 69 |
+
return {
|
| 70 |
+
id,
|
| 71 |
+
slug: id,
|
| 72 |
+
projectID: `project-${id}`,
|
| 73 |
+
directory,
|
| 74 |
+
title,
|
| 75 |
+
version: "dev",
|
| 76 |
+
time: { created: 1, updated: 1 },
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
async function mockServers(page: Page, requests: string[]) {
|
| 81 |
+
await page.route("**/*", async (route) => {
|
| 82 |
+
const url = new URL(route.request().url())
|
| 83 |
+
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
| 84 |
+
requests.push(url.toString())
|
| 85 |
+
const current = url.origin === serverA ? sessionA : sessionB
|
| 86 |
+
const directory = url.searchParams.get("directory")
|
| 87 |
+
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
| 88 |
+
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
| 89 |
+
return sse(route)
|
| 90 |
+
if (url.pathname === "/global/health") return json(route, {}, 404)
|
| 91 |
+
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
| 92 |
+
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
| 93 |
+
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
| 94 |
+
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
| 95 |
+
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
| 96 |
+
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
| 97 |
+
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
| 98 |
+
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
| 99 |
+
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
| 100 |
+
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
| 101 |
+
return json(route, [])
|
| 102 |
+
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
| 103 |
+
if (url.pathname === "/provider")
|
| 104 |
+
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
| 105 |
+
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
| 106 |
+
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
| 107 |
+
const project = {
|
| 108 |
+
id: current.projectID,
|
| 109 |
+
worktree: current.directory,
|
| 110 |
+
vcs: "git",
|
| 111 |
+
time: { created: 1, updated: 1 },
|
| 112 |
+
sandboxes: [],
|
| 113 |
+
}
|
| 114 |
+
return json(route, url.pathname === "/project" ? [project] : project)
|
| 115 |
+
}
|
| 116 |
+
if (url.pathname === "/path")
|
| 117 |
+
return json(route, {
|
| 118 |
+
state: current.directory,
|
| 119 |
+
config: current.directory,
|
| 120 |
+
worktree: current.directory,
|
| 121 |
+
directory: current.directory,
|
| 122 |
+
home: current.directory,
|
| 123 |
+
})
|
| 124 |
+
if (url.pathname === "/api/path")
|
| 125 |
+
return json(route, {
|
| 126 |
+
state: current.directory,
|
| 127 |
+
config: current.directory,
|
| 128 |
+
worktree: current.directory,
|
| 129 |
+
directory: current.directory,
|
| 130 |
+
home: current.directory,
|
| 131 |
+
})
|
| 132 |
+
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
| 133 |
+
if (url.pathname === "/api/vcs")
|
| 134 |
+
return json(route, {
|
| 135 |
+
location: { directory: current.directory },
|
| 136 |
+
data: { branch: "main", defaultBranch: "main" },
|
| 137 |
+
})
|
| 138 |
+
return json(route, {})
|
| 139 |
+
})
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function json(route: Route, body: unknown, status = 200) {
|
| 143 |
+
return route.fulfill({
|
| 144 |
+
status,
|
| 145 |
+
contentType: "application/json",
|
| 146 |
+
headers: { "access-control-allow-origin": "*" },
|
| 147 |
+
body: JSON.stringify(body),
|
| 148 |
+
})
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function sse(route: Route) {
|
| 152 |
+
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
| 153 |
+
}
|
packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/FileBrowserSidebar"
|
| 7 |
+
const projectID = "proj_file_browser_sidebar"
|
| 8 |
+
const sessionID = "ses_file_browser_sidebar"
|
| 9 |
+
const title = "File browser sidebar"
|
| 10 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 11 |
+
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
|
| 12 |
+
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
|
| 13 |
+
const PROBE = "original"
|
| 14 |
+
|
| 15 |
+
test.use({ viewport: { width: 1440, height: 900 } })
|
| 16 |
+
|
| 17 |
+
// The file-browser sidebar must stay mounted across preview/pinned file-tab
|
| 18 |
+
// switches. Remounting resets scroll and filter state.
|
| 19 |
+
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
|
| 20 |
+
await setup(page)
|
| 21 |
+
|
| 22 |
+
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
| 23 |
+
await expectSessionTitle(page, title)
|
| 24 |
+
|
| 25 |
+
const panel = page.locator("#review-panel")
|
| 26 |
+
await panel.getByRole("button", { name: "Open file" }).click()
|
| 27 |
+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
| 28 |
+
|
| 29 |
+
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
| 30 |
+
await expect(sidebar).toBeVisible()
|
| 31 |
+
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
|
| 32 |
+
|
| 33 |
+
await panel.getByRole("button", { name: "file-00.ts" }).click()
|
| 34 |
+
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
|
| 35 |
+
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
|
| 36 |
+
|
| 37 |
+
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
| 38 |
+
await viewport.hover()
|
| 39 |
+
await page.mouse.wheel(0, 100_000)
|
| 40 |
+
await expect
|
| 41 |
+
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
| 42 |
+
.toBeLessThanOrEqual(1)
|
| 43 |
+
const scrolled = await viewport.evaluate((element) => element.scrollTop)
|
| 44 |
+
expect(scrolled).toBeGreaterThan(0)
|
| 45 |
+
await writeProbe(page)
|
| 46 |
+
|
| 47 |
+
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
| 48 |
+
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
| 49 |
+
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
|
| 50 |
+
expect(await readProbe(page)).toBe(PROBE)
|
| 51 |
+
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
| 52 |
+
|
| 53 |
+
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
|
| 54 |
+
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
| 55 |
+
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
| 56 |
+
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
| 57 |
+
await panel.getByRole("tab", { name: "file-78.ts" }).click()
|
| 58 |
+
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
| 59 |
+
expect(await readProbe(page)).toBe(PROBE)
|
| 60 |
+
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
test("keeps previous file search results visible while the next search loads", async ({ page }) => {
|
| 64 |
+
const searchPending = Promise.withResolvers<void>()
|
| 65 |
+
await setup(page, async ({ query }) => {
|
| 66 |
+
if (query === "file-0") return ["file-00.ts"]
|
| 67 |
+
if (query === "file-7") {
|
| 68 |
+
await searchPending.promise
|
| 69 |
+
return ["file-79.ts"]
|
| 70 |
+
}
|
| 71 |
+
return []
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
| 75 |
+
await expectSessionTitle(page, title)
|
| 76 |
+
|
| 77 |
+
const panel = page.locator("#review-panel")
|
| 78 |
+
await panel.getByRole("button", { name: "Open file" }).click()
|
| 79 |
+
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
| 80 |
+
await filter.fill("file-0")
|
| 81 |
+
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
|
| 82 |
+
|
| 83 |
+
const nextSearch = page.waitForRequest((request) => {
|
| 84 |
+
const url = new URL(request.url())
|
| 85 |
+
return url.pathname === "/find/file" && url.searchParams.get("query") === "file-7"
|
| 86 |
+
})
|
| 87 |
+
await filter.fill("file-7")
|
| 88 |
+
await nextSearch
|
| 89 |
+
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeVisible()
|
| 90 |
+
|
| 91 |
+
searchPending.resolve()
|
| 92 |
+
await expect(panel.getByRole("option", { name: "file-79.ts" })).toBeVisible()
|
| 93 |
+
await expect(panel.getByRole("option", { name: "file-00.ts" })).toBeHidden()
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
type Probed = HTMLElement & { __e2eProbe?: string }
|
| 97 |
+
|
| 98 |
+
async function writeProbe(page: Page) {
|
| 99 |
+
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
|
| 100 |
+
;(el as Probed).__e2eProbe = probe
|
| 101 |
+
}, PROBE)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
async function readProbe(page: Page) {
|
| 105 |
+
return page
|
| 106 |
+
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
|
| 107 |
+
.evaluate((el) => (el as Probed).__e2eProbe)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async function setup(
|
| 111 |
+
page: Page,
|
| 112 |
+
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown | Promise<unknown>,
|
| 113 |
+
) {
|
| 114 |
+
await mockOpenCodeServer(page, {
|
| 115 |
+
directory,
|
| 116 |
+
project: {
|
| 117 |
+
id: projectID,
|
| 118 |
+
worktree: directory,
|
| 119 |
+
vcs: "git",
|
| 120 |
+
name: "file-browser-sidebar",
|
| 121 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 122 |
+
sandboxes: [],
|
| 123 |
+
},
|
| 124 |
+
provider: {
|
| 125 |
+
all: [
|
| 126 |
+
{
|
| 127 |
+
id: "opencode",
|
| 128 |
+
name: "OpenCode",
|
| 129 |
+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
| 130 |
+
},
|
| 131 |
+
],
|
| 132 |
+
connected: ["opencode"],
|
| 133 |
+
default: { providerID: "opencode", modelID: "test" },
|
| 134 |
+
},
|
| 135 |
+
sessions: [
|
| 136 |
+
{
|
| 137 |
+
id: sessionID,
|
| 138 |
+
slug: sessionID,
|
| 139 |
+
projectID,
|
| 140 |
+
directory,
|
| 141 |
+
title,
|
| 142 |
+
version: "dev",
|
| 143 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 144 |
+
},
|
| 145 |
+
],
|
| 146 |
+
vcsDiff: [],
|
| 147 |
+
fileList: (path) => {
|
| 148 |
+
if (path) return []
|
| 149 |
+
return files.map((name) => ({
|
| 150 |
+
name,
|
| 151 |
+
path: name,
|
| 152 |
+
absolute: `${directory}/${name}`,
|
| 153 |
+
type: "file" as const,
|
| 154 |
+
ignored: false,
|
| 155 |
+
}))
|
| 156 |
+
},
|
| 157 |
+
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
| 158 |
+
findFiles,
|
| 159 |
+
pageMessages: () => ({ items: [] }),
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
await page.addInitScript(
|
| 163 |
+
({ directory, server, sessionID }) => {
|
| 164 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 165 |
+
localStorage.setItem(
|
| 166 |
+
"opencode.global.dat:server",
|
| 167 |
+
JSON.stringify({
|
| 168 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 169 |
+
lastProject: { local: directory },
|
| 170 |
+
}),
|
| 171 |
+
)
|
| 172 |
+
localStorage.setItem(
|
| 173 |
+
"opencode.global.dat:layout",
|
| 174 |
+
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
| 175 |
+
)
|
| 176 |
+
localStorage.setItem(
|
| 177 |
+
"opencode.global.dat:review-panel-v2",
|
| 178 |
+
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
| 179 |
+
)
|
| 180 |
+
localStorage.setItem(
|
| 181 |
+
"opencode.window.browser.dat:tabs",
|
| 182 |
+
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
| 183 |
+
)
|
| 184 |
+
},
|
| 185 |
+
{ directory, server, sessionID },
|
| 186 |
+
)
|
| 187 |
+
}
|
packages/app/e2e/regression/new-session-panel-corner.spec.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 3 |
+
import { expectAppVisible } from "../utils/waits"
|
| 4 |
+
|
| 5 |
+
const draftID = "draft_new_session_panel_corner"
|
| 6 |
+
const directory = "C:/OpenCode/NewSessionPanelCorner"
|
| 7 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 8 |
+
|
| 9 |
+
test.use({
|
| 10 |
+
viewport: { width: 935, height: 522 },
|
| 11 |
+
deviceScaleFactor: 1,
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => {
|
| 15 |
+
await mockOpenCodeServer(page, {
|
| 16 |
+
directory,
|
| 17 |
+
project: {
|
| 18 |
+
id: "proj_new_session_panel_corner",
|
| 19 |
+
worktree: directory,
|
| 20 |
+
vcs: "git",
|
| 21 |
+
name: "new-session-panel-corner",
|
| 22 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 23 |
+
sandboxes: [],
|
| 24 |
+
},
|
| 25 |
+
provider: { all: [], connected: [], default: {} },
|
| 26 |
+
sessions: [],
|
| 27 |
+
pageMessages: () => ({ items: [] }),
|
| 28 |
+
})
|
| 29 |
+
await page.addInitScript(
|
| 30 |
+
({ directory, draftID, server }) => {
|
| 31 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 32 |
+
localStorage.setItem("opencode-theme-id", "oc-2")
|
| 33 |
+
localStorage.setItem("opencode-color-scheme", "dark")
|
| 34 |
+
localStorage.setItem(
|
| 35 |
+
"opencode.global.dat:server",
|
| 36 |
+
JSON.stringify({
|
| 37 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 38 |
+
lastProject: { local: directory },
|
| 39 |
+
}),
|
| 40 |
+
)
|
| 41 |
+
localStorage.setItem(
|
| 42 |
+
"opencode.window.browser.dat:tabs",
|
| 43 |
+
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
| 44 |
+
)
|
| 45 |
+
},
|
| 46 |
+
{ directory, draftID, server },
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
await page.goto(`/new-session?draftId=${draftID}`)
|
| 50 |
+
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
| 51 |
+
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
| 52 |
+
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
|
| 53 |
+
await expect(panel).toHaveCount(1)
|
| 54 |
+
const box = await panel.boundingBox()
|
| 55 |
+
if (!box) throw new Error("New-session panel bounds are unavailable")
|
| 56 |
+
|
| 57 |
+
const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") })
|
| 58 |
+
const corners = await page.evaluate(
|
| 59 |
+
async ({ source, points }) => {
|
| 60 |
+
const image = new Image()
|
| 61 |
+
image.src = source
|
| 62 |
+
await image.decode()
|
| 63 |
+
const canvas = document.createElement("canvas")
|
| 64 |
+
canvas.width = image.naturalWidth
|
| 65 |
+
canvas.height = image.naturalHeight
|
| 66 |
+
const context = canvas.getContext("2d")
|
| 67 |
+
if (!context) throw new Error("2D canvas is unavailable")
|
| 68 |
+
context.drawImage(image, 0, 0)
|
| 69 |
+
return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
| 73 |
+
points: [
|
| 74 |
+
{ x: Math.floor(box.x), y: Math.floor(box.y) },
|
| 75 |
+
{ x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
|
| 76 |
+
{ x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
|
| 77 |
+
{ x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
|
| 78 |
+
],
|
| 79 |
+
},
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true)
|
| 83 |
+
})
|
packages/app/e2e/regression/open-file-expand-folder.spec.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/OpenFileExpand"
|
| 7 |
+
const projectID = "proj_open_file_expand"
|
| 8 |
+
const sessionID = "ses_open_file_expand"
|
| 9 |
+
const title = "Open file expand"
|
| 10 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 11 |
+
|
| 12 |
+
test.use({ viewport: { width: 1440, height: 900 } })
|
| 13 |
+
|
| 14 |
+
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
| 15 |
+
await mockOpenCodeServer(page, {
|
| 16 |
+
directory,
|
| 17 |
+
project: {
|
| 18 |
+
id: projectID,
|
| 19 |
+
worktree: directory,
|
| 20 |
+
vcs: "git",
|
| 21 |
+
name: "open-file-expand",
|
| 22 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 23 |
+
sandboxes: [],
|
| 24 |
+
},
|
| 25 |
+
provider: {
|
| 26 |
+
all: [
|
| 27 |
+
{
|
| 28 |
+
id: "opencode",
|
| 29 |
+
name: "OpenCode",
|
| 30 |
+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
| 31 |
+
},
|
| 32 |
+
],
|
| 33 |
+
connected: ["opencode"],
|
| 34 |
+
default: { providerID: "opencode", modelID: "test" },
|
| 35 |
+
},
|
| 36 |
+
sessions: [
|
| 37 |
+
{
|
| 38 |
+
id: sessionID,
|
| 39 |
+
slug: sessionID,
|
| 40 |
+
projectID,
|
| 41 |
+
directory,
|
| 42 |
+
title,
|
| 43 |
+
version: "dev",
|
| 44 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 45 |
+
},
|
| 46 |
+
],
|
| 47 |
+
vcsDiff: [],
|
| 48 |
+
fileList: (path) => {
|
| 49 |
+
if (path === "frontend\\" || path === "frontend") {
|
| 50 |
+
return [
|
| 51 |
+
{
|
| 52 |
+
name: "app.ts",
|
| 53 |
+
path: "frontend\\app.ts",
|
| 54 |
+
absolute: `${directory}/frontend/app.ts`,
|
| 55 |
+
type: "file" as const,
|
| 56 |
+
ignored: false,
|
| 57 |
+
},
|
| 58 |
+
]
|
| 59 |
+
}
|
| 60 |
+
if (path) return []
|
| 61 |
+
return [
|
| 62 |
+
{
|
| 63 |
+
name: "frontend",
|
| 64 |
+
path: "frontend\\",
|
| 65 |
+
absolute: `${directory}/frontend`,
|
| 66 |
+
type: "directory" as const,
|
| 67 |
+
ignored: false,
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
name: "README.md",
|
| 71 |
+
path: "README.md",
|
| 72 |
+
absolute: `${directory}/README.md`,
|
| 73 |
+
type: "file" as const,
|
| 74 |
+
ignored: false,
|
| 75 |
+
},
|
| 76 |
+
]
|
| 77 |
+
},
|
| 78 |
+
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
| 79 |
+
pageMessages: () => ({ items: [] }),
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
await page.addInitScript(
|
| 83 |
+
({ directory, server, sessionID }) => {
|
| 84 |
+
localStorage.setItem(
|
| 85 |
+
"settings.v3",
|
| 86 |
+
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
| 87 |
+
)
|
| 88 |
+
localStorage.setItem(
|
| 89 |
+
"opencode.global.dat:server",
|
| 90 |
+
JSON.stringify({
|
| 91 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 92 |
+
lastProject: { local: directory },
|
| 93 |
+
}),
|
| 94 |
+
)
|
| 95 |
+
localStorage.setItem(
|
| 96 |
+
"opencode.global.dat:layout",
|
| 97 |
+
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
| 98 |
+
)
|
| 99 |
+
localStorage.setItem(
|
| 100 |
+
"opencode.global.dat:review-panel-v2",
|
| 101 |
+
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
| 102 |
+
)
|
| 103 |
+
localStorage.setItem(
|
| 104 |
+
"opencode.window.browser.dat:tabs",
|
| 105 |
+
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
| 106 |
+
)
|
| 107 |
+
},
|
| 108 |
+
{ directory, server, sessionID },
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
| 112 |
+
await expectSessionTitle(page, title)
|
| 113 |
+
|
| 114 |
+
const panel = page.locator("#review-panel")
|
| 115 |
+
await panel.getByRole("button", { name: "Open file" }).click()
|
| 116 |
+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
| 117 |
+
|
| 118 |
+
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
| 119 |
+
await expect(sidebar).toBeVisible()
|
| 120 |
+
|
| 121 |
+
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
| 122 |
+
await expect(frontendRow).toBeVisible()
|
| 123 |
+
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
| 124 |
+
await frontendRow.click()
|
| 125 |
+
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
| 126 |
+
|
| 127 |
+
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
| 128 |
+
await expect(appRow).toBeVisible()
|
| 129 |
+
await appRow.click()
|
| 130 |
+
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
| 131 |
+
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
| 132 |
+
})
|
packages/app/e2e/regression/project-picker-recent-search.spec.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import type { Page } from "@playwright/test"
|
| 3 |
+
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
| 4 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 5 |
+
import { expectAppVisible } from "../utils/waits"
|
| 6 |
+
|
| 7 |
+
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
|
| 8 |
+
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
|
| 9 |
+
|
| 10 |
+
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
|
| 11 |
+
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
|
| 12 |
+
const OUTSIDE_CAP = "foxtrot-docs"
|
| 13 |
+
|
| 14 |
+
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
|
| 15 |
+
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
|
| 16 |
+
const rows = (page: Page) => page.locator("[data-directory-path]")
|
| 17 |
+
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
|
| 18 |
+
|
| 19 |
+
async function openProjectDialog(page: Page) {
|
| 20 |
+
await mockOpenCodeServer(page, {
|
| 21 |
+
sessions: fixture.sessions,
|
| 22 |
+
provider: fixture.provider,
|
| 23 |
+
directory: fixture.directory,
|
| 24 |
+
project: fixture.project,
|
| 25 |
+
pageMessages,
|
| 26 |
+
fileList: () => [],
|
| 27 |
+
findFiles: () => [],
|
| 28 |
+
})
|
| 29 |
+
await page.addInitScript((dirs) => {
|
| 30 |
+
localStorage.setItem(
|
| 31 |
+
"opencode.global.dat:server",
|
| 32 |
+
JSON.stringify({
|
| 33 |
+
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
|
| 34 |
+
lastProject: {},
|
| 35 |
+
}),
|
| 36 |
+
)
|
| 37 |
+
}, worktrees)
|
| 38 |
+
await page.goto("/")
|
| 39 |
+
const add = page.getByRole("button", { name: "Add project" }).first()
|
| 40 |
+
await expectAppVisible(add)
|
| 41 |
+
await add.click()
|
| 42 |
+
await expect(rows(page)).toHaveCount(5)
|
| 43 |
+
return page.getByRole("textbox").last()
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
test("searches every recent project, not just the five most recent", async ({ page }) => {
|
| 47 |
+
const search = await openProjectDialog(page)
|
| 48 |
+
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
| 49 |
+
|
| 50 |
+
await search.fill("foxtrot")
|
| 51 |
+
|
| 52 |
+
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
test("still caps the idle recent list at five projects", async ({ page }) => {
|
| 56 |
+
await openProjectDialog(page)
|
| 57 |
+
|
| 58 |
+
await expect(row(page, NAMES[4])).toHaveCount(1)
|
| 59 |
+
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
| 60 |
+
})
|
packages/app/e2e/regression/prompt-input-v2-command-draft.spec.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectAppVisible } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/PromptInputV2Editing"
|
| 7 |
+
const projectID = "proj_prompt_input_v2_editing"
|
| 8 |
+
const sessionID = "ses_prompt_input_v2_editing"
|
| 9 |
+
|
| 10 |
+
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
| 11 |
+
await mockOpenCodeServer(page, {
|
| 12 |
+
directory,
|
| 13 |
+
project: {
|
| 14 |
+
id: projectID,
|
| 15 |
+
worktree: directory,
|
| 16 |
+
vcs: "git",
|
| 17 |
+
name: "prompt-input-v2-editing",
|
| 18 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 19 |
+
sandboxes: [],
|
| 20 |
+
},
|
| 21 |
+
provider: { all: [], connected: [], default: {} },
|
| 22 |
+
sessions: [
|
| 23 |
+
{
|
| 24 |
+
id: sessionID,
|
| 25 |
+
slug: "prompt-input-v2-editing",
|
| 26 |
+
projectID,
|
| 27 |
+
directory,
|
| 28 |
+
title: "Prompt input V2 editing",
|
| 29 |
+
version: "dev",
|
| 30 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 31 |
+
},
|
| 32 |
+
],
|
| 33 |
+
pageMessages: () => ({ items: [] }),
|
| 34 |
+
})
|
| 35 |
+
await page.addInitScript(() => {
|
| 36 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 40 |
+
const composer = page.locator('[data-component="prompt-input-v2"]')
|
| 41 |
+
const input = composer.locator('[data-component="prompt-input"]')
|
| 42 |
+
await expectAppVisible(composer)
|
| 43 |
+
|
| 44 |
+
await input.fill("keep me")
|
| 45 |
+
await composer.getByRole("button", { name: "Add images and files" }).click()
|
| 46 |
+
await page.getByRole("menuitem", { name: "Commands" }).click()
|
| 47 |
+
await page.locator('[data-suggestion-id="model.choose"]').click()
|
| 48 |
+
|
| 49 |
+
await expect(input).toHaveText("keep me")
|
| 50 |
+
})
|
packages/app/e2e/regression/prompt-thinking-level.spec.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectAppVisible } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
|
| 7 |
+
const projectID = "proj_prompt_thinking_level_regression"
|
| 8 |
+
const sessionID = "ses_prompt_thinking_level_regression"
|
| 9 |
+
|
| 10 |
+
test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
| 11 |
+
await mockOpenCodeServer(page, {
|
| 12 |
+
directory,
|
| 13 |
+
project: {
|
| 14 |
+
id: projectID,
|
| 15 |
+
worktree: directory,
|
| 16 |
+
vcs: "git",
|
| 17 |
+
name: "prompt-thinking-level-regression",
|
| 18 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 19 |
+
sandboxes: [],
|
| 20 |
+
},
|
| 21 |
+
provider: {
|
| 22 |
+
all: [
|
| 23 |
+
{
|
| 24 |
+
id: "opencode",
|
| 25 |
+
name: "OpenCode",
|
| 26 |
+
models: {
|
| 27 |
+
"thinking-model": {
|
| 28 |
+
id: "thinking-model",
|
| 29 |
+
name: "Thinking Model",
|
| 30 |
+
limit: { context: 200_000 },
|
| 31 |
+
variants: { high: {} },
|
| 32 |
+
},
|
| 33 |
+
},
|
| 34 |
+
},
|
| 35 |
+
],
|
| 36 |
+
connected: ["opencode"],
|
| 37 |
+
default: { providerID: "opencode", modelID: "thinking-model" },
|
| 38 |
+
},
|
| 39 |
+
sessions: [
|
| 40 |
+
{
|
| 41 |
+
id: sessionID,
|
| 42 |
+
slug: "prompt-thinking-level-regression",
|
| 43 |
+
projectID,
|
| 44 |
+
directory,
|
| 45 |
+
title: "Prompt thinking level regression",
|
| 46 |
+
version: "dev",
|
| 47 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 48 |
+
},
|
| 49 |
+
],
|
| 50 |
+
pageMessages: () => ({ items: [] }),
|
| 51 |
+
})
|
| 52 |
+
await page.addInitScript(() => {
|
| 53 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 57 |
+
const composer = page.locator('[data-component="prompt-input-v2"]')
|
| 58 |
+
const input = composer.locator('[data-component="prompt-input"]')
|
| 59 |
+
const control = composer.getByRole("button", { name: "Choose model variant" })
|
| 60 |
+
await expectAppVisible(composer)
|
| 61 |
+
|
| 62 |
+
await idleComposer(page)
|
| 63 |
+
await expect(control).toBeVisible()
|
| 64 |
+
|
| 65 |
+
await control.click()
|
| 66 |
+
const high = page.getByRole("menuitemradio", { name: "high" })
|
| 67 |
+
await expect(high).toBeVisible()
|
| 68 |
+
await page.mouse.move(0, 0)
|
| 69 |
+
await expect(control).toBeVisible()
|
| 70 |
+
await expect(high).toBeVisible()
|
| 71 |
+
await high.click()
|
| 72 |
+
|
| 73 |
+
await idleComposer(page)
|
| 74 |
+
await input.focus()
|
| 75 |
+
await expect(control).toBeVisible()
|
| 76 |
+
|
| 77 |
+
await idleComposer(page)
|
| 78 |
+
await expect(control).toBeVisible()
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
async function idleComposer(page: Page) {
|
| 82 |
+
await page.mouse.move(0, 0)
|
| 83 |
+
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
| 84 |
+
}
|
packages/app/e2e/regression/remote-session-settings.spec.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page, type Route } from "@playwright/test"
|
| 3 |
+
import { installSseTransport } from "../utils/sse-transport"
|
| 4 |
+
import { currentSession } from "../utils/mock-server"
|
| 5 |
+
|
| 6 |
+
const serverA = "http://127.0.0.1:4096"
|
| 7 |
+
const serverB = "http://127.0.0.1:4097"
|
| 8 |
+
const directoryA = "C:/server-a"
|
| 9 |
+
const directoryB = "/home/server-b"
|
| 10 |
+
const sessionA = session("ses_server_a", directoryA, "Server A session")
|
| 11 |
+
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
|
| 12 |
+
const sessionB = session("ses_server_b", directoryB, "Server B session")
|
| 13 |
+
|
| 14 |
+
test("session settings use the remote server context", async ({ page }) => {
|
| 15 |
+
const permissionRequests: string[] = []
|
| 16 |
+
await mockServers(page, permissionRequests)
|
| 17 |
+
await configureServers(page)
|
| 18 |
+
|
| 19 |
+
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
| 20 |
+
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
| 21 |
+
await page.keyboard.press("Control+,")
|
| 22 |
+
|
| 23 |
+
const dialog = page.locator(".settings-v2-dialog")
|
| 24 |
+
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
| 25 |
+
const input = autoAccept.getByRole("switch")
|
| 26 |
+
await expect(autoAccept).toBeVisible()
|
| 27 |
+
await expect(input).toBeEnabled()
|
| 28 |
+
permissionRequests.length = 0
|
| 29 |
+
await autoAccept.locator('[data-slot="switch-control"]').click()
|
| 30 |
+
await expect(input).toBeChecked()
|
| 31 |
+
await expect
|
| 32 |
+
.poll(() =>
|
| 33 |
+
permissionRequests.some((request) => {
|
| 34 |
+
const url = new URL(request)
|
| 35 |
+
return url.origin === serverB && url.searchParams.get("directory") === directoryB
|
| 36 |
+
}),
|
| 37 |
+
)
|
| 38 |
+
.toBe(true)
|
| 39 |
+
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
|
| 40 |
+
|
| 41 |
+
await dialog.getByRole("tab", { name: "Models" }).click()
|
| 42 |
+
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
| 43 |
+
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
| 47 |
+
const permissionRequests: string[] = []
|
| 48 |
+
const permissionResponses: PermissionResponse[] = []
|
| 49 |
+
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
| 50 |
+
server: serverA,
|
| 51 |
+
retry: 20,
|
| 52 |
+
})
|
| 53 |
+
await mockServers(page, permissionRequests, permissionResponses)
|
| 54 |
+
await configureServers(page, [
|
| 55 |
+
{ type: "session", server: serverA, sessionId: sessionA.id },
|
| 56 |
+
{ type: "session", server: serverB, sessionId: sessionB.id },
|
| 57 |
+
])
|
| 58 |
+
|
| 59 |
+
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
| 60 |
+
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
| 61 |
+
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
| 62 |
+
await page.keyboard.press("Control+,")
|
| 63 |
+
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
| 64 |
+
await autoAccept.locator('[data-slot="switch-control"]').click()
|
| 65 |
+
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
| 66 |
+
await expect
|
| 67 |
+
.poll(() =>
|
| 68 |
+
permissionRequests.some((request) => {
|
| 69 |
+
const url = new URL(request)
|
| 70 |
+
return url.origin === serverA && url.searchParams.get("directory") === directoryA
|
| 71 |
+
}),
|
| 72 |
+
)
|
| 73 |
+
.toBe(true)
|
| 74 |
+
await page.keyboard.press("Escape")
|
| 75 |
+
|
| 76 |
+
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
|
| 77 |
+
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
| 78 |
+
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
| 79 |
+
await transport.waitForConnection()
|
| 80 |
+
|
| 81 |
+
await transport.send({
|
| 82 |
+
directory: directoryA,
|
| 83 |
+
payload: {
|
| 84 |
+
id: "event-permission-background-a",
|
| 85 |
+
type: "permission.asked",
|
| 86 |
+
properties: {
|
| 87 |
+
id: "permission-background-a",
|
| 88 |
+
sessionID: sessionA.id,
|
| 89 |
+
permission: "bash",
|
| 90 |
+
patterns: ["git status"],
|
| 91 |
+
metadata: {},
|
| 92 |
+
always: [],
|
| 93 |
+
},
|
| 94 |
+
},
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
await expect
|
| 98 |
+
.poll(() => permissionResponses)
|
| 99 |
+
.toEqual([
|
| 100 |
+
{
|
| 101 |
+
origin: serverA,
|
| 102 |
+
directory: directoryA,
|
| 103 |
+
sessionID: sessionA.id,
|
| 104 |
+
permissionID: "permission-background-a",
|
| 105 |
+
body: { response: "once" },
|
| 106 |
+
},
|
| 107 |
+
])
|
| 108 |
+
|
| 109 |
+
await transport.send({
|
| 110 |
+
directory: directoryA,
|
| 111 |
+
payload: {
|
| 112 |
+
id: "event-permission-background-a-child",
|
| 113 |
+
type: "permission.asked",
|
| 114 |
+
properties: {
|
| 115 |
+
id: "permission-background-a-child",
|
| 116 |
+
sessionID: childSessionA.id,
|
| 117 |
+
permission: "bash",
|
| 118 |
+
patterns: ["git diff"],
|
| 119 |
+
metadata: {},
|
| 120 |
+
always: [],
|
| 121 |
+
},
|
| 122 |
+
},
|
| 123 |
+
})
|
| 124 |
+
|
| 125 |
+
await expect
|
| 126 |
+
.poll(() => permissionResponses)
|
| 127 |
+
.toEqual([
|
| 128 |
+
{
|
| 129 |
+
origin: serverA,
|
| 130 |
+
directory: directoryA,
|
| 131 |
+
sessionID: sessionA.id,
|
| 132 |
+
permissionID: "permission-background-a",
|
| 133 |
+
body: { response: "once" },
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
origin: serverA,
|
| 137 |
+
directory: directoryA,
|
| 138 |
+
sessionID: childSessionA.id,
|
| 139 |
+
permissionID: "permission-background-a-child",
|
| 140 |
+
body: { response: "once" },
|
| 141 |
+
},
|
| 142 |
+
])
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
type PermissionResponse = {
|
| 146 |
+
origin: string
|
| 147 |
+
directory?: string
|
| 148 |
+
sessionID: string
|
| 149 |
+
permissionID: string
|
| 150 |
+
body: unknown
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
| 154 |
+
await page.addInitScript(
|
| 155 |
+
({ serverB, tabs }) => {
|
| 156 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 157 |
+
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
| 158 |
+
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
|
| 159 |
+
},
|
| 160 |
+
{ serverB, tabs },
|
| 161 |
+
)
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
|
| 165 |
+
await page.route("**/*", async (route) => {
|
| 166 |
+
const url = new URL(route.request().url())
|
| 167 |
+
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
| 168 |
+
const remote = url.origin === serverB
|
| 169 |
+
const directory = remote ? directoryB : directoryA
|
| 170 |
+
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
|
| 171 |
+
const requestDirectory = url.searchParams.get("directory")
|
| 172 |
+
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
| 173 |
+
if (route.request().method() === "POST" && response) {
|
| 174 |
+
permissionResponses.push({
|
| 175 |
+
origin: url.origin,
|
| 176 |
+
directory: requestDirectory ?? undefined,
|
| 177 |
+
sessionID: response[1]!,
|
| 178 |
+
permissionID: response[2]!,
|
| 179 |
+
body: route.request().postDataJSON(),
|
| 180 |
+
})
|
| 181 |
+
return json(route, true)
|
| 182 |
+
}
|
| 183 |
+
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
| 184 |
+
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
| 185 |
+
return sse(route)
|
| 186 |
+
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
| 187 |
+
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
| 188 |
+
return json(route, { data: [] })
|
| 189 |
+
if (url.pathname === "/api/model/default") return json(route, { data: null })
|
| 190 |
+
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
|
| 191 |
+
return json(route, { location: { directory }, data: [] })
|
| 192 |
+
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
|
| 193 |
+
if (url.pathname === "/api/mcp/resource")
|
| 194 |
+
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
|
| 195 |
+
if (url.pathname === "/api/project") {
|
| 196 |
+
return json(route, [
|
| 197 |
+
{
|
| 198 |
+
id: remote ? sessionB.projectID : "project-server-a",
|
| 199 |
+
worktree: directory,
|
| 200 |
+
vcs: "git",
|
| 201 |
+
time: { created: 1, updated: 1 },
|
| 202 |
+
sandboxes: [],
|
| 203 |
+
},
|
| 204 |
+
])
|
| 205 |
+
}
|
| 206 |
+
if (url.pathname === "/api/project/current")
|
| 207 |
+
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
| 208 |
+
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
| 209 |
+
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
| 210 |
+
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
| 211 |
+
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
| 212 |
+
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
| 213 |
+
return json(route, { data: [], cursor: {} })
|
| 214 |
+
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
| 215 |
+
if (current) return json(route, current)
|
| 216 |
+
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
| 217 |
+
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
| 218 |
+
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
| 219 |
+
if (url.pathname === "/permission") {
|
| 220 |
+
permissionRequests.push(url.toString())
|
| 221 |
+
return json(route, [])
|
| 222 |
+
}
|
| 223 |
+
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
| 224 |
+
return json(route, [])
|
| 225 |
+
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
| 226 |
+
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
| 227 |
+
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
| 228 |
+
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
| 229 |
+
const project = {
|
| 230 |
+
id: remote ? sessionB.projectID : "project-server-a",
|
| 231 |
+
worktree: directory,
|
| 232 |
+
vcs: "git",
|
| 233 |
+
time: { created: 1, updated: 1 },
|
| 234 |
+
sandboxes: [],
|
| 235 |
+
}
|
| 236 |
+
return json(route, url.pathname === "/project" ? [project] : project)
|
| 237 |
+
}
|
| 238 |
+
if (url.pathname === "/path")
|
| 239 |
+
return json(route, {
|
| 240 |
+
state: directory,
|
| 241 |
+
config: directory,
|
| 242 |
+
worktree: directory,
|
| 243 |
+
directory,
|
| 244 |
+
home: directory,
|
| 245 |
+
})
|
| 246 |
+
if (url.pathname === "/api/path")
|
| 247 |
+
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
|
| 248 |
+
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
| 249 |
+
if (url.pathname === "/api/vcs")
|
| 250 |
+
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
| 251 |
+
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
| 252 |
+
return json(route, {})
|
| 253 |
+
})
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function session(id: string, directory: string, title: string) {
|
| 257 |
+
return {
|
| 258 |
+
id,
|
| 259 |
+
slug: id,
|
| 260 |
+
projectID: `project-${id}`,
|
| 261 |
+
directory,
|
| 262 |
+
title,
|
| 263 |
+
version: "dev",
|
| 264 |
+
time: { created: 1, updated: 1 },
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
function provider(id: string) {
|
| 269 |
+
const name = id === "server-b" ? "Server B" : "Server A"
|
| 270 |
+
return {
|
| 271 |
+
all: [
|
| 272 |
+
{
|
| 273 |
+
id,
|
| 274 |
+
name: `${name} Provider`,
|
| 275 |
+
models: {
|
| 276 |
+
[id]: {
|
| 277 |
+
id,
|
| 278 |
+
name: `${name} Model`,
|
| 279 |
+
family: id,
|
| 280 |
+
release_date: "2026-01-01",
|
| 281 |
+
limit: { context: 200_000 },
|
| 282 |
+
},
|
| 283 |
+
},
|
| 284 |
+
},
|
| 285 |
+
],
|
| 286 |
+
connected: [id],
|
| 287 |
+
default: { providerID: id, modelID: id },
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function json(route: Route, body: unknown, status = 200) {
|
| 292 |
+
return route.fulfill({
|
| 293 |
+
status,
|
| 294 |
+
contentType: "application/json",
|
| 295 |
+
headers: { "access-control-allow-origin": "*" },
|
| 296 |
+
body: JSON.stringify(body),
|
| 297 |
+
})
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
function sse(route: Route) {
|
| 301 |
+
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
| 302 |
+
}
|
packages/app/e2e/regression/remote-tab-busy.spec.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page, type Route } from "@playwright/test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { currentSession } from "../utils/mock-server"
|
| 4 |
+
|
| 5 |
+
const serverA = "http://127.0.0.1:4096"
|
| 6 |
+
const serverB = "http://127.0.0.1:4097"
|
| 7 |
+
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
| 8 |
+
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
| 9 |
+
|
| 10 |
+
test("tab busy indicator reflects the tab server's own session status", async ({ page }) => {
|
| 11 |
+
await mockServers(page)
|
| 12 |
+
await page.addInitScript(
|
| 13 |
+
({ serverA, serverB, sessionA, sessionB }) => {
|
| 14 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 15 |
+
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
| 16 |
+
localStorage.setItem(
|
| 17 |
+
"opencode.window.browser.dat:tabs",
|
| 18 |
+
JSON.stringify([
|
| 19 |
+
{ type: "session", server: serverA, sessionId: sessionA },
|
| 20 |
+
{ type: "session", server: serverB, sessionId: sessionB },
|
| 21 |
+
]),
|
| 22 |
+
)
|
| 23 |
+
},
|
| 24 |
+
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
| 28 |
+
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
| 29 |
+
await page.goto(hrefA)
|
| 30 |
+
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
| 31 |
+
|
| 32 |
+
// Session B is busy on server B while server A stays the active server, so the
|
| 33 |
+
// busy indicator must come from the tab server's status, not the active server's.
|
| 34 |
+
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
| 35 |
+
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
| 36 |
+
|
| 37 |
+
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
| 38 |
+
await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
|
| 39 |
+
await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
function session(id: string, directory: string, title: string) {
|
| 43 |
+
return {
|
| 44 |
+
id,
|
| 45 |
+
slug: id,
|
| 46 |
+
projectID: `project-${id}`,
|
| 47 |
+
directory,
|
| 48 |
+
title,
|
| 49 |
+
version: "dev",
|
| 50 |
+
time: { created: 1, updated: 1 },
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async function mockServers(page: Page) {
|
| 55 |
+
await page.route("**/*", async (route) => {
|
| 56 |
+
const url = new URL(route.request().url())
|
| 57 |
+
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
| 58 |
+
const current = url.origin === serverA ? sessionA : sessionB
|
| 59 |
+
const directory = url.searchParams.get("directory")
|
| 60 |
+
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
| 61 |
+
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
| 62 |
+
return sse(route, url.pathname === "/api/event")
|
| 63 |
+
if (url.pathname === "/global/health") return json(route, {}, 404)
|
| 64 |
+
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
| 65 |
+
if (url.pathname === "/api/session/active")
|
| 66 |
+
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
| 67 |
+
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
| 68 |
+
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
| 69 |
+
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
| 70 |
+
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
| 71 |
+
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
| 72 |
+
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
| 73 |
+
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
| 74 |
+
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
| 75 |
+
return json(route, [])
|
| 76 |
+
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
| 77 |
+
if (url.pathname === "/provider")
|
| 78 |
+
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
| 79 |
+
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
| 80 |
+
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
| 81 |
+
const project = {
|
| 82 |
+
id: current.projectID,
|
| 83 |
+
worktree: current.directory,
|
| 84 |
+
vcs: "git",
|
| 85 |
+
time: { created: 1, updated: 1 },
|
| 86 |
+
sandboxes: [],
|
| 87 |
+
}
|
| 88 |
+
return json(route, url.pathname === "/project" ? [project] : project)
|
| 89 |
+
}
|
| 90 |
+
if (url.pathname === "/path")
|
| 91 |
+
return json(route, {
|
| 92 |
+
state: current.directory,
|
| 93 |
+
config: current.directory,
|
| 94 |
+
worktree: current.directory,
|
| 95 |
+
directory: current.directory,
|
| 96 |
+
home: current.directory,
|
| 97 |
+
})
|
| 98 |
+
if (url.pathname === "/api/path")
|
| 99 |
+
return json(route, {
|
| 100 |
+
state: current.directory,
|
| 101 |
+
config: current.directory,
|
| 102 |
+
worktree: current.directory,
|
| 103 |
+
directory: current.directory,
|
| 104 |
+
home: current.directory,
|
| 105 |
+
})
|
| 106 |
+
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
| 107 |
+
if (url.pathname === "/api/vcs")
|
| 108 |
+
return json(route, {
|
| 109 |
+
location: { directory: current.directory },
|
| 110 |
+
data: { branch: "main", defaultBranch: "main" },
|
| 111 |
+
})
|
| 112 |
+
return json(route, {})
|
| 113 |
+
})
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function json(route: Route, body: unknown, status = 200) {
|
| 117 |
+
return route.fulfill({
|
| 118 |
+
status,
|
| 119 |
+
contentType: "application/json",
|
| 120 |
+
headers: { "access-control-allow-origin": "*" },
|
| 121 |
+
body: JSON.stringify(body),
|
| 122 |
+
})
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function sse(route: Route, current: boolean) {
|
| 126 |
+
return route.fulfill({
|
| 127 |
+
status: 200,
|
| 128 |
+
contentType: "text/event-stream",
|
| 129 |
+
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
| 130 |
+
})
|
| 131 |
+
}
|
packages/app/e2e/regression/review-image-flash.spec.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/ReviewImageFlashRegression"
|
| 7 |
+
const sessionID = "ses_review_image_flash_regression"
|
| 8 |
+
const title = "Review image flash regression"
|
| 9 |
+
const imageFile = "assets/preview.png"
|
| 10 |
+
|
| 11 |
+
test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
|
| 12 |
+
await openReview(page)
|
| 13 |
+
await installReviewFlashProbe(page)
|
| 14 |
+
|
| 15 |
+
await page.getByRole("button", { name: /preview\.png/ }).click()
|
| 16 |
+
await waitForReviewFlashProbe(page, 400)
|
| 17 |
+
const trace = await collectReviewFlashProbe(page)
|
| 18 |
+
const bad = trace.samples.filter((sample) => sample.blank || sample.blackCenter)
|
| 19 |
+
|
| 20 |
+
expect(trace.samples.length).toBeGreaterThan(0)
|
| 21 |
+
expect(
|
| 22 |
+
bad,
|
| 23 |
+
JSON.stringify({ bad: bad.slice(0, 8), first: trace.samples.slice(0, 8), last: trace.samples.slice(-4) }, null, 2),
|
| 24 |
+
).toEqual([])
|
| 25 |
+
})
|
| 26 |
+
|
| 27 |
+
async function openReview(page: Page) {
|
| 28 |
+
await page.setViewportSize({ width: 960, height: 900 })
|
| 29 |
+
await page.addInitScript(() => {
|
| 30 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 31 |
+
})
|
| 32 |
+
await mockOpenCodeServer(page, {
|
| 33 |
+
directory,
|
| 34 |
+
project: {
|
| 35 |
+
id: "proj_review_image_flash_regression",
|
| 36 |
+
worktree: directory,
|
| 37 |
+
vcs: "git",
|
| 38 |
+
name: "review-image-flash-regression",
|
| 39 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 40 |
+
sandboxes: [],
|
| 41 |
+
},
|
| 42 |
+
provider: { all: [], connected: [], default: {} },
|
| 43 |
+
sessions: [
|
| 44 |
+
{
|
| 45 |
+
id: sessionID,
|
| 46 |
+
slug: "review-image-flash-regression",
|
| 47 |
+
projectID: "proj_review_image_flash_regression",
|
| 48 |
+
directory,
|
| 49 |
+
title,
|
| 50 |
+
version: "dev",
|
| 51 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 52 |
+
},
|
| 53 |
+
],
|
| 54 |
+
vcsDiff: [
|
| 55 |
+
{
|
| 56 |
+
file: "src/example.ts",
|
| 57 |
+
additions: 1,
|
| 58 |
+
deletions: 1,
|
| 59 |
+
status: "modified",
|
| 60 |
+
patch:
|
| 61 |
+
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
file: imageFile,
|
| 65 |
+
patch: "",
|
| 66 |
+
additions: 1,
|
| 67 |
+
deletions: 0,
|
| 68 |
+
status: "added",
|
| 69 |
+
},
|
| 70 |
+
],
|
| 71 |
+
fileContent: async (path) => {
|
| 72 |
+
if (path !== imageFile) return undefined
|
| 73 |
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
| 74 |
+
return {
|
| 75 |
+
type: "binary",
|
| 76 |
+
content: "iVBORw0KGgo=",
|
| 77 |
+
encoding: "base64",
|
| 78 |
+
mimeType: "image/png",
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
fileList: (path) => {
|
| 82 |
+
if (!path) {
|
| 83 |
+
return [
|
| 84 |
+
{ name: "assets", path: "assets", absolute: `${directory}/assets`, type: "directory", ignored: false },
|
| 85 |
+
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
|
| 86 |
+
]
|
| 87 |
+
}
|
| 88 |
+
if (path === "assets") {
|
| 89 |
+
return [
|
| 90 |
+
{
|
| 91 |
+
name: "preview.png",
|
| 92 |
+
path: imageFile,
|
| 93 |
+
absolute: `${directory}/${imageFile}`,
|
| 94 |
+
type: "file",
|
| 95 |
+
ignored: false,
|
| 96 |
+
},
|
| 97 |
+
]
|
| 98 |
+
}
|
| 99 |
+
if (path === "src") {
|
| 100 |
+
return [
|
| 101 |
+
{
|
| 102 |
+
name: "example.ts",
|
| 103 |
+
path: "src/example.ts",
|
| 104 |
+
absolute: `${directory}/src/example.ts`,
|
| 105 |
+
type: "file",
|
| 106 |
+
ignored: false,
|
| 107 |
+
},
|
| 108 |
+
]
|
| 109 |
+
}
|
| 110 |
+
return []
|
| 111 |
+
},
|
| 112 |
+
pageMessages: () => ({
|
| 113 |
+
items: [
|
| 114 |
+
{
|
| 115 |
+
info: {
|
| 116 |
+
id: "msg_review_image_flash_regression",
|
| 117 |
+
sessionID,
|
| 118 |
+
role: "user",
|
| 119 |
+
time: { created: 1700000000000 },
|
| 120 |
+
summary: { diffs: [] },
|
| 121 |
+
agent: "build",
|
| 122 |
+
model: { providerID: "opencode", modelID: "test" },
|
| 123 |
+
},
|
| 124 |
+
parts: [
|
| 125 |
+
{
|
| 126 |
+
id: "prt_review_image_flash_regression",
|
| 127 |
+
sessionID,
|
| 128 |
+
messageID: "msg_review_image_flash_regression",
|
| 129 |
+
type: "text",
|
| 130 |
+
text: "Review this change.",
|
| 131 |
+
},
|
| 132 |
+
],
|
| 133 |
+
},
|
| 134 |
+
],
|
| 135 |
+
}),
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 139 |
+
await expectSessionTitle(page, title)
|
| 140 |
+
await page.getByRole("button", { name: "Toggle review" }).click()
|
| 141 |
+
await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
|
| 142 |
+
await expectAppVisible(page.getByRole("button", { name: /preview\.png/ }))
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
async function installReviewFlashProbe(page: Page) {
|
| 146 |
+
await page.evaluate(() => {
|
| 147 |
+
const samples: Array<{
|
| 148 |
+
observedAtMs: number
|
| 149 |
+
blank: boolean
|
| 150 |
+
blackCenter: boolean
|
| 151 |
+
text: string
|
| 152 |
+
background: string
|
| 153 |
+
}> = []
|
| 154 |
+
const startedAt = performance.now()
|
| 155 |
+
const sample = () => {
|
| 156 |
+
const panel = document.querySelector<HTMLElement>('#review-panel [data-component="session-review-v2"]')
|
| 157 |
+
const rect = panel?.getBoundingClientRect()
|
| 158 |
+
const center = rect
|
| 159 |
+
? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
|
| 160 |
+
: undefined
|
| 161 |
+
const background = center instanceof Element ? getComputedStyle(center).backgroundColor : ""
|
| 162 |
+
samples.push({
|
| 163 |
+
observedAtMs: performance.now() - startedAt,
|
| 164 |
+
blank: !panel || panel.textContent?.trim().length === 0,
|
| 165 |
+
blackCenter: background === "rgb(0, 0, 0)",
|
| 166 |
+
text: panel?.textContent?.trim().slice(0, 80) ?? "",
|
| 167 |
+
background,
|
| 168 |
+
})
|
| 169 |
+
if (performance.now() - startedAt < 500) requestAnimationFrame(sample)
|
| 170 |
+
}
|
| 171 |
+
document.addEventListener(
|
| 172 |
+
"click",
|
| 173 |
+
(event) => {
|
| 174 |
+
const target = event.target instanceof Element ? event.target : undefined
|
| 175 |
+
if (!target?.closest('[data-slot="file-tree-v2-row"]')) return
|
| 176 |
+
requestAnimationFrame(sample)
|
| 177 |
+
},
|
| 178 |
+
{ capture: true, once: true },
|
| 179 |
+
)
|
| 180 |
+
;(window as Window & { __reviewImageFlash?: { samples: typeof samples; startedAt: number } }).__reviewImageFlash = {
|
| 181 |
+
samples,
|
| 182 |
+
startedAt,
|
| 183 |
+
}
|
| 184 |
+
})
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
async function waitForReviewFlashProbe(page: Page, durationMs: number) {
|
| 188 |
+
await page.waitForFunction((durationMs) => {
|
| 189 |
+
const state = (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } })
|
| 190 |
+
.__reviewImageFlash
|
| 191 |
+
return !!state && state.samples.length > 0 && performance.now() - state.startedAt >= durationMs
|
| 192 |
+
}, durationMs)
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
async function collectReviewFlashProbe(page: Page) {
|
| 196 |
+
return page.evaluate(() => {
|
| 197 |
+
return (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }).__reviewImageFlash!
|
| 198 |
+
}) as Promise<{
|
| 199 |
+
startedAt: number
|
| 200 |
+
samples: Array<{ observedAtMs: number; blank: boolean; blackCenter: boolean; text: string; background: string }>
|
| 201 |
+
}>
|
| 202 |
+
}
|
packages/app/e2e/regression/review-line-comment.spec.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/ReviewLineCommentRegression"
|
| 7 |
+
const sessionID = "ses_review_line_comment_regression"
|
| 8 |
+
const title = "Review line comment regression"
|
| 9 |
+
|
| 10 |
+
test.beforeEach(async ({ page }) => {
|
| 11 |
+
await openReview(page)
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
test("opens the comment editor when code is clicked", async ({ page }) => {
|
| 15 |
+
const review = page.locator('[data-component="session-review"]')
|
| 16 |
+
const line = review.getByText("export const value = 'after'", { exact: true })
|
| 17 |
+
await expectAppVisible(line)
|
| 18 |
+
await line.click()
|
| 19 |
+
|
| 20 |
+
await expect(review.getByRole("textbox")).toBeVisible()
|
| 21 |
+
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
| 25 |
+
const review = page.locator('[data-component="session-review"]')
|
| 26 |
+
const lineNumber = review.locator('[data-column-number="1"]').last()
|
| 27 |
+
await expectAppVisible(lineNumber)
|
| 28 |
+
await lineNumber.click()
|
| 29 |
+
|
| 30 |
+
await expect(review.getByRole("textbox")).toBeVisible()
|
| 31 |
+
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
test("opens the comment editor for a line number range", async ({ page }) => {
|
| 35 |
+
const review = page.locator('[data-component="session-review"]')
|
| 36 |
+
const start = review.locator('[data-column-number="1"]').last()
|
| 37 |
+
const end = review.locator('[data-column-number="3"]').last()
|
| 38 |
+
await expectAppVisible(start)
|
| 39 |
+
await expectAppVisible(end)
|
| 40 |
+
|
| 41 |
+
await start.dragTo(end)
|
| 42 |
+
|
| 43 |
+
await expect(review.getByRole("textbox")).toBeVisible()
|
| 44 |
+
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
| 48 |
+
const review = page.locator('[data-component="session-review"]')
|
| 49 |
+
const lineNumber = review.locator('[data-column-number="1"]').last()
|
| 50 |
+
await expectAppVisible(lineNumber)
|
| 51 |
+
|
| 52 |
+
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
| 53 |
+
await expect(async () => {
|
| 54 |
+
await lineNumber.hover()
|
| 55 |
+
await expect(lineNumber).toHaveAttribute("data-hovered", "")
|
| 56 |
+
await expect(comment).toHaveCount(1)
|
| 57 |
+
await expect(comment).toHaveCSS("pointer-events", "auto")
|
| 58 |
+
await comment.focus()
|
| 59 |
+
await expect(comment).toBeFocused()
|
| 60 |
+
}).toPass({ timeout: 10_000 })
|
| 61 |
+
await comment.press("Enter")
|
| 62 |
+
await expect(review.getByRole("textbox")).toBeVisible()
|
| 63 |
+
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
| 67 |
+
page.on("request", (request) => {
|
| 68 |
+
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
const review = page.locator('[data-component="session-review"]')
|
| 72 |
+
await review.getByText("export const value = 'after'", { exact: true }).click()
|
| 73 |
+
const textbox = review.getByRole("textbox")
|
| 74 |
+
await expect(textbox).toBeVisible()
|
| 75 |
+
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
| 76 |
+
await textbox.fill("Use the existing value instead")
|
| 77 |
+
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
|
| 78 |
+
await expect(submit).toBeEnabled()
|
| 79 |
+
await submit.click()
|
| 80 |
+
|
| 81 |
+
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
|
| 82 |
+
await page.getByRole("tab", { name: "Session" }).click()
|
| 83 |
+
const context = page.getByText("Use the existing value instead", { exact: true }).last()
|
| 84 |
+
await expect(context).toBeVisible()
|
| 85 |
+
await expect(context.locator("..")).toContainText("review.ts:2")
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
async function openReview(page: Page) {
|
| 89 |
+
await page.setViewportSize({ width: 700, height: 900 })
|
| 90 |
+
await mockOpenCodeServer(page, {
|
| 91 |
+
protocol: "v2",
|
| 92 |
+
directory,
|
| 93 |
+
project: {
|
| 94 |
+
id: "proj_review_line_comment_regression",
|
| 95 |
+
worktree: directory,
|
| 96 |
+
vcs: "git",
|
| 97 |
+
name: "review-line-comment-regression",
|
| 98 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 99 |
+
sandboxes: [],
|
| 100 |
+
},
|
| 101 |
+
provider: { all: [], connected: [], default: {} },
|
| 102 |
+
sessions: [
|
| 103 |
+
{
|
| 104 |
+
id: sessionID,
|
| 105 |
+
slug: "review-line-comment-regression",
|
| 106 |
+
projectID: "proj_review_line_comment_regression",
|
| 107 |
+
directory,
|
| 108 |
+
title,
|
| 109 |
+
version: "dev",
|
| 110 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 111 |
+
},
|
| 112 |
+
],
|
| 113 |
+
vcsDiff: [
|
| 114 |
+
{
|
| 115 |
+
file: "src/review.ts",
|
| 116 |
+
additions: 1,
|
| 117 |
+
deletions: 1,
|
| 118 |
+
status: "modified",
|
| 119 |
+
patch:
|
| 120 |
+
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
|
| 121 |
+
},
|
| 122 |
+
],
|
| 123 |
+
pageMessages: () => ({
|
| 124 |
+
items: [
|
| 125 |
+
{
|
| 126 |
+
info: {
|
| 127 |
+
id: "msg_review_line_comment_regression",
|
| 128 |
+
sessionID,
|
| 129 |
+
role: "user",
|
| 130 |
+
time: { created: 1700000000000 },
|
| 131 |
+
summary: { diffs: [] },
|
| 132 |
+
agent: "build",
|
| 133 |
+
model: { providerID: "opencode", modelID: "test" },
|
| 134 |
+
},
|
| 135 |
+
parts: [
|
| 136 |
+
{
|
| 137 |
+
id: "prt_review_line_comment_regression",
|
| 138 |
+
sessionID,
|
| 139 |
+
messageID: "msg_review_line_comment_regression",
|
| 140 |
+
type: "text",
|
| 141 |
+
text: "Review this change.",
|
| 142 |
+
},
|
| 143 |
+
],
|
| 144 |
+
},
|
| 145 |
+
],
|
| 146 |
+
}),
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 150 |
+
await expectSessionTitle(page, title)
|
| 151 |
+
const changes = page.getByRole("tab", { name: "Changes" })
|
| 152 |
+
const diffResponse = page.waitForResponse(
|
| 153 |
+
(response) =>
|
| 154 |
+
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
|
| 155 |
+
)
|
| 156 |
+
await changes.click()
|
| 157 |
+
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
| 158 |
+
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
|
| 159 |
+
|
| 160 |
+
const review = page.locator('[data-component="session-review"]')
|
| 161 |
+
await expectAppVisible(review)
|
| 162 |
+
const file = review.locator('[data-file="src/review.ts"]')
|
| 163 |
+
await expectAppVisible(file)
|
| 164 |
+
const trigger = file.getByRole("button", { expanded: false })
|
| 165 |
+
await expect(trigger).toHaveCount(1)
|
| 166 |
+
await trigger.click()
|
| 167 |
+
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
|
| 168 |
+
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
|
| 169 |
+
}
|
packages/app/e2e/regression/review-open-file.spec.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/ReviewOpenFile"
|
| 7 |
+
const projectID = "proj_review_open_file"
|
| 8 |
+
const sessionID = "ses_review_open_file"
|
| 9 |
+
const title = "Review open file"
|
| 10 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 11 |
+
|
| 12 |
+
test.use({ viewport: { width: 1440, height: 900 } })
|
| 13 |
+
|
| 14 |
+
test("opens and searches project files inline", async ({ page }) => {
|
| 15 |
+
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
| 16 |
+
await mockOpenCodeServer(page, {
|
| 17 |
+
directory,
|
| 18 |
+
project: {
|
| 19 |
+
id: projectID,
|
| 20 |
+
worktree: directory,
|
| 21 |
+
vcs: "git",
|
| 22 |
+
name: "open-file-project",
|
| 23 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 24 |
+
sandboxes: [],
|
| 25 |
+
},
|
| 26 |
+
provider: {
|
| 27 |
+
all: [
|
| 28 |
+
{
|
| 29 |
+
id: "opencode",
|
| 30 |
+
name: "OpenCode",
|
| 31 |
+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
| 32 |
+
},
|
| 33 |
+
],
|
| 34 |
+
connected: ["opencode"],
|
| 35 |
+
default: { providerID: "opencode", modelID: "test" },
|
| 36 |
+
},
|
| 37 |
+
sessions: [
|
| 38 |
+
{
|
| 39 |
+
id: sessionID,
|
| 40 |
+
slug: sessionID,
|
| 41 |
+
projectID,
|
| 42 |
+
directory,
|
| 43 |
+
title,
|
| 44 |
+
version: "dev",
|
| 45 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 46 |
+
},
|
| 47 |
+
],
|
| 48 |
+
vcsDiff: [fileDiff("src/changed.ts")],
|
| 49 |
+
fileList: (path) => {
|
| 50 |
+
if (path) return []
|
| 51 |
+
return [
|
| 52 |
+
fileNode("README.md"),
|
| 53 |
+
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
|
| 54 |
+
]
|
| 55 |
+
},
|
| 56 |
+
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
| 57 |
+
findFiles: (input) => {
|
| 58 |
+
searches.push(input)
|
| 59 |
+
return input.query === "nested" ? ["src/nested.ts"] : []
|
| 60 |
+
},
|
| 61 |
+
pageMessages: () => ({ items: [] }),
|
| 62 |
+
})
|
| 63 |
+
await page.addInitScript(
|
| 64 |
+
({ directory, server, sessionID }) => {
|
| 65 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 66 |
+
localStorage.setItem(
|
| 67 |
+
"opencode.global.dat:server",
|
| 68 |
+
JSON.stringify({
|
| 69 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 70 |
+
lastProject: { local: directory },
|
| 71 |
+
}),
|
| 72 |
+
)
|
| 73 |
+
localStorage.setItem(
|
| 74 |
+
"opencode.global.dat:layout",
|
| 75 |
+
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
| 76 |
+
)
|
| 77 |
+
localStorage.setItem(
|
| 78 |
+
"opencode.global.dat:review-panel-v2",
|
| 79 |
+
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
| 80 |
+
)
|
| 81 |
+
localStorage.setItem(
|
| 82 |
+
"opencode.window.browser.dat:tabs",
|
| 83 |
+
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
| 84 |
+
)
|
| 85 |
+
},
|
| 86 |
+
{ directory, server, sessionID },
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
| 90 |
+
await expectSessionTitle(page, title)
|
| 91 |
+
|
| 92 |
+
const panel = page.locator("#review-panel")
|
| 93 |
+
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
| 94 |
+
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
| 95 |
+
const contextButton = page.getByRole("button", { name: "View context usage" })
|
| 96 |
+
await contextButton.click()
|
| 97 |
+
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
| 98 |
+
await panel.getByRole("button", { name: "Open file" }).click()
|
| 99 |
+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
| 100 |
+
await expect(sidebarToggle).toBeDisabled()
|
| 101 |
+
await expect(sidebar).toBeVisible()
|
| 102 |
+
await contextButton.click()
|
| 103 |
+
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
| 104 |
+
await expect(sidebar).toBeHidden()
|
| 105 |
+
await panel.getByRole("button", { name: "Open file" }).click()
|
| 106 |
+
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
| 107 |
+
await expect(filter).toBeFocused()
|
| 108 |
+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
| 109 |
+
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
| 110 |
+
|
| 111 |
+
await panel.getByRole("button", { name: "README.md" }).click()
|
| 112 |
+
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
| 113 |
+
await expect(sidebarToggle).toBeEnabled()
|
| 114 |
+
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
| 115 |
+
await expect(sidebar).toHaveCount(0)
|
| 116 |
+
|
| 117 |
+
await panel.getByRole("button", { name: "Open file" }).click()
|
| 118 |
+
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
| 119 |
+
await expect(sidebar).toBeVisible()
|
| 120 |
+
await filter.fill("nested")
|
| 121 |
+
const result = panel.getByRole("option", { name: /nested\.ts/ })
|
| 122 |
+
await expect(result).toBeVisible()
|
| 123 |
+
const resultID = await result.getAttribute("id")
|
| 124 |
+
expect(resultID).toBeTruthy()
|
| 125 |
+
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
| 126 |
+
await filter.press("Enter")
|
| 127 |
+
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
| 128 |
+
await expect(sidebarToggle).toBeEnabled()
|
| 129 |
+
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
| 130 |
+
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
| 131 |
+
|
| 132 |
+
await panel.getByRole("button", { name: "Open file" }).click()
|
| 133 |
+
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
| 134 |
+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
| 135 |
+
await expect(sidebarToggle).toBeDisabled()
|
| 136 |
+
await panel.locator("#session-side-panel-review-tab").click()
|
| 137 |
+
await expect(sidebarToggle).toBeEnabled()
|
| 138 |
+
await panel.getByRole("tab", { name: "Open file" }).click()
|
| 139 |
+
await page.keyboard.press("Control+w")
|
| 140 |
+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
| 141 |
+
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
function fileNode(path: string) {
|
| 145 |
+
return {
|
| 146 |
+
name: path,
|
| 147 |
+
path,
|
| 148 |
+
absolute: `${directory}/${path}`,
|
| 149 |
+
type: "file",
|
| 150 |
+
ignored: false,
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function fileDiff(file: string) {
|
| 155 |
+
return {
|
| 156 |
+
file,
|
| 157 |
+
before: "before\n",
|
| 158 |
+
after: "after\n",
|
| 159 |
+
additions: 1,
|
| 160 |
+
deletions: 1,
|
| 161 |
+
status: "modified",
|
| 162 |
+
}
|
| 163 |
+
}
|
packages/app/e2e/regression/review-state-persistence.spec.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/ReviewStatePersistence"
|
| 7 |
+
const projectID = "proj_review_state_persistence"
|
| 8 |
+
const sessionA = "ses_review_state_a"
|
| 9 |
+
const sessionB = "ses_review_state_b"
|
| 10 |
+
const titleA = "Alpha review state"
|
| 11 |
+
const titleB = "Beta review state"
|
| 12 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 13 |
+
|
| 14 |
+
test.use({ viewport: { width: 1440, height: 900 } })
|
| 15 |
+
|
| 16 |
+
test("restores review mode and selected file per session", async ({ page }) => {
|
| 17 |
+
await setup(page)
|
| 18 |
+
await page.goto(sessionHref(sessionA))
|
| 19 |
+
await expectSessionTitle(page, titleA)
|
| 20 |
+
await page.getByRole("button", { name: "Toggle review" }).click()
|
| 21 |
+
|
| 22 |
+
await selectMode(page, "Git changes", "Branch changes")
|
| 23 |
+
await selectFile(page, "beta.ts")
|
| 24 |
+
|
| 25 |
+
await switchSession(page, titleB)
|
| 26 |
+
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
| 27 |
+
await selectFile(page, "gamma.ts")
|
| 28 |
+
|
| 29 |
+
await switchSession(page, titleA)
|
| 30 |
+
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
| 31 |
+
await expectSelectedFile(page, "beta.ts")
|
| 32 |
+
await selectMode(page, "Branch changes", "Git changes")
|
| 33 |
+
await expectSelectedFile(page, "alpha.ts")
|
| 34 |
+
await selectMode(page, "Git changes", "Branch changes")
|
| 35 |
+
await expectSelectedFile(page, "beta.ts")
|
| 36 |
+
|
| 37 |
+
await page.reload()
|
| 38 |
+
await expectSessionTitle(page, titleA)
|
| 39 |
+
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
| 40 |
+
await expectSelectedFile(page, "beta.ts")
|
| 41 |
+
|
| 42 |
+
await switchSession(page, titleB)
|
| 43 |
+
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
| 44 |
+
await expectSelectedFile(page, "gamma.ts")
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
async function selectMode(page: Page, current: string, next: string) {
|
| 48 |
+
await page.getByRole("button", { name: current }).click()
|
| 49 |
+
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
async function selectFile(page: Page, file: string) {
|
| 53 |
+
await page.getByRole("button", { name: file }).click()
|
| 54 |
+
await expectSelectedFile(page, file)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
async function expectSelectedFile(page: Page, file: string) {
|
| 58 |
+
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
async function switchSession(page: Page, title: string) {
|
| 62 |
+
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
| 63 |
+
await expectSessionTitle(page, title)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
async function setup(page: Page) {
|
| 67 |
+
await mockOpenCodeServer(page, {
|
| 68 |
+
protocol: "v1",
|
| 69 |
+
directory,
|
| 70 |
+
project: {
|
| 71 |
+
id: projectID,
|
| 72 |
+
worktree: directory,
|
| 73 |
+
vcs: "git",
|
| 74 |
+
name: "review-state-persistence",
|
| 75 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 76 |
+
sandboxes: [],
|
| 77 |
+
},
|
| 78 |
+
provider: {
|
| 79 |
+
all: [
|
| 80 |
+
{
|
| 81 |
+
id: "opencode",
|
| 82 |
+
name: "OpenCode",
|
| 83 |
+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
| 84 |
+
},
|
| 85 |
+
],
|
| 86 |
+
connected: ["opencode"],
|
| 87 |
+
default: { providerID: "opencode", modelID: "test" },
|
| 88 |
+
},
|
| 89 |
+
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
| 90 |
+
pageMessages: () => ({ items: [] }),
|
| 91 |
+
})
|
| 92 |
+
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
| 93 |
+
route.fulfill({
|
| 94 |
+
status: 200,
|
| 95 |
+
contentType: "application/json",
|
| 96 |
+
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
|
| 97 |
+
}),
|
| 98 |
+
)
|
| 99 |
+
await page.route("**/vcs/diff**", (route) =>
|
| 100 |
+
route.fulfill({
|
| 101 |
+
status: 200,
|
| 102 |
+
contentType: "application/json",
|
| 103 |
+
body: JSON.stringify(
|
| 104 |
+
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
| 105 |
+
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
| 106 |
+
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
| 107 |
+
),
|
| 108 |
+
}),
|
| 109 |
+
)
|
| 110 |
+
await page.addInitScript(
|
| 111 |
+
({ directory, server, sessions }) => {
|
| 112 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 113 |
+
localStorage.setItem(
|
| 114 |
+
"opencode.global.dat:server",
|
| 115 |
+
JSON.stringify({
|
| 116 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 117 |
+
lastProject: { local: directory },
|
| 118 |
+
}),
|
| 119 |
+
)
|
| 120 |
+
localStorage.setItem(
|
| 121 |
+
"opencode.window.browser.dat:tabs",
|
| 122 |
+
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
| 123 |
+
)
|
| 124 |
+
},
|
| 125 |
+
{ directory, server, sessions: [sessionA, sessionB] },
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function session(id: string, title: string, created: number) {
|
| 130 |
+
return {
|
| 131 |
+
id,
|
| 132 |
+
slug: id,
|
| 133 |
+
projectID,
|
| 134 |
+
directory,
|
| 135 |
+
title,
|
| 136 |
+
version: "dev",
|
| 137 |
+
time: { created, updated: created },
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function diff(file: string) {
|
| 142 |
+
return {
|
| 143 |
+
file,
|
| 144 |
+
additions: 1,
|
| 145 |
+
deletions: 1,
|
| 146 |
+
status: "modified",
|
| 147 |
+
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function sessionHref(sessionID: string) {
|
| 152 |
+
return `/server/${base64Encode(server)}/session/${sessionID}`
|
| 153 |
+
}
|
packages/app/e2e/regression/review-tab-switch.spec.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/ReviewTabSwitch"
|
| 7 |
+
const projectID = "proj_review_tab_switch"
|
| 8 |
+
const sessionA = "ses_review_tab_a"
|
| 9 |
+
const sessionB = "ses_review_tab_b"
|
| 10 |
+
const titleA = "Alpha session"
|
| 11 |
+
const titleB = "Beta session"
|
| 12 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 13 |
+
const diffs = Array.from({ length: 2_740 }, (_, index) =>
|
| 14 |
+
fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
|
| 15 |
+
)
|
| 16 |
+
// Marks the review pane DOM node so a remount (fresh node) is detectable.
|
| 17 |
+
const PROBE = "original"
|
| 18 |
+
|
| 19 |
+
test.use({ viewport: { width: 1440, height: 900 } })
|
| 20 |
+
|
| 21 |
+
// The v2 review pane's diff data is workspace-scoped: switching between session
|
| 22 |
+
// tabs in the same workspace must update its parameters reactively instead of
|
| 23 |
+
// tearing the pane down and remounting it (which flickers).
|
| 24 |
+
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
|
| 25 |
+
await setup(page)
|
| 26 |
+
|
| 27 |
+
await page.goto(sessionHref(sessionA))
|
| 28 |
+
await expectSessionTitle(page, titleA)
|
| 29 |
+
|
| 30 |
+
await page.getByRole("button", { name: "Toggle review" }).click()
|
| 31 |
+
const reviewTab = page.locator("#session-side-panel-review-tab")
|
| 32 |
+
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
|
| 33 |
+
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
| 34 |
+
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
| 35 |
+
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
| 36 |
+
await expectAppVisible(review)
|
| 37 |
+
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
| 38 |
+
await writeProbe(page)
|
| 39 |
+
|
| 40 |
+
await switchTab(page, titleB)
|
| 41 |
+
await expectSessionTitle(page, titleB)
|
| 42 |
+
await expectAppVisible(review)
|
| 43 |
+
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
| 44 |
+
expect(await readProbe(page)).toBe(PROBE)
|
| 45 |
+
|
| 46 |
+
await switchTab(page, titleA)
|
| 47 |
+
await expectSessionTitle(page, titleA)
|
| 48 |
+
await expectAppVisible(review)
|
| 49 |
+
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
| 50 |
+
expect(await readProbe(page)).toBe(PROBE)
|
| 51 |
+
|
| 52 |
+
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
| 53 |
+
await viewport.hover()
|
| 54 |
+
await page.mouse.wheel(0, 100_000)
|
| 55 |
+
await expect
|
| 56 |
+
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
| 57 |
+
.toBeLessThanOrEqual(1)
|
| 58 |
+
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
|
| 59 |
+
})
|
| 60 |
+
|
| 61 |
+
type Probed = HTMLElement & { __e2eProbe?: string }
|
| 62 |
+
|
| 63 |
+
async function switchTab(page: Page, title: string) {
|
| 64 |
+
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
async function writeProbe(page: Page) {
|
| 68 |
+
await page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el, probe) => {
|
| 69 |
+
;(el as Probed).__e2eProbe = probe
|
| 70 |
+
}, PROBE)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
async function readProbe(page: Page) {
|
| 74 |
+
return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
async function setup(page: Page) {
|
| 78 |
+
await mockOpenCodeServer(page, {
|
| 79 |
+
directory,
|
| 80 |
+
project: {
|
| 81 |
+
id: projectID,
|
| 82 |
+
worktree: directory,
|
| 83 |
+
vcs: "git",
|
| 84 |
+
name: "review-tab-switch",
|
| 85 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 86 |
+
sandboxes: [],
|
| 87 |
+
},
|
| 88 |
+
provider: {
|
| 89 |
+
all: [
|
| 90 |
+
{
|
| 91 |
+
id: "opencode",
|
| 92 |
+
name: "OpenCode",
|
| 93 |
+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
| 94 |
+
},
|
| 95 |
+
],
|
| 96 |
+
connected: ["opencode"],
|
| 97 |
+
default: { providerID: "opencode", modelID: "test" },
|
| 98 |
+
},
|
| 99 |
+
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
| 100 |
+
vcsDiff: diffs,
|
| 101 |
+
pageMessages: () => ({ items: [] }),
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
await page.addInitScript(
|
| 105 |
+
({ directory, server, sessions }) => {
|
| 106 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 107 |
+
localStorage.setItem(
|
| 108 |
+
"opencode.global.dat:server",
|
| 109 |
+
JSON.stringify({
|
| 110 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 111 |
+
lastProject: { local: directory },
|
| 112 |
+
}),
|
| 113 |
+
)
|
| 114 |
+
localStorage.setItem(
|
| 115 |
+
"opencode.window.browser.dat:tabs",
|
| 116 |
+
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
| 117 |
+
)
|
| 118 |
+
},
|
| 119 |
+
{ directory, server, sessions: [sessionA, sessionB] },
|
| 120 |
+
)
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function session(id: string, title: string, created: number) {
|
| 124 |
+
return {
|
| 125 |
+
id,
|
| 126 |
+
slug: id,
|
| 127 |
+
projectID,
|
| 128 |
+
directory,
|
| 129 |
+
title,
|
| 130 |
+
version: "dev",
|
| 131 |
+
time: { created, updated: created },
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function sessionHref(sessionID: string) {
|
| 136 |
+
return `/server/${base64Encode(server)}/session/${sessionID}`
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function fileDiff(file: string) {
|
| 140 |
+
return {
|
| 141 |
+
file,
|
| 142 |
+
additions: 1,
|
| 143 |
+
deletions: 1,
|
| 144 |
+
status: "modified",
|
| 145 |
+
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
| 146 |
+
}
|
| 147 |
+
}
|
packages/app/e2e/regression/review-terminal-stacked.spec.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 2 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 3 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 4 |
+
|
| 5 |
+
const directory = "C:/OpenCode/ReviewTerminalStacked"
|
| 6 |
+
const projectID = "proj_review_terminal_stacked"
|
| 7 |
+
const sessionID = "ses_review_terminal_stacked"
|
| 8 |
+
const title = "Review terminal stacked"
|
| 9 |
+
const branchDiffs = [
|
| 10 |
+
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
| 11 |
+
...Array.from({ length: 2_739 }, (_, index) =>
|
| 12 |
+
fileDiff(
|
| 13 |
+
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
|
| 14 |
+
100,
|
| 15 |
+
false,
|
| 16 |
+
),
|
| 17 |
+
),
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
| 21 |
+
test.setTimeout(120_000)
|
| 22 |
+
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
| 23 |
+
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
|
| 24 |
+
let detailVersion = 1
|
| 25 |
+
let detailFailures = 1
|
| 26 |
+
await page.setViewportSize({ width: 1400, height: 900 })
|
| 27 |
+
await mockOpenCodeServer(page, {
|
| 28 |
+
protocol: "v1",
|
| 29 |
+
directory,
|
| 30 |
+
project: {
|
| 31 |
+
id: projectID,
|
| 32 |
+
worktree: directory,
|
| 33 |
+
vcs: "git",
|
| 34 |
+
name: "review-terminal-stacked",
|
| 35 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 36 |
+
sandboxes: [],
|
| 37 |
+
},
|
| 38 |
+
provider: {
|
| 39 |
+
all: [
|
| 40 |
+
{
|
| 41 |
+
id: "opencode",
|
| 42 |
+
name: "OpenCode",
|
| 43 |
+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
| 44 |
+
},
|
| 45 |
+
],
|
| 46 |
+
connected: ["opencode"],
|
| 47 |
+
default: { providerID: "opencode", modelID: "test" },
|
| 48 |
+
},
|
| 49 |
+
sessions: [
|
| 50 |
+
{
|
| 51 |
+
id: sessionID,
|
| 52 |
+
slug: "review-terminal-stacked",
|
| 53 |
+
projectID,
|
| 54 |
+
directory,
|
| 55 |
+
title,
|
| 56 |
+
version: "dev",
|
| 57 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 58 |
+
},
|
| 59 |
+
],
|
| 60 |
+
sessionStatus: () => sessionStatus,
|
| 61 |
+
pageMessages: () => ({ items: [] }),
|
| 62 |
+
events: () => events.splice(0, 1),
|
| 63 |
+
eventRetry: 16,
|
| 64 |
+
})
|
| 65 |
+
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
| 66 |
+
route.fulfill({
|
| 67 |
+
status: 200,
|
| 68 |
+
contentType: "application/json",
|
| 69 |
+
body: JSON.stringify({
|
| 70 |
+
branch: "review-pane-performance",
|
| 71 |
+
default_branch: "dev",
|
| 72 |
+
}),
|
| 73 |
+
}),
|
| 74 |
+
)
|
| 75 |
+
await page.route("**/vcs/diff**", (route) => {
|
| 76 |
+
const url = new URL(route.request().url())
|
| 77 |
+
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
|
| 78 |
+
const detail = scope?.endsWith("/src/branch/d00027")
|
| 79 |
+
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
| 80 |
+
return route.fulfill({
|
| 81 |
+
status: 200,
|
| 82 |
+
contentType: "application/json",
|
| 83 |
+
body: JSON.stringify(
|
| 84 |
+
url.searchParams.get("mode") === "branch"
|
| 85 |
+
? detail
|
| 86 |
+
? branchDiffs
|
| 87 |
+
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
| 88 |
+
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
| 89 |
+
: branchDiffs
|
| 90 |
+
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
| 91 |
+
),
|
| 92 |
+
})
|
| 93 |
+
})
|
| 94 |
+
await page.route("**/pty*", (route) =>
|
| 95 |
+
route.fulfill({
|
| 96 |
+
status: 200,
|
| 97 |
+
contentType: "application/json",
|
| 98 |
+
body: JSON.stringify({
|
| 99 |
+
location: { directory, project: { id: projectID, directory } },
|
| 100 |
+
data: {
|
| 101 |
+
id: "pty_review_terminal",
|
| 102 |
+
title: "Terminal 1",
|
| 103 |
+
command: "cmd.exe",
|
| 104 |
+
args: [],
|
| 105 |
+
cwd: directory,
|
| 106 |
+
status: "running",
|
| 107 |
+
pid: 1,
|
| 108 |
+
},
|
| 109 |
+
}),
|
| 110 |
+
}),
|
| 111 |
+
)
|
| 112 |
+
await page.route("**/pty/pty_review_terminal*", (route) =>
|
| 113 |
+
route.fulfill({
|
| 114 |
+
status: 200,
|
| 115 |
+
contentType: "application/json",
|
| 116 |
+
body: JSON.stringify({
|
| 117 |
+
location: { directory, project: { id: projectID, directory } },
|
| 118 |
+
data: {
|
| 119 |
+
id: "pty_review_terminal",
|
| 120 |
+
title: "Terminal 1",
|
| 121 |
+
command: "cmd.exe",
|
| 122 |
+
args: [],
|
| 123 |
+
cwd: directory,
|
| 124 |
+
status: "running",
|
| 125 |
+
pid: 1,
|
| 126 |
+
},
|
| 127 |
+
}),
|
| 128 |
+
}),
|
| 129 |
+
)
|
| 130 |
+
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
| 131 |
+
route.fulfill({
|
| 132 |
+
status: 200,
|
| 133 |
+
contentType: "application/json",
|
| 134 |
+
body: JSON.stringify({
|
| 135 |
+
location: { directory, project: { id: projectID, directory } },
|
| 136 |
+
data: { ticket: "e2e-ticket", expires_in: 60 },
|
| 137 |
+
}),
|
| 138 |
+
}),
|
| 139 |
+
)
|
| 140 |
+
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
| 141 |
+
await page.addInitScript(() => {
|
| 142 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 143 |
+
localStorage.setItem(
|
| 144 |
+
"opencode.global.dat:layout",
|
| 145 |
+
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
| 146 |
+
)
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 150 |
+
await expectSessionTitle(page, title)
|
| 151 |
+
await expect(page.locator("#review-panel")).toBeVisible()
|
| 152 |
+
await expectTree(page, 8, "git-0.ts")
|
| 153 |
+
|
| 154 |
+
await selectMode(page, "Git changes", "Branch changes")
|
| 155 |
+
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
|
| 156 |
+
await page.keyboard.press("Control+Backquote")
|
| 157 |
+
await expect(page.locator("#terminal-panel")).toBeVisible()
|
| 158 |
+
await expectTree(page, 2_773, "action.yml")
|
| 159 |
+
await expectStackGeometry(page)
|
| 160 |
+
|
| 161 |
+
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
| 162 |
+
await treeViewport.hover()
|
| 163 |
+
await page.mouse.wheel(0, 100_000)
|
| 164 |
+
await expect
|
| 165 |
+
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
| 166 |
+
.toBeLessThanOrEqual(1)
|
| 167 |
+
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
|
| 168 |
+
await expect(lastFile).toBeVisible()
|
| 169 |
+
const bottomGap = await lastFile.evaluate((element) => {
|
| 170 |
+
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
|
| 171 |
+
return viewport.bottom - element.getBoundingClientRect().bottom
|
| 172 |
+
})
|
| 173 |
+
expect(bottomGap).toBeGreaterThanOrEqual(0)
|
| 174 |
+
expect(bottomGap).toBeLessThanOrEqual(16)
|
| 175 |
+
const lazyDiff = page.waitForRequest((request) => {
|
| 176 |
+
const url = new URL(request.url())
|
| 177 |
+
return (
|
| 178 |
+
url.pathname === "/vcs/diff" &&
|
| 179 |
+
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
| 180 |
+
)
|
| 181 |
+
})
|
| 182 |
+
await lastFile.click()
|
| 183 |
+
await lazyDiff
|
| 184 |
+
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
| 185 |
+
await expect(preview).toContainText("after-1")
|
| 186 |
+
detailVersion = 2
|
| 187 |
+
sessionStatus[sessionID] = { type: "busy" }
|
| 188 |
+
events.push(statusEvent("busy"))
|
| 189 |
+
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
| 190 |
+
const refreshedDiff = page.waitForRequest((request) => {
|
| 191 |
+
const url = new URL(request.url())
|
| 192 |
+
return (
|
| 193 |
+
url.pathname === "/vcs/diff" &&
|
| 194 |
+
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
| 195 |
+
)
|
| 196 |
+
})
|
| 197 |
+
sessionStatus[sessionID] = { type: "idle" }
|
| 198 |
+
events.push(statusEvent("idle"))
|
| 199 |
+
await refreshedDiff
|
| 200 |
+
await expect(preview).toContainText("after-2")
|
| 201 |
+
await selectMode(page, "Branch changes", "Git changes")
|
| 202 |
+
await expectTree(page, 8, "git-0.ts")
|
| 203 |
+
await page.getByRole("button", { name: "git-0.ts" }).click()
|
| 204 |
+
await selectMode(page, "Git changes", "Branch changes")
|
| 205 |
+
await expectTree(page, 2_773, "action.yml")
|
| 206 |
+
|
| 207 |
+
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
| 208 |
+
await filter.fill("generated-2738")
|
| 209 |
+
await expectTree(page, 1, "generated-2738.ts")
|
| 210 |
+
await filter.fill("")
|
| 211 |
+
await expectTree(page, 2_773, "action.yml")
|
| 212 |
+
|
| 213 |
+
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
| 214 |
+
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
|
| 215 |
+
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
|
| 216 |
+
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
| 217 |
+
await expectTree(page, 2_773, "action.yml")
|
| 218 |
+
|
| 219 |
+
await page.keyboard.press("Control+Backquote")
|
| 220 |
+
await expect(page.locator("#terminal-panel")).toHaveCount(0)
|
| 221 |
+
await expectTree(page, 2_773, "action.yml")
|
| 222 |
+
await page.keyboard.press("Control+Backquote")
|
| 223 |
+
await expect(page.locator("#terminal-panel")).toBeVisible()
|
| 224 |
+
await expectTree(page, 2_773, "action.yml")
|
| 225 |
+
|
| 226 |
+
await page.getByRole("button", { name: "Toggle review" }).click()
|
| 227 |
+
await expect(page.locator("#review-panel")).toHaveCount(0)
|
| 228 |
+
await page.getByRole("button", { name: "Toggle review" }).click()
|
| 229 |
+
await expectTree(page, 2_773, "action.yml")
|
| 230 |
+
await page.setViewportSize({ width: 1_000, height: 700 })
|
| 231 |
+
await expectTree(page, 2_773, "action.yml")
|
| 232 |
+
await expectStackGeometry(page)
|
| 233 |
+
await page.setViewportSize({ width: 1_000, height: 120 })
|
| 234 |
+
await page.setViewportSize({ width: 1_400, height: 900 })
|
| 235 |
+
await expectTree(page, 2_773, "action.yml")
|
| 236 |
+
await expectStackGeometry(page)
|
| 237 |
+
})
|
| 238 |
+
|
| 239 |
+
async function selectMode(page: Page, current: string, next: string) {
|
| 240 |
+
await page.getByRole("button", { name: current }).click()
|
| 241 |
+
const option = page.getByRole("option", { name: next })
|
| 242 |
+
await expect(option).toBeVisible()
|
| 243 |
+
await option.click()
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
async function expectTree(page: Page, total: number, file: string) {
|
| 247 |
+
await expectMountedTree(page, total)
|
| 248 |
+
await expect(page.getByRole("button", { name: file })).toBeVisible()
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
async function expectMountedTree(page: Page, total: number) {
|
| 252 |
+
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
|
| 253 |
+
await expect(tree).toHaveAttribute("data-total-rows", String(total))
|
| 254 |
+
await expect
|
| 255 |
+
.poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
|
| 256 |
+
.toBeGreaterThan(0)
|
| 257 |
+
const state = await tree.evaluate((element) => ({
|
| 258 |
+
root: element.getBoundingClientRect().height,
|
| 259 |
+
viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
|
| 260 |
+
rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
|
| 261 |
+
}))
|
| 262 |
+
expect(state.viewport).toBeGreaterThan(0)
|
| 263 |
+
expect(state.root).toBeGreaterThan(0)
|
| 264 |
+
expect(state.rows).toBeGreaterThan(0)
|
| 265 |
+
expect(state.rows).toBeLessThanOrEqual(60)
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
async function expectStackGeometry(page: Page) {
|
| 269 |
+
const geometry = await page.evaluate(() => {
|
| 270 |
+
const review = document.querySelector<HTMLElement>("#review-panel")!
|
| 271 |
+
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
|
| 272 |
+
const reviewParent = review.parentElement!.getBoundingClientRect()
|
| 273 |
+
const terminalParent = terminal.parentElement!.getBoundingClientRect()
|
| 274 |
+
return {
|
| 275 |
+
review: review.getBoundingClientRect().height,
|
| 276 |
+
reviewParent: reviewParent.height,
|
| 277 |
+
terminal: terminal.getBoundingClientRect().height,
|
| 278 |
+
terminalParent: terminalParent.height,
|
| 279 |
+
}
|
| 280 |
+
})
|
| 281 |
+
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
|
| 282 |
+
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function base64Encode(value: string) {
|
| 286 |
+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function statusEvent(type: "busy" | "idle") {
|
| 290 |
+
return {
|
| 291 |
+
directory,
|
| 292 |
+
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
|
| 297 |
+
return {
|
| 298 |
+
file,
|
| 299 |
+
additions,
|
| 300 |
+
deletions: 0,
|
| 301 |
+
status: "modified",
|
| 302 |
+
patch: loaded
|
| 303 |
+
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
|
| 304 |
+
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
|
| 305 |
+
}
|
| 306 |
+
}
|
packages/app/e2e/regression/session-list-path-loading.spec.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test } from "@playwright/test"
|
| 2 |
+
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectAppVisible } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
test("shows loaded sessions before the directory path request resolves", async ({ page }) => {
|
| 7 |
+
await mockOpenCodeServer(page, {
|
| 8 |
+
sessions: fixture.sessions,
|
| 9 |
+
provider: fixture.provider,
|
| 10 |
+
directory: fixture.directory,
|
| 11 |
+
project: fixture.project,
|
| 12 |
+
pageMessages,
|
| 13 |
+
})
|
| 14 |
+
|
| 15 |
+
let releasePath!: () => void
|
| 16 |
+
const pathBlocked = new Promise<void>((resolve) => {
|
| 17 |
+
releasePath = resolve
|
| 18 |
+
})
|
| 19 |
+
await page.route("**/api/path?*", async (route) => {
|
| 20 |
+
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
| 21 |
+
await pathBlocked
|
| 22 |
+
return route.fallback()
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
await page.addInitScript((directory) => {
|
| 26 |
+
localStorage.setItem(
|
| 27 |
+
"opencode.global.dat:server",
|
| 28 |
+
JSON.stringify({
|
| 29 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 30 |
+
lastProject: { local: directory },
|
| 31 |
+
}),
|
| 32 |
+
)
|
| 33 |
+
}, fixture.directory)
|
| 34 |
+
|
| 35 |
+
await page.goto("/")
|
| 36 |
+
try {
|
| 37 |
+
await expectAppVisible(page.getByText(fixture.expected.sourceTitle).first())
|
| 38 |
+
} finally {
|
| 39 |
+
releasePath()
|
| 40 |
+
}
|
| 41 |
+
})
|
packages/app/e2e/regression/session-rename.spec.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
|
| 5 |
+
test.beforeEach(async ({ page }) => {
|
| 6 |
+
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
| 7 |
+
await mockOpenCodeServer(page, {
|
| 8 |
+
protocol: "v1",
|
| 9 |
+
sessions,
|
| 10 |
+
provider: fixture.provider,
|
| 11 |
+
directory: fixture.directory,
|
| 12 |
+
project: fixture.project,
|
| 13 |
+
pageMessages,
|
| 14 |
+
})
|
| 15 |
+
await page.route(/\/session\/[^/]+(?:\?.*)?$/, async (route) => {
|
| 16 |
+
if (route.request().method() !== "PATCH") return route.fallback()
|
| 17 |
+
const id = new URL(route.request().url()).pathname.split("/").at(-1)
|
| 18 |
+
const session = sessions.find((item) => item.id === id)
|
| 19 |
+
const payload: unknown = route.request().postDataJSON()
|
| 20 |
+
if (
|
| 21 |
+
!session ||
|
| 22 |
+
!payload ||
|
| 23 |
+
typeof payload !== "object" ||
|
| 24 |
+
!("title" in payload) ||
|
| 25 |
+
typeof payload.title !== "string"
|
| 26 |
+
)
|
| 27 |
+
throw new Error("Invalid rename request")
|
| 28 |
+
session.title = payload.title
|
| 29 |
+
await route.fulfill({ json: session, headers: { "access-control-allow-origin": "*" } })
|
| 30 |
+
})
|
| 31 |
+
await page.addInitScript((directory) => {
|
| 32 |
+
localStorage.setItem(
|
| 33 |
+
"opencode.global.dat:server",
|
| 34 |
+
JSON.stringify({
|
| 35 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 36 |
+
lastProject: { local: directory },
|
| 37 |
+
}),
|
| 38 |
+
)
|
| 39 |
+
}, fixture.directory)
|
| 40 |
+
await page.goto("/")
|
| 41 |
+
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click()
|
| 42 |
+
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
for (const commit of ["Enter", "blur", "click outside"]) {
|
| 46 |
+
test(`saves the session heading on ${commit}`, async ({ page }) => {
|
| 47 |
+
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
| 48 |
+
const input = page.locator('input[data-slot="session-title-child"]')
|
| 49 |
+
await expect(input).toBeFocused()
|
| 50 |
+
await input.fill("Renamed session")
|
| 51 |
+
if (commit === "Enter") await input.press("Enter")
|
| 52 |
+
if (commit === "blur") await input.press("Tab")
|
| 53 |
+
if (commit === "click outside") await page.getByRole("textbox", { name: "Prompt", exact: true }).click()
|
| 54 |
+
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
|
| 55 |
+
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible()
|
| 56 |
+
await page.reload()
|
| 57 |
+
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
|
| 58 |
+
})
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
test("cancels the session heading with Escape", async ({ page }) => {
|
| 62 |
+
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
| 63 |
+
const input = page.locator('input[data-slot="session-title-child"]')
|
| 64 |
+
await input.fill("Discard this title")
|
| 65 |
+
await input.press("Escape")
|
| 66 |
+
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
| 67 |
+
await page.reload()
|
| 68 |
+
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
test("keeps the draft when saving the session heading fails", async ({ page }) => {
|
| 72 |
+
await page.route(/\/session\/[^/]+(?:\?.*)?$/, (route) => {
|
| 73 |
+
if (route.request().method() !== "PATCH") return route.fallback()
|
| 74 |
+
return route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } })
|
| 75 |
+
})
|
| 76 |
+
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
| 77 |
+
const input = page.locator('input[data-slot="session-title-child"]')
|
| 78 |
+
await input.fill("Retry this title")
|
| 79 |
+
await input.press("Tab")
|
| 80 |
+
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
| 81 |
+
await expect(input).toBeEnabled()
|
| 82 |
+
await expect(input).toHaveValue("Retry this title")
|
| 83 |
+
await expect(
|
| 84 |
+
page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }),
|
| 85 |
+
).toBeVisible()
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
test("does not save an empty session heading", async ({ page }) => {
|
| 89 |
+
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
| 90 |
+
const input = page.locator('input[data-slot="session-title-child"]')
|
| 91 |
+
await input.fill(" ")
|
| 92 |
+
await input.press("Tab")
|
| 93 |
+
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
| 94 |
+
await page.reload()
|
| 95 |
+
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
test("renames and closes the session tab from its context menu", async ({ page }) => {
|
| 99 |
+
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
| 100 |
+
await tab.click({ button: "right" })
|
| 101 |
+
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible()
|
| 102 |
+
await page.keyboard.press("Escape")
|
| 103 |
+
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden()
|
| 104 |
+
await expect(tab).toBeFocused()
|
| 105 |
+
await tab.press("Shift+F10")
|
| 106 |
+
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
| 107 |
+
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
| 108 |
+
await expect(input).toBeFocused()
|
| 109 |
+
await input.fill("Renamed from tab")
|
| 110 |
+
await input.press("Enter")
|
| 111 |
+
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
|
| 112 |
+
await page.reload()
|
| 113 |
+
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
|
| 114 |
+
const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" })
|
| 115 |
+
await renamed.click({ button: "right" })
|
| 116 |
+
await page.getByRole("menuitem", { name: "Close tab", exact: true }).click()
|
| 117 |
+
await expect(renamed).toBeHidden()
|
| 118 |
+
await page.getByRole("button", { name: "Home", exact: true }).click()
|
| 119 |
+
await expect(
|
| 120 |
+
page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }),
|
| 121 |
+
).toBeVisible()
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
test("renames an inactive tab without switching sessions", async ({ page }) => {
|
| 125 |
+
await page.getByRole("button", { name: "Home", exact: true }).click()
|
| 126 |
+
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
|
| 127 |
+
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
| 128 |
+
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
| 129 |
+
await tab.click({ button: "right" })
|
| 130 |
+
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
| 131 |
+
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
| 132 |
+
await expect(input).toBeFocused()
|
| 133 |
+
await input.fill("Inactive tab renamed")
|
| 134 |
+
await input.press("Tab")
|
| 135 |
+
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
| 136 |
+
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
|
| 137 |
+
await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click()
|
| 138 |
+
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
|
| 139 |
+
await page.reload()
|
| 140 |
+
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
|
| 141 |
+
})
|
packages/app/e2e/regression/session-request-docks.spec.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { installSseTransport } from "../utils/sse-transport"
|
| 5 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 6 |
+
|
| 7 |
+
const directory = "C:/OpenCode/RequestDocks"
|
| 8 |
+
const projectID = "proj_request_docks"
|
| 9 |
+
const sessionID = "ses_request_docks"
|
| 10 |
+
const title = "Request dock regression"
|
| 11 |
+
|
| 12 |
+
test("shows a pending question dock", async ({ page }) => {
|
| 13 |
+
await mockServer(page, {
|
| 14 |
+
questions: [
|
| 15 |
+
{
|
| 16 |
+
id: "question-request",
|
| 17 |
+
sessionID,
|
| 18 |
+
questions: [
|
| 19 |
+
{
|
| 20 |
+
header: "Implementation",
|
| 21 |
+
question: "Which implementation should be used?",
|
| 22 |
+
options: [
|
| 23 |
+
{ label: "Minimal", description: "Use the smallest correct change" },
|
| 24 |
+
{ label: "Extended", description: "Include additional behavior" },
|
| 25 |
+
],
|
| 26 |
+
},
|
| 27 |
+
],
|
| 28 |
+
},
|
| 29 |
+
],
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 33 |
+
await expectSessionTitle(page, title)
|
| 34 |
+
|
| 35 |
+
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
| 36 |
+
await expect(question).toBeVisible()
|
| 37 |
+
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
| 38 |
+
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
|
| 39 |
+
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
|
| 40 |
+
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
| 41 |
+
|
| 42 |
+
const rejectRequests: string[] = []
|
| 43 |
+
page.on("request", (request) => {
|
| 44 |
+
if (request.method() !== "POST") return
|
| 45 |
+
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
| 46 |
+
rejectRequests.push(request.url())
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
| 50 |
+
await expect(question).toBeVisible()
|
| 51 |
+
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
| 52 |
+
await expect(question.getByText("Select one answer")).toBeHidden()
|
| 53 |
+
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
|
| 54 |
+
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
|
| 55 |
+
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
|
| 56 |
+
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
|
| 57 |
+
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
|
| 58 |
+
expect(rejectRequests).toEqual([])
|
| 59 |
+
|
| 60 |
+
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
| 61 |
+
await expect(question).toBeVisible()
|
| 62 |
+
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
| 63 |
+
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
|
| 64 |
+
expect(rejectRequests).toEqual([])
|
| 65 |
+
|
| 66 |
+
await question.getByRole("radio", { name: /Minimal/ }).click()
|
| 67 |
+
const reply = page.waitForRequest(
|
| 68 |
+
(request) =>
|
| 69 |
+
request.method() === "POST" &&
|
| 70 |
+
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
| 71 |
+
)
|
| 72 |
+
await question.getByRole("button", { name: "Submit" }).click()
|
| 73 |
+
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
test("shows a pending permission dock", async ({ page }) => {
|
| 77 |
+
await mockServer(page, {
|
| 78 |
+
permissions: [
|
| 79 |
+
{
|
| 80 |
+
id: "permission-request",
|
| 81 |
+
sessionID,
|
| 82 |
+
permission: "bash",
|
| 83 |
+
patterns: ["git status", "git diff"],
|
| 84 |
+
metadata: {},
|
| 85 |
+
always: [],
|
| 86 |
+
},
|
| 87 |
+
],
|
| 88 |
+
})
|
| 89 |
+
|
| 90 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 91 |
+
await expectSessionTitle(page, title)
|
| 92 |
+
|
| 93 |
+
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
|
| 94 |
+
await expect(permission).toBeVisible()
|
| 95 |
+
await expect(permission.getByText("git status")).toBeVisible()
|
| 96 |
+
await expect(permission.getByText("git diff")).toBeVisible()
|
| 97 |
+
await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
|
| 98 |
+
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
| 99 |
+
|
| 100 |
+
const reply = page.waitForRequest((request) => request.method() === "POST")
|
| 101 |
+
await permission.getByRole("button", { name: "Allow once" }).click()
|
| 102 |
+
const request = await reply
|
| 103 |
+
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
| 104 |
+
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
| 108 |
+
const transport = await installSseTransport(page, {
|
| 109 |
+
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
| 110 |
+
retry: 20,
|
| 111 |
+
})
|
| 112 |
+
await mockServer(page, { questions: [] })
|
| 113 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 114 |
+
await transport.waitForConnection()
|
| 115 |
+
await expectSessionTitle(page, title)
|
| 116 |
+
|
| 117 |
+
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
| 118 |
+
const draft = "keep the caret at the end"
|
| 119 |
+
await editor.fill(draft)
|
| 120 |
+
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
|
| 121 |
+
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
|
| 122 |
+
const cursor = draft.length - 4
|
| 123 |
+
await expect
|
| 124 |
+
.poll(() =>
|
| 125 |
+
editor.evaluate((element) => {
|
| 126 |
+
const selection = window.getSelection()
|
| 127 |
+
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
|
| 128 |
+
const range = selection.getRangeAt(0).cloneRange()
|
| 129 |
+
range.selectNodeContents(element)
|
| 130 |
+
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
| 131 |
+
return range.toString().length
|
| 132 |
+
}),
|
| 133 |
+
)
|
| 134 |
+
.toBe(cursor)
|
| 135 |
+
await transport.send({
|
| 136 |
+
directory,
|
| 137 |
+
payload: {
|
| 138 |
+
type: "question.asked",
|
| 139 |
+
properties: {
|
| 140 |
+
id: "question-caret",
|
| 141 |
+
sessionID,
|
| 142 |
+
questions: [
|
| 143 |
+
{
|
| 144 |
+
header: "Continue",
|
| 145 |
+
question: "Continue?",
|
| 146 |
+
options: [{ label: "Yes", description: "Continue the session" }],
|
| 147 |
+
},
|
| 148 |
+
],
|
| 149 |
+
tool: { messageID: "message-caret", callID: "call-caret" },
|
| 150 |
+
},
|
| 151 |
+
},
|
| 152 |
+
})
|
| 153 |
+
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
| 154 |
+
await expect(question).toBeVisible()
|
| 155 |
+
await expect(editor).toHaveCount(0)
|
| 156 |
+
|
| 157 |
+
await transport.send({
|
| 158 |
+
directory,
|
| 159 |
+
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
| 160 |
+
})
|
| 161 |
+
await expect(question).toHaveCount(0)
|
| 162 |
+
await expect(editor).toBeVisible()
|
| 163 |
+
await page.keyboard.press("x")
|
| 164 |
+
|
| 165 |
+
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
async function mockServer(
|
| 169 |
+
page: Page,
|
| 170 |
+
requests: {
|
| 171 |
+
permissions?: unknown[] | (() => unknown[])
|
| 172 |
+
questions?: unknown[] | (() => unknown[])
|
| 173 |
+
},
|
| 174 |
+
) {
|
| 175 |
+
await mockOpenCodeServer(page, {
|
| 176 |
+
protocol: "v2",
|
| 177 |
+
directory,
|
| 178 |
+
project: {
|
| 179 |
+
id: projectID,
|
| 180 |
+
worktree: directory,
|
| 181 |
+
vcs: "git",
|
| 182 |
+
name: "request-docks",
|
| 183 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 184 |
+
sandboxes: [],
|
| 185 |
+
},
|
| 186 |
+
provider: {
|
| 187 |
+
all: [
|
| 188 |
+
{
|
| 189 |
+
id: "opencode",
|
| 190 |
+
name: "OpenCode",
|
| 191 |
+
models: {
|
| 192 |
+
"claude-opus-4-6": {
|
| 193 |
+
id: "claude-opus-4-6",
|
| 194 |
+
name: "Claude Opus 4.6",
|
| 195 |
+
limit: { context: 200_000 },
|
| 196 |
+
},
|
| 197 |
+
},
|
| 198 |
+
},
|
| 199 |
+
],
|
| 200 |
+
connected: ["opencode"],
|
| 201 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 202 |
+
},
|
| 203 |
+
sessions: [
|
| 204 |
+
{
|
| 205 |
+
id: sessionID,
|
| 206 |
+
slug: "request-docks",
|
| 207 |
+
projectID,
|
| 208 |
+
directory,
|
| 209 |
+
title,
|
| 210 |
+
version: "dev",
|
| 211 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 212 |
+
},
|
| 213 |
+
],
|
| 214 |
+
pageMessages: () => ({ items: [] }),
|
| 215 |
+
permissions: requests.permissions,
|
| 216 |
+
questions: requests.questions,
|
| 217 |
+
})
|
| 218 |
+
await page.addInitScript(() => {
|
| 219 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 220 |
+
})
|
| 221 |
+
}
|
packages/app/e2e/regression/session-timeline-accessibility.spec.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
|
| 3 |
+
|
| 4 |
+
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
|
| 5 |
+
const shellID = "prt_space_button_shell"
|
| 6 |
+
await setupTimeline(page, {
|
| 7 |
+
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
| 8 |
+
settings: { shellToolPartsExpanded: false },
|
| 9 |
+
reducedMotion: true,
|
| 10 |
+
})
|
| 11 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 12 |
+
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
| 13 |
+
await trigger.focus()
|
| 14 |
+
const before = await scroller.evaluate((element) => element.scrollTop)
|
| 15 |
+
await trigger.press("Space")
|
| 16 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 17 |
+
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
|
| 18 |
+
})
|
| 19 |
+
|
| 20 |
+
function lines(count: number) {
|
| 21 |
+
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
| 22 |
+
}
|
packages/app/e2e/regression/session-timeline-collapse-state.spec.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Locator, type Page } from "@playwright/test"
|
| 2 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 3 |
+
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
| 4 |
+
|
| 5 |
+
const directory = "C:/OpenCode/TimelineStateRegression"
|
| 6 |
+
const projectID = "proj_timeline_state_regression"
|
| 7 |
+
const sessionID = "ses_timeline_state_regression"
|
| 8 |
+
const userMessageID = "msg_user_regression"
|
| 9 |
+
const assistantMessageID = "msg_assistant_regression"
|
| 10 |
+
const editPartID = "prt_0001_edit"
|
| 11 |
+
const textPartID = "prt_9999_text"
|
| 12 |
+
const title = "Timeline collapse state regression"
|
| 13 |
+
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
| 14 |
+
|
| 15 |
+
type EventPayload = {
|
| 16 |
+
directory: string
|
| 17 |
+
payload: Record<string, unknown>
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
declare global {
|
| 21 |
+
interface Window {
|
| 22 |
+
__timelineDiffProbe: {
|
| 23 |
+
reset: () => void
|
| 24 |
+
shadowRoots: () => number
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const userMessage = {
|
| 30 |
+
info: {
|
| 31 |
+
id: userMessageID,
|
| 32 |
+
sessionID,
|
| 33 |
+
role: "user",
|
| 34 |
+
time: { created: 1700000000000 },
|
| 35 |
+
summary: { diffs: [] },
|
| 36 |
+
agent: "build",
|
| 37 |
+
model,
|
| 38 |
+
},
|
| 39 |
+
parts: [
|
| 40 |
+
{
|
| 41 |
+
id: "prt_user_text",
|
| 42 |
+
sessionID,
|
| 43 |
+
messageID: userMessageID,
|
| 44 |
+
type: "text",
|
| 45 |
+
text: "Please edit the file.",
|
| 46 |
+
},
|
| 47 |
+
],
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const editPart = {
|
| 51 |
+
id: editPartID,
|
| 52 |
+
sessionID,
|
| 53 |
+
messageID: assistantMessageID,
|
| 54 |
+
type: "tool",
|
| 55 |
+
callID: "call_edit_regression",
|
| 56 |
+
tool: "edit",
|
| 57 |
+
state: {
|
| 58 |
+
status: "completed",
|
| 59 |
+
input: { filePath: "src/regression.ts" },
|
| 60 |
+
output: "Edited src/regression.ts",
|
| 61 |
+
title: "src/regression.ts",
|
| 62 |
+
metadata: {
|
| 63 |
+
filediff: {
|
| 64 |
+
file: "src/regression.ts",
|
| 65 |
+
additions: 1,
|
| 66 |
+
deletions: 1,
|
| 67 |
+
before: "export const value = 'before'\n",
|
| 68 |
+
after: "export const value = 'after'\n",
|
| 69 |
+
},
|
| 70 |
+
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
|
| 71 |
+
},
|
| 72 |
+
time: { start: 1700000001000, end: 1700000002000 },
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const streamedTextPart = {
|
| 77 |
+
id: textPartID,
|
| 78 |
+
sessionID,
|
| 79 |
+
messageID: assistantMessageID,
|
| 80 |
+
type: "text",
|
| 81 |
+
text: "Streaming added a later assistant text part.",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const assistantMessage = {
|
| 85 |
+
info: {
|
| 86 |
+
id: assistantMessageID,
|
| 87 |
+
sessionID,
|
| 88 |
+
role: "assistant",
|
| 89 |
+
time: { created: 1700000001000 },
|
| 90 |
+
parentID: userMessageID,
|
| 91 |
+
modelID: model.modelID,
|
| 92 |
+
providerID: model.providerID,
|
| 93 |
+
mode: "build",
|
| 94 |
+
agent: "build",
|
| 95 |
+
path: { cwd: directory, root: directory },
|
| 96 |
+
cost: 0.01,
|
| 97 |
+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
| 98 |
+
variant: "max",
|
| 99 |
+
},
|
| 100 |
+
parts: [editPart],
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
test.describe("regression: session timeline local row state", () => {
|
| 104 |
+
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
| 105 |
+
const events: EventPayload[] = []
|
| 106 |
+
await mockServer(page, events)
|
| 107 |
+
await configurePage(page)
|
| 108 |
+
|
| 109 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 110 |
+
await expectSessionTitle(page, title)
|
| 111 |
+
|
| 112 |
+
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
| 113 |
+
await expectAppVisible(wrapper)
|
| 114 |
+
await expectExpanded(wrapper, true)
|
| 115 |
+
|
| 116 |
+
await wrapper.evaluate((element) => {
|
| 117 |
+
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
|
| 118 |
+
})
|
| 119 |
+
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
|
| 120 |
+
await expectExpanded(wrapper, false)
|
| 121 |
+
|
| 122 |
+
events.push({
|
| 123 |
+
directory,
|
| 124 |
+
payload: {
|
| 125 |
+
type: "message.part.updated",
|
| 126 |
+
properties: { part: streamedTextPart },
|
| 127 |
+
},
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
| 131 |
+
|
| 132 |
+
expect(await readToolState(page)).toEqual({
|
| 133 |
+
expanded: false,
|
| 134 |
+
row: "AssistantPart",
|
| 135 |
+
streamedTextVisible: true,
|
| 136 |
+
})
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
|
| 140 |
+
const events: EventPayload[] = []
|
| 141 |
+
await installDiffProbe(page)
|
| 142 |
+
await mockServer(page, events)
|
| 143 |
+
await configurePage(page)
|
| 144 |
+
|
| 145 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 146 |
+
await expectSessionTitle(page, title)
|
| 147 |
+
|
| 148 |
+
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
| 149 |
+
await expectAppVisible(wrapper)
|
| 150 |
+
const file = wrapper.locator('[data-component="file"][data-mode="diff"]').first()
|
| 151 |
+
await expectAppVisible(file)
|
| 152 |
+
await markDiffProbe(page)
|
| 153 |
+
|
| 154 |
+
events.push({
|
| 155 |
+
directory,
|
| 156 |
+
payload: {
|
| 157 |
+
type: "message.part.updated",
|
| 158 |
+
properties: { part: streamedTextPart },
|
| 159 |
+
},
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
| 163 |
+
const siblingProbe = await readDiffProbe(page)
|
| 164 |
+
expect(siblingProbe).toEqual({
|
| 165 |
+
fileMarker: "before",
|
| 166 |
+
frameMarker: "before",
|
| 167 |
+
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
| 168 |
+
rowMarker: "before",
|
| 169 |
+
shadowRoots: 0,
|
| 170 |
+
toolMarker: "before",
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
await markDiffProbe(page)
|
| 174 |
+
events.push({
|
| 175 |
+
directory,
|
| 176 |
+
payload: {
|
| 177 |
+
type: "message.part.updated",
|
| 178 |
+
properties: { part: editPartWithAdditions(2) },
|
| 179 |
+
},
|
| 180 |
+
})
|
| 181 |
+
|
| 182 |
+
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
| 183 |
+
{ timeout: 10_000 },
|
| 184 |
+
)
|
| 185 |
+
expect(await readDiffProbe(page)).toEqual({
|
| 186 |
+
fileMarker: "before",
|
| 187 |
+
frameMarker: "before",
|
| 188 |
+
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
| 189 |
+
rowMarker: "before",
|
| 190 |
+
shadowRoots: 0,
|
| 191 |
+
toolMarker: "before",
|
| 192 |
+
})
|
| 193 |
+
})
|
| 194 |
+
|
| 195 |
+
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
|
| 196 |
+
const events: EventPayload[] = []
|
| 197 |
+
const lines = Array.from({ length: 1_000 }, (_, index) => `export const value${index} = ${index}\n`).join("")
|
| 198 |
+
const after = [100, 300, 500, 700, 900].reduce(
|
| 199 |
+
(result, index) =>
|
| 200 |
+
result.replace(`export const value${index} = ${index}`, `export const value${index} = compute(${index})`),
|
| 201 |
+
lines,
|
| 202 |
+
)
|
| 203 |
+
const part = {
|
| 204 |
+
...editPart,
|
| 205 |
+
state: {
|
| 206 |
+
...editPart.state,
|
| 207 |
+
metadata: {
|
| 208 |
+
...editPart.state.metadata,
|
| 209 |
+
filediff: {
|
| 210 |
+
file: "src/regression.ts",
|
| 211 |
+
additions: 1,
|
| 212 |
+
deletions: 1,
|
| 213 |
+
before: lines,
|
| 214 |
+
after,
|
| 215 |
+
},
|
| 216 |
+
},
|
| 217 |
+
},
|
| 218 |
+
}
|
| 219 |
+
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
|
| 220 |
+
await configurePage(page)
|
| 221 |
+
|
| 222 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 223 |
+
await expectSessionTitle(page, title)
|
| 224 |
+
|
| 225 |
+
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
| 226 |
+
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
| 227 |
+
const diff = wrapper.locator('[data-component="edit-content"]').first()
|
| 228 |
+
await expectAppVisible(diff)
|
| 229 |
+
await expect.poll(() => wrapper.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(500)
|
| 230 |
+
const samples = await wrapper.evaluate(async (element) => {
|
| 231 |
+
const root = element.closest<HTMLElement>(".scroll-view__viewport")!
|
| 232 |
+
element.scrollIntoView({ block: "start" })
|
| 233 |
+
const result = []
|
| 234 |
+
for (const offset of [0, 120, 240, 360, 480]) {
|
| 235 |
+
root.scrollBy(0, offset - (result.at(-1)?.offset ?? 0))
|
| 236 |
+
await new Promise(requestAnimationFrame)
|
| 237 |
+
const trigger = element.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')!
|
| 238 |
+
const diff = element.querySelector<HTMLElement>('[data-component="edit-content"]')!
|
| 239 |
+
result.push({
|
| 240 |
+
offset,
|
| 241 |
+
trigger: trigger.getBoundingClientRect().y,
|
| 242 |
+
diff: diff.getBoundingClientRect().y,
|
| 243 |
+
bottom: element.getBoundingClientRect().bottom,
|
| 244 |
+
})
|
| 245 |
+
}
|
| 246 |
+
return result
|
| 247 |
+
})
|
| 248 |
+
|
| 249 |
+
expect(samples[0]!.trigger).toBeLessThan(samples[0]!.diff)
|
| 250 |
+
expect(samples.every((sample) => Math.abs(sample.trigger - samples[0]!.trigger) <= 1)).toBe(true)
|
| 251 |
+
expect(samples.every((sample) => sample.trigger < sample.bottom)).toBe(true)
|
| 252 |
+
})
|
| 253 |
+
})
|
| 254 |
+
|
| 255 |
+
async function configurePage(page: Page) {
|
| 256 |
+
await page.addInitScript(() => {
|
| 257 |
+
localStorage.setItem(
|
| 258 |
+
"settings.v3",
|
| 259 |
+
JSON.stringify({
|
| 260 |
+
general: {
|
| 261 |
+
editToolPartsExpanded: true,
|
| 262 |
+
shellToolPartsExpanded: true,
|
| 263 |
+
showReasoningSummaries: true,
|
| 264 |
+
},
|
| 265 |
+
}),
|
| 266 |
+
)
|
| 267 |
+
})
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
async function expectExpanded(locator: Locator, expected: boolean) {
|
| 271 |
+
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
async function readToolState(page: Page) {
|
| 275 |
+
return page
|
| 276 |
+
.locator(`[data-timeline-part-id="${editPartID}"]`)
|
| 277 |
+
.first()
|
| 278 |
+
.evaluate(
|
| 279 |
+
(element, textPartID) => ({
|
| 280 |
+
expanded: (() => {
|
| 281 |
+
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
| 282 |
+
const aria = trigger?.getAttribute("aria-expanded")
|
| 283 |
+
if (aria === "true") return true
|
| 284 |
+
if (aria === "false") return false
|
| 285 |
+
|
| 286 |
+
const root = element.querySelector('[data-component="collapsible"]')
|
| 287 |
+
if (root?.hasAttribute("data-expanded")) return true
|
| 288 |
+
if (root?.hasAttribute("data-closed")) return false
|
| 289 |
+
|
| 290 |
+
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
|
| 291 |
+
return !!content && content.getBoundingClientRect().height > 0
|
| 292 |
+
})(),
|
| 293 |
+
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
| 294 |
+
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
|
| 295 |
+
}),
|
| 296 |
+
textPartID,
|
| 297 |
+
)
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
async function installDiffProbe(page: Page) {
|
| 301 |
+
await page.addInitScript(() => {
|
| 302 |
+
let shadowRootCount = 0
|
| 303 |
+
const attachShadow = Element.prototype.attachShadow
|
| 304 |
+
Element.prototype.attachShadow = function (init) {
|
| 305 |
+
shadowRootCount += 1
|
| 306 |
+
return attachShadow.call(this, init)
|
| 307 |
+
}
|
| 308 |
+
window.__timelineDiffProbe = {
|
| 309 |
+
reset: () => {
|
| 310 |
+
shadowRootCount = 0
|
| 311 |
+
},
|
| 312 |
+
shadowRoots: () => shadowRootCount,
|
| 313 |
+
}
|
| 314 |
+
})
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
async function markDiffProbe(page: Page) {
|
| 318 |
+
await page
|
| 319 |
+
.locator(`[data-timeline-part-id="${editPartID}"]`)
|
| 320 |
+
.first()
|
| 321 |
+
.evaluate((element) => {
|
| 322 |
+
const tool = element as HTMLElement
|
| 323 |
+
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
| 324 |
+
const row = tool.closest<HTMLElement>("[data-timeline-key]")
|
| 325 |
+
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
|
| 326 |
+
if (!file) throw new Error("missing edit diff file")
|
| 327 |
+
if (!row) throw new Error("missing virtual timeline row")
|
| 328 |
+
if (!frame) throw new Error("missing timeline row frame")
|
| 329 |
+
|
| 330 |
+
tool.dataset.timelineProbe = "before"
|
| 331 |
+
file.dataset.timelineProbe = "before"
|
| 332 |
+
row.dataset.timelineProbe = "before"
|
| 333 |
+
frame.dataset.timelineProbe = "before"
|
| 334 |
+
window.__timelineDiffProbe.reset()
|
| 335 |
+
})
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
async function readDiffProbe(page: Page) {
|
| 339 |
+
return page
|
| 340 |
+
.locator(`[data-timeline-part-id="${editPartID}"]`)
|
| 341 |
+
.first()
|
| 342 |
+
.evaluate((element) => {
|
| 343 |
+
const tool = element as HTMLElement
|
| 344 |
+
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
|
| 345 |
+
const row = tool.closest<HTMLElement>("[data-timeline-key]")
|
| 346 |
+
const frame = tool.closest<HTMLElement>("[data-timeline-row]")
|
| 347 |
+
return {
|
| 348 |
+
fileMarker: file?.dataset.timelineProbe,
|
| 349 |
+
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
|
| 350 |
+
toolMarker: tool.dataset.timelineProbe,
|
| 351 |
+
rowMarker: row?.dataset.timelineProbe,
|
| 352 |
+
rowKey: row?.dataset.timelineKey,
|
| 353 |
+
frameMarker: frame?.dataset.timelineProbe,
|
| 354 |
+
}
|
| 355 |
+
})
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
function editPartWithAdditions(additions: number) {
|
| 359 |
+
return {
|
| 360 |
+
...editPart,
|
| 361 |
+
state: {
|
| 362 |
+
...editPart.state,
|
| 363 |
+
metadata: {
|
| 364 |
+
...editPart.state.metadata,
|
| 365 |
+
filediff: {
|
| 366 |
+
...editPart.state.metadata.filediff,
|
| 367 |
+
additions,
|
| 368 |
+
},
|
| 369 |
+
},
|
| 370 |
+
},
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
function readExpanded(element: Element) {
|
| 375 |
+
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
| 376 |
+
const aria = trigger?.getAttribute("aria-expanded")
|
| 377 |
+
if (aria === "true") return true
|
| 378 |
+
if (aria === "false") return false
|
| 379 |
+
|
| 380 |
+
const root = element.querySelector('[data-component="collapsible"]')
|
| 381 |
+
if (root?.hasAttribute("data-expanded")) return true
|
| 382 |
+
if (root?.hasAttribute("data-closed")) return false
|
| 383 |
+
|
| 384 |
+
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
|
| 385 |
+
return !!content && content.getBoundingClientRect().height > 0
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
|
| 389 |
+
await mockOpenCodeServer(page, {
|
| 390 |
+
directory,
|
| 391 |
+
project: project(),
|
| 392 |
+
provider: provider(),
|
| 393 |
+
sessions: [session()],
|
| 394 |
+
pageMessages: () => ({ items: messages }),
|
| 395 |
+
events: () => events.splice(0, 1),
|
| 396 |
+
eventRetry: 16,
|
| 397 |
+
})
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
function project() {
|
| 401 |
+
return {
|
| 402 |
+
id: projectID,
|
| 403 |
+
worktree: directory,
|
| 404 |
+
vcs: "git",
|
| 405 |
+
name: "timeline-state-regression",
|
| 406 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 407 |
+
sandboxes: [],
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
function session() {
|
| 412 |
+
return {
|
| 413 |
+
id: sessionID,
|
| 414 |
+
slug: "timeline-state-regression",
|
| 415 |
+
projectID,
|
| 416 |
+
directory,
|
| 417 |
+
title,
|
| 418 |
+
version: "dev",
|
| 419 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 420 |
+
}
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
function provider() {
|
| 424 |
+
return {
|
| 425 |
+
all: [
|
| 426 |
+
{
|
| 427 |
+
id: "opencode",
|
| 428 |
+
name: "OpenCode",
|
| 429 |
+
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
| 430 |
+
},
|
| 431 |
+
],
|
| 432 |
+
connected: ["opencode"],
|
| 433 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 434 |
+
}
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
function base64Encode(value: string) {
|
| 438 |
+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
| 439 |
+
}
|
packages/app/e2e/regression/session-timeline-context-resize.spec.ts
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 2 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 3 |
+
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
| 4 |
+
import {
|
| 5 |
+
analyzeVisualObservations,
|
| 6 |
+
defineVisualRegions,
|
| 7 |
+
startVisualProbe,
|
| 8 |
+
stopVisualProbe,
|
| 9 |
+
visualPlan,
|
| 10 |
+
} from "../utils/visual-stability"
|
| 11 |
+
|
| 12 |
+
const directory = "C:/OpenCode/ContextResizeRegression"
|
| 13 |
+
const projectID = "proj_context_resize_regression"
|
| 14 |
+
const sessionID = "ses_context_resize_regression"
|
| 15 |
+
const title = "Context resize regression"
|
| 16 |
+
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
| 17 |
+
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
|
| 18 |
+
const followingTextID = "prt_0104_text"
|
| 19 |
+
|
| 20 |
+
type Message = {
|
| 21 |
+
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
| 22 |
+
parts: Record<string, unknown>[]
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
|
| 26 |
+
|
| 27 |
+
test.describe("regression: session timeline context group resize", () => {
|
| 28 |
+
test("remeasures a recent explored context group before the next paint", async ({ page }) => {
|
| 29 |
+
await page.setViewportSize({ width: 1400, height: 900 })
|
| 30 |
+
await mockServer(page)
|
| 31 |
+
await configurePage(page)
|
| 32 |
+
|
| 33 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 34 |
+
await expectSessionTitle(page, title)
|
| 35 |
+
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
|
| 36 |
+
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
|
| 37 |
+
await settle(page)
|
| 38 |
+
|
| 39 |
+
const samples = await sampleExpansion(page)
|
| 40 |
+
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
|
| 41 |
+
|
| 42 |
+
expect(samples[0]?.overlap).toBe(0)
|
| 43 |
+
expect(visibleOverlap).toEqual([])
|
| 44 |
+
expect(samples.at(-1)?.expanded).toBe("true")
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
test("paints a stable exploring to explored transition", async ({ page }) => {
|
| 48 |
+
const events: { directory: string; payload: Record<string, unknown> }[] = []
|
| 49 |
+
await page.setViewportSize({ width: 1400, height: 900 })
|
| 50 |
+
await mockServer(page, events, [
|
| 51 |
+
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
|
| 52 |
+
...turn(10, true, "running"),
|
| 53 |
+
])
|
| 54 |
+
await configurePage(page)
|
| 55 |
+
|
| 56 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 57 |
+
await expectSessionTitle(page, title)
|
| 58 |
+
const devtools = await page.context().newCDPSession(page)
|
| 59 |
+
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
| 60 |
+
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
| 61 |
+
await expectAppVisible(context)
|
| 62 |
+
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
|
| 63 |
+
|
| 64 |
+
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
| 65 |
+
const regions = defineVisualRegions({
|
| 66 |
+
status: {
|
| 67 |
+
selector: `${contextSelector} [data-component="tool-status-title"]`,
|
| 68 |
+
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
|
| 69 |
+
},
|
| 70 |
+
context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
| 71 |
+
following: {
|
| 72 |
+
selector: `[data-timeline-part-id="${followingTextID}"]`,
|
| 73 |
+
closest: '[data-timeline-row="AssistantPart"]',
|
| 74 |
+
},
|
| 75 |
+
})
|
| 76 |
+
await startVisualProbe(page, regions)
|
| 77 |
+
for (const [index, delay] of [120, 350, 80, 500].entries()) {
|
| 78 |
+
events.push({
|
| 79 |
+
directory,
|
| 80 |
+
payload: {
|
| 81 |
+
type: "message.part.updated",
|
| 82 |
+
properties: {
|
| 83 |
+
part: contextTool(
|
| 84 |
+
contextIDs[index]!,
|
| 85 |
+
id("msg_assistant", 10),
|
| 86 |
+
["read", "glob", "grep", "list"][index]!,
|
| 87 |
+
[
|
| 88 |
+
{ filePath: "src/recent-a.ts" },
|
| 89 |
+
{ path: directory, pattern: "**/*.ts" },
|
| 90 |
+
{ path: directory, pattern: "Explored" },
|
| 91 |
+
{ path: "src" },
|
| 92 |
+
][index]!,
|
| 93 |
+
),
|
| 94 |
+
},
|
| 95 |
+
},
|
| 96 |
+
})
|
| 97 |
+
await page.waitForTimeout(delay)
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
| 101 |
+
await page.waitForTimeout(700)
|
| 102 |
+
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
| 103 |
+
const labels = trace.samples
|
| 104 |
+
.map((sample) => sample.regions.status?.label)
|
| 105 |
+
.filter((value): value is string => !!value)
|
| 106 |
+
.filter((value, index, all) => value !== all[index - 1])
|
| 107 |
+
const issues = analyzeVisualObservations(
|
| 108 |
+
trace.samples,
|
| 109 |
+
visualPlan(regions, [
|
| 110 |
+
{ type: "required", regions: ["context", "following"] },
|
| 111 |
+
{ type: "opacity", regions: "all" },
|
| 112 |
+
{ type: "continuity", regions: "all" },
|
| 113 |
+
{ type: "motion", regions: "all" },
|
| 114 |
+
{ type: "label-stability", regions: "all" },
|
| 115 |
+
{ type: "flow", regions: ["context", "following"] },
|
| 116 |
+
]),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
expect(labels).toEqual(["Exploring", "Explored"])
|
| 120 |
+
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
| 121 |
+
})
|
| 122 |
+
})
|
| 123 |
+
|
| 124 |
+
async function configurePage(page: Page) {
|
| 125 |
+
await page.addInitScript(() => {
|
| 126 |
+
localStorage.setItem(
|
| 127 |
+
"settings.v3",
|
| 128 |
+
JSON.stringify({
|
| 129 |
+
general: {
|
| 130 |
+
editToolPartsExpanded: true,
|
| 131 |
+
shellToolPartsExpanded: true,
|
| 132 |
+
showReasoningSummaries: true,
|
| 133 |
+
},
|
| 134 |
+
}),
|
| 135 |
+
)
|
| 136 |
+
})
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
async function sampleExpansion(page: Page) {
|
| 140 |
+
return page.evaluate(
|
| 141 |
+
({ contextIDs, followingTextID }) =>
|
| 142 |
+
new Promise<
|
| 143 |
+
{
|
| 144 |
+
frame: number
|
| 145 |
+
label: string
|
| 146 |
+
scrollTop: number
|
| 147 |
+
scrollHeight: number
|
| 148 |
+
contextBottom: number
|
| 149 |
+
textTop: number
|
| 150 |
+
overlap: number
|
| 151 |
+
gap: number
|
| 152 |
+
expanded: string | null
|
| 153 |
+
}[]
|
| 154 |
+
>((resolve) => {
|
| 155 |
+
const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
|
| 156 |
+
const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
|
| 157 |
+
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
|
| 158 |
+
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
|
| 159 |
+
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
|
| 160 |
+
const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
|
| 161 |
+
if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
|
| 162 |
+
throw new Error("missing regression nodes")
|
| 163 |
+
|
| 164 |
+
scroller.scrollTop = scroller.scrollHeight
|
| 165 |
+
const samples: {
|
| 166 |
+
frame: number
|
| 167 |
+
label: string
|
| 168 |
+
scrollTop: number
|
| 169 |
+
scrollHeight: number
|
| 170 |
+
contextBottom: number
|
| 171 |
+
textTop: number
|
| 172 |
+
overlap: number
|
| 173 |
+
gap: number
|
| 174 |
+
expanded: string | null
|
| 175 |
+
}[] = []
|
| 176 |
+
const capture = (frame: number, label: string) => {
|
| 177 |
+
const contextRect = contextRow.getBoundingClientRect()
|
| 178 |
+
const textRect = textRow.getBoundingClientRect()
|
| 179 |
+
samples.push({
|
| 180 |
+
frame,
|
| 181 |
+
label,
|
| 182 |
+
scrollTop: Math.round(scroller.scrollTop * 10) / 10,
|
| 183 |
+
scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
|
| 184 |
+
contextBottom: Math.round(contextRect.bottom * 10) / 10,
|
| 185 |
+
textTop: Math.round(textRect.top * 10) / 10,
|
| 186 |
+
overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
|
| 187 |
+
gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
|
| 188 |
+
expanded: trigger.getAttribute("aria-expanded"),
|
| 189 |
+
})
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
capture(-1, "before")
|
| 193 |
+
trigger.click()
|
| 194 |
+
capture(0, "sync-after-click")
|
| 195 |
+
|
| 196 |
+
let frame = 1
|
| 197 |
+
const tick = () => {
|
| 198 |
+
setTimeout(() => {
|
| 199 |
+
capture(frame, "painted")
|
| 200 |
+
frame += 1
|
| 201 |
+
if (frame > 8) {
|
| 202 |
+
resolve(samples)
|
| 203 |
+
return
|
| 204 |
+
}
|
| 205 |
+
requestAnimationFrame(tick)
|
| 206 |
+
}, 0)
|
| 207 |
+
}
|
| 208 |
+
requestAnimationFrame(tick)
|
| 209 |
+
}),
|
| 210 |
+
{ contextIDs, followingTextID },
|
| 211 |
+
)
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
|
| 215 |
+
const userID = id("msg_user", index)
|
| 216 |
+
const assistantID = id("msg_assistant", index)
|
| 217 |
+
return [
|
| 218 |
+
{
|
| 219 |
+
info: {
|
| 220 |
+
id: userID,
|
| 221 |
+
sessionID,
|
| 222 |
+
role: "user",
|
| 223 |
+
time: { created: 1700000000000 + index * 10_000 },
|
| 224 |
+
summary: { diffs: [] },
|
| 225 |
+
agent: "build",
|
| 226 |
+
model,
|
| 227 |
+
},
|
| 228 |
+
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
info: {
|
| 232 |
+
id: assistantID,
|
| 233 |
+
sessionID,
|
| 234 |
+
role: "assistant",
|
| 235 |
+
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
|
| 236 |
+
parentID: userID,
|
| 237 |
+
modelID: model.modelID,
|
| 238 |
+
providerID: model.providerID,
|
| 239 |
+
mode: "build",
|
| 240 |
+
agent: "build",
|
| 241 |
+
path: { cwd: directory, root: directory },
|
| 242 |
+
cost: 0.01,
|
| 243 |
+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
| 244 |
+
variant: "max",
|
| 245 |
+
finish: "stop",
|
| 246 |
+
},
|
| 247 |
+
parts: target
|
| 248 |
+
? [
|
| 249 |
+
contextTool(
|
| 250 |
+
contextIDs[0]!,
|
| 251 |
+
assistantID,
|
| 252 |
+
"read",
|
| 253 |
+
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
| 254 |
+
status,
|
| 255 |
+
),
|
| 256 |
+
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
|
| 257 |
+
contextTool(
|
| 258 |
+
contextIDs[2]!,
|
| 259 |
+
assistantID,
|
| 260 |
+
"grep",
|
| 261 |
+
{ path: directory, pattern: "Explored", include: "*.ts" },
|
| 262 |
+
status,
|
| 263 |
+
),
|
| 264 |
+
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
| 265 |
+
{
|
| 266 |
+
id: followingTextID,
|
| 267 |
+
sessionID,
|
| 268 |
+
messageID: assistantID,
|
| 269 |
+
type: "text",
|
| 270 |
+
text: "This assistant text is immediately after the explored context group.",
|
| 271 |
+
},
|
| 272 |
+
]
|
| 273 |
+
: [
|
| 274 |
+
{
|
| 275 |
+
id: id("prt_text", index),
|
| 276 |
+
sessionID,
|
| 277 |
+
messageID: assistantID,
|
| 278 |
+
type: "text",
|
| 279 |
+
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
|
| 280 |
+
},
|
| 281 |
+
],
|
| 282 |
+
},
|
| 283 |
+
]
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function contextTool(
|
| 287 |
+
partID: string,
|
| 288 |
+
messageID: string,
|
| 289 |
+
tool: string,
|
| 290 |
+
input: Record<string, unknown>,
|
| 291 |
+
status: "running" | "completed" = "completed",
|
| 292 |
+
) {
|
| 293 |
+
return {
|
| 294 |
+
id: partID,
|
| 295 |
+
sessionID,
|
| 296 |
+
messageID,
|
| 297 |
+
type: "tool",
|
| 298 |
+
callID: `call_${partID}`,
|
| 299 |
+
tool,
|
| 300 |
+
state: {
|
| 301 |
+
status,
|
| 302 |
+
input,
|
| 303 |
+
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
|
| 304 |
+
title: input.filePath || input.path || input.pattern || "completed",
|
| 305 |
+
metadata: {},
|
| 306 |
+
time: { start: 1700000000000, end: 1700000000100 },
|
| 307 |
+
},
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
async function mockServer(
|
| 312 |
+
page: Page,
|
| 313 |
+
events: { directory: string; payload: Record<string, unknown> }[] = [],
|
| 314 |
+
fixtureMessages = messages,
|
| 315 |
+
) {
|
| 316 |
+
await mockOpenCodeServer(page, {
|
| 317 |
+
directory,
|
| 318 |
+
project: project(),
|
| 319 |
+
provider: provider(),
|
| 320 |
+
sessions: [session()],
|
| 321 |
+
pageMessages: () => ({ items: fixtureMessages }),
|
| 322 |
+
events: () => events.splice(0, 1),
|
| 323 |
+
eventRetry: 50,
|
| 324 |
+
})
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
async function settle(page: Page) {
|
| 328 |
+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
function id(prefix: string, index: number) {
|
| 332 |
+
return `${prefix}_${String(index).padStart(4, "0")}`
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function project() {
|
| 336 |
+
return {
|
| 337 |
+
id: projectID,
|
| 338 |
+
worktree: directory,
|
| 339 |
+
vcs: "git",
|
| 340 |
+
name: "context-resize-regression",
|
| 341 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 342 |
+
sandboxes: [],
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
function session() {
|
| 347 |
+
return {
|
| 348 |
+
id: sessionID,
|
| 349 |
+
slug: "context-resize-regression",
|
| 350 |
+
projectID,
|
| 351 |
+
directory,
|
| 352 |
+
title,
|
| 353 |
+
version: "dev",
|
| 354 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
function provider() {
|
| 359 |
+
return {
|
| 360 |
+
all: [
|
| 361 |
+
{
|
| 362 |
+
id: "opencode",
|
| 363 |
+
name: "OpenCode",
|
| 364 |
+
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
| 365 |
+
},
|
| 366 |
+
],
|
| 367 |
+
connected: ["opencode"],
|
| 368 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
function base64Encode(value: string) {
|
| 373 |
+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
| 374 |
+
}
|
packages/app/e2e/regression/session-timeline-context-state.spec.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
partUpdated,
|
| 5 |
+
setupTimeline,
|
| 6 |
+
toolPart,
|
| 7 |
+
userMessage,
|
| 8 |
+
} from "../performance/timeline-stability/fixture"
|
| 9 |
+
|
| 10 |
+
test("preserves a collapsed context group through count and status updates", async ({ page }) => {
|
| 11 |
+
const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
|
| 12 |
+
const inputs = {
|
| 13 |
+
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
| 14 |
+
glob: { path: ".", pattern: "**/*.ts" },
|
| 15 |
+
}
|
| 16 |
+
const timeline = await setupTimeline(page, {
|
| 17 |
+
messages: [
|
| 18 |
+
userMessage(),
|
| 19 |
+
assistantMessage(
|
| 20 |
+
[toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)],
|
| 21 |
+
{ completed: false },
|
| 22 |
+
),
|
| 23 |
+
],
|
| 24 |
+
})
|
| 25 |
+
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
| 26 |
+
const trigger = group.locator('[data-slot="collapsible-trigger"]')
|
| 27 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
| 28 |
+
await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100)
|
| 29 |
+
await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300)
|
| 30 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
| 31 |
+
})
|
packages/app/e2e/regression/session-timeline-file-projection.spec.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
| 3 |
+
|
| 4 |
+
test("renders completed write content", async ({ page }) => {
|
| 5 |
+
const id = "prt_file_projection_write"
|
| 6 |
+
await setupTimeline(page, {
|
| 7 |
+
messages: [
|
| 8 |
+
userMessage(),
|
| 9 |
+
assistantMessage([
|
| 10 |
+
toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
|
| 11 |
+
]),
|
| 12 |
+
],
|
| 13 |
+
settings: { editToolPartsExpanded: true },
|
| 14 |
+
})
|
| 15 |
+
|
| 16 |
+
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
test("renders a completed single-file patch", async ({ page }) => {
|
| 20 |
+
const id = "prt_file_projection_single_patch"
|
| 21 |
+
await setupTimeline(page, {
|
| 22 |
+
messages: [
|
| 23 |
+
userMessage(),
|
| 24 |
+
assistantMessage([
|
| 25 |
+
toolPart(
|
| 26 |
+
id,
|
| 27 |
+
"apply_patch",
|
| 28 |
+
"completed",
|
| 29 |
+
{ files: ["src/a.ts"] },
|
| 30 |
+
{
|
| 31 |
+
metadata: {
|
| 32 |
+
files: [
|
| 33 |
+
{
|
| 34 |
+
filePath: "src/a.ts",
|
| 35 |
+
relativePath: "src/a.ts",
|
| 36 |
+
type: "update",
|
| 37 |
+
additions: 1,
|
| 38 |
+
deletions: 1,
|
| 39 |
+
before: "export const value = 1\n",
|
| 40 |
+
after: "export const value = 2\n",
|
| 41 |
+
},
|
| 42 |
+
],
|
| 43 |
+
},
|
| 44 |
+
},
|
| 45 |
+
),
|
| 46 |
+
]),
|
| 47 |
+
],
|
| 48 |
+
settings: { editToolPartsExpanded: true },
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
|
| 52 |
+
})
|
packages/app/e2e/regression/session-timeline-file-state.spec.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
partUpdated,
|
| 5 |
+
setupTimeline,
|
| 6 |
+
toolPart,
|
| 7 |
+
userMessage,
|
| 8 |
+
} from "../performance/timeline-stability/fixture"
|
| 9 |
+
|
| 10 |
+
test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
|
| 11 |
+
const editID = "prt_diagnostics_edit"
|
| 12 |
+
const base = editPart(editID, [])
|
| 13 |
+
const timeline = await setupTimeline(page, {
|
| 14 |
+
messages: [userMessage(), assistantMessage([base])],
|
| 15 |
+
settings: { editToolPartsExpanded: true },
|
| 16 |
+
})
|
| 17 |
+
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
| 18 |
+
await trigger.click()
|
| 19 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
| 20 |
+
await timeline.send(
|
| 21 |
+
partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
|
| 22 |
+
300,
|
| 23 |
+
)
|
| 24 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
| 25 |
+
await timeline.send(partUpdated(editPart(editID, [])), 300)
|
| 26 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
|
| 30 |
+
const patchID = "prt_nested_patch"
|
| 31 |
+
const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
|
| 32 |
+
await setupTimeline(page, {
|
| 33 |
+
messages: [
|
| 34 |
+
userMessage(),
|
| 35 |
+
assistantMessage([
|
| 36 |
+
toolPart(
|
| 37 |
+
patchID,
|
| 38 |
+
"apply_patch",
|
| 39 |
+
"completed",
|
| 40 |
+
{ files: files.map((file) => file.filePath) },
|
| 41 |
+
{ metadata: { files } },
|
| 42 |
+
),
|
| 43 |
+
]),
|
| 44 |
+
],
|
| 45 |
+
settings: { editToolPartsExpanded: true },
|
| 46 |
+
})
|
| 47 |
+
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
| 48 |
+
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
| 49 |
+
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
|
| 50 |
+
await deleted.getByRole("button").click()
|
| 51 |
+
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
| 52 |
+
await outer.click()
|
| 53 |
+
await expect(outer).toHaveAttribute("aria-expanded", "false")
|
| 54 |
+
await outer.click()
|
| 55 |
+
await expect(outer).toHaveAttribute("aria-expanded", "true")
|
| 56 |
+
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
| 60 |
+
return {
|
| 61 |
+
filePath,
|
| 62 |
+
relativePath: filePath,
|
| 63 |
+
type,
|
| 64 |
+
additions: type === "delete" ? 0 : 4,
|
| 65 |
+
deletions: type === "add" ? 0 : 3,
|
| 66 |
+
before: type === "add" ? undefined : source(false),
|
| 67 |
+
after: type === "delete" ? undefined : source(true),
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function editPart(id: string, diagnostics: Record<string, unknown>[]) {
|
| 72 |
+
return toolPart(
|
| 73 |
+
id,
|
| 74 |
+
"edit",
|
| 75 |
+
"completed",
|
| 76 |
+
{ filePath: "src/edit.ts" },
|
| 77 |
+
{
|
| 78 |
+
metadata: {
|
| 79 |
+
filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
|
| 80 |
+
diagnostics,
|
| 81 |
+
},
|
| 82 |
+
},
|
| 83 |
+
)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function diagnostic(message: string, line: number) {
|
| 87 |
+
return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function source(changed: boolean) {
|
| 91 |
+
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
| 92 |
+
"",
|
| 93 |
+
)
|
| 94 |
+
}
|
packages/app/e2e/regression/session-timeline-history-root.spec.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 3 |
+
import {
|
| 4 |
+
assistantMessage,
|
| 5 |
+
directory,
|
| 6 |
+
messageUpdated,
|
| 7 |
+
project,
|
| 8 |
+
session,
|
| 9 |
+
sessionID,
|
| 10 |
+
status,
|
| 11 |
+
textPart,
|
| 12 |
+
title,
|
| 13 |
+
userID,
|
| 14 |
+
userMessage,
|
| 15 |
+
} from "../performance/timeline-stability/fixture"
|
| 16 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 17 |
+
import { installSseTransport } from "../utils/sse-transport"
|
| 18 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 19 |
+
|
| 20 |
+
const initialPageSize = 20
|
| 21 |
+
const historyPageSize = 200
|
| 22 |
+
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
| 23 |
+
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
| 24 |
+
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
| 25 |
+
parentID: userID,
|
| 26 |
+
created: 1700000001000 + index * 1_000,
|
| 27 |
+
completed: index < initialPageSize,
|
| 28 |
+
}),
|
| 29 |
+
)
|
| 30 |
+
const messages = [userMessage(), ...assistants]
|
| 31 |
+
const lastAssistant = assistants.at(-1)!
|
| 32 |
+
const lastPartID = assistants.at(-1)!.parts[0]!.id
|
| 33 |
+
const userPartID = `prt_${userID}_text`
|
| 34 |
+
const completed = {
|
| 35 |
+
...lastAssistant.info,
|
| 36 |
+
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
| 37 |
+
}
|
| 38 |
+
const scenarios = [
|
| 39 |
+
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
|
| 40 |
+
{
|
| 41 |
+
name: "interruption",
|
| 42 |
+
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
|
| 43 |
+
idleFirst: true,
|
| 44 |
+
interrupted: true,
|
| 45 |
+
},
|
| 46 |
+
] as const
|
| 47 |
+
|
| 48 |
+
test.use({ viewport: { width: 646, height: 1385 } })
|
| 49 |
+
|
| 50 |
+
for (const scenario of scenarios) {
|
| 51 |
+
test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
|
| 52 |
+
const requests: { before?: string; phase: "start" | "end" }[] = []
|
| 53 |
+
const pages: { before?: string; limit: number }[] = []
|
| 54 |
+
const roots: { sessionID: string; messageID: string }[] = []
|
| 55 |
+
const sequence: string[] = []
|
| 56 |
+
const history = Promise.withResolvers<void>()
|
| 57 |
+
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
| 58 |
+
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
| 59 |
+
retry: 20,
|
| 60 |
+
})
|
| 61 |
+
await mockOpenCodeServer(page, {
|
| 62 |
+
directory,
|
| 63 |
+
project: project(),
|
| 64 |
+
provider: {
|
| 65 |
+
all: [
|
| 66 |
+
{
|
| 67 |
+
id: "opencode",
|
| 68 |
+
name: "OpenCode",
|
| 69 |
+
models: {
|
| 70 |
+
"claude-opus-4-6": {
|
| 71 |
+
id: "claude-opus-4-6",
|
| 72 |
+
name: "Claude Opus 4.6",
|
| 73 |
+
limit: { context: 200_000 },
|
| 74 |
+
},
|
| 75 |
+
},
|
| 76 |
+
},
|
| 77 |
+
],
|
| 78 |
+
connected: ["opencode"],
|
| 79 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 80 |
+
},
|
| 81 |
+
sessions: [session()],
|
| 82 |
+
sessionStatus: { [sessionID]: { type: "busy" } },
|
| 83 |
+
beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
|
| 84 |
+
onMessages: (request) => {
|
| 85 |
+
requests.push(request)
|
| 86 |
+
sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
|
| 87 |
+
},
|
| 88 |
+
onMessage: (request) => {
|
| 89 |
+
roots.push(request)
|
| 90 |
+
sequence.push(`message:${request.messageID}`)
|
| 91 |
+
},
|
| 92 |
+
message: (requestedSessionID, messageID) => {
|
| 93 |
+
if (requestedSessionID !== sessionID) return
|
| 94 |
+
return messages.find((item) => item.info.id === messageID)
|
| 95 |
+
},
|
| 96 |
+
pageMessages: (_, limit, before) => {
|
| 97 |
+
pages.push({ before, limit })
|
| 98 |
+
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
|
| 99 |
+
const start = Math.max(0, end - limit)
|
| 100 |
+
return {
|
| 101 |
+
items: messages.slice(start, end),
|
| 102 |
+
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
| 103 |
+
}
|
| 104 |
+
},
|
| 105 |
+
})
|
| 106 |
+
await page.addInitScript(() => {
|
| 107 |
+
const visibleParts = () => {
|
| 108 |
+
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
| 109 |
+
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
| 110 |
+
const view = viewport?.getBoundingClientRect()
|
| 111 |
+
if (!viewport || !view) return []
|
| 112 |
+
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
| 113 |
+
.filter((part) => {
|
| 114 |
+
const rect = part.getBoundingClientRect()
|
| 115 |
+
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
| 116 |
+
})
|
| 117 |
+
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
|
| 118 |
+
}
|
| 119 |
+
const state = {
|
| 120 |
+
armed: false,
|
| 121 |
+
hidden: false,
|
| 122 |
+
visibleParts: [] as string[],
|
| 123 |
+
samples: 0,
|
| 124 |
+
stop: false,
|
| 125 |
+
arm() {
|
| 126 |
+
state.visibleParts = visibleParts()
|
| 127 |
+
state.armed = true
|
| 128 |
+
},
|
| 129 |
+
}
|
| 130 |
+
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
| 131 |
+
const sample = () => {
|
| 132 |
+
if (state.armed) {
|
| 133 |
+
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
| 134 |
+
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
| 135 |
+
const view = viewport?.getBoundingClientRect()
|
| 136 |
+
const visible = (partID: string) => {
|
| 137 |
+
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
|
| 138 |
+
const rect = part?.getBoundingClientRect()
|
| 139 |
+
return (
|
| 140 |
+
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
| 141 |
+
)
|
| 142 |
+
}
|
| 143 |
+
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
|
| 144 |
+
state.hidden = true
|
| 145 |
+
state.samples++
|
| 146 |
+
}
|
| 147 |
+
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
| 148 |
+
}
|
| 149 |
+
requestAnimationFrame(() => setTimeout(sample, 0))
|
| 150 |
+
})
|
| 151 |
+
|
| 152 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 153 |
+
await transport.waitForConnection()
|
| 154 |
+
await expectSessionTitle(page, title)
|
| 155 |
+
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
|
| 156 |
+
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
| 157 |
+
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
|
| 158 |
+
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
| 159 |
+
expect(sequence.slice(0, 4)).toEqual([
|
| 160 |
+
"messages:start:latest",
|
| 161 |
+
"messages:end:latest",
|
| 162 |
+
`message:${userID}`,
|
| 163 |
+
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
| 164 |
+
])
|
| 165 |
+
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
|
| 166 |
+
await page.evaluate(() => {
|
| 167 |
+
;(
|
| 168 |
+
window as Window & {
|
| 169 |
+
__historyRootProbe?: { arm(): void }
|
| 170 |
+
}
|
| 171 |
+
).__historyRootProbe!.arm()
|
| 172 |
+
})
|
| 173 |
+
await waitForProbeSamples(page, 0)
|
| 174 |
+
expect(await visibleContentHidden(page)).toBe(false)
|
| 175 |
+
const beforeHistory = await probeSamples(page)
|
| 176 |
+
history.resolve()
|
| 177 |
+
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
|
| 178 |
+
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
| 179 |
+
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
| 180 |
+
await waitForProbeSamples(page, beforeHistory)
|
| 181 |
+
expect(pages).toEqual([
|
| 182 |
+
{ before: undefined, limit: initialPageSize },
|
| 183 |
+
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
| 184 |
+
])
|
| 185 |
+
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
| 186 |
+
|
| 187 |
+
const message = messageUpdated(scenario.info)
|
| 188 |
+
const idle = status("idle")
|
| 189 |
+
for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
|
| 190 |
+
const beforeEvent = await probeSamples(page)
|
| 191 |
+
await transport.send(event)
|
| 192 |
+
if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
| 193 |
+
if (event === message && scenario.interrupted)
|
| 194 |
+
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
| 195 |
+
await waitForProbeSamples(page, beforeEvent)
|
| 196 |
+
const current = await timelineState(page)
|
| 197 |
+
expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
|
| 198 |
+
expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
|
| 202 |
+
expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
|
| 203 |
+
await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
| 204 |
+
await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
|
| 205 |
+
if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
| 206 |
+
expect(
|
| 207 |
+
await page.evaluate(() => {
|
| 208 |
+
const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
|
| 209 |
+
.__historyRootProbe!
|
| 210 |
+
state.stop = true
|
| 211 |
+
return state.hidden
|
| 212 |
+
}),
|
| 213 |
+
).toBe(false)
|
| 214 |
+
})
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function timelineState(page: Page) {
|
| 218 |
+
return page.evaluate(() => ({
|
| 219 |
+
virtual: !!document.querySelector("[data-timeline-virtual-content]"),
|
| 220 |
+
rows: document.querySelectorAll("[data-timeline-key]").length,
|
| 221 |
+
}))
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
function probeSamples(page: Page) {
|
| 225 |
+
return page.evaluate(
|
| 226 |
+
() => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
|
| 227 |
+
)
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
async function waitForProbeSamples(page: Page, after: number) {
|
| 231 |
+
await page.waitForFunction(
|
| 232 |
+
(after) =>
|
| 233 |
+
(window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
|
| 234 |
+
after,
|
| 235 |
+
)
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function visibleContentHidden(page: Page) {
|
| 239 |
+
return page.evaluate(
|
| 240 |
+
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
| 241 |
+
)
|
| 242 |
+
}
|
packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
completedAssistantInfo,
|
| 5 |
+
messageUpdated,
|
| 6 |
+
partUpdated,
|
| 7 |
+
reasoningPart,
|
| 8 |
+
setupTimeline,
|
| 9 |
+
shell,
|
| 10 |
+
status,
|
| 11 |
+
textPart,
|
| 12 |
+
userMessage,
|
| 13 |
+
} from "../performance/timeline-stability/fixture"
|
| 14 |
+
|
| 15 |
+
for (const expanded of [false, true]) {
|
| 16 |
+
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
|
| 17 |
+
const id = `prt_shell_default_${expanded}`
|
| 18 |
+
const timeline = await setupTimeline(page, {
|
| 19 |
+
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
|
| 20 |
+
settings: { shellToolPartsExpanded: expanded },
|
| 21 |
+
})
|
| 22 |
+
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
| 23 |
+
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
| 24 |
+
await trigger.click()
|
| 25 |
+
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
| 26 |
+
|
| 27 |
+
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
|
| 28 |
+
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
|
| 29 |
+
await timeline.send(status("busy"), 100)
|
| 30 |
+
await timeline.send(status("idle"), 250)
|
| 31 |
+
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
| 32 |
+
})
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
|
| 36 |
+
const id = "prt_shell_running_command"
|
| 37 |
+
const command = "sleep 10 && echo done"
|
| 38 |
+
await setupTimeline(page, {
|
| 39 |
+
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
|
| 40 |
+
settings: { shellToolPartsExpanded: false },
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
| 44 |
+
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
| 45 |
+
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
|
| 46 |
+
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
|
| 47 |
+
await tool.locator('[data-slot="collapsible-trigger"]').click()
|
| 48 |
+
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
| 49 |
+
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
| 53 |
+
const reasoningID = "prt_reasoning_hidden"
|
| 54 |
+
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
| 55 |
+
const timeline = await setupTimeline(page, {
|
| 56 |
+
messages: [userMessage(), assistant],
|
| 57 |
+
settings: { showReasoningSummaries: false },
|
| 58 |
+
cpuRate: 4,
|
| 59 |
+
})
|
| 60 |
+
await timeline.send(status("busy"), 150)
|
| 61 |
+
|
| 62 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
| 63 |
+
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
| 64 |
+
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
| 65 |
+
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
| 66 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
| 67 |
+
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
| 68 |
+
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
| 69 |
+
await timeline.send(status("idle"), 300)
|
| 70 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
| 71 |
+
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
|
| 75 |
+
const assistant = assistantMessage([], { completed: false })
|
| 76 |
+
const timeline = await setupTimeline(page, {
|
| 77 |
+
messages: [
|
| 78 |
+
userMessage(undefined, {
|
| 79 |
+
summary: {
|
| 80 |
+
diffs: [
|
| 81 |
+
{
|
| 82 |
+
file: "src/retry.ts",
|
| 83 |
+
additions: 1,
|
| 84 |
+
deletions: 1,
|
| 85 |
+
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
|
| 86 |
+
},
|
| 87 |
+
],
|
| 88 |
+
},
|
| 89 |
+
}),
|
| 90 |
+
assistant,
|
| 91 |
+
],
|
| 92 |
+
})
|
| 93 |
+
await timeline.send(status("busy"), 140)
|
| 94 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
| 95 |
+
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
| 96 |
+
await timeline.send(status("retry"), 180)
|
| 97 |
+
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
| 98 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
| 99 |
+
await timeline.send(status("busy", 2), 180)
|
| 100 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
| 101 |
+
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
| 102 |
+
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
| 103 |
+
await timeline.send(status("idle"), 350)
|
| 104 |
+
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
| 105 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
| 106 |
+
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
| 107 |
+
})
|
| 108 |
+
|
| 109 |
+
function lines(count: number) {
|
| 110 |
+
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
| 111 |
+
}
|
packages/app/e2e/regression/session-timeline-locale-projection.spec.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
| 3 |
+
|
| 4 |
+
for (const profile of [
|
| 5 |
+
{ locale: "de", label: "Erkundung abgeschlossen" },
|
| 6 |
+
{ locale: "ar", label: "تم الاستكشاف" },
|
| 7 |
+
] as const) {
|
| 8 |
+
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
| 9 |
+
const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
|
| 10 |
+
await setupTimeline(page, {
|
| 11 |
+
messages: [
|
| 12 |
+
userMessage(),
|
| 13 |
+
assistantMessage([
|
| 14 |
+
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
| 15 |
+
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
| 16 |
+
]),
|
| 17 |
+
],
|
| 18 |
+
locale: profile.locale,
|
| 19 |
+
})
|
| 20 |
+
|
| 21 |
+
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
| 22 |
+
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
|
| 23 |
+
await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
|
| 24 |
+
})
|
| 25 |
+
}
|
packages/app/e2e/regression/session-timeline-projection.spec.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
setupTimeline,
|
| 5 |
+
status,
|
| 6 |
+
toolPart,
|
| 7 |
+
userMessage,
|
| 8 |
+
userText,
|
| 9 |
+
type PartSeed,
|
| 10 |
+
} from "../performance/timeline-stability/fixture"
|
| 11 |
+
|
| 12 |
+
test.describe("session timeline projection", () => {
|
| 13 |
+
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
|
| 14 |
+
const parts = [
|
| 15 |
+
toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
|
| 16 |
+
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
| 17 |
+
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
|
| 18 |
+
toolPart("prt_04_list", "list", "completed", { path: "src" }),
|
| 19 |
+
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
|
| 20 |
+
toolPart(
|
| 21 |
+
"prt_websearch",
|
| 22 |
+
"websearch",
|
| 23 |
+
"completed",
|
| 24 |
+
{ query: "timeline stability" },
|
| 25 |
+
{ output: "https://example.com/result" },
|
| 26 |
+
),
|
| 27 |
+
toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
|
| 28 |
+
toolPart(
|
| 29 |
+
"prt_bash",
|
| 30 |
+
"bash",
|
| 31 |
+
"completed",
|
| 32 |
+
{ command: "printf stable" },
|
| 33 |
+
{ output: "stable", title: "printf stable" },
|
| 34 |
+
),
|
| 35 |
+
editPart("prt_edit"),
|
| 36 |
+
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
| 37 |
+
patchPart("prt_patch"),
|
| 38 |
+
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
| 39 |
+
toolPart(
|
| 40 |
+
"prt_question",
|
| 41 |
+
"question",
|
| 42 |
+
"completed",
|
| 43 |
+
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
|
| 44 |
+
{ metadata: { answers: [["Yes"]] } },
|
| 45 |
+
),
|
| 46 |
+
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
|
| 47 |
+
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
|
| 48 |
+
]
|
| 49 |
+
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
| 50 |
+
|
| 51 |
+
await expect(
|
| 52 |
+
page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
|
| 53 |
+
).toBeVisible()
|
| 54 |
+
for (const id of [
|
| 55 |
+
"prt_webfetch",
|
| 56 |
+
"prt_websearch",
|
| 57 |
+
"prt_task",
|
| 58 |
+
"prt_bash",
|
| 59 |
+
"prt_edit",
|
| 60 |
+
"prt_write",
|
| 61 |
+
"prt_patch",
|
| 62 |
+
"prt_question",
|
| 63 |
+
"prt_skill",
|
| 64 |
+
"prt_custom",
|
| 65 |
+
]) {
|
| 66 |
+
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
| 67 |
+
}
|
| 68 |
+
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
| 69 |
+
})
|
| 70 |
+
|
| 71 |
+
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
| 72 |
+
const firstUser = userMessage(
|
| 73 |
+
[
|
| 74 |
+
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
| 75 |
+
id: "prt_comment",
|
| 76 |
+
synthetic: true,
|
| 77 |
+
metadata: {
|
| 78 |
+
opencodeComment: {
|
| 79 |
+
path: "src/a.ts",
|
| 80 |
+
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
| 81 |
+
comment: "Keep this stable",
|
| 82 |
+
},
|
| 83 |
+
},
|
| 84 |
+
}),
|
| 85 |
+
userText("Continue after the comment", { id: "prt_visible_user" }),
|
| 86 |
+
],
|
| 87 |
+
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
| 88 |
+
)
|
| 89 |
+
const aborted = assistantMessage(
|
| 90 |
+
[
|
| 91 |
+
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
|
| 92 |
+
{ id: "prt_compaction", type: "compaction", auto: true },
|
| 93 |
+
],
|
| 94 |
+
{
|
| 95 |
+
id: "msg_1001_assistant_aborted",
|
| 96 |
+
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
| 97 |
+
},
|
| 98 |
+
)
|
| 99 |
+
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
| 100 |
+
id: "msg_1002_assistant_failed",
|
| 101 |
+
error: {
|
| 102 |
+
name: "APIError",
|
| 103 |
+
data: {
|
| 104 |
+
message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
|
| 105 |
+
isRetryable: false,
|
| 106 |
+
},
|
| 107 |
+
},
|
| 108 |
+
created: 1700000003000,
|
| 109 |
+
})
|
| 110 |
+
const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
|
| 111 |
+
id: "msg_2000_second_user",
|
| 112 |
+
created: 1700000005000,
|
| 113 |
+
})
|
| 114 |
+
const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
|
| 115 |
+
id: "msg_2001_second_assistant",
|
| 116 |
+
parentID: "msg_2000_second_user",
|
| 117 |
+
created: 1700000006000,
|
| 118 |
+
})
|
| 119 |
+
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
|
| 120 |
+
await timeline.send(status("idle"), 100)
|
| 121 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 122 |
+
await scroller.evaluate((element) => (element.scrollTop = 0))
|
| 123 |
+
|
| 124 |
+
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
|
| 125 |
+
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
|
| 126 |
+
await expect(page.getByText("Visible provider failure")).toBeVisible()
|
| 127 |
+
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
| 128 |
+
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
test("renders inline comments and historical diff summary overflow", async ({ page }) => {
|
| 132 |
+
const user = userMessage(
|
| 133 |
+
[
|
| 134 |
+
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
| 135 |
+
id: "prt_comment_only",
|
| 136 |
+
synthetic: true,
|
| 137 |
+
metadata: {
|
| 138 |
+
opencodeComment: {
|
| 139 |
+
path: "src/a.ts",
|
| 140 |
+
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
| 141 |
+
comment: "Keep this stable",
|
| 142 |
+
},
|
| 143 |
+
},
|
| 144 |
+
}),
|
| 145 |
+
userText("Continue after the comment", { id: "prt_comment_visible" }),
|
| 146 |
+
],
|
| 147 |
+
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
| 148 |
+
)
|
| 149 |
+
const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
|
| 150 |
+
const nextAssistant = assistantMessage([], {
|
| 151 |
+
id: "msg_2001_diff_next_assistant",
|
| 152 |
+
parentID: "msg_2000_diff_next_user",
|
| 153 |
+
created: 1700000011000,
|
| 154 |
+
})
|
| 155 |
+
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
| 156 |
+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 157 |
+
await scroller.evaluate((element) => (element.scrollTop = 0))
|
| 158 |
+
|
| 159 |
+
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
|
| 160 |
+
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
| 161 |
+
await expect(page.getByText(/show all/i)).toBeVisible()
|
| 162 |
+
})
|
| 163 |
+
|
| 164 |
+
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
| 165 |
+
const user = userMessage()
|
| 166 |
+
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
| 167 |
+
id: "msg_1001_before",
|
| 168 |
+
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
| 169 |
+
})
|
| 170 |
+
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
| 171 |
+
id: "msg_1002_after",
|
| 172 |
+
created: 1700000003000,
|
| 173 |
+
})
|
| 174 |
+
await setupTimeline(page, { messages: [user, before, after] })
|
| 175 |
+
|
| 176 |
+
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
| 177 |
+
const rows = await page
|
| 178 |
+
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
|
| 179 |
+
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
|
| 180 |
+
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
|
| 181 |
+
})
|
| 182 |
+
|
| 183 |
+
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
|
| 184 |
+
const text = "Use @explore with @src/a.ts and inspect the attachments"
|
| 185 |
+
const parts: PartSeed<"user">[] = [
|
| 186 |
+
userText(text, { id: "prt_user_rich" }),
|
| 187 |
+
{
|
| 188 |
+
id: "prt_user_image",
|
| 189 |
+
type: "file",
|
| 190 |
+
mime: "image/png",
|
| 191 |
+
filename: "pixel.png",
|
| 192 |
+
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
id: "prt_user_attachment",
|
| 196 |
+
type: "file",
|
| 197 |
+
mime: "application/json",
|
| 198 |
+
filename: "tsconfig.json",
|
| 199 |
+
url: "data:application/json;base64,e30=",
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
id: "prt_user_reference",
|
| 203 |
+
type: "file",
|
| 204 |
+
mime: "text/plain",
|
| 205 |
+
filename: "a.ts",
|
| 206 |
+
url: "src/a.ts",
|
| 207 |
+
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
id: "prt_user_agent",
|
| 211 |
+
type: "agent",
|
| 212 |
+
name: "explore",
|
| 213 |
+
source: { value: "@explore", start: 4, end: 12 },
|
| 214 |
+
},
|
| 215 |
+
]
|
| 216 |
+
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
|
| 217 |
+
|
| 218 |
+
await expect(page.getByAltText("pixel.png")).toBeVisible()
|
| 219 |
+
await expect(page.getByText("tsconfig.json")).toBeVisible()
|
| 220 |
+
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
|
| 221 |
+
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
|
| 222 |
+
})
|
| 223 |
+
})
|
| 224 |
+
|
| 225 |
+
function editPart(id: string) {
|
| 226 |
+
return toolPart(
|
| 227 |
+
id,
|
| 228 |
+
"edit",
|
| 229 |
+
"completed",
|
| 230 |
+
{ filePath: "src/a.ts" },
|
| 231 |
+
{
|
| 232 |
+
metadata: {
|
| 233 |
+
filediff: {
|
| 234 |
+
file: "src/a.ts",
|
| 235 |
+
additions: 1,
|
| 236 |
+
deletions: 1,
|
| 237 |
+
before: "export const value = 1\n",
|
| 238 |
+
after: "export const value = 2\n",
|
| 239 |
+
},
|
| 240 |
+
},
|
| 241 |
+
},
|
| 242 |
+
)
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
function patchPart(id: string) {
|
| 246 |
+
return toolPart(
|
| 247 |
+
id,
|
| 248 |
+
"apply_patch",
|
| 249 |
+
"completed",
|
| 250 |
+
{ files: ["src/a.ts", "src/b.ts"] },
|
| 251 |
+
{
|
| 252 |
+
metadata: {
|
| 253 |
+
files: [
|
| 254 |
+
patchFile("src/a.ts", "update"),
|
| 255 |
+
patchFile("src/b.ts", "add"),
|
| 256 |
+
patchFile("src/old.ts", "delete"),
|
| 257 |
+
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
|
| 258 |
+
],
|
| 259 |
+
},
|
| 260 |
+
},
|
| 261 |
+
)
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
| 265 |
+
return {
|
| 266 |
+
filePath,
|
| 267 |
+
relativePath: filePath,
|
| 268 |
+
type,
|
| 269 |
+
additions: type === "delete" ? 0 : 1,
|
| 270 |
+
deletions: type === "add" ? 0 : 1,
|
| 271 |
+
before: type === "add" ? undefined : "export const before = true\n",
|
| 272 |
+
after: type === "delete" ? undefined : "export const after = true\n",
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
function summaryDiff(index: number) {
|
| 277 |
+
return {
|
| 278 |
+
file: `src/diff-${index}.ts`,
|
| 279 |
+
additions: 1,
|
| 280 |
+
deletions: 1,
|
| 281 |
+
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
| 282 |
+
}
|
| 283 |
+
}
|
packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
reasoningPart,
|
| 5 |
+
setupTimeline,
|
| 6 |
+
status,
|
| 7 |
+
textPart,
|
| 8 |
+
toolPart,
|
| 9 |
+
userMessage,
|
| 10 |
+
} from "../performance/timeline-stability/fixture"
|
| 11 |
+
|
| 12 |
+
const profiles = [
|
| 13 |
+
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
|
| 14 |
+
{
|
| 15 |
+
name: "summaries off reasoning heading",
|
| 16 |
+
summaries: false,
|
| 17 |
+
reasoning: "## Inspecting stability",
|
| 18 |
+
other: false,
|
| 19 |
+
thinking: true,
|
| 20 |
+
body: false,
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
name: "summaries off with visible tool",
|
| 24 |
+
summaries: false,
|
| 25 |
+
reasoning: "## Inspecting stability",
|
| 26 |
+
other: true,
|
| 27 |
+
thinking: true,
|
| 28 |
+
body: false,
|
| 29 |
+
},
|
| 30 |
+
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
|
| 31 |
+
{
|
| 32 |
+
name: "summaries on blank reasoning",
|
| 33 |
+
summaries: true,
|
| 34 |
+
reasoning: " ",
|
| 35 |
+
other: false,
|
| 36 |
+
thinking: true,
|
| 37 |
+
body: false,
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
name: "summaries on visible reasoning",
|
| 41 |
+
summaries: true,
|
| 42 |
+
reasoning: "## Inspecting stability",
|
| 43 |
+
other: false,
|
| 44 |
+
thinking: false,
|
| 45 |
+
body: true,
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
name: "summaries on visible tool no reasoning",
|
| 49 |
+
summaries: true,
|
| 50 |
+
reasoning: "",
|
| 51 |
+
other: true,
|
| 52 |
+
thinking: false,
|
| 53 |
+
body: false,
|
| 54 |
+
},
|
| 55 |
+
] as const
|
| 56 |
+
|
| 57 |
+
for (const profile of profiles) {
|
| 58 |
+
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
|
| 59 |
+
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
|
| 60 |
+
const parts = [
|
| 61 |
+
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
|
| 62 |
+
...(profile.other
|
| 63 |
+
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
|
| 64 |
+
: []),
|
| 65 |
+
]
|
| 66 |
+
const timeline = await setupTimeline(page, {
|
| 67 |
+
messages: [userMessage(), assistantMessage(parts, { completed: false })],
|
| 68 |
+
settings: { showReasoningSummaries: profile.summaries },
|
| 69 |
+
})
|
| 70 |
+
await timeline.send(status("busy"), 150)
|
| 71 |
+
|
| 72 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
|
| 73 |
+
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
|
| 74 |
+
if (!profile.summaries && profile.reasoning.trim()) {
|
| 75 |
+
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
| 76 |
+
}
|
| 77 |
+
})
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
|
| 81 |
+
const timeline = await setupTimeline(page, {
|
| 82 |
+
messages: [
|
| 83 |
+
userMessage(),
|
| 84 |
+
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
|
| 85 |
+
],
|
| 86 |
+
settings: { showReasoningSummaries: true },
|
| 87 |
+
})
|
| 88 |
+
await timeline.send(status("busy"), 150)
|
| 89 |
+
|
| 90 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
| 91 |
+
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
|
| 92 |
+
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
|
| 93 |
+
})
|
packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
completedAssistantInfo,
|
| 5 |
+
messageUpdated,
|
| 6 |
+
partUpdated,
|
| 7 |
+
setupTimeline,
|
| 8 |
+
shell,
|
| 9 |
+
status,
|
| 10 |
+
textPart,
|
| 11 |
+
toolPart,
|
| 12 |
+
userMessage,
|
| 13 |
+
} from "../performance/timeline-stability/fixture"
|
| 14 |
+
|
| 15 |
+
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
|
| 16 |
+
const parts = [
|
| 17 |
+
toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
|
| 18 |
+
textPart("prt_boundary_02_text", "Boundary text"),
|
| 19 |
+
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
| 20 |
+
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
|
| 21 |
+
shell("prt_boundary_05_shell", "completed", "done"),
|
| 22 |
+
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
|
| 23 |
+
]
|
| 24 |
+
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
| 25 |
+
|
| 26 |
+
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
| 27 |
+
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
| 28 |
+
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
| 29 |
+
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
| 33 |
+
const textID = "prt_event_order_text"
|
| 34 |
+
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
| 35 |
+
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
| 36 |
+
await timeline.send(status("busy"), 100)
|
| 37 |
+
await timeline.send(status("idle"), 100)
|
| 38 |
+
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
| 39 |
+
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
|
| 40 |
+
|
| 41 |
+
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
| 42 |
+
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
|
| 43 |
+
})
|
packages/app/e2e/regression/session-timeline-shell-outline.spec.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Locator, type Page } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
setupTimeline,
|
| 5 |
+
shell,
|
| 6 |
+
textPart,
|
| 7 |
+
toolPart,
|
| 8 |
+
userMessage,
|
| 9 |
+
} from "../performance/timeline-stability/fixture"
|
| 10 |
+
|
| 11 |
+
for (const deviceScaleFactor of [1.25, 1.5]) {
|
| 12 |
+
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
|
| 13 |
+
const shellID = "prt_shell_outline"
|
| 14 |
+
const timeline = await setupTimeline(page, {
|
| 15 |
+
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
|
| 16 |
+
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
|
| 17 |
+
reducedMotion: true,
|
| 18 |
+
deviceScaleFactor,
|
| 19 |
+
})
|
| 20 |
+
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
|
| 21 |
+
const output = part.locator('[data-component="bash-output"]')
|
| 22 |
+
const row = page.locator("[data-timeline-key]", { has: part })
|
| 23 |
+
await expect(output).toBeVisible()
|
| 24 |
+
await timeline.settle()
|
| 25 |
+
|
| 26 |
+
const geometry = await row.evaluate((element) => {
|
| 27 |
+
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
|
| 28 |
+
if (!output) throw new Error("Shell output is unavailable")
|
| 29 |
+
const rowRect = element.getBoundingClientRect()
|
| 30 |
+
const outputRect = output.getBoundingClientRect()
|
| 31 |
+
// Match a rounded-down measurement at a fractional device-pixel phase.
|
| 32 |
+
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
|
| 33 |
+
element.style.transform = "translateY(0.25px)"
|
| 34 |
+
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
|
| 35 |
+
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
|
| 36 |
+
const style = getComputedStyle(output)
|
| 37 |
+
return {
|
| 38 |
+
outputWidth: outputRect.width,
|
| 39 |
+
outputHeight: outputRect.height,
|
| 40 |
+
borderColor: style.borderTopColor,
|
| 41 |
+
boxShadow: style.boxShadow,
|
| 42 |
+
clipMargin: getComputedStyle(element).overflowClipMargin,
|
| 43 |
+
}
|
| 44 |
+
})
|
| 45 |
+
await timeline.settle()
|
| 46 |
+
|
| 47 |
+
const clipped = await row.evaluate((element) => {
|
| 48 |
+
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
|
| 49 |
+
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
|
| 50 |
+
})
|
| 51 |
+
expect(clipped).toBeCloseTo(0.49, 1)
|
| 52 |
+
|
| 53 |
+
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
|
| 54 |
+
const edges = await captureCardEdges(page, output)
|
| 55 |
+
|
| 56 |
+
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
|
| 57 |
+
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
|
| 58 |
+
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
|
| 59 |
+
expect(geometry.boxShadow).toBe("none")
|
| 60 |
+
expect(geometry.clipMargin).toBe("0.5px")
|
| 61 |
+
expect(edges.magenta.top).toBeGreaterThan(0.75)
|
| 62 |
+
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
|
| 63 |
+
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
|
| 64 |
+
})
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
|
| 68 |
+
const patchID = "prt_patch_outline"
|
| 69 |
+
const file = {
|
| 70 |
+
filePath: "src/outline.ts",
|
| 71 |
+
relativePath: "src/outline.ts",
|
| 72 |
+
type: "update",
|
| 73 |
+
additions: 1,
|
| 74 |
+
deletions: 1,
|
| 75 |
+
before: "const outline = false\n",
|
| 76 |
+
after: "const outline = true\n",
|
| 77 |
+
}
|
| 78 |
+
const timeline = await setupTimeline(page, {
|
| 79 |
+
messages: [
|
| 80 |
+
userMessage(),
|
| 81 |
+
assistantMessage([
|
| 82 |
+
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
| 83 |
+
]),
|
| 84 |
+
],
|
| 85 |
+
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
|
| 86 |
+
reducedMotion: true,
|
| 87 |
+
})
|
| 88 |
+
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
| 89 |
+
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
| 90 |
+
const row = page.locator("[data-timeline-key]", { has: part })
|
| 91 |
+
await expect(card).toBeVisible()
|
| 92 |
+
await timeline.settle()
|
| 93 |
+
|
| 94 |
+
const geometry = await row.evaluate((element) => {
|
| 95 |
+
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
| 96 |
+
if (!card) throw new Error("Patch card is unavailable")
|
| 97 |
+
const rowRect = element.getBoundingClientRect()
|
| 98 |
+
const cardRect = card.getBoundingClientRect()
|
| 99 |
+
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
|
| 100 |
+
const clipMargin = getComputedStyle(element).overflowClipMargin
|
| 101 |
+
const bottom = element.getBoundingClientRect().bottom
|
| 102 |
+
return {
|
| 103 |
+
overflow: card.getBoundingClientRect().bottom - bottom,
|
| 104 |
+
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
|
| 105 |
+
clipMargin,
|
| 106 |
+
cardWidth: cardRect.width,
|
| 107 |
+
cardHeight: cardRect.height,
|
| 108 |
+
}
|
| 109 |
+
})
|
| 110 |
+
await timeline.settle()
|
| 111 |
+
|
| 112 |
+
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
| 113 |
+
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
| 114 |
+
const edges = await captureCardEdges(page, card)
|
| 115 |
+
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
|
| 116 |
+
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
|
| 117 |
+
expect(edges.luminance.top).toBeLessThan(245)
|
| 118 |
+
expect(edges.luminance.bottom).toBeLessThan(245)
|
| 119 |
+
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
|
| 120 |
+
expect(geometry.clipMargin).toBe("0.5px")
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
|
| 124 |
+
const secondUserID = "msg_outline_second_user"
|
| 125 |
+
await setupTimeline(page, {
|
| 126 |
+
messages: [
|
| 127 |
+
userMessage(undefined, {
|
| 128 |
+
summary: {
|
| 129 |
+
diffs: [
|
| 130 |
+
{
|
| 131 |
+
file: "src/summary.ts",
|
| 132 |
+
additions: 1,
|
| 133 |
+
deletions: 1,
|
| 134 |
+
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
| 135 |
+
},
|
| 136 |
+
],
|
| 137 |
+
},
|
| 138 |
+
}),
|
| 139 |
+
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
|
| 140 |
+
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
|
| 141 |
+
assistantMessage([], {
|
| 142 |
+
id: "msg_outline_second_assistant",
|
| 143 |
+
parentID: secondUserID,
|
| 144 |
+
created: 1700000011000,
|
| 145 |
+
}),
|
| 146 |
+
],
|
| 147 |
+
})
|
| 148 |
+
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
| 149 |
+
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
| 150 |
+
|
| 151 |
+
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
| 152 |
+
elements.map((element) => ({
|
| 153 |
+
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
|
| 154 |
+
clipMargin: getComputedStyle(element).overflowClipMargin,
|
| 155 |
+
})),
|
| 156 |
+
)
|
| 157 |
+
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
|
| 158 |
+
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
|
| 159 |
+
})
|
| 160 |
+
|
| 161 |
+
async function captureCardEdges(page: Page, card: Locator) {
|
| 162 |
+
const box = await card.boundingBox()
|
| 163 |
+
if (!box) throw new Error("Tool card bounds are unavailable")
|
| 164 |
+
const viewport = page.viewportSize()
|
| 165 |
+
if (!viewport) throw new Error("Viewport bounds are unavailable")
|
| 166 |
+
const screenshot = await page.screenshot()
|
| 167 |
+
return page.evaluate(
|
| 168 |
+
async ({ source, box, viewport }) => {
|
| 169 |
+
const image = new Image()
|
| 170 |
+
image.src = source
|
| 171 |
+
await image.decode()
|
| 172 |
+
const canvas = document.createElement("canvas")
|
| 173 |
+
canvas.width = image.naturalWidth
|
| 174 |
+
canvas.height = image.naturalHeight
|
| 175 |
+
const context = canvas.getContext("2d")
|
| 176 |
+
if (!context) throw new Error("2D canvas is unavailable")
|
| 177 |
+
context.drawImage(image, 0, 0)
|
| 178 |
+
const scale = {
|
| 179 |
+
x: image.naturalWidth / viewport.width,
|
| 180 |
+
y: image.naturalHeight / viewport.height,
|
| 181 |
+
}
|
| 182 |
+
const rows = (candidates: number[]) => {
|
| 183 |
+
const left = Math.floor((box.x + 8) * scale.x)
|
| 184 |
+
const width = Math.floor((box.width - 16) * scale.x)
|
| 185 |
+
return candidates.map((row) => {
|
| 186 |
+
const pixels = context.getImageData(left, row, width, 1).data
|
| 187 |
+
const indexes = Array.from({ length: width }, (_, index) => index * 4)
|
| 188 |
+
return {
|
| 189 |
+
luminance:
|
| 190 |
+
indexes
|
| 191 |
+
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
|
| 192 |
+
.reduce((sum, value) => sum + value, 0) / width,
|
| 193 |
+
magenta:
|
| 194 |
+
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
|
| 195 |
+
.length / width,
|
| 196 |
+
}
|
| 197 |
+
})
|
| 198 |
+
}
|
| 199 |
+
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
|
| 200 |
+
const columns = new Uint32Array(image.naturalWidth)
|
| 201 |
+
for (let index = 0; index < pixels.length; index += 4) {
|
| 202 |
+
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
|
| 203 |
+
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
|
| 204 |
+
}
|
| 205 |
+
const top = box.y * scale.y
|
| 206 |
+
const bottom = (box.y + box.height) * scale.y
|
| 207 |
+
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
|
| 208 |
+
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
|
| 209 |
+
return {
|
| 210 |
+
box,
|
| 211 |
+
luminance: {
|
| 212 |
+
top: Math.min(...topRows.map((row) => row.luminance)),
|
| 213 |
+
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
|
| 214 |
+
},
|
| 215 |
+
magenta: {
|
| 216 |
+
top: Math.max(...topRows.map((row) => row.magenta)),
|
| 217 |
+
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
|
| 218 |
+
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
|
| 219 |
+
},
|
| 220 |
+
}
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
| 224 |
+
viewport,
|
| 225 |
+
box,
|
| 226 |
+
},
|
| 227 |
+
)
|
| 228 |
+
}
|
packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
partUpdated,
|
| 5 |
+
setupTimeline,
|
| 6 |
+
toolPart,
|
| 7 |
+
userMessage,
|
| 8 |
+
} from "../performance/timeline-stability/fixture"
|
| 9 |
+
|
| 10 |
+
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
| 11 |
+
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
| 12 |
+
const parts = ordinary.map((tool, index) =>
|
| 13 |
+
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
| 14 |
+
)
|
| 15 |
+
parts.push(
|
| 16 |
+
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
|
| 17 |
+
error: "The user dismissed this question",
|
| 18 |
+
}),
|
| 19 |
+
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
| 20 |
+
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
| 21 |
+
)
|
| 22 |
+
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
| 23 |
+
|
| 24 |
+
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
| 25 |
+
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
| 26 |
+
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
| 27 |
+
for (let index = 0; index < ordinary.length; index++) {
|
| 28 |
+
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
| 29 |
+
}
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
test("transitions shell and question through running error outcomes", async ({ page }) => {
|
| 33 |
+
const shellID = "prt_transition_error_shell"
|
| 34 |
+
const questionID = "prt_transition_error_question"
|
| 35 |
+
const timeline = await setupTimeline(page, {
|
| 36 |
+
messages: [
|
| 37 |
+
userMessage(),
|
| 38 |
+
assistantMessage(
|
| 39 |
+
[
|
| 40 |
+
toolPart(shellID, "bash", "pending", { command: "exit 1" }),
|
| 41 |
+
toolPart(questionID, "question", "pending", questionInput()),
|
| 42 |
+
],
|
| 43 |
+
{ completed: false },
|
| 44 |
+
),
|
| 45 |
+
],
|
| 46 |
+
})
|
| 47 |
+
await timeline.waitForPart(shellID)
|
| 48 |
+
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
| 49 |
+
await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
|
| 50 |
+
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
|
| 51 |
+
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
| 52 |
+
await timeline.send(
|
| 53 |
+
partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
|
| 54 |
+
180,
|
| 55 |
+
)
|
| 56 |
+
await timeline.send(
|
| 57 |
+
partUpdated(
|
| 58 |
+
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
|
| 59 |
+
),
|
| 60 |
+
250,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
|
| 64 |
+
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
test("labels all web search provider variants", async ({ page }) => {
|
| 68 |
+
const parts = [
|
| 69 |
+
toolPart(
|
| 70 |
+
"prt_search_parallel",
|
| 71 |
+
"websearch",
|
| 72 |
+
"completed",
|
| 73 |
+
{ query: "parallel" },
|
| 74 |
+
{ metadata: { provider: "parallel" } },
|
| 75 |
+
),
|
| 76 |
+
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
|
| 77 |
+
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
|
| 78 |
+
]
|
| 79 |
+
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
| 80 |
+
|
| 81 |
+
await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
| 82 |
+
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
| 83 |
+
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
function questionInput() {
|
| 87 |
+
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function errorInput(tool: string) {
|
| 91 |
+
if (tool === "bash") return { command: "exit 1" }
|
| 92 |
+
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
| 93 |
+
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
| 94 |
+
if (tool === "webfetch") return { url: "https://example.com" }
|
| 95 |
+
if (tool === "websearch") return { query: "failure" }
|
| 96 |
+
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
| 97 |
+
if (tool === "skill") return { name: "failure" }
|
| 98 |
+
return { target: "failure" }
|
| 99 |
+
}
|
packages/app/e2e/regression/session-timeline-tool-state.spec.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
partUpdated,
|
| 5 |
+
setupTimeline,
|
| 6 |
+
toolPart,
|
| 7 |
+
userMessage,
|
| 8 |
+
} from "../performance/timeline-stability/fixture"
|
| 9 |
+
|
| 10 |
+
test("updates expanded web search links without resetting expansion", async ({ page }) => {
|
| 11 |
+
const searchID = "prt_websearch_mutation"
|
| 12 |
+
const input = { query: "timeline stability" }
|
| 13 |
+
const timeline = await setupTimeline(page, {
|
| 14 |
+
messages: [
|
| 15 |
+
userMessage(),
|
| 16 |
+
assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
|
| 17 |
+
],
|
| 18 |
+
})
|
| 19 |
+
const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
|
| 20 |
+
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
| 21 |
+
await trigger.click()
|
| 22 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 23 |
+
await timeline.send(
|
| 24 |
+
partUpdated(
|
| 25 |
+
toolPart(searchID, "websearch", "completed", input, {
|
| 26 |
+
output: "https://example.com/one\nhttps://example.com/two",
|
| 27 |
+
}),
|
| 28 |
+
),
|
| 29 |
+
300,
|
| 30 |
+
)
|
| 31 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 32 |
+
await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
|
| 36 |
+
const toolID = "prt_duplicate_error"
|
| 37 |
+
const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
|
| 38 |
+
const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
|
| 39 |
+
const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
|
| 40 |
+
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
| 41 |
+
await trigger.click()
|
| 42 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 43 |
+
await timeline.send(partUpdated(failed), 150)
|
| 44 |
+
await timeline.send(partUpdated(failed), 250)
|
| 45 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 46 |
+
await expect(wrapper).toContainText("Command failed visibly")
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
|
| 50 |
+
const questionID = "prt_multi_question"
|
| 51 |
+
const input = {
|
| 52 |
+
questions: [
|
| 53 |
+
{ header: "First", question: "First choice?", options: [] },
|
| 54 |
+
{ header: "Second", question: "Second choice?", options: [], multiple: true },
|
| 55 |
+
],
|
| 56 |
+
}
|
| 57 |
+
const timeline = await setupTimeline(page, {
|
| 58 |
+
messages: [
|
| 59 |
+
userMessage(),
|
| 60 |
+
assistantMessage([
|
| 61 |
+
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
|
| 62 |
+
]),
|
| 63 |
+
],
|
| 64 |
+
})
|
| 65 |
+
const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
|
| 66 |
+
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
| 67 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 68 |
+
await timeline.send(
|
| 69 |
+
partUpdated(
|
| 70 |
+
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
|
| 71 |
+
),
|
| 72 |
+
300,
|
| 73 |
+
)
|
| 74 |
+
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
| 75 |
+
await expect(wrapper).toContainText("Updated")
|
| 76 |
+
await expect(wrapper).toContainText("B, C")
|
| 77 |
+
})
|
packages/app/e2e/regression/session-timeline-transport.spec.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 2 |
+
import {
|
| 3 |
+
assistantMessage,
|
| 4 |
+
partUpdated,
|
| 5 |
+
setupTimeline,
|
| 6 |
+
textPart,
|
| 7 |
+
userMessage,
|
| 8 |
+
} from "../performance/timeline-stability/fixture"
|
| 9 |
+
|
| 10 |
+
test("keeps one connection open while delivering multiple events", async ({ page }) => {
|
| 11 |
+
const timeline = await setupTimeline(page)
|
| 12 |
+
|
| 13 |
+
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
|
| 14 |
+
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
|
| 15 |
+
|
| 16 |
+
await timeline.waitForPart("prt_transport_first")
|
| 17 |
+
await timeline.waitForPart("prt_transport_second")
|
| 18 |
+
expect(first.connectionID).toBe(second.connectionID)
|
| 19 |
+
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
| 20 |
+
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
test("delivers a burst from one stream chunk", async ({ page }) => {
|
| 24 |
+
const timeline = await setupTimeline(page)
|
| 25 |
+
const acknowledgements = await timeline.transport.burst([
|
| 26 |
+
partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
| 27 |
+
partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
| 28 |
+
])
|
| 29 |
+
|
| 30 |
+
await timeline.waitForPart("prt_transport_burst_a")
|
| 31 |
+
await timeline.waitForPart("prt_transport_burst_b")
|
| 32 |
+
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
|
| 33 |
+
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
| 37 |
+
const timeline = await setupTimeline(page)
|
| 38 |
+
const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
| 39 |
+
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
|
| 40 |
+
const snowman = new TextEncoder().encode("\u2603")[0]!
|
| 41 |
+
const multibyte = encoded.indexOf(snowman)
|
| 42 |
+
|
| 43 |
+
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
|
| 44 |
+
|
| 45 |
+
await timeline.waitForPart("prt_transport_split")
|
| 46 |
+
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
|
| 47 |
+
"split snowman \u2603\u2603\u2603",
|
| 48 |
+
)
|
| 49 |
+
expect(acknowledgement.chunkCount).toBe(4)
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
| 53 |
+
const sentinelID = "prt_transport_heartbeat_sentinel"
|
| 54 |
+
const timeline = await setupTimeline(page, {
|
| 55 |
+
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
|
| 56 |
+
})
|
| 57 |
+
await timeline.waitForPart("prt_transport_steady")
|
| 58 |
+
const before = await stableTimelineRows(page)
|
| 59 |
+
|
| 60 |
+
await timeline.transport.writeRaw(": heartbeat\n\n")
|
| 61 |
+
await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed")))
|
| 62 |
+
await timeline.waitForPart(sentinelID)
|
| 63 |
+
|
| 64 |
+
await expect
|
| 65 |
+
.poll(async () => {
|
| 66 |
+
const rows = await timelineRows(page)
|
| 67 |
+
return rows.filter((row) => before.some((item) => item.key === row.key))
|
| 68 |
+
})
|
| 69 |
+
.toEqual(before)
|
| 70 |
+
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
test("reconnects after a clean close", async ({ page }) => {
|
| 74 |
+
const timeline = await setupTimeline(page)
|
| 75 |
+
const first = await timeline.transport.waitForConnection()
|
| 76 |
+
|
| 77 |
+
await timeline.transport.close()
|
| 78 |
+
const second = await timeline.transport.waitForConnection({ after: first.id })
|
| 79 |
+
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
|
| 80 |
+
|
| 81 |
+
await timeline.waitForPart("prt_transport_close")
|
| 82 |
+
expect(second.id).toBeGreaterThan(first.id)
|
| 83 |
+
expect((await timeline.transport.connections())[0]?.endedBy).toBe("close")
|
| 84 |
+
})
|
| 85 |
+
|
| 86 |
+
test("reconnects after a stream error", async ({ page }) => {
|
| 87 |
+
const timeline = await setupTimeline(page)
|
| 88 |
+
const first = await timeline.transport.waitForConnection()
|
| 89 |
+
|
| 90 |
+
await timeline.transport.error("contract failure")
|
| 91 |
+
const second = await timeline.transport.waitForConnection({ after: first.id })
|
| 92 |
+
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
|
| 93 |
+
|
| 94 |
+
await timeline.waitForPart("prt_transport_error")
|
| 95 |
+
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
| 96 |
+
expect(second.id).toBeGreaterThan(first.id)
|
| 97 |
+
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
| 101 |
+
const timeline = await setupTimeline(page, { protocol: "v2" })
|
| 102 |
+
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
| 103 |
+
id: "timeline-event-7",
|
| 104 |
+
})
|
| 105 |
+
await timeline.waitForPart("prt_transport_id")
|
| 106 |
+
|
| 107 |
+
await timeline.transport.error("retry with event id")
|
| 108 |
+
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
| 109 |
+
|
| 110 |
+
expect(first.eventID).toBe("timeline-event-7")
|
| 111 |
+
expect(connection.headers["last-event-id"]).toBeUndefined()
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
test("passes through non-event fetches", async ({ page }) => {
|
| 115 |
+
const timeline = await setupTimeline(page)
|
| 116 |
+
|
| 117 |
+
const health = await page.evaluate(async () => {
|
| 118 |
+
const response = await fetch("/global/health")
|
| 119 |
+
return response.json()
|
| 120 |
+
})
|
| 121 |
+
|
| 122 |
+
expect(health).toEqual({ healthy: true })
|
| 123 |
+
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
async function stableTimelineRows(page: Page) {
|
| 127 |
+
let previous: Awaited<ReturnType<typeof timelineRows>> | undefined
|
| 128 |
+
let stable = 0
|
| 129 |
+
await expect
|
| 130 |
+
.poll(
|
| 131 |
+
async () => {
|
| 132 |
+
const next = await timelineRows(page)
|
| 133 |
+
stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0
|
| 134 |
+
previous = next
|
| 135 |
+
return stable
|
| 136 |
+
},
|
| 137 |
+
{ intervals: [50, 50, 100] },
|
| 138 |
+
)
|
| 139 |
+
.toBeGreaterThanOrEqual(2)
|
| 140 |
+
return previous!
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function timelineRows(page: Page) {
|
| 144 |
+
return page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
| 145 |
+
elements.map((element) => ({
|
| 146 |
+
key: element.getAttribute("data-timeline-key"),
|
| 147 |
+
row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
| 148 |
+
parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) =>
|
| 149 |
+
part.getAttribute("data-timeline-part-id"),
|
| 150 |
+
),
|
| 151 |
+
text: element.textContent,
|
| 152 |
+
})),
|
| 153 |
+
)
|
| 154 |
+
}
|
packages/app/e2e/regression/session-todo-dock-navigation.spec.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/TodoDockNavigation"
|
| 7 |
+
const projectID = "proj_todo_dock_navigation"
|
| 8 |
+
const sourceID = "ses_todo_dock_source"
|
| 9 |
+
const otherID = "ses_todo_dock_other"
|
| 10 |
+
const sourceTitle = "Todo dock animation"
|
| 11 |
+
const otherTitle = "Separate session"
|
| 12 |
+
|
| 13 |
+
const activeTodos = [
|
| 14 |
+
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
| 15 |
+
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
| 16 |
+
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
type EventPayload = {
|
| 20 |
+
directory: string
|
| 21 |
+
payload: Record<string, unknown>
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
| 25 |
+
|
| 26 |
+
test("animates todo lifecycle without replaying it across session tabs", async ({ page }) => {
|
| 27 |
+
test.setTimeout(90_000)
|
| 28 |
+
const events: EventPayload[] = []
|
| 29 |
+
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
| 30 |
+
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
| 31 |
+
|
| 32 |
+
await mockOpenCodeServer(page, {
|
| 33 |
+
directory,
|
| 34 |
+
project: {
|
| 35 |
+
id: projectID,
|
| 36 |
+
worktree: directory,
|
| 37 |
+
vcs: "git",
|
| 38 |
+
name: "todo-dock-navigation",
|
| 39 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 40 |
+
sandboxes: [],
|
| 41 |
+
},
|
| 42 |
+
provider: {
|
| 43 |
+
all: [
|
| 44 |
+
{
|
| 45 |
+
id: "opencode",
|
| 46 |
+
name: "OpenCode",
|
| 47 |
+
models: {
|
| 48 |
+
"claude-opus-4-6": {
|
| 49 |
+
id: "claude-opus-4-6",
|
| 50 |
+
name: "Claude Opus 4.6",
|
| 51 |
+
limit: { context: 200_000 },
|
| 52 |
+
},
|
| 53 |
+
},
|
| 54 |
+
},
|
| 55 |
+
],
|
| 56 |
+
connected: ["opencode"],
|
| 57 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 58 |
+
},
|
| 59 |
+
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
| 60 |
+
sessionStatus: { [sourceID]: { type: "busy" } },
|
| 61 |
+
pageMessages: () => ({ items: [] }),
|
| 62 |
+
events: () => events.splice(0, 1),
|
| 63 |
+
eventRetry: 16,
|
| 64 |
+
sessionStatus: () => sessionStatus,
|
| 65 |
+
todos: (sessionID) => todos[sessionID] ?? [],
|
| 66 |
+
})
|
| 67 |
+
await configurePage(page)
|
| 68 |
+
|
| 69 |
+
await page.goto(sessionHref(sourceID))
|
| 70 |
+
await expectSessionTitle(page, sourceTitle)
|
| 71 |
+
const dock = page.locator('[data-component="session-todo-dock"]')
|
| 72 |
+
await expect(dock).toHaveCount(0)
|
| 73 |
+
|
| 74 |
+
sessionStatus[sourceID] = { type: "busy" }
|
| 75 |
+
events.push(statusEvent(sourceID, "busy"))
|
| 76 |
+
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
| 77 |
+
|
| 78 |
+
await page.waitForTimeout(700)
|
| 79 |
+
const opening = sampleDock(page, 1_000)
|
| 80 |
+
todos[sourceID] = activeTodos
|
| 81 |
+
events.push(todoEvent(sourceID, activeTodos))
|
| 82 |
+
await expect(dock).toBeVisible()
|
| 83 |
+
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
| 84 |
+
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
| 85 |
+
|
| 86 |
+
await switchSession(page, otherID, otherTitle)
|
| 87 |
+
await expect(dock).toHaveCount(0)
|
| 88 |
+
|
| 89 |
+
const returningOpen = sampleDock(page, 700)
|
| 90 |
+
await switchSession(page, sourceID, sourceTitle)
|
| 91 |
+
const openSamples = (await returningOpen).filter((sample) => sample.present)
|
| 92 |
+
expect(openSamples.length).toBeGreaterThan(0)
|
| 93 |
+
expect(openSamples[0]!.opacity).toBeGreaterThan(0.98)
|
| 94 |
+
expect(openSamples[0]!.height).toBeGreaterThan(70)
|
| 95 |
+
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
| 96 |
+
|
| 97 |
+
const completedTodos = activeTodos.map((todo) => ({ ...todo, status: "completed" }))
|
| 98 |
+
const closing = sampleDock(page, 1_000)
|
| 99 |
+
todos[sourceID] = completedTodos
|
| 100 |
+
events.push(todoEvent(sourceID, completedTodos))
|
| 101 |
+
await expect(dock).toHaveCount(0)
|
| 102 |
+
expect((await closing).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
| 103 |
+
todos[sourceID] = []
|
| 104 |
+
events.push(todoEvent(sourceID, []))
|
| 105 |
+
|
| 106 |
+
await switchSession(page, otherID, otherTitle)
|
| 107 |
+
const returningEmpty = sampleDock(page, 700)
|
| 108 |
+
await switchSession(page, sourceID, sourceTitle)
|
| 109 |
+
await expect(dock).toHaveCount(0)
|
| 110 |
+
expect((await returningEmpty).every((sample) => !sample.present)).toBe(true)
|
| 111 |
+
})
|
| 112 |
+
|
| 113 |
+
function session(id: string, title: string, created: number) {
|
| 114 |
+
return {
|
| 115 |
+
id,
|
| 116 |
+
slug: id,
|
| 117 |
+
projectID,
|
| 118 |
+
directory,
|
| 119 |
+
title,
|
| 120 |
+
version: "dev",
|
| 121 |
+
time: { created, updated: created },
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
| 126 |
+
return {
|
| 127 |
+
directory,
|
| 128 |
+
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
| 133 |
+
return {
|
| 134 |
+
directory,
|
| 135 |
+
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
async function configurePage(page: Page) {
|
| 140 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 141 |
+
await page.addInitScript(
|
| 142 |
+
({ directory, dirBase64, server, sessionIDs }) => {
|
| 143 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 144 |
+
localStorage.setItem(
|
| 145 |
+
"opencode.global.dat:server",
|
| 146 |
+
JSON.stringify({
|
| 147 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 148 |
+
lastProject: { local: directory },
|
| 149 |
+
}),
|
| 150 |
+
)
|
| 151 |
+
localStorage.setItem(
|
| 152 |
+
"opencode.window.browser.dat:tabs",
|
| 153 |
+
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
| 154 |
+
)
|
| 155 |
+
},
|
| 156 |
+
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
| 157 |
+
)
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
function sessionHref(sessionID: string) {
|
| 161 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 162 |
+
return `/server/${base64Encode(server)}/session/${sessionID}`
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function switchSession(page: Page, sessionID: string, title: string) {
|
| 166 |
+
const href = sessionHref(sessionID)
|
| 167 |
+
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
| 168 |
+
await expect(tab).toBeVisible()
|
| 169 |
+
await tab.click()
|
| 170 |
+
await expectSessionTitle(page, title)
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function sampleDock(page: Page, duration: number) {
|
| 174 |
+
return page.evaluate(async (duration) => {
|
| 175 |
+
const samples: { present: boolean; height: number; opacity: number }[] = []
|
| 176 |
+
const start = performance.now()
|
| 177 |
+
while (performance.now() - start < duration) {
|
| 178 |
+
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
| 179 |
+
const clip = dock?.parentElement?.parentElement
|
| 180 |
+
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
| 181 |
+
samples.push({
|
| 182 |
+
present: !!dock,
|
| 183 |
+
height: clip?.getBoundingClientRect().height ?? 0,
|
| 184 |
+
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
| 185 |
+
})
|
| 186 |
+
await new Promise(requestAnimationFrame)
|
| 187 |
+
}
|
| 188 |
+
return samples
|
| 189 |
+
}, duration)
|
| 190 |
+
}
|
packages/app/e2e/regression/subagent-child-navigation.spec.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 3 |
+
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/SubagentNavigation"
|
| 7 |
+
const projectID = "proj_subagent_navigation"
|
| 8 |
+
const parentID = "ses_subagent_parent"
|
| 9 |
+
const childID = "ses_subagent_child"
|
| 10 |
+
const parentTitle = "Parent session"
|
| 11 |
+
const childTitle = "Subagent child session"
|
| 12 |
+
// Child session pages derive their heading from the task part that spawned them.
|
| 13 |
+
const taskDescription = "Inspect child navigation"
|
| 14 |
+
|
| 15 |
+
type EventPayload = { directory: string; payload: Record<string, unknown> }
|
| 16 |
+
|
| 17 |
+
test.use({ viewport: { width: 1440, height: 900 } })
|
| 18 |
+
|
| 19 |
+
test("navigates to a subagent child session missing from the session list", async ({ page }) => {
|
| 20 |
+
await setup(page)
|
| 21 |
+
await openChildFromParent(page)
|
| 22 |
+
|
| 23 |
+
await expectSessionTitle(page, taskDescription)
|
| 24 |
+
await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
|
| 25 |
+
|
| 26 |
+
const titlebarRight = page.locator("#opencode-titlebar-right")
|
| 27 |
+
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
| 31 |
+
const events: EventPayload[] = []
|
| 32 |
+
await setup(page, () => events.splice(0, 1))
|
| 33 |
+
await openChildFromParent(page)
|
| 34 |
+
await expectSessionTitle(page, taskDescription)
|
| 35 |
+
|
| 36 |
+
events.push({
|
| 37 |
+
directory,
|
| 38 |
+
payload: { type: "session.deleted", properties: { info: childSession() } },
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
| 42 |
+
await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible()
|
| 43 |
+
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
async function setup(page: Page, events?: () => EventPayload[]) {
|
| 47 |
+
await mockOpenCodeServer(page, {
|
| 48 |
+
directory,
|
| 49 |
+
project: {
|
| 50 |
+
id: projectID,
|
| 51 |
+
worktree: directory,
|
| 52 |
+
vcs: "git",
|
| 53 |
+
name: "subagent-navigation",
|
| 54 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 55 |
+
sandboxes: [],
|
| 56 |
+
},
|
| 57 |
+
provider: {
|
| 58 |
+
all: [
|
| 59 |
+
{
|
| 60 |
+
id: "opencode",
|
| 61 |
+
name: "OpenCode",
|
| 62 |
+
models: {
|
| 63 |
+
"claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } },
|
| 64 |
+
},
|
| 65 |
+
},
|
| 66 |
+
],
|
| 67 |
+
connected: ["opencode"],
|
| 68 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 69 |
+
},
|
| 70 |
+
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
|
| 71 |
+
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
|
| 72 |
+
events,
|
| 73 |
+
eventRetry: events ? 16 : undefined,
|
| 74 |
+
})
|
| 75 |
+
// The child session resolves by ID but is absent from the session list,
|
| 76 |
+
// matching a subagent session that has not been loaded into the list cache yet.
|
| 77 |
+
await page.route(
|
| 78 |
+
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
| 79 |
+
(route) =>
|
| 80 |
+
route.fulfill({
|
| 81 |
+
status: 200,
|
| 82 |
+
contentType: "application/json",
|
| 83 |
+
headers: { "access-control-allow-origin": "*" },
|
| 84 |
+
body: JSON.stringify({
|
| 85 |
+
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
|
| 86 |
+
cursor: {},
|
| 87 |
+
}),
|
| 88 |
+
}),
|
| 89 |
+
)
|
| 90 |
+
await configurePage(page)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
async function openChildFromParent(page: Page) {
|
| 94 |
+
await page.goto(sessionHref(parentID))
|
| 95 |
+
await expectSessionTitle(page, parentTitle)
|
| 96 |
+
|
| 97 |
+
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
| 98 |
+
await expect(card).toBeVisible()
|
| 99 |
+
await card.click()
|
| 100 |
+
|
| 101 |
+
await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 })
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function session(id: string, title: string, created: number, extra?: Record<string, unknown>) {
|
| 105 |
+
return {
|
| 106 |
+
id,
|
| 107 |
+
slug: id,
|
| 108 |
+
projectID,
|
| 109 |
+
directory,
|
| 110 |
+
title,
|
| 111 |
+
version: "dev",
|
| 112 |
+
time: { created, updated: created },
|
| 113 |
+
...extra,
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function childSession() {
|
| 118 |
+
return session(childID, childTitle, 1700000001000, { parentID })
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function parentMessages() {
|
| 122 |
+
const userID = "msg_user_0001"
|
| 123 |
+
const assistantID = "msg_assistant_0001"
|
| 124 |
+
return [
|
| 125 |
+
{
|
| 126 |
+
info: {
|
| 127 |
+
id: userID,
|
| 128 |
+
sessionID: parentID,
|
| 129 |
+
role: "user",
|
| 130 |
+
time: { created: 1700000000000 },
|
| 131 |
+
agent: "build",
|
| 132 |
+
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 133 |
+
},
|
| 134 |
+
parts: [
|
| 135 |
+
{
|
| 136 |
+
id: "prt_user_text_0001",
|
| 137 |
+
sessionID: parentID,
|
| 138 |
+
messageID: userID,
|
| 139 |
+
type: "text",
|
| 140 |
+
text: "Delegate work to a subagent",
|
| 141 |
+
},
|
| 142 |
+
],
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
info: {
|
| 146 |
+
id: assistantID,
|
| 147 |
+
sessionID: parentID,
|
| 148 |
+
role: "assistant",
|
| 149 |
+
time: { created: 1700000001000, completed: 1700000002000 },
|
| 150 |
+
parentID: userID,
|
| 151 |
+
modelID: "claude-opus-4-6",
|
| 152 |
+
providerID: "opencode",
|
| 153 |
+
mode: "build",
|
| 154 |
+
agent: "build",
|
| 155 |
+
path: { cwd: directory, root: directory },
|
| 156 |
+
cost: 0.01,
|
| 157 |
+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
| 158 |
+
finish: "stop",
|
| 159 |
+
},
|
| 160 |
+
parts: [
|
| 161 |
+
{
|
| 162 |
+
id: "prt_tool_task_0001",
|
| 163 |
+
sessionID: parentID,
|
| 164 |
+
messageID: assistantID,
|
| 165 |
+
type: "tool",
|
| 166 |
+
callID: "call_task_0001",
|
| 167 |
+
tool: "task",
|
| 168 |
+
state: {
|
| 169 |
+
status: "completed",
|
| 170 |
+
input: { description: taskDescription, subagent_type: "explore" },
|
| 171 |
+
output: "Subagent finished",
|
| 172 |
+
title: taskDescription,
|
| 173 |
+
metadata: { sessionId: childID },
|
| 174 |
+
time: { start: 1700000001000, end: 1700000002000 },
|
| 175 |
+
},
|
| 176 |
+
},
|
| 177 |
+
],
|
| 178 |
+
},
|
| 179 |
+
]
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
async function configurePage(page: Page) {
|
| 183 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 184 |
+
await page.addInitScript(
|
| 185 |
+
({ directory, server, sessionId }) => {
|
| 186 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 187 |
+
localStorage.setItem(
|
| 188 |
+
"opencode.global.dat:server",
|
| 189 |
+
JSON.stringify({
|
| 190 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 191 |
+
lastProject: { local: directory },
|
| 192 |
+
}),
|
| 193 |
+
)
|
| 194 |
+
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }]))
|
| 195 |
+
},
|
| 196 |
+
{ directory, server, sessionId: parentID },
|
| 197 |
+
)
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
function sessionHref(sessionID: string) {
|
| 201 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 202 |
+
return `/server/${base64Encode(server)}/session/${sessionID}`
|
| 203 |
+
}
|
packages/app/e2e/regression/tab-navigate-mousedown.spec.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page, type Route } from "@playwright/test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { currentSession } from "../utils/mock-server"
|
| 4 |
+
|
| 5 |
+
const server = "http://127.0.0.1:4096"
|
| 6 |
+
const sessionA = session("ses_tab_a", "Tab A session")
|
| 7 |
+
const sessionB = session("ses_tab_b", "Tab B session")
|
| 8 |
+
const sessionC = session("ses_tab_c", "Tab C session")
|
| 9 |
+
const unresolvedSessionID = "ses_tab_unresolved"
|
| 10 |
+
|
| 11 |
+
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
| 12 |
+
await mockServer(page)
|
| 13 |
+
await page.addInitScript(
|
| 14 |
+
({ server, sessionA, sessionB }) => {
|
| 15 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 16 |
+
localStorage.setItem(
|
| 17 |
+
"opencode.window.browser.dat:tabs",
|
| 18 |
+
JSON.stringify([
|
| 19 |
+
{ type: "session", server, sessionId: sessionA },
|
| 20 |
+
{ type: "session", server, sessionId: sessionB },
|
| 21 |
+
]),
|
| 22 |
+
)
|
| 23 |
+
},
|
| 24 |
+
{ server, sessionA: sessionA.id, sessionB: sessionB.id },
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
| 28 |
+
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
| 29 |
+
await page.goto(hrefA)
|
| 30 |
+
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
| 31 |
+
|
| 32 |
+
const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`)
|
| 33 |
+
await expect(linkB).toBeVisible()
|
| 34 |
+
const box = await linkB.boundingBox()
|
| 35 |
+
if (!box) throw new Error("tab link has no bounding box")
|
| 36 |
+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
| 37 |
+
await page.mouse.down()
|
| 38 |
+
|
| 39 |
+
// Navigation must happen on mousedown, before the button is released.
|
| 40 |
+
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
| 41 |
+
await page.mouse.up()
|
| 42 |
+
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
| 46 |
+
await mockServer(page)
|
| 47 |
+
await page.addInitScript(
|
| 48 |
+
({ server, sessionA, unresolved, sessionC }) => {
|
| 49 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 50 |
+
localStorage.setItem(
|
| 51 |
+
"opencode.window.browser.dat:tabs",
|
| 52 |
+
JSON.stringify([
|
| 53 |
+
{ type: "session", server, sessionId: sessionA },
|
| 54 |
+
{ type: "session", server, sessionId: unresolved },
|
| 55 |
+
{ type: "session", server, sessionId: sessionC },
|
| 56 |
+
]),
|
| 57 |
+
)
|
| 58 |
+
},
|
| 59 |
+
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
| 63 |
+
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
|
| 64 |
+
await page.goto(hrefA)
|
| 65 |
+
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
|
| 66 |
+
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
|
| 67 |
+
|
| 68 |
+
await page.keyboard.press("Control+Alt+ArrowRight")
|
| 69 |
+
|
| 70 |
+
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
| 71 |
+
})
|
| 72 |
+
|
| 73 |
+
function session(id: string, title: string) {
|
| 74 |
+
return {
|
| 75 |
+
id,
|
| 76 |
+
slug: id,
|
| 77 |
+
projectID: "project-tabs",
|
| 78 |
+
directory: "C:/tab-project",
|
| 79 |
+
title,
|
| 80 |
+
version: "dev",
|
| 81 |
+
time: { created: 1, updated: 1 },
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async function mockServer(page: Page) {
|
| 86 |
+
const sessions = [sessionA, sessionB, sessionC]
|
| 87 |
+
await page.route("**/*", async (route) => {
|
| 88 |
+
const url = new URL(route.request().url())
|
| 89 |
+
if (url.origin !== server) return route.fallback()
|
| 90 |
+
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
| 91 |
+
return new Promise(() => {})
|
| 92 |
+
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
| 93 |
+
return sse(route)
|
| 94 |
+
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
| 95 |
+
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
| 96 |
+
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
| 97 |
+
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
| 98 |
+
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
| 99 |
+
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
| 100 |
+
return json(route, { data: [], cursor: {} })
|
| 101 |
+
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
| 102 |
+
if (byId) return json(route, byId)
|
| 103 |
+
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
| 104 |
+
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
| 105 |
+
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
| 106 |
+
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
| 107 |
+
return json(route, [])
|
| 108 |
+
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
| 109 |
+
if (url.pathname === "/provider")
|
| 110 |
+
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
| 111 |
+
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
| 112 |
+
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
| 113 |
+
const project = {
|
| 114 |
+
id: sessionA.projectID,
|
| 115 |
+
worktree: sessionA.directory,
|
| 116 |
+
vcs: "git",
|
| 117 |
+
time: { created: 1, updated: 1 },
|
| 118 |
+
sandboxes: [],
|
| 119 |
+
}
|
| 120 |
+
return json(route, url.pathname === "/project" ? [project] : project)
|
| 121 |
+
}
|
| 122 |
+
if (url.pathname === "/path")
|
| 123 |
+
return json(route, {
|
| 124 |
+
state: sessionA.directory,
|
| 125 |
+
config: sessionA.directory,
|
| 126 |
+
worktree: sessionA.directory,
|
| 127 |
+
directory: sessionA.directory,
|
| 128 |
+
home: sessionA.directory,
|
| 129 |
+
})
|
| 130 |
+
if (url.pathname === "/api/path")
|
| 131 |
+
return json(route, {
|
| 132 |
+
state: sessionA.directory,
|
| 133 |
+
config: sessionA.directory,
|
| 134 |
+
worktree: sessionA.directory,
|
| 135 |
+
directory: sessionA.directory,
|
| 136 |
+
home: sessionA.directory,
|
| 137 |
+
})
|
| 138 |
+
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
| 139 |
+
if (url.pathname === "/api/vcs")
|
| 140 |
+
return json(route, {
|
| 141 |
+
location: { directory: sessionA.directory },
|
| 142 |
+
data: { branch: "main", defaultBranch: "main" },
|
| 143 |
+
})
|
| 144 |
+
return json(route, {})
|
| 145 |
+
})
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function json(route: Route, body: unknown, status = 200) {
|
| 149 |
+
return route.fulfill({
|
| 150 |
+
status,
|
| 151 |
+
contentType: "application/json",
|
| 152 |
+
headers: { "access-control-allow-origin": "*" },
|
| 153 |
+
body: JSON.stringify(body),
|
| 154 |
+
})
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function sse(route: Route) {
|
| 158 |
+
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
| 159 |
+
}
|
packages/app/e2e/regression/terminal-composer-focus.spec.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/TerminalComposerFocus"
|
| 7 |
+
const projectID = "proj_terminal_composer_focus"
|
| 8 |
+
const sessionID = "ses_terminal_composer_focus"
|
| 9 |
+
const ptyID = "pty_terminal_composer_focus"
|
| 10 |
+
const newPtyID = "pty_terminal_composer_focus_new"
|
| 11 |
+
|
| 12 |
+
test.use({ viewport: { width: 1440, height: 900 } })
|
| 13 |
+
|
| 14 |
+
test.beforeEach(async ({ page }) => {
|
| 15 |
+
await mockOpenCodeServer(page, {
|
| 16 |
+
protocol: "v2",
|
| 17 |
+
directory,
|
| 18 |
+
project: {
|
| 19 |
+
id: projectID,
|
| 20 |
+
worktree: directory,
|
| 21 |
+
vcs: "git",
|
| 22 |
+
name: "terminal-composer-focus",
|
| 23 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 24 |
+
sandboxes: [],
|
| 25 |
+
},
|
| 26 |
+
provider: {
|
| 27 |
+
all: [
|
| 28 |
+
{
|
| 29 |
+
id: "opencode",
|
| 30 |
+
name: "OpenCode",
|
| 31 |
+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
| 32 |
+
},
|
| 33 |
+
],
|
| 34 |
+
connected: ["opencode"],
|
| 35 |
+
default: { providerID: "opencode", modelID: "test" },
|
| 36 |
+
},
|
| 37 |
+
sessions: [
|
| 38 |
+
{
|
| 39 |
+
id: sessionID,
|
| 40 |
+
slug: "terminal-composer-focus",
|
| 41 |
+
projectID,
|
| 42 |
+
directory,
|
| 43 |
+
title: "Terminal composer focus",
|
| 44 |
+
version: "dev",
|
| 45 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 46 |
+
},
|
| 47 |
+
],
|
| 48 |
+
pageMessages: () => ({ items: [] }),
|
| 49 |
+
})
|
| 50 |
+
await page.route("**/api/pty*", (route) => {
|
| 51 |
+
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
|
| 52 |
+
return route.fulfill({
|
| 53 |
+
status: 200,
|
| 54 |
+
contentType: "application/json",
|
| 55 |
+
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
| 56 |
+
})
|
| 57 |
+
})
|
| 58 |
+
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
| 59 |
+
route.fulfill({
|
| 60 |
+
status: 200,
|
| 61 |
+
contentType: "application/json",
|
| 62 |
+
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
| 63 |
+
}),
|
| 64 |
+
)
|
| 65 |
+
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
|
| 66 |
+
route.fulfill({
|
| 67 |
+
status: 200,
|
| 68 |
+
contentType: "application/json",
|
| 69 |
+
headers: { "access-control-allow-origin": "*" },
|
| 70 |
+
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
| 71 |
+
}),
|
| 72 |
+
)
|
| 73 |
+
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
| 74 |
+
await page.addInitScript(() => {
|
| 75 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 76 |
+
})
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
| 80 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 81 |
+
await expectSessionTitle(page, "Terminal composer focus")
|
| 82 |
+
|
| 83 |
+
const composer = page.locator('[data-component="prompt-input"]')
|
| 84 |
+
const terminal = page.locator('[data-component="terminal"]')
|
| 85 |
+
await page.keyboard.press("Control+Backquote")
|
| 86 |
+
await expect(terminal).toBeVisible()
|
| 87 |
+
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
| 88 |
+
|
| 89 |
+
await page.keyboard.type("x")
|
| 90 |
+
await expect(composer).toHaveText("")
|
| 91 |
+
|
| 92 |
+
await page.waitForTimeout(300)
|
| 93 |
+
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
| 94 |
+
await page.keyboard.type("a")
|
| 95 |
+
|
| 96 |
+
await expect(composer).toBeFocused()
|
| 97 |
+
await expect(composer).toHaveText("a")
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
|
| 101 |
+
const ghostty = Promise.withResolvers<void>()
|
| 102 |
+
const release = Promise.withResolvers<void>()
|
| 103 |
+
const created = { count: 0 }
|
| 104 |
+
await page.route("**/api/pty*", (route) => {
|
| 105 |
+
created.count += 1
|
| 106 |
+
return route.fulfill({
|
| 107 |
+
status: 200,
|
| 108 |
+
contentType: "application/json",
|
| 109 |
+
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
| 110 |
+
})
|
| 111 |
+
})
|
| 112 |
+
await page.route(/ghostty-web/, async (route) => {
|
| 113 |
+
ghostty.resolve()
|
| 114 |
+
await release.promise
|
| 115 |
+
await route.continue()
|
| 116 |
+
})
|
| 117 |
+
await seedCachedTerminal(page)
|
| 118 |
+
|
| 119 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
| 120 |
+
await expectSessionTitle(page, "Terminal composer focus")
|
| 121 |
+
|
| 122 |
+
const composer = page.locator('[data-component="prompt-input"]')
|
| 123 |
+
const terminal = page.locator('[data-component="terminal"]')
|
| 124 |
+
await expect(terminal).toBeVisible()
|
| 125 |
+
expect(created.count).toBe(0)
|
| 126 |
+
await ghostty.promise
|
| 127 |
+
await composer.click()
|
| 128 |
+
await expect(composer).toBeFocused()
|
| 129 |
+
|
| 130 |
+
release.resolve()
|
| 131 |
+
await expect(terminal.locator("textarea")).toHaveCount(1)
|
| 132 |
+
await page.waitForTimeout(300)
|
| 133 |
+
await expect(composer).toBeFocused()
|
| 134 |
+
})
|
| 135 |
+
|
| 136 |
+
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
|
| 137 |
+
const ghostty = Promise.withResolvers<void>()
|
| 138 |
+
const release = Promise.withResolvers<void>()
|
| 139 |
+
await page.route(/ghostty-web/, async (route) => {
|
| 140 |
+
ghostty.resolve()
|
| 141 |
+
await release.promise
|
| 142 |
+
await route.continue()
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 146 |
+
await expectSessionTitle(page, "Terminal composer focus")
|
| 147 |
+
|
| 148 |
+
const composer = page.locator('[data-component="prompt-input"]')
|
| 149 |
+
const terminal = page.locator('[data-component="terminal"]')
|
| 150 |
+
await page.keyboard.press("Control+Backquote")
|
| 151 |
+
await expect(terminal).toBeVisible()
|
| 152 |
+
await ghostty.promise
|
| 153 |
+
await composer.click()
|
| 154 |
+
await expect(composer).toBeFocused()
|
| 155 |
+
|
| 156 |
+
release.resolve()
|
| 157 |
+
await expect(terminal.locator("textarea")).toHaveCount(1)
|
| 158 |
+
await page.waitForTimeout(50)
|
| 159 |
+
await expect(composer).toBeFocused()
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
| 163 |
+
const created = { count: 0 }
|
| 164 |
+
await page.route("**/api/pty*", (route) => {
|
| 165 |
+
created.count += 1
|
| 166 |
+
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
|
| 167 |
+
return route.fulfill({
|
| 168 |
+
status: 200,
|
| 169 |
+
contentType: "application/json",
|
| 170 |
+
body: JSON.stringify({ location: ptyLocation(), data: next }),
|
| 171 |
+
})
|
| 172 |
+
})
|
| 173 |
+
await page.route(`**/api/pty/${newPtyID}*`, (route) =>
|
| 174 |
+
route.fulfill({
|
| 175 |
+
status: 200,
|
| 176 |
+
contentType: "application/json",
|
| 177 |
+
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
|
| 178 |
+
}),
|
| 179 |
+
)
|
| 180 |
+
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
|
| 181 |
+
route.fulfill({
|
| 182 |
+
status: 200,
|
| 183 |
+
contentType: "application/json",
|
| 184 |
+
headers: { "access-control-allow-origin": "*" },
|
| 185 |
+
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
| 186 |
+
}),
|
| 187 |
+
)
|
| 188 |
+
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
| 189 |
+
|
| 190 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 191 |
+
await expectSessionTitle(page, "Terminal composer focus")
|
| 192 |
+
|
| 193 |
+
const composer = page.locator('[data-component="prompt-input"]')
|
| 194 |
+
const terminal = page.locator('[data-component="terminal"]')
|
| 195 |
+
await page.keyboard.press("Control+Backquote")
|
| 196 |
+
await expect(terminal.locator("textarea")).toHaveCount(1)
|
| 197 |
+
await composer.click()
|
| 198 |
+
await expect(composer).toBeFocused()
|
| 199 |
+
|
| 200 |
+
await page.getByRole("button", { name: "New terminal" }).click()
|
| 201 |
+
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
| 202 |
+
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
| 203 |
+
})
|
| 204 |
+
|
| 205 |
+
function seedCachedTerminal(page: Page) {
|
| 206 |
+
return page.addInitScript(
|
| 207 |
+
({ terminalKey, ptyID }) => {
|
| 208 |
+
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
| 209 |
+
localStorage.setItem(
|
| 210 |
+
terminalKey,
|
| 211 |
+
JSON.stringify({
|
| 212 |
+
active: ptyID,
|
| 213 |
+
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
|
| 214 |
+
}),
|
| 215 |
+
)
|
| 216 |
+
},
|
| 217 |
+
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
| 218 |
+
)
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function ptyLocation() {
|
| 222 |
+
return { directory, project: { id: projectID, directory } }
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
function ptyInfo(id: string, title: string) {
|
| 226 |
+
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
| 227 |
+
}
|
packages/app/e2e/regression/terminal-hidden.spec.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test } from "@playwright/test"
|
| 2 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 3 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 4 |
+
|
| 5 |
+
const directory = "C:/OpenCode/HiddenTerminalRegression"
|
| 6 |
+
const projectID = "proj_hidden_terminal_regression"
|
| 7 |
+
const sessionID = "ses_hidden_terminal_regression"
|
| 8 |
+
const title = "Hidden terminal regression"
|
| 9 |
+
|
| 10 |
+
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
| 11 |
+
await page.setViewportSize({ width: 1400, height: 900 })
|
| 12 |
+
await mockOpenCodeServer(page, {
|
| 13 |
+
protocol: "v2",
|
| 14 |
+
directory,
|
| 15 |
+
project: {
|
| 16 |
+
id: projectID,
|
| 17 |
+
worktree: directory,
|
| 18 |
+
vcs: "git",
|
| 19 |
+
name: "hidden-terminal-regression",
|
| 20 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 21 |
+
sandboxes: [],
|
| 22 |
+
},
|
| 23 |
+
provider: {
|
| 24 |
+
all: [
|
| 25 |
+
{
|
| 26 |
+
id: "opencode",
|
| 27 |
+
name: "OpenCode",
|
| 28 |
+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
| 29 |
+
},
|
| 30 |
+
],
|
| 31 |
+
connected: ["opencode"],
|
| 32 |
+
default: { providerID: "opencode", modelID: "test" },
|
| 33 |
+
},
|
| 34 |
+
sessions: [
|
| 35 |
+
{
|
| 36 |
+
id: sessionID,
|
| 37 |
+
slug: "hidden-terminal-regression",
|
| 38 |
+
projectID,
|
| 39 |
+
directory,
|
| 40 |
+
title,
|
| 41 |
+
version: "dev",
|
| 42 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 43 |
+
},
|
| 44 |
+
],
|
| 45 |
+
pageMessages: () => ({ items: [] }),
|
| 46 |
+
})
|
| 47 |
+
await page.route("**/api/pty*", (route) =>
|
| 48 |
+
route.fulfill({
|
| 49 |
+
status: 200,
|
| 50 |
+
contentType: "application/json",
|
| 51 |
+
body: JSON.stringify({
|
| 52 |
+
location: { directory, project: { id: projectID, directory } },
|
| 53 |
+
data: {
|
| 54 |
+
id: "pty_hidden_terminal",
|
| 55 |
+
title: "Terminal 1",
|
| 56 |
+
command: "cmd.exe",
|
| 57 |
+
args: [],
|
| 58 |
+
cwd: directory,
|
| 59 |
+
status: "running",
|
| 60 |
+
pid: 1,
|
| 61 |
+
},
|
| 62 |
+
}),
|
| 63 |
+
}),
|
| 64 |
+
)
|
| 65 |
+
await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
|
| 66 |
+
route.fulfill({
|
| 67 |
+
status: 200,
|
| 68 |
+
contentType: "application/json",
|
| 69 |
+
body: JSON.stringify({
|
| 70 |
+
location: { directory, project: { id: projectID, directory } },
|
| 71 |
+
data: {
|
| 72 |
+
id: "pty_hidden_terminal",
|
| 73 |
+
title: "Terminal 1",
|
| 74 |
+
command: "cmd.exe",
|
| 75 |
+
args: [],
|
| 76 |
+
cwd: directory,
|
| 77 |
+
status: "running",
|
| 78 |
+
pid: 1,
|
| 79 |
+
},
|
| 80 |
+
}),
|
| 81 |
+
}),
|
| 82 |
+
)
|
| 83 |
+
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
|
| 84 |
+
route.fulfill({
|
| 85 |
+
status: 200,
|
| 86 |
+
contentType: "application/json",
|
| 87 |
+
body: JSON.stringify({
|
| 88 |
+
location: { directory, project: { id: projectID, directory } },
|
| 89 |
+
data: { ticket: "e2e-ticket", expires_in: 60 },
|
| 90 |
+
}),
|
| 91 |
+
}),
|
| 92 |
+
)
|
| 93 |
+
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
| 94 |
+
|
| 95 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
| 96 |
+
await expectSessionTitle(page, title)
|
| 97 |
+
|
| 98 |
+
await page.keyboard.press("Control+Backquote")
|
| 99 |
+
const panel = page.locator("#terminal-panel")
|
| 100 |
+
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
| 101 |
+
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
| 102 |
+
|
| 103 |
+
await page.keyboard.press("Control+Backquote")
|
| 104 |
+
await expect(panel).toHaveCount(0)
|
| 105 |
+
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
| 106 |
+
|
| 107 |
+
await page.setViewportSize({ width: 1200, height: 700 })
|
| 108 |
+
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
| 109 |
+
|
| 110 |
+
await page.keyboard.press("Control+Backquote")
|
| 111 |
+
await expect(panel).toBeVisible()
|
| 112 |
+
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
function base64Encode(value: string) {
|
| 116 |
+
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
| 117 |
+
}
|
packages/app/e2e/regression/terminal-tab-switch.spec.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 2 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 3 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 4 |
+
import { expectSessionTitle } from "../utils/waits"
|
| 5 |
+
|
| 6 |
+
const directory = "C:/OpenCode/TerminalTabSwitch"
|
| 7 |
+
const projectID = "proj_terminal_tab_switch"
|
| 8 |
+
const sessionA = "ses_terminal_tab_a"
|
| 9 |
+
const sessionB = "ses_terminal_tab_b"
|
| 10 |
+
const titleA = "Alpha session"
|
| 11 |
+
const titleB = "Beta session"
|
| 12 |
+
const ptyID = "pty_tab_switch"
|
| 13 |
+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
| 14 |
+
// Marks the terminal DOM node so a remount (fresh node) is detectable.
|
| 15 |
+
const PROBE = "original"
|
| 16 |
+
|
| 17 |
+
test.use({ viewport: { width: 1440, height: 900 } })
|
| 18 |
+
|
| 19 |
+
// Terminals are workspace-scoped: switching between session tabs in the same
|
| 20 |
+
// workspace must keep the terminal mounted and its PTY connection open instead
|
| 21 |
+
// of tearing it down and reconnecting.
|
| 22 |
+
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
|
| 23 |
+
const connections = await setup(page)
|
| 24 |
+
|
| 25 |
+
await page.goto(sessionHref(sessionA))
|
| 26 |
+
await expectSessionTitle(page, titleA)
|
| 27 |
+
|
| 28 |
+
await page.keyboard.press("Control+Backquote")
|
| 29 |
+
const terminal = page.locator('[data-component="terminal"]')
|
| 30 |
+
await expect(terminal).toBeVisible()
|
| 31 |
+
await expect.poll(() => connections.length).toBe(1)
|
| 32 |
+
const connection = new URL(connections[0]!)
|
| 33 |
+
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
| 34 |
+
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
| 35 |
+
expect(connection.searchParams.get("ticket")).toBeNull()
|
| 36 |
+
await writeProbe(page)
|
| 37 |
+
|
| 38 |
+
await switchTab(page, titleB)
|
| 39 |
+
await expectSessionTitle(page, titleB)
|
| 40 |
+
await expect(terminal).toBeVisible()
|
| 41 |
+
expect(await readProbe(page)).toBe(PROBE)
|
| 42 |
+
expect(connections.length).toBe(1)
|
| 43 |
+
|
| 44 |
+
await switchTab(page, titleA)
|
| 45 |
+
await expectSessionTitle(page, titleA)
|
| 46 |
+
await expect(terminal).toBeVisible()
|
| 47 |
+
expect(await readProbe(page)).toBe(PROBE)
|
| 48 |
+
expect(connections.length).toBe(1)
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
type Probed = HTMLElement & { __e2eProbe?: string }
|
| 52 |
+
|
| 53 |
+
async function switchTab(page: Page, title: string) {
|
| 54 |
+
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
async function writeProbe(page: Page) {
|
| 58 |
+
await page.locator('[data-component="terminal"]').evaluate((el, probe) => {
|
| 59 |
+
;(el as Probed).__e2eProbe = probe
|
| 60 |
+
}, PROBE)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
async function readProbe(page: Page) {
|
| 64 |
+
return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
async function setup(page: Page) {
|
| 68 |
+
await mockOpenCodeServer(page, {
|
| 69 |
+
protocol: "v2",
|
| 70 |
+
directory,
|
| 71 |
+
project: {
|
| 72 |
+
id: projectID,
|
| 73 |
+
worktree: directory,
|
| 74 |
+
vcs: "git",
|
| 75 |
+
name: "terminal-tab-switch",
|
| 76 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 77 |
+
sandboxes: [],
|
| 78 |
+
},
|
| 79 |
+
provider: {
|
| 80 |
+
all: [
|
| 81 |
+
{
|
| 82 |
+
id: "opencode",
|
| 83 |
+
name: "OpenCode",
|
| 84 |
+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
| 85 |
+
},
|
| 86 |
+
],
|
| 87 |
+
connected: ["opencode"],
|
| 88 |
+
default: { providerID: "opencode", modelID: "test" },
|
| 89 |
+
},
|
| 90 |
+
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
| 91 |
+
pageMessages: () => ({ items: [] }),
|
| 92 |
+
})
|
| 93 |
+
await page.route("**/api/pty*", (route) =>
|
| 94 |
+
route.fulfill({
|
| 95 |
+
status: 200,
|
| 96 |
+
contentType: "application/json",
|
| 97 |
+
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
| 98 |
+
}),
|
| 99 |
+
)
|
| 100 |
+
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
| 101 |
+
route.fulfill({
|
| 102 |
+
status: 200,
|
| 103 |
+
contentType: "application/json",
|
| 104 |
+
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
| 105 |
+
}),
|
| 106 |
+
)
|
| 107 |
+
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
|
| 108 |
+
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
|
| 109 |
+
const url = new URL(route.request().url())
|
| 110 |
+
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
| 111 |
+
return route.fulfill({
|
| 112 |
+
status: 200,
|
| 113 |
+
contentType: "application/json",
|
| 114 |
+
headers: { "access-control-allow-origin": "*" },
|
| 115 |
+
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
| 116 |
+
})
|
| 117 |
+
})
|
| 118 |
+
const connections: string[] = []
|
| 119 |
+
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
| 120 |
+
connections.push(ws.url())
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
await page.addInitScript(
|
| 124 |
+
({ directory, server, sessions }) => {
|
| 125 |
+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
| 126 |
+
localStorage.setItem(
|
| 127 |
+
"opencode.global.dat:server",
|
| 128 |
+
JSON.stringify({
|
| 129 |
+
projects: { local: [{ worktree: directory, expanded: true }] },
|
| 130 |
+
lastProject: { local: directory },
|
| 131 |
+
}),
|
| 132 |
+
)
|
| 133 |
+
localStorage.setItem(
|
| 134 |
+
"opencode.window.browser.dat:tabs",
|
| 135 |
+
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
| 136 |
+
)
|
| 137 |
+
},
|
| 138 |
+
{ directory, server, sessions: [sessionA, sessionB] },
|
| 139 |
+
)
|
| 140 |
+
return connections
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function session(id: string, title: string, created: number) {
|
| 144 |
+
return {
|
| 145 |
+
id,
|
| 146 |
+
slug: id,
|
| 147 |
+
projectID,
|
| 148 |
+
directory,
|
| 149 |
+
title,
|
| 150 |
+
version: "dev",
|
| 151 |
+
time: { created, updated: created },
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function sessionHref(sessionID: string) {
|
| 156 |
+
return `/server/${base64Encode(server)}/session/${sessionID}`
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function ptyLocation() {
|
| 160 |
+
return { directory, project: { id: projectID, directory } }
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function ptyInfo() {
|
| 164 |
+
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
| 165 |
+
}
|
packages/app/e2e/smoke/session-timeline.fixture.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const words = [
|
| 2 |
+
"alpha",
|
| 3 |
+
"bravo",
|
| 4 |
+
"charlie",
|
| 5 |
+
"delta",
|
| 6 |
+
"echo",
|
| 7 |
+
"foxtrot",
|
| 8 |
+
"golf",
|
| 9 |
+
"hotel",
|
| 10 |
+
"india",
|
| 11 |
+
"juliet",
|
| 12 |
+
"kilo",
|
| 13 |
+
"lima",
|
| 14 |
+
"metro",
|
| 15 |
+
"nova",
|
| 16 |
+
"orbit",
|
| 17 |
+
"pixel",
|
| 18 |
+
"quartz",
|
| 19 |
+
"river",
|
| 20 |
+
"signal",
|
| 21 |
+
"vector",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
const serverKey = "http://127.0.0.1:4096"
|
| 25 |
+
const sourceID = "ses_smoke_source"
|
| 26 |
+
const targetID = "ses_smoke_target"
|
| 27 |
+
const directory = "C:/OpenCode/SmokeProject"
|
| 28 |
+
const projectID = "proj_smoke_timeline"
|
| 29 |
+
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
| 30 |
+
|
| 31 |
+
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
| 32 |
+
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
| 33 |
+
type Message = { info: MessageInfo; parts: MessagePart[] }
|
| 34 |
+
|
| 35 |
+
function lorem(seed: number, length: number) {
|
| 36 |
+
let out = ""
|
| 37 |
+
let i = seed
|
| 38 |
+
while (out.length < length) {
|
| 39 |
+
const word = words[i % words.length]
|
| 40 |
+
out += (out ? " " : "") + word
|
| 41 |
+
if (i % 17 === 0) out += ".\n\n"
|
| 42 |
+
i += 7
|
| 43 |
+
}
|
| 44 |
+
return out.slice(0, length)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function id(prefix: string, value: number) {
|
| 48 |
+
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
| 52 |
+
const messageID = id("msg_user", index)
|
| 53 |
+
return {
|
| 54 |
+
info: {
|
| 55 |
+
id: messageID,
|
| 56 |
+
sessionID,
|
| 57 |
+
role: "user",
|
| 58 |
+
time: { created: 1700000000000 + index * 10_000 },
|
| 59 |
+
summary: { diffs },
|
| 60 |
+
agent: "build",
|
| 61 |
+
model,
|
| 62 |
+
},
|
| 63 |
+
parts: [
|
| 64 |
+
{
|
| 65 |
+
id: id("prt_user_text", index),
|
| 66 |
+
sessionID,
|
| 67 |
+
messageID,
|
| 68 |
+
type: "text",
|
| 69 |
+
text: lorem(index, textLength),
|
| 70 |
+
},
|
| 71 |
+
],
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
| 76 |
+
const messageID = id("msg_assistant", index)
|
| 77 |
+
return {
|
| 78 |
+
info: {
|
| 79 |
+
id: messageID,
|
| 80 |
+
sessionID,
|
| 81 |
+
role: "assistant",
|
| 82 |
+
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
| 83 |
+
parentID,
|
| 84 |
+
modelID: model.modelID,
|
| 85 |
+
providerID: model.providerID,
|
| 86 |
+
mode: "build",
|
| 87 |
+
agent: "build",
|
| 88 |
+
path: { cwd: directory, root: directory },
|
| 89 |
+
cost: 0.01,
|
| 90 |
+
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
| 91 |
+
variant: "max",
|
| 92 |
+
finish: "stop",
|
| 93 |
+
},
|
| 94 |
+
parts: parts.map((part) => ({
|
| 95 |
+
...part,
|
| 96 |
+
sessionID,
|
| 97 |
+
messageID,
|
| 98 |
+
})),
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function textPart(index: number, partIndex: number, length: number): MessagePart {
|
| 103 |
+
return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
|
| 107 |
+
return {
|
| 108 |
+
id: id(`prt_reasoning_${partIndex}`, index),
|
| 109 |
+
type: "reasoning",
|
| 110 |
+
text: lorem(index * 19 + partIndex, length),
|
| 111 |
+
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function toolPart(
|
| 116 |
+
index: number,
|
| 117 |
+
partIndex: number,
|
| 118 |
+
tool: string,
|
| 119 |
+
input: Record<string, unknown>,
|
| 120 |
+
outputLength = 160,
|
| 121 |
+
): MessagePart {
|
| 122 |
+
const metadata =
|
| 123 |
+
tool === "apply_patch"
|
| 124 |
+
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
| 125 |
+
: tool === "edit" || tool === "write"
|
| 126 |
+
? {
|
| 127 |
+
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
| 128 |
+
diff: patch(index, outputLength),
|
| 129 |
+
preview: patch(index + 1, 420),
|
| 130 |
+
}
|
| 131 |
+
: tool === "question"
|
| 132 |
+
? { answers: [["Proceed"], ["Keep sample output"]] }
|
| 133 |
+
: {}
|
| 134 |
+
return {
|
| 135 |
+
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
| 136 |
+
type: "tool",
|
| 137 |
+
callID: id("call", index * 10 + partIndex),
|
| 138 |
+
tool,
|
| 139 |
+
state: {
|
| 140 |
+
status: "completed",
|
| 141 |
+
input,
|
| 142 |
+
output: lorem(index * 23 + partIndex, outputLength),
|
| 143 |
+
title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed",
|
| 144 |
+
metadata,
|
| 145 |
+
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
| 146 |
+
},
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
| 151 |
+
return {
|
| 152 |
+
filePath: `src/generated/patch-${seed}.ts`,
|
| 153 |
+
relativePath: `src/generated/patch-${seed}.ts`,
|
| 154 |
+
type,
|
| 155 |
+
additions: (seed % 7) + 1,
|
| 156 |
+
deletions: type === "add" ? 0 : seed % 4,
|
| 157 |
+
patch: patch(seed, 520),
|
| 158 |
+
before: type === "add" ? undefined : code(seed, 18),
|
| 159 |
+
after: type === "delete" ? undefined : code(seed + 1, 24),
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function fileDiff(file: string, seed: number) {
|
| 164 |
+
return {
|
| 165 |
+
file,
|
| 166 |
+
additions: (seed % 9) + 1,
|
| 167 |
+
deletions: seed % 4,
|
| 168 |
+
before: code(seed, 32),
|
| 169 |
+
after: code(seed + 1, 38),
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function patch(seed: number, length: number) {
|
| 174 |
+
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function code(seed: number, lines: number) {
|
| 178 |
+
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
|
| 179 |
+
"\n",
|
| 180 |
+
)
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function turn(index: number): Message[] {
|
| 184 |
+
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
| 185 |
+
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
| 186 |
+
const parts = [
|
| 187 |
+
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
| 188 |
+
...(index % 3 === 0
|
| 189 |
+
? [
|
| 190 |
+
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
| 191 |
+
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
|
| 192 |
+
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
|
| 193 |
+
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
|
| 194 |
+
]
|
| 195 |
+
: []),
|
| 196 |
+
textPart(index, 2, 160 + (index % 6) * 90),
|
| 197 |
+
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
| 198 |
+
...(index % 6 === 0
|
| 199 |
+
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
| 200 |
+
: []),
|
| 201 |
+
...(index % 8 === 0
|
| 202 |
+
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
| 203 |
+
: []),
|
| 204 |
+
...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []),
|
| 205 |
+
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
| 206 |
+
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
| 207 |
+
...(index % 13 === 0
|
| 208 |
+
? [
|
| 209 |
+
toolPart(
|
| 210 |
+
index,
|
| 211 |
+
11,
|
| 212 |
+
"question",
|
| 213 |
+
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
|
| 214 |
+
120,
|
| 215 |
+
),
|
| 216 |
+
]
|
| 217 |
+
: []),
|
| 218 |
+
...(index % 17 === 0
|
| 219 |
+
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
| 220 |
+
: []),
|
| 221 |
+
]
|
| 222 |
+
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
| 226 |
+
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
| 227 |
+
userMessage(sourceID, index + 1000, 120),
|
| 228 |
+
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
| 229 |
+
]).flat()
|
| 230 |
+
|
| 231 |
+
function renderable(part: MessagePart) {
|
| 232 |
+
if (part.type === "tool" && part.tool === "todowrite") return false
|
| 233 |
+
if (part.type === "text") return !!part.text.trim()
|
| 234 |
+
if (part.type === "reasoning") return !!part.text.trim()
|
| 235 |
+
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function orderedParts(message: Message) {
|
| 239 |
+
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
export const fixture = {
|
| 243 |
+
directory,
|
| 244 |
+
serverKey,
|
| 245 |
+
project: {
|
| 246 |
+
id: projectID,
|
| 247 |
+
worktree: directory,
|
| 248 |
+
vcs: "git",
|
| 249 |
+
name: "smoke-project",
|
| 250 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 251 |
+
sandboxes: [],
|
| 252 |
+
},
|
| 253 |
+
provider: {
|
| 254 |
+
all: [
|
| 255 |
+
{
|
| 256 |
+
id: "opencode",
|
| 257 |
+
name: "OpenCode",
|
| 258 |
+
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
| 259 |
+
},
|
| 260 |
+
],
|
| 261 |
+
connected: ["opencode"],
|
| 262 |
+
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
| 263 |
+
},
|
| 264 |
+
sessions: [
|
| 265 |
+
{
|
| 266 |
+
id: sourceID,
|
| 267 |
+
slug: "source",
|
| 268 |
+
projectID,
|
| 269 |
+
directory,
|
| 270 |
+
title: "Uncommitted changes inquiry",
|
| 271 |
+
version: "dev",
|
| 272 |
+
time: { created: 1700000000000, updated: 1700000000000 },
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
id: targetID,
|
| 276 |
+
slug: "target",
|
| 277 |
+
projectID,
|
| 278 |
+
directory,
|
| 279 |
+
title: "Example Game: sample jump movement & sample physics analysis",
|
| 280 |
+
version: "dev",
|
| 281 |
+
time: { created: 1700000001000, updated: 1700000001000 },
|
| 282 |
+
},
|
| 283 |
+
],
|
| 284 |
+
sourceID,
|
| 285 |
+
targetID,
|
| 286 |
+
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
| 287 |
+
expected: {
|
| 288 |
+
sourceTitle: "Uncommitted changes inquiry",
|
| 289 |
+
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
| 290 |
+
targetMessageIDs: targetMessages
|
| 291 |
+
.filter((message) => message.info.role === "user")
|
| 292 |
+
.map((message) => message.info.id),
|
| 293 |
+
targetPartIDs: targetMessages.flatMap((message) =>
|
| 294 |
+
orderedParts(message)
|
| 295 |
+
.filter(renderable)
|
| 296 |
+
.map((part) => part.id),
|
| 297 |
+
),
|
| 298 |
+
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
|
| 299 |
+
},
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
| 303 |
+
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
| 304 |
+
const end = before
|
| 305 |
+
? Math.max(
|
| 306 |
+
0,
|
| 307 |
+
messages.findIndex((message) => message.info.id === before),
|
| 308 |
+
)
|
| 309 |
+
: messages.length
|
| 310 |
+
const start = Math.max(0, end - limit)
|
| 311 |
+
return {
|
| 312 |
+
items: messages.slice(start, end),
|
| 313 |
+
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
| 314 |
+
}
|
| 315 |
+
}
|
packages/app/e2e/smoke/session-timeline.spec.ts
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { expect, test, type Page } from "@playwright/test"
|
| 2 |
+
import { base64Encode } from "@opencode-ai/core/util/encode"
|
| 3 |
+
import { fixture, pageMessages } from "./session-timeline.fixture"
|
| 4 |
+
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
| 5 |
+
import { mockOpenCodeServer } from "../utils/mock-server"
|
| 6 |
+
import { APP_READY_TIMEOUT, expectAppVisible, expectSessionTitle } from "../utils/waits"
|
| 7 |
+
|
| 8 |
+
const forbiddenText = ["Load details", "Show earlier steps"]
|
| 9 |
+
|
| 10 |
+
type SmokeState = {
|
| 11 |
+
ids: string[]
|
| 12 |
+
visibleIds: string[]
|
| 13 |
+
messageIds: string[]
|
| 14 |
+
visibleMessageIds: string[]
|
| 15 |
+
topVisibleId?: string
|
| 16 |
+
signature: string
|
| 17 |
+
scrollTop: number
|
| 18 |
+
scrollHeight: number
|
| 19 |
+
clientHeight: number
|
| 20 |
+
errorToasts: string[]
|
| 21 |
+
forbiddenText: string[]
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
type SmokeWindow = Window & {
|
| 25 |
+
__timelineSmokeState?: () => SmokeState
|
| 26 |
+
__timelineSmokeErrorToasts?: string[]
|
| 27 |
+
__timelineSmokeForbiddenText?: string[]
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
test.describe("smoke: session timeline", () => {
|
| 31 |
+
test.setTimeout(240_000)
|
| 32 |
+
|
| 33 |
+
test("keeps the visible message fixed while prepending history", async ({ page }) => {
|
| 34 |
+
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
|
| 35 |
+
await mockOpenCodeServer(page, {
|
| 36 |
+
sessions: fixture.sessions,
|
| 37 |
+
provider: fixture.provider,
|
| 38 |
+
directory: fixture.directory,
|
| 39 |
+
project: fixture.project,
|
| 40 |
+
pageMessages,
|
| 41 |
+
messageDelay: 3_000,
|
| 42 |
+
onMessages: (input) => requests.push({ before: input.before, phase: input.phase, at: performance.now() }),
|
| 43 |
+
})
|
| 44 |
+
await configureSmokePage(page, fixture.directory)
|
| 45 |
+
|
| 46 |
+
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
| 47 |
+
await waitForTimelineStable(page)
|
| 48 |
+
const scroller = timelineScroller(page)
|
| 49 |
+
await pointAtTimeline(page)
|
| 50 |
+
const deadline = Date.now() + 120_000
|
| 51 |
+
while (!requests.some((request) => request.before && request.phase === "start")) {
|
| 52 |
+
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
| 53 |
+
await page.mouse.wheel(0, -240)
|
| 54 |
+
await page.waitForTimeout(20)
|
| 55 |
+
}
|
| 56 |
+
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
|
| 57 |
+
for (let index = 0; index < 12; index++) {
|
| 58 |
+
await page.mouse.wheel(0, -120)
|
| 59 |
+
await page.waitForTimeout(20)
|
| 60 |
+
}
|
| 61 |
+
const keys = await scroller.evaluate((element) => {
|
| 62 |
+
const view = element.getBoundingClientRect()
|
| 63 |
+
return [...element.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
| 64 |
+
.filter((row) => {
|
| 65 |
+
const rect = row.getBoundingClientRect()
|
| 66 |
+
return rect.bottom > view.top && rect.top < view.bottom
|
| 67 |
+
})
|
| 68 |
+
.map((row) => row.dataset.timelinePartId)
|
| 69 |
+
.filter((id): id is string => !!id)
|
| 70 |
+
.slice(0, 3)
|
| 71 |
+
})
|
| 72 |
+
expect(keys.length).toBeGreaterThan(0)
|
| 73 |
+
const positions = () =>
|
| 74 |
+
scroller.evaluate((element, keys) => {
|
| 75 |
+
const top = element.getBoundingClientRect().top
|
| 76 |
+
return Object.fromEntries(
|
| 77 |
+
keys.map((key) => {
|
| 78 |
+
const row = element.querySelector<HTMLElement>(`[data-timeline-part-id="${key}"]`)
|
| 79 |
+
if (!row) throw new Error(`Missing stable timeline key: ${key}`)
|
| 80 |
+
return [key, Math.round((row.getBoundingClientRect().top - top) * devicePixelRatio) / devicePixelRatio]
|
| 81 |
+
}),
|
| 82 |
+
)
|
| 83 |
+
}, keys)
|
| 84 |
+
const before = await positions()
|
| 85 |
+
expect(requests.some((request) => request.before && request.phase === "end")).toBe(false)
|
| 86 |
+
|
| 87 |
+
await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
|
| 88 |
+
await waitForTimelineStable(page)
|
| 89 |
+
await expect.poll(positions).toEqual(before)
|
| 90 |
+
})
|
| 91 |
+
|
| 92 |
+
test("preserves the timeline gap above the composer", async ({ page }) => {
|
| 93 |
+
await mockOpenCodeServer(page, {
|
| 94 |
+
sessions: fixture.sessions,
|
| 95 |
+
provider: fixture.provider,
|
| 96 |
+
directory: fixture.directory,
|
| 97 |
+
project: fixture.project,
|
| 98 |
+
pageMessages,
|
| 99 |
+
})
|
| 100 |
+
await configureSmokePage(page, fixture.directory)
|
| 101 |
+
|
| 102 |
+
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
| 103 |
+
await waitForTimelineStable(page)
|
| 104 |
+
const scroller = timelineScroller(page)
|
| 105 |
+
await scroller.evaluate((element) => {
|
| 106 |
+
element.scrollTop = element.scrollHeight
|
| 107 |
+
})
|
| 108 |
+
await waitForTimelineStable(page)
|
| 109 |
+
|
| 110 |
+
const spacer = scroller.locator('[data-timeline-row="bottom-spacer"]')
|
| 111 |
+
await expect(spacer).toBeVisible()
|
| 112 |
+
expect(await spacer.evaluate((element) => element.getBoundingClientRect().height)).toBe(64)
|
| 113 |
+
await expect
|
| 114 |
+
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
| 115 |
+
.toBeLessThanOrEqual(1)
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
test("paints cached session tabs at the latest message", async ({ page }) => {
|
| 119 |
+
await mockOpenCodeServer(page, {
|
| 120 |
+
sessions: fixture.sessions,
|
| 121 |
+
provider: fixture.provider,
|
| 122 |
+
directory: fixture.directory,
|
| 123 |
+
project: fixture.project,
|
| 124 |
+
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
| 125 |
+
})
|
| 126 |
+
await configureSmokePage(page, fixture.directory)
|
| 127 |
+
await page.addInitScript(
|
| 128 |
+
({ dirBase64, sourceID, targetID }) => {
|
| 129 |
+
localStorage.setItem(
|
| 130 |
+
"opencode.window.browser.dat:tabs",
|
| 131 |
+
JSON.stringify(
|
| 132 |
+
[sourceID, targetID].map((sessionId) => ({
|
| 133 |
+
type: "session",
|
| 134 |
+
server: "http://127.0.0.1:4096",
|
| 135 |
+
dirBase64,
|
| 136 |
+
sessionId,
|
| 137 |
+
})),
|
| 138 |
+
),
|
| 139 |
+
)
|
| 140 |
+
},
|
| 141 |
+
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
| 145 |
+
await expectSessionTitle(page, fixture.expected.targetTitle)
|
| 146 |
+
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
| 147 |
+
|
| 148 |
+
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
| 149 |
+
const last = fixture.expected.targetMessageIDs.at(-1)!
|
| 150 |
+
await page.evaluate(
|
| 151 |
+
({ destination, last }) => {
|
| 152 |
+
const ids = new Set(destination)
|
| 153 |
+
const samples: Array<{ ids: string[]; last: boolean; bottomError?: number }> = []
|
| 154 |
+
const firstPaintNodes = new WeakSet<Node>()
|
| 155 |
+
let firstPaint = false
|
| 156 |
+
let removedFirstPaintNodes = 0
|
| 157 |
+
let running = true
|
| 158 |
+
new MutationObserver((records) => {
|
| 159 |
+
if (!firstPaint || !running) return
|
| 160 |
+
records.forEach((record) =>
|
| 161 |
+
record.removedNodes.forEach((node) => {
|
| 162 |
+
if (firstPaintNodes.has(node)) removedFirstPaintNodes += 1
|
| 163 |
+
if (!(node instanceof Element)) return
|
| 164 |
+
node.querySelectorAll("*").forEach((element) => {
|
| 165 |
+
if (firstPaintNodes.has(element)) removedFirstPaintNodes += 1
|
| 166 |
+
})
|
| 167 |
+
}),
|
| 168 |
+
)
|
| 169 |
+
}).observe(document.documentElement, { childList: true, subtree: true })
|
| 170 |
+
const sample = () => {
|
| 171 |
+
if (!running) return
|
| 172 |
+
setTimeout(() => {
|
| 173 |
+
if (!running) return
|
| 174 |
+
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
| 175 |
+
element.querySelector("[data-timeline-row]"),
|
| 176 |
+
)
|
| 177 |
+
if (root) {
|
| 178 |
+
const view = root.getBoundingClientRect()
|
| 179 |
+
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
|
| 180 |
+
.filter((element) => {
|
| 181 |
+
const rect = element.getBoundingClientRect()
|
| 182 |
+
return rect.bottom > view.top && rect.top < view.bottom
|
| 183 |
+
})
|
| 184 |
+
.map((element) => element.dataset.messageId!)
|
| 185 |
+
.filter((id) => ids.has(id))
|
| 186 |
+
const bottom = root
|
| 187 |
+
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
| 188 |
+
?.getBoundingClientRect()
|
| 189 |
+
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
| 190 |
+
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
|
| 191 |
+
firstPaint = true
|
| 192 |
+
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
| 193 |
+
const rect = row.getBoundingClientRect()
|
| 194 |
+
if (rect.bottom <= view.top || rect.top >= view.bottom) return
|
| 195 |
+
firstPaintNodes.add(row)
|
| 196 |
+
row.querySelectorAll("*").forEach((element) => firstPaintNodes.add(element))
|
| 197 |
+
})
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
requestAnimationFrame(sample)
|
| 201 |
+
}, 0)
|
| 202 |
+
}
|
| 203 |
+
;(
|
| 204 |
+
window as Window & {
|
| 205 |
+
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
|
| 206 |
+
}
|
| 207 |
+
).__sessionTabPaint = {
|
| 208 |
+
samples,
|
| 209 |
+
removed: () => removedFirstPaintNodes,
|
| 210 |
+
stop: () => {
|
| 211 |
+
running = false
|
| 212 |
+
},
|
| 213 |
+
}
|
| 214 |
+
requestAnimationFrame(sample)
|
| 215 |
+
},
|
| 216 |
+
{ destination, last },
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
| 220 |
+
await page.waitForFunction(() =>
|
| 221 |
+
(
|
| 222 |
+
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
|
| 223 |
+
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
|
| 224 |
+
)
|
| 225 |
+
await page.waitForTimeout(200)
|
| 226 |
+
const first = await page.evaluate(() => {
|
| 227 |
+
const probe = (
|
| 228 |
+
window as Window & {
|
| 229 |
+
__sessionTabPaint?: {
|
| 230 |
+
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
| 231 |
+
removed: () => number
|
| 232 |
+
stop: () => void
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
).__sessionTabPaint!
|
| 236 |
+
probe.stop()
|
| 237 |
+
return { first: probe.samples.find((sample) => sample.ids.length > 0), removed: probe.removed() }
|
| 238 |
+
})
|
| 239 |
+
expect(first.first?.last).toBe(true)
|
| 240 |
+
expect(Math.abs(first.first?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
| 241 |
+
expect(first.removed).toBe(0)
|
| 242 |
+
})
|
| 243 |
+
|
| 244 |
+
test("paints a cold session tab at the latest message", async ({ page }) => {
|
| 245 |
+
await mockOpenCodeServer(page, {
|
| 246 |
+
sessions: fixture.sessions,
|
| 247 |
+
provider: fixture.provider,
|
| 248 |
+
directory: fixture.directory,
|
| 249 |
+
project: fixture.project,
|
| 250 |
+
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
| 251 |
+
})
|
| 252 |
+
await configureSmokePage(page, fixture.directory)
|
| 253 |
+
await page.addInitScript(
|
| 254 |
+
({ dirBase64, sourceID, targetID }) => {
|
| 255 |
+
localStorage.setItem(
|
| 256 |
+
"opencode.window.browser.dat:tabs",
|
| 257 |
+
JSON.stringify(
|
| 258 |
+
[sourceID, targetID].map((sessionId) => ({
|
| 259 |
+
type: "session",
|
| 260 |
+
server: "http://127.0.0.1:4096",
|
| 261 |
+
dirBase64,
|
| 262 |
+
sessionId,
|
| 263 |
+
})),
|
| 264 |
+
),
|
| 265 |
+
)
|
| 266 |
+
},
|
| 267 |
+
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
|
| 268 |
+
)
|
| 269 |
+
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
|
| 270 |
+
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
| 271 |
+
const last = fixture.expected.targetMessageIDs.at(-1)!
|
| 272 |
+
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
| 273 |
+
await page.evaluate(
|
| 274 |
+
({ destination, last }) => {
|
| 275 |
+
const ids = new Set(destination)
|
| 276 |
+
const samples: Array<{ destination: boolean; last: boolean; bottomError?: number }> = []
|
| 277 |
+
const sample = () => {
|
| 278 |
+
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
| 279 |
+
element.querySelector("[data-timeline-row]"),
|
| 280 |
+
)
|
| 281 |
+
if (root) {
|
| 282 |
+
const view = root.getBoundingClientRect()
|
| 283 |
+
const spacer = root
|
| 284 |
+
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
| 285 |
+
?.getBoundingClientRect()
|
| 286 |
+
const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].filter((element) => {
|
| 287 |
+
const rect = element.getBoundingClientRect()
|
| 288 |
+
return rect.bottom > view.top && rect.top < view.bottom
|
| 289 |
+
})
|
| 290 |
+
samples.push({
|
| 291 |
+
destination: messages.some((element) => ids.has(element.dataset.messageId!)),
|
| 292 |
+
last: messages.some((element) => element.dataset.messageId === last),
|
| 293 |
+
bottomError: spacer ? spacer.bottom - view.bottom : undefined,
|
| 294 |
+
})
|
| 295 |
+
}
|
| 296 |
+
requestAnimationFrame(() => setTimeout(sample, 0))
|
| 297 |
+
}
|
| 298 |
+
;(window as Window & { __coldTabSamples?: typeof samples }).__coldTabSamples = samples
|
| 299 |
+
requestAnimationFrame(() => setTimeout(sample, 0))
|
| 300 |
+
},
|
| 301 |
+
{ destination, last },
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
| 305 |
+
await page.waitForFunction(() =>
|
| 306 |
+
(window as Window & { __coldTabSamples?: Array<{ destination: boolean }> }).__coldTabSamples?.some(
|
| 307 |
+
(sample) => sample.destination,
|
| 308 |
+
),
|
| 309 |
+
)
|
| 310 |
+
const result = await page.evaluate(() => {
|
| 311 |
+
const samples = (
|
| 312 |
+
window as Window & {
|
| 313 |
+
__coldTabSamples?: Array<{ destination: boolean; last: boolean; bottomError?: number }>
|
| 314 |
+
}
|
| 315 |
+
).__coldTabSamples!
|
| 316 |
+
return samples.find((sample) => sample.destination)!
|
| 317 |
+
})
|
| 318 |
+
expect(result.last).toBe(true)
|
| 319 |
+
expect(Math.abs(result.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
|
| 320 |
+
})
|
| 321 |
+
|
| 322 |
+
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
| 323 |
+
const errors = trackPageErrors(page)
|
| 324 |
+
await mockOpenCodeServer(page, {
|
| 325 |
+
sessions: fixture.sessions,
|
| 326 |
+
provider: fixture.provider,
|
| 327 |
+
directory: fixture.directory,
|
| 328 |
+
project: fixture.project,
|
| 329 |
+
pageMessages,
|
| 330 |
+
})
|
| 331 |
+
await configureSmokePage(page, fixture.directory)
|
| 332 |
+
|
| 333 |
+
await selectHomeProject(page, fixture.project.name)
|
| 334 |
+
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
|
| 335 |
+
await expectSessionReady(page)
|
| 336 |
+
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
| 337 |
+
const expectedPartIDs = fixture.expected.targetPartIDs
|
| 338 |
+
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
| 339 |
+
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
| 340 |
+
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
| 341 |
+
|
| 342 |
+
const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`)
|
| 343 |
+
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
| 344 |
+
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
| 345 |
+
await expect(shellSubtitle).toHaveCount(0)
|
| 346 |
+
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
|
| 347 |
+
await shellTrigger.click()
|
| 348 |
+
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
|
| 349 |
+
await expect(shellSubtitle).toHaveText("bun typecheck")
|
| 350 |
+
await shellTrigger.click()
|
| 351 |
+
await expect(shellTrigger).toHaveAttribute("aria-expanded", "true")
|
| 352 |
+
await expect(shellSubtitle).toHaveCount(0)
|
| 353 |
+
})
|
| 354 |
+
})
|
| 355 |
+
|
| 356 |
+
async function configureSmokePage(page: Page, directory: string) {
|
| 357 |
+
await page.addInitScript(() => {
|
| 358 |
+
localStorage.setItem(
|
| 359 |
+
"settings.v3",
|
| 360 |
+
JSON.stringify({
|
| 361 |
+
general: {
|
| 362 |
+
editToolPartsExpanded: true,
|
| 363 |
+
shellToolPartsExpanded: true,
|
| 364 |
+
showReasoningSummaries: true,
|
| 365 |
+
},
|
| 366 |
+
}),
|
| 367 |
+
)
|
| 368 |
+
})
|
| 369 |
+
|
| 370 |
+
await page.addInitScript((directory) => {
|
| 371 |
+
localStorage.setItem(
|
| 372 |
+
"opencode.global.dat:server",
|
| 373 |
+
JSON.stringify({
|
| 374 |
+
projects: {
|
| 375 |
+
local: [{ worktree: directory, expanded: true }],
|
| 376 |
+
},
|
| 377 |
+
lastProject: {
|
| 378 |
+
local: directory,
|
| 379 |
+
},
|
| 380 |
+
}),
|
| 381 |
+
)
|
| 382 |
+
}, directory)
|
| 383 |
+
|
| 384 |
+
await page.addInitScript(() => {
|
| 385 |
+
const smoke = window as SmokeWindow
|
| 386 |
+
smoke.__timelineSmokeErrorToasts = []
|
| 387 |
+
smoke.__timelineSmokeForbiddenText = []
|
| 388 |
+
const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
|
| 389 |
+
const idsOf = (el: HTMLElement) =>
|
| 390 |
+
[el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
|
| 391 |
+
|
| 392 |
+
smoke.__timelineSmokeState = () => {
|
| 393 |
+
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
| 394 |
+
el.querySelector("[data-timeline-row], [data-session-title]"),
|
| 395 |
+
)
|
| 396 |
+
if (!scroller) {
|
| 397 |
+
return {
|
| 398 |
+
ids: [],
|
| 399 |
+
visibleIds: [],
|
| 400 |
+
messageIds: [],
|
| 401 |
+
visibleMessageIds: [],
|
| 402 |
+
topVisibleId: undefined,
|
| 403 |
+
signature: "",
|
| 404 |
+
scrollTop: 0,
|
| 405 |
+
scrollHeight: 0,
|
| 406 |
+
clientHeight: 0,
|
| 407 |
+
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
| 408 |
+
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
const ids: string[] = []
|
| 413 |
+
const visibleIds: string[] = []
|
| 414 |
+
const scrollerRect = scroller.getBoundingClientRect()
|
| 415 |
+
let topVisibleId: string | undefined
|
| 416 |
+
for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
|
| 417 |
+
const next = idsOf(el)
|
| 418 |
+
ids.push(...next)
|
| 419 |
+
|
| 420 |
+
const rect = el.getBoundingClientRect()
|
| 421 |
+
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
|
| 422 |
+
if (!topVisibleId) topVisibleId = next[0]
|
| 423 |
+
visibleIds.push(...next)
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
const messageIds: string[] = []
|
| 428 |
+
const visibleMessageIds: string[] = []
|
| 429 |
+
const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
|
| 430 |
+
const rect = el.getBoundingClientRect()
|
| 431 |
+
const id = el.dataset.messageId
|
| 432 |
+
if (id) {
|
| 433 |
+
messageIds.push(id)
|
| 434 |
+
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
|
| 435 |
+
}
|
| 436 |
+
return {
|
| 437 |
+
id,
|
| 438 |
+
top: Math.round(rect.top),
|
| 439 |
+
bottom: Math.round(rect.bottom),
|
| 440 |
+
}
|
| 441 |
+
})
|
| 442 |
+
const signature = JSON.stringify({
|
| 443 |
+
top: Math.round(scroller.scrollTop),
|
| 444 |
+
height: Math.round(scroller.scrollHeight),
|
| 445 |
+
rows,
|
| 446 |
+
ids,
|
| 447 |
+
})
|
| 448 |
+
|
| 449 |
+
return {
|
| 450 |
+
ids,
|
| 451 |
+
visibleIds,
|
| 452 |
+
messageIds,
|
| 453 |
+
visibleMessageIds,
|
| 454 |
+
topVisibleId,
|
| 455 |
+
signature,
|
| 456 |
+
scrollTop: Math.round(scroller.scrollTop),
|
| 457 |
+
scrollHeight: Math.round(scroller.scrollHeight),
|
| 458 |
+
clientHeight: Math.round(scroller.clientHeight),
|
| 459 |
+
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
| 460 |
+
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
let recordFrame: number | undefined
|
| 464 |
+
const record = () => {
|
| 465 |
+
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
|
| 466 |
+
const text = toast.textContent?.trim()
|
| 467 |
+
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
|
| 468 |
+
}
|
| 469 |
+
const text = document.body?.textContent ?? ""
|
| 470 |
+
for (const value of ["Load details", "Show earlier steps"]) {
|
| 471 |
+
if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
|
| 472 |
+
smoke.__timelineSmokeForbiddenText!.push(value)
|
| 473 |
+
}
|
| 474 |
+
}
|
| 475 |
+
}
|
| 476 |
+
const start = () => {
|
| 477 |
+
const root = document.documentElement ?? document.body
|
| 478 |
+
if (!root) return
|
| 479 |
+
new MutationObserver(() => {
|
| 480 |
+
if (recordFrame) return
|
| 481 |
+
recordFrame = requestAnimationFrame(() => {
|
| 482 |
+
recordFrame = undefined
|
| 483 |
+
record()
|
| 484 |
+
})
|
| 485 |
+
}).observe(root, { childList: true, subtree: true })
|
| 486 |
+
record()
|
| 487 |
+
}
|
| 488 |
+
if (document.documentElement ?? document.body) start()
|
| 489 |
+
else document.addEventListener("DOMContentLoaded", start, { once: true })
|
| 490 |
+
})
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
async function expectCanScrollToStart(
|
| 494 |
+
page: Page,
|
| 495 |
+
expectedPartIDs: string[],
|
| 496 |
+
expectedMessageIDs: string[],
|
| 497 |
+
errors: string[],
|
| 498 |
+
) {
|
| 499 |
+
await pointAtTimeline(page)
|
| 500 |
+
const seenParts = new Set<string>()
|
| 501 |
+
const seenMessages = new Set<string>()
|
| 502 |
+
const samples: TraversalSample[] = []
|
| 503 |
+
let current = await timelineState(page)
|
| 504 |
+
let unchangedAtTop = 0
|
| 505 |
+
|
| 506 |
+
for (let attempt = 0; attempt < 600; attempt++) {
|
| 507 |
+
collectSeen(current, seenParts, seenMessages)
|
| 508 |
+
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
| 509 |
+
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
|
| 510 |
+
expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
|
| 511 |
+
expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
|
| 512 |
+
expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
|
| 513 |
+
expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
|
| 514 |
+
|
| 515 |
+
if (
|
| 516 |
+
current.scrollTop <= 1 &&
|
| 517 |
+
seenParts.size === expectedPartIDs.length &&
|
| 518 |
+
seenMessages.size === expectedMessageIDs.length
|
| 519 |
+
) {
|
| 520 |
+
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
| 521 |
+
return
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
const before = current
|
| 525 |
+
const changed = await scrollTimelineUp(page, current)
|
| 526 |
+
current = await timelineState(page)
|
| 527 |
+
if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
|
| 528 |
+
else unchangedAtTop = 0
|
| 529 |
+
if (unchangedAtTop >= 2) break
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
collectSeen(current, seenParts, seenMessages)
|
| 533 |
+
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
| 534 |
+
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
async function timelineState(page: Page) {
|
| 538 |
+
return page.evaluate(
|
| 539 |
+
() =>
|
| 540 |
+
(window as SmokeWindow).__timelineSmokeState?.() ?? {
|
| 541 |
+
ids: [],
|
| 542 |
+
visibleIds: [],
|
| 543 |
+
messageIds: [],
|
| 544 |
+
visibleMessageIds: [],
|
| 545 |
+
topVisibleId: undefined,
|
| 546 |
+
signature: "",
|
| 547 |
+
scrollTop: 0,
|
| 548 |
+
scrollHeight: 0,
|
| 549 |
+
clientHeight: 0,
|
| 550 |
+
errorToasts: [],
|
| 551 |
+
forbiddenText: [],
|
| 552 |
+
},
|
| 553 |
+
)
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
function timelineScroller(page: Page) {
|
| 557 |
+
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
async function pointAtTimeline(page: Page) {
|
| 561 |
+
const box = await timelineScroller(page).boundingBox()
|
| 562 |
+
if (!box) throw new Error("Timeline scroller is not visible")
|
| 563 |
+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
async function scrollTimelineUp(page: Page, before: SmokeState) {
|
| 567 |
+
return page.evaluate(
|
| 568 |
+
(prev) =>
|
| 569 |
+
new Promise<boolean>((resolve) => {
|
| 570 |
+
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
| 571 |
+
el.querySelector("[data-timeline-row], [data-session-title]"),
|
| 572 |
+
)
|
| 573 |
+
if (!scroller) {
|
| 574 |
+
resolve(false)
|
| 575 |
+
return
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
|
| 579 |
+
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
|
| 580 |
+
|
| 581 |
+
const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
| 582 |
+
let frames = 0
|
| 583 |
+
let stableFrames = 0
|
| 584 |
+
let last = ""
|
| 585 |
+
let changed = false
|
| 586 |
+
const check = () => {
|
| 587 |
+
const current = read()
|
| 588 |
+
if (current !== prev) changed = true
|
| 589 |
+
if (current === last) stableFrames++
|
| 590 |
+
else {
|
| 591 |
+
stableFrames = 0
|
| 592 |
+
last = current
|
| 593 |
+
}
|
| 594 |
+
if (changed && stableFrames >= 2) {
|
| 595 |
+
resolve(true)
|
| 596 |
+
return
|
| 597 |
+
}
|
| 598 |
+
frames++
|
| 599 |
+
if (frames >= 30) {
|
| 600 |
+
resolve(changed)
|
| 601 |
+
return
|
| 602 |
+
}
|
| 603 |
+
requestAnimationFrame(check)
|
| 604 |
+
}
|
| 605 |
+
requestAnimationFrame(check)
|
| 606 |
+
}),
|
| 607 |
+
before.signature,
|
| 608 |
+
)
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
function expectOrderedIDs(expected: string[], actual: string[], label: string) {
|
| 612 |
+
expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
|
| 613 |
+
const actualSet = new Set(actual)
|
| 614 |
+
expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
function unique(values: string[]) {
|
| 618 |
+
return values.filter((value, index) => values.indexOf(value) === index)
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
|
| 622 |
+
for (const id of state.ids) seenParts.add(id)
|
| 623 |
+
for (const id of state.visibleIds) seenParts.add(id)
|
| 624 |
+
for (const id of state.messageIds) seenMessages.add(id)
|
| 625 |
+
for (const id of state.visibleMessageIds) seenMessages.add(id)
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
type TraversalSample = ReturnType<typeof sampleTraversal>
|
| 629 |
+
|
| 630 |
+
function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
|
| 631 |
+
return {
|
| 632 |
+
seenParts,
|
| 633 |
+
seenMessages,
|
| 634 |
+
mounted: state.ids.length,
|
| 635 |
+
visible: state.visibleIds.length,
|
| 636 |
+
mountedMessages: unique(state.messageIds).length,
|
| 637 |
+
visibleMessages: unique(state.visibleMessageIds).length,
|
| 638 |
+
top: state.scrollTop,
|
| 639 |
+
height: state.scrollHeight,
|
| 640 |
+
first: state.ids[0],
|
| 641 |
+
last: state.ids.at(-1),
|
| 642 |
+
topVisible: state.topVisibleId,
|
| 643 |
+
visibleFirst: state.visibleIds[0],
|
| 644 |
+
visibleLast: state.visibleIds.at(-1),
|
| 645 |
+
}
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
function sampleSummary(samples: TraversalSample[]) {
|
| 649 |
+
return samples
|
| 650 |
+
.filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
|
| 651 |
+
.map(
|
| 652 |
+
(sample, index) =>
|
| 653 |
+
`${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
|
| 654 |
+
)
|
| 655 |
+
.join("\n")
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
async function waitForTimelineStable(page: Page) {
|
| 659 |
+
await page.waitForFunction(
|
| 660 |
+
() =>
|
| 661 |
+
new Promise<boolean>((resolve) => {
|
| 662 |
+
requestAnimationFrame(() => {
|
| 663 |
+
const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
| 664 |
+
requestAnimationFrame(() => {
|
| 665 |
+
const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
| 666 |
+
requestAnimationFrame(() =>
|
| 667 |
+
resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
|
| 668 |
+
)
|
| 669 |
+
})
|
| 670 |
+
})
|
| 671 |
+
}),
|
| 672 |
+
)
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
async function expectSessionTimelineReady(
|
| 676 |
+
page: Page,
|
| 677 |
+
expectedPartIDs: string[],
|
| 678 |
+
expectedMessageIDs: string[],
|
| 679 |
+
errors: string[],
|
| 680 |
+
) {
|
| 681 |
+
await waitForTimelineStable(page)
|
| 682 |
+
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
|
| 683 |
+
const currentState = await timelineState(page)
|
| 684 |
+
expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
|
| 685 |
+
expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
|
| 686 |
+
expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
|
| 687 |
+
expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
|
| 688 |
+
expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
function expectCompleteScroll(
|
| 692 |
+
state: SmokeState,
|
| 693 |
+
expectedPartIDs: string[],
|
| 694 |
+
expectedMessageIDs: string[],
|
| 695 |
+
seenParts: Set<string>,
|
| 696 |
+
seenMessages: Set<string>,
|
| 697 |
+
samples: TraversalSample[],
|
| 698 |
+
) {
|
| 699 |
+
expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
|
| 700 |
+
expect(
|
| 701 |
+
expectedPartIDs.filter((id) => !seenParts.has(id)),
|
| 702 |
+
`missing visible timeline parts\n${sampleSummary(samples)}`,
|
| 703 |
+
).toEqual([])
|
| 704 |
+
expect(
|
| 705 |
+
expectedMessageIDs.filter((id) => !seenMessages.has(id)),
|
| 706 |
+
`missing visible messages\n${sampleSummary(samples)}`,
|
| 707 |
+
).toEqual([])
|
| 708 |
+
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
| 709 |
+
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
| 710 |
+
expect(expectedPartIDs.length).toBe(331)
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
async function selectHomeProject(page: Page, projectName: string) {
|
| 714 |
+
await page.goto("/")
|
| 715 |
+
const row = page
|
| 716 |
+
.locator('[data-component="home-project-row"]')
|
| 717 |
+
.filter({ hasText: new RegExp(projectName, "i") })
|
| 718 |
+
.first()
|
| 719 |
+
await expectAppVisible(row)
|
| 720 |
+
await row.click()
|
| 721 |
+
await expect(row).toHaveAttribute("data-selected", "", { timeout: APP_READY_TIMEOUT })
|
| 722 |
+
await expect(page).toHaveURL(/\/$/)
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
|
| 726 |
+
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
|
| 727 |
+
await expectSessionTitle(page, expectedTitle)
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
| 731 |
+
const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`
|
| 732 |
+
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
| 733 |
+
await expect(tab).toBeVisible()
|
| 734 |
+
await tab.click()
|
| 735 |
+
await expectSessionTitle(page, title)
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
async function expectSessionReady(page: Page) {
|
| 739 |
+
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
| 740 |
+
}
|