mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 05:42:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b12daabe35 | ||
|
|
43d27328e4 | ||
|
|
b79b4bd5fc | ||
|
|
dac6cd141a | ||
|
|
c3ae54a1a2 |
@@ -52,7 +52,7 @@ jobs:
|
||||
filters: .github/ci-paths.yml
|
||||
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
|
||||
|
||||
format:
|
||||
name: format
|
||||
@@ -348,12 +348,12 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Build native app dependency
|
||||
run: npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
|
||||
@@ -1,44 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.6.1 - 2026-08-25
|
||||
|
||||
### Improved
|
||||
|
||||
- Command Center now matches partial multi-word queries in any order and ranks visible results by relevance ([#2971](https://github.com/getpaseo/paseo/pull/2971) by [@cleiter](https://github.com/cleiter))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed upgrades placing persisted Side panel tabs inside Explorer instead of an ordinary side pane ([#3861](https://github.com/getpaseo/paseo/pull/3861))
|
||||
- Fixed Escape leaving Settings open on desktop and web ([#2828](https://github.com/getpaseo/paseo/pull/2828) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed branch switches hiding the current pull request or arming auto-archive from an already-merged pull request ([#3799](https://github.com/getpaseo/paseo/pull/3799))
|
||||
|
||||
## 0.6.0 - 2026-08-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Restored the dedicated Explorer sidebar with Files and Changes by default, replacing the empty user-directed Side panel introduced in 0.5.0 ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Changed content opened to the side to use an ordinary closable workspace pane instead of the Explorer sidebar ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Restored the resizable combined Explorer dock on tablets ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
|
||||
### Added
|
||||
|
||||
- Added per-action settings for opening files, changes, subagents, and pull requests in the main panel or on the side ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Added New tab and drag support for compatible files, diffs, agents, terminals, pull requests, and plugin panels in Explorer ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Added standalone Diff tabs while keeping an optional persisted inline-diff mode in Changes ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
- Added custom Windows and Linux window controls ([#3826](https://github.com/getpaseo/paseo/pull/3826))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed OpenCode turns failing when the first event-stream connection stalls during startup ([#3814](https://github.com/getpaseo/paseo/pull/3814))
|
||||
- Fixed OpenCode prompts starting before the provider reports a ready connection ([#3821](https://github.com/getpaseo/paseo/pull/3821))
|
||||
|
||||
## 0.5.2 - 2026-08-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
|
||||
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
|
||||
|
||||
## 0.5.1 - 2026-08-23
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -21,46 +21,47 @@ This is an npm workspace monorepo:
|
||||
|
||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
|
||||
|
||||
| Doc | What's in it |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
|
||||
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
|
||||
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||
|
||||
### Writing docs
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent stream performance
|
||||
|
||||
How assistant text gets from a provider to the screen, and why it is paced on the way. Read this before changing `packages/server/src/server/agent/agent-stream-coalescer.ts`, the reducer queue in `packages/app/src/timeline/session-stream-reducers.ts`, or the reveal in `packages/app/src/hooks/use-revealed-text.ts`.
|
||||
|
||||
For terminal output, which is a separate pipeline with separate budgets, see [terminal-performance.md](terminal-performance.md).
|
||||
|
||||
## The pipeline
|
||||
|
||||
```
|
||||
provider deltas (every provider streams incrementally)
|
||||
→ AgentStreamCoalescer (daemon, leading + trailing, ≤1 message per 60ms per agent)
|
||||
→ recordTimeline: one canonical row per flushed item
|
||||
→ agent_stream ws message
|
||||
→ reducer queue (app, one commit per frame) → session store
|
||||
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → paint
|
||||
```
|
||||
|
||||
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
|
||||
|
||||
## Why the reveal is paced
|
||||
|
||||
Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged.
|
||||
|
||||
So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
|
||||
## Measuring
|
||||
|
||||
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
|
||||
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
|
||||
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
|
||||
|
||||
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
|
||||
|
||||
| | paint on arrival | paced |
|
||||
| -------------------------------- | ---------------- | ----- |
|
||||
| frames that advanced the text | 6% | 87% |
|
||||
| chars-per-frame CV | 4.11 | 1.86 |
|
||||
| gap between visible updates, p50 | 317ms | 17ms |
|
||||
| gap between visible updates, p95 | 383ms | 17ms |
|
||||
|
||||
Total characters painted is roughly the same either way — the reveal changes when they land, not how many arrive.
|
||||
|
||||
Measure the **total** length across every `assistant-message` element, not the last one. A turn emits many assistant messages, so the tail element keeps changing identity and its length is not monotonic; sampling only the tail reads those handovers as resets and reports almost no growth. `sampleStreamFrames` does this correctly.
|
||||
+34
-1
@@ -15,7 +15,9 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
|
||||
|
||||
## Version codes
|
||||
|
||||
`packages/app/app.config.js` derives Android `versionCode` from the package version with:
|
||||
`packages/app/native-release-version.js` is the single definition of native and F-Droid version-code math. Do not re-derive these numbers anywhere else — a drifted copy produces changelog files that match no published APK, and nothing fails loudly.
|
||||
|
||||
The base version code comes from the package version:
|
||||
|
||||
```text
|
||||
major * 1_000_000 + minor * 1_000 + patch
|
||||
@@ -145,6 +147,37 @@ Keep the excluded npm packages installed. Normal builds use them, while the F-Dr
|
||||
|
||||
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
|
||||
|
||||
### F-Droid store metadata
|
||||
|
||||
F-Droid reads the store listing from `fastlane/metadata/android/<locale>/` **at the repo root**. This location provides the best compatibility with the F-Droid release process.
|
||||
|
||||
```text
|
||||
fastlane/metadata/android/
|
||||
├── en-US/ (F-Droid fallback locale, mandatory)
|
||||
│ ├── title.txt (<=50 chars)
|
||||
│ ├── short_description.txt (<=80 chars)
|
||||
│ ├── full_description.txt (<=4000 chars, limited HTML)
|
||||
│ ├── images/
|
||||
│ │ ├── icon.png (512x512)
|
||||
│ │ ├── featureGraphic.png (1024x500)
|
||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
||||
│ └── changelogs/ (generated — see below)
|
||||
├── ja/
|
||||
└── zh-CN/
|
||||
```
|
||||
|
||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
||||
|
||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
||||
|
||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
||||
|
||||
Stable sync fails loudly if `CHANGELOG.md` has no entry for the version being cut. That is intentional — the release checklist requires the entry to be committed first, so an abort here means the checklist was skipped.
|
||||
|
||||
Because the generator runs off the version in `package.json`, it must run **before** the tag is created: fdroidserver only reads metadata from the tag it builds, so the file for version N has to exist in the commit N points at.
|
||||
|
||||
Beta releases are an explicit no-op: they do not create or rewrite F-Droid changelog files. Stable releases and promotions generate the four ABI entries from their final changelog.
|
||||
|
||||
### React version lockstep
|
||||
|
||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||
|
||||
@@ -104,7 +104,7 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
|
||||
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false.
|
||||
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
@@ -115,12 +115,15 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
The replica cache paints stale data immediately while the host connects. Directory cursors are
|
||||
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
|
||||
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
|
||||
Pending permission requests are not restored from it. The row store is not encrypted, so the cached
|
||||
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
|
||||
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
|
||||
host snapshots in least-recently-written order; a single oversized host is omitted rather than
|
||||
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
|
||||
AsyncStorage, and Android reserves 64 MiB for that database.
|
||||
separate product/security decision. Agents, workspaces, and projects have individual rows; the
|
||||
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
|
||||
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
|
||||
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
|
||||
payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is
|
||||
an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use
|
||||
IndexedDB. Native builds use expo-sqlite.
|
||||
|
||||
The three directory entity types have independent monotonic sequences and share one daemon
|
||||
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
||||
|
||||
@@ -62,10 +62,6 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
||||
|
||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||
|
||||
Header and toolbar controls use `interactionHighlight` for hovered, pressed, open, and selected
|
||||
backgrounds. It is a translucent semantic fill so the same control works over the main surface and
|
||||
the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control also fades its content.
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
@@ -159,8 +155,6 @@ On a narrow desktop route, app navigation yields to the rendered content topolog
|
||||
|
||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
||||
|
||||
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
|
||||
+2
-13
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
|
||||
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
||||
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
||||
|
||||
For the desktop Explorer sidebar toggle, run the app against the root checkout's daemon and use:
|
||||
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
|
||||
|
||||
```bash
|
||||
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Explorer pane,
|
||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane,
|
||||
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
||||
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
||||
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
||||
@@ -278,17 +278,6 @@ That report includes input and composer height changes plus React work grouped i
|
||||
stream, and ancestor/root scopes. Ancestor/root timings include descendant work because the Profiler
|
||||
boundaries are nested. A printable key after an empty newline should not change either height.
|
||||
|
||||
### Preview Windows and Linux window controls on macOS
|
||||
|
||||
Desktop development can replace native macOS traffic lights with Paseo's custom controls:
|
||||
|
||||
```bash
|
||||
PASEO_DESKTOP_WINDOW_CONTROLS=windows npm run dev:desktop
|
||||
PASEO_DESKTOP_WINDOW_CONTROLS=linux npm run dev:desktop
|
||||
```
|
||||
|
||||
The override is rejected in packaged builds. Restart the desktop process when changing it.
|
||||
|
||||
### Desktop macOS compositor watchdog
|
||||
|
||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# Explorer sidebar and side pane
|
||||
|
||||
The Explorer sidebar and the side pane share panel implementations, but they have different shell
|
||||
contracts.
|
||||
|
||||
| Surface | Purpose | Lifecycle |
|
||||
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
||||
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
||||
|
||||
## Panel host contract
|
||||
|
||||
Every desktop panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
fixed-target labels and icons from that registration, filter by host, and never substitute one
|
||||
panel type for another. Tab moves reject unsupported destinations, and placement resolves only to
|
||||
a compatible pane.
|
||||
|
||||
Files and Changes are the Explorer defaults and its singleton navigation views. Other compatible
|
||||
tabs, including agents, terminals, files, and diffs, can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
On desktop, the shell is rendered outside the workspace split canvas so it divides the full
|
||||
workspace, including the header. It has its own persisted width and resize handle. Main-pane splits
|
||||
never read or modify that width.
|
||||
|
||||
The persisted layout still contains the Explorer pane so tabs survive reloads. The renderer removes
|
||||
that pane from the workspace split tree and docks it separately. Persisted identifiers retain the
|
||||
literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibility.
|
||||
|
||||
The tab rail has no inline add or close controls. Its context menu opens a New Tab launcher and
|
||||
toggles the singleton Files and Changes views. Individual tab menus close instances or move
|
||||
compatible tabs to main. Explorer tabs can be reordered, but the dock cannot be split. Selecting
|
||||
an Explorer tab does not change workspace focus.
|
||||
|
||||
Cmd+E selects Changes for Git workspaces and Files otherwise. Compact layouts use the combined
|
||||
full-screen Explorer overlay and close it after a file opens. Wide native layouts without pane
|
||||
splits use the same combined content in a resizable inline dock; opening a file leaves that dock
|
||||
visible. Both presentations keep their selection in the panel store and reuse the layout store's
|
||||
per-workspace Explorer width. They do not create a second Explorer lifecycle.
|
||||
|
||||
## Side pane
|
||||
|
||||
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
|
||||
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||
right split around the workspace root; later side opens reuse it.
|
||||
|
||||
Closing the pane or moving away its final tab removes it normally and clears the remembered id. A
|
||||
later side open creates a new pane. There is no hidden side-pane lifecycle.
|
||||
|
||||
Placement intent still controls existing tabs:
|
||||
|
||||
| Mode | New target | Existing target |
|
||||
| --------- | --------------------------- | --------------------------------- |
|
||||
| `pane` | opens in the requested pane | moves to the requested pane |
|
||||
| `prefer` | opens in the requested pane | stays where the user placed it |
|
||||
| `focused` | opens in the focused pane | focuses it where it already lives |
|
||||
| `ambient` | opens in a compatible pane | focuses it where it already lives |
|
||||
|
||||
Explicit **Open to Side** uses `pane`. Implicit opens use `prefer`, so a preference affects only a
|
||||
new target and never yanks an existing tab out of a user-selected pane.
|
||||
|
||||
## Routing preferences
|
||||
|
||||
Desktop **Settings → Layout → Open in side pane** has independent switches for Explorer Files,
|
||||
Explorer Changes, chat files, diff files, subagents, pull requests, and Changes links. All switches
|
||||
default off. Mobile ignores them.
|
||||
|
||||
Panels request an implicit open through the narrow `openPreferredTarget(target, source)` pane
|
||||
contract. Entry points outside panels use `openPreferredWorkspaceTarget`. Do not branch on a
|
||||
specific shell inside a panel.
|
||||
+33
-3
@@ -26,8 +26,9 @@ For forge `acme`, the expected end state is:
|
||||
3. **App modules** - a forge splits into a pure logic half and a view half so
|
||||
logic consumers (URL builders, merge-capability, native checks, and the
|
||||
Node-based e2e harness) never pull the client rendering stack:
|
||||
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`,
|
||||
optional `facts` (schema, merge-capability, native-check fallbacks). No
|
||||
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`
|
||||
(source links and pasted issue/change-request reference paths), optional
|
||||
`facts` (schema, merge-capability, native-check fallbacks). No
|
||||
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
|
||||
`packages/app/src/git/forges/index.ts`.
|
||||
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
|
||||
@@ -57,8 +58,20 @@ schema-mismatched facts render neutrally instead of failing the whole message
|
||||
parse. This is the version-skew win: an old client can receive facts from a
|
||||
newer forge and still show the PR/MR in a neutral state.
|
||||
|
||||
Check results keep a stable normalized `status` for aggregation and an optional
|
||||
open `traits: string[]` for composable forge-neutral refinements such as
|
||||
`manual`, `action_required`, and `warning`. Consumers ignore unknown traits and
|
||||
fall back to `status`; `rawStatus` preserves provider-native detail. Do not add
|
||||
provider vocabulary to the normalized status union. Producers and the
|
||||
presentation layer share the trait constants in
|
||||
`packages/protocol/src/check-traits.ts` — use those instead of string literals
|
||||
so classification and emission cannot drift apart.
|
||||
|
||||
Shipped GitHub compatibility stays separate:
|
||||
|
||||
- The forge compatibility layer first shipped in `v0.2.0-beta.1`. Its initial
|
||||
six-month cleanup target is `2027-01-17`, after supported client and daemon
|
||||
floors reach stable `v0.2.0`.
|
||||
- `status.github` remains accepted for released peers.
|
||||
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
|
||||
into `status.github` for older clients.
|
||||
@@ -112,7 +125,7 @@ rendering stack:
|
||||
`acme.ts` exports a `ClientForgeLogicModule`:
|
||||
|
||||
- `id`
|
||||
- optional `urlGrammar`
|
||||
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment)
|
||||
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
||||
|
||||
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
||||
@@ -181,10 +194,27 @@ are stale, run `npm run build:server`.
|
||||
real shims tagged with `COMPAT(name)`.
|
||||
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
|
||||
top-level brand is Forgejo or Codeberg.
|
||||
- Gitea's legacy Actions task endpoint cannot filter by commit, may enforce a
|
||||
page size below the requested limit, and omits rerun-attempt numbers. Walk a
|
||||
bounded number of pages using `total_count` and filter locally. Preserve
|
||||
ambiguous same-run tasks: display names are not stable job identities, so
|
||||
name-based deduplication can hide a real failure.
|
||||
- A blocked Gitea Actions task is not necessarily manual: only add the
|
||||
`action_required` trait when the matching run metadata reports
|
||||
`need_approval`.
|
||||
- Brand icons are bundled React components, so they cannot come from protocol
|
||||
manifest data.
|
||||
- Source URL grammars are app-side because blob/tree path syntax is
|
||||
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
|
||||
rather than constructing a wrong URL.
|
||||
- Pasted issue/change-request URL recognition is also app-side and registry
|
||||
driven. Put the forge's route infixes in `urlGrammar.referencePaths`; the
|
||||
neutral extractor matches the pasted host and full repository path against
|
||||
the current remote before it invokes cwd-routed forge search. Do not add a
|
||||
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
|
||||
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
||||
not protocol.
|
||||
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
|
||||
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
||||
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
||||
until GitLab reports a terminal status.
|
||||
|
||||
+1
-2
@@ -19,8 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
|
||||
@@ -165,10 +165,6 @@ RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cann
|
||||
app state or a memoized selection. Panels use one persisted
|
||||
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
|
||||
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
|
||||
Panels declare `locations: ["workspace", "explorer"]` to opt into Explorer hosting; omission means
|
||||
workspace only. Location controls hosting, not context. An agent panel target keeps its `agentId`
|
||||
when moved between hosts. Explorer configuration can create workspace-context panels and remove
|
||||
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
|
||||
|
||||
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
|
||||
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
|
||||
|
||||
@@ -436,6 +436,7 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- The npm `version` lifecycle regenerates F-Droid changelog files from `CHANGELOG.md` for stable releases only (`npm run fdroid:changelogs`) and stages them, so the release tag carries them. Betas are a no-op. A stable run **aborts the release** if `CHANGELOG.md` has no entry for the version being cut — commit the changelog entry first. See [docs/android.md](android.md) for why these files are generated per ABI.
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Side panel
|
||||
|
||||
The companion surface beside the user's work: Changes, Files, the pull request, and
|
||||
anything an agent asks to show alongside its output.
|
||||
|
||||
Everything that opens, reveals, hides, or asks about it goes through
|
||||
`packages/app/src/workspace-tabs/side-panel.ts`. Nothing else branches on which
|
||||
surface the current layout uses.
|
||||
|
||||
## Three renderings, two facts
|
||||
|
||||
| Layout | Rendering |
|
||||
| ---------------------- | ----------------------------------------------- |
|
||||
| compact | slide-over overlay, owned by the panel store |
|
||||
| non-compact + splits | dedicated right pane, owned by the layout store |
|
||||
| non-compact, no splits | ordinary tabs in the focused pane |
|
||||
|
||||
The third case is the one that bites. A native tablet renders one pane at a time and
|
||||
the tab row only lists the focused pane's tabs, so anything routed into a separate
|
||||
side panel pane there disappears with no way back.
|
||||
|
||||
## Placement
|
||||
|
||||
Where a tab lands is an intent the caller declares, not something the store infers
|
||||
from a `paneId` that happens to be in scope. `WorkspaceTabPlacement`
|
||||
(`packages/app/src/stores/workspace-layout-actions.ts`) has four modes, and the
|
||||
difference between them only shows up when the tab is already open somewhere:
|
||||
|
||||
| Mode | New tab | Tab already open |
|
||||
| --------- | ----------------- | ---------------------------- |
|
||||
| `pane` | opens in the pane | **moves** to the pane |
|
||||
| `prefer` | opens in the pane | stays where the user left it |
|
||||
| `focused` | focused pane | focuses it in place |
|
||||
| `ambient` | focused pane | focuses it in place |
|
||||
|
||||
`pane` is a pane-local affordance — that pane's `+` menu, its empty-state launcher.
|
||||
The user is placing the tab and expects it to arrive.
|
||||
|
||||
`prefer` is an implicit supporting open — an agent's file link or a failed workspace
|
||||
setup. It has an opinion about new tabs only. Yanking a tab out of the pane the user
|
||||
deliberately moved it to is the failure this mode exists to prevent.
|
||||
|
||||
`ambient` differs from `focused` in one rule: reconciliation opens tabs with nobody
|
||||
behind the click, and the side panel can hold focus from an earlier reveal, so an
|
||||
ambient open of something the user works _in_ (an agent, a terminal, a browser) is
|
||||
pushed out of the side panel. A `focused` open honours the focused pane whatever it
|
||||
is, because the user is standing in it.
|
||||
|
||||
Deduplication lives in `openTabInLayoutFocused`, so it obeys the placement intent for
|
||||
every caller. Do not re-place a tab after opening it.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The side panel lives in the ordinary split tree, so it is a pane like any other until
|
||||
it empties or the user dismisses it:
|
||||
|
||||
- **Close pane** on the side panel **hides** it; its tabs are waiting on the next reveal.
|
||||
- **Close pane** on any other pane **removes** it.
|
||||
- Closing the Side panel's final tab hides the pane. Revealing it returns the empty launcher.
|
||||
This is close-only: claiming or dragging that tab to another pane leaves the empty Side panel
|
||||
visible.
|
||||
- Nothing takes the last visible pane away. `closePane` will not remove it, and
|
||||
neither `closePane` nor `hideSidePanel` will hide it — including the header toggle
|
||||
reaching `hideSidePanel` after every other pane is gone. The gate is one check in
|
||||
`setPaneHiddenInLayout`; do not add a second one upstream.
|
||||
|
||||
`closePane` in the layout store owns that distinction. React renders the button and
|
||||
does not decide what it means.
|
||||
|
||||
Dragging a pane's last tab into another pane collapses the source pane — except the
|
||||
side panel, which the user summons and expects to find again, split size and all.
|
||||
|
||||
## What the side panel never does
|
||||
|
||||
It does not seed itself. Revealing it opens an empty pane with the launcher; the user
|
||||
picks what goes in. Detecting a pull request makes the launcher's card available and
|
||||
opens nothing. A failed workspace setup adds a background tab to the main pane.
|
||||
Running and successful setup never seed tabs.
|
||||
|
||||
## Routing preference
|
||||
|
||||
**Settings → General → Open supporting tabs in the Side panel**, on by default,
|
||||
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
||||
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
||||
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
||||
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
|
||||
supporting opens: they follow the preference and focus an existing Changes tab wherever
|
||||
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
|
||||
hides the panel without closing the tab. A different visible Side panel tab switches to
|
||||
Changes instead.
|
||||
|
||||
## The name
|
||||
|
||||
The product surface is the **Side panel**. **File Explorer** is still the name of the
|
||||
Files view inside it. Persisted identifiers keep their pre-rename spelling: the pane
|
||||
id is the literal string `"explorer"` (`SIDE_PANEL_PANE_ID`) and the persisted layout
|
||||
key is `explorerPaneIdByWorkspace`. The persisted state schema is strict, so renaming
|
||||
either would fail every saved blob and wipe the layout it describes.
|
||||
@@ -43,6 +43,6 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
||||
|
||||
## Known remaining contention (follow-up candidates)
|
||||
|
||||
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread.
|
||||
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread. See [agent-stream-performance.md](agent-stream-performance.md) for that pipeline's own budgets.
|
||||
- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames.
|
||||
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
|
||||
|
||||
@@ -186,13 +186,9 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated.
|
||||
- Playwright specs that exercise only the daemon import `daemonTest` from the shared fixtures so they do not create a browser context or page.
|
||||
- Helpers that create projects or workspaces own those records until cleanup. Their clients remove the daemon project on close, and an automatic fixture fails any test that still leaks a project record. Deleting only the temporary directory is not cleanup. Agent helpers pass the intended `workspaceId` through to agent creation; they never infer ownership from `cwd`.
|
||||
- Tests whose subject is daemon-global state, such as an empty history or daemon restart, start a dedicated host explicitly. Filenames and directories describe product behavior, never execution order or isolation mechanics.
|
||||
- Global setup accepts Metro as ready only when `/status` returns `packager-status:running`, then fetches the document's scripts so the cold bundle compilation finishes before Playwright's per-test timeout starts. A generic TCP listener is not sufficient readiness evidence. The browser suite uses direct local daemon connections and does not start a relay.
|
||||
- The app Playwright harness boots on Windows as well as POSIX. Spawn Node entrypoints through `process.execPath`, not `npx` or `node_modules/.bin` shims: Node refuses to spawn `.cmd` or `.bat` without `shell: true`, and shell mode concatenates argv without escaping and sends kill signals to `cmd.exe` instead of the real child.
|
||||
- Teardown kills the process tree, because a Windows signal reaches only the direct child and leaves forked workers holding the listening port.
|
||||
- The `asdf`-backed local Elixir relay stays POSIX-only, so the `relay-deployment` Playwright project is unavailable on Windows.
|
||||
|
||||
## Pull-request test routing
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
Paseo is a client for AI coding agents that run on your own machine. Your dev environment, in your pocket.
|
||||
|
||||
Paseo runs a local server called the daemon on your computer. It manages your coding agents and streams their output in real time. This app connects to that daemon, so your code never leaves your machine and there is no cloud service in between.
|
||||
|
||||
<b>Supported agents</b>
|
||||
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Codex</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenCode</li>
|
||||
<li>Pi</li>
|
||||
</ul>
|
||||
|
||||
You bring your own provider credentials and use your existing subscriptions. Paseo adds no markup and no inference costs of its own.
|
||||
|
||||
<b>What you can do</b>
|
||||
|
||||
<ul>
|
||||
<li>Start agents, watch them work, and answer their questions from your phone</li>
|
||||
<li>Run several agents in parallel across projects and git worktrees</li>
|
||||
<li>Review diffs, browse commit history, and edit files</li>
|
||||
<li>Work with pull requests from GitHub, GitLab, Gitea, Forgejo, and Codeberg</li>
|
||||
<li>Open a terminal in any workspace</li>
|
||||
<li>Dictate prompts or talk through a problem in voice mode</li>
|
||||
<li>Schedule agents to run on a recurring basis</li>
|
||||
</ul>
|
||||
|
||||
<b>Privacy</b>
|
||||
|
||||
No telemetry, no tracking, no account required. The app talks only to daemons you point it at. Remote access goes through an end-to-end encrypted relay that cannot read your traffic, or over your own network or VPN if you prefer.
|
||||
|
||||
<b>You need a daemon to connect to</b>
|
||||
|
||||
This app is not useful on its own. Install the Paseo daemon on the computer where your code and agent CLIs live, then pair this app with it from Settings. The desktop app bundles the daemon, or you can run it from npm, Docker, or Nix.
|
||||
|
||||
Setup instructions: https://paseo.sh
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1 @@
|
||||
../../../../../../packages/website/public/phone-1.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../packages/website/public/phone-2.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../packages/website/public/phone-3.png
|
||||
@@ -0,0 +1 @@
|
||||
Monitor and control your local AI coding agents from anywhere
|
||||
@@ -0,0 +1 @@
|
||||
Paseo
|
||||
@@ -0,0 +1,37 @@
|
||||
Paseo は、自分のマシン上で動作する AI コーディングエージェントのクライアントです。あなたの開発環境を、ポケットの中に。
|
||||
|
||||
Paseo はデーモンと呼ばれるローカルサーバーをあなたのコンピューター上で実行します。デーモンがコーディングエージェントを管理し、その出力をリアルタイムに配信します。このアプリはそのデーモンに接続するため、コードがマシンの外に出ることはなく、間にクラウドサービスも入りません。
|
||||
|
||||
<b>対応エージェント</b>
|
||||
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Codex</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenCode</li>
|
||||
<li>Pi</li>
|
||||
</ul>
|
||||
|
||||
プロバイダーの認証情報はご自身のものを使用し、既存のサブスクリプションをそのまま活用できます。Paseo が独自の手数料や推論コストを追加することはありません。
|
||||
|
||||
<b>できること</b>
|
||||
|
||||
<ul>
|
||||
<li>スマートフォンからエージェントを起動し、作業を確認し、質問に回答する</li>
|
||||
<li>プロジェクトや git worktree をまたいで複数のエージェントを並列実行する</li>
|
||||
<li>差分のレビュー、コミット履歴の閲覧、ファイルの編集</li>
|
||||
<li>GitHub、GitLab、Gitea、Forgejo、Codeberg のプルリクエストを扱う</li>
|
||||
<li>任意のワークスペースでターミナルを開く</li>
|
||||
<li>音声でプロンプトを入力したり、音声モードで問題を相談する</li>
|
||||
<li>エージェントを定期実行するようスケジュールする</li>
|
||||
</ul>
|
||||
|
||||
<b>プライバシー</b>
|
||||
|
||||
テレメトリー、トラッキング、アカウント登録は一切ありません。アプリは、あなたが指定したデーモンにのみ通信します。リモートアクセスは通信内容を読み取れないエンドツーエンド暗号化リレーを経由するか、ご自身のネットワークや VPN を利用できます。
|
||||
|
||||
<b>接続先のデーモンが必要です</b>
|
||||
|
||||
このアプリは単体では機能しません。コードとエージェント CLI があるコンピューターに Paseo デーモンをインストールし、設定画面からこのアプリをペアリングしてください。デスクトップアプリにはデーモンが同梱されています。npm、Docker、Nix から実行することもできます。
|
||||
|
||||
セットアップ手順: https://paseo.sh
|
||||
@@ -0,0 +1 @@
|
||||
ローカルのAIコーディングエージェントをどこからでも操作
|
||||
@@ -0,0 +1,37 @@
|
||||
Paseo 是运行在你自己机器上的 AI 编程 agents 的客户端。把你的开发环境装进口袋。
|
||||
|
||||
Paseo 会在你的电脑上运行一个名为 daemon 的本地服务,由它管理编程 agents 并实时推送输出。本应用连接到该 daemon,因此你的代码不会离开你的机器,中间也没有任何云服务。
|
||||
|
||||
<b>支持的 agents</b>
|
||||
|
||||
<ul>
|
||||
<li>Claude Code</li>
|
||||
<li>Codex</li>
|
||||
<li>GitHub Copilot</li>
|
||||
<li>OpenCode</li>
|
||||
<li>Pi</li>
|
||||
</ul>
|
||||
|
||||
你使用自己的提供商凭据和现有订阅。Paseo 不加价,也不产生自己的推理费用。
|
||||
|
||||
<b>你可以做什么</b>
|
||||
|
||||
<ul>
|
||||
<li>在手机上启动 agents、查看进展并回答它们的提问</li>
|
||||
<li>跨项目和 git worktree 并行运行多个 agents</li>
|
||||
<li>审查差异、浏览提交历史、编辑文件</li>
|
||||
<li>处理来自 GitHub、GitLab、Gitea、Forgejo 和 Codeberg 的合并请求</li>
|
||||
<li>在任意工作区打开终端</li>
|
||||
<li>用语音输入提示,或在语音模式下讨论问题</li>
|
||||
<li>将 agents 设置为定期运行</li>
|
||||
</ul>
|
||||
|
||||
<b>隐私</b>
|
||||
|
||||
没有遥测、没有追踪、无需账号。应用只与你指定的 daemon 通信。远程访问通过无法读取你流量的端到端加密中继,或者你也可以使用自己的网络或 VPN。
|
||||
|
||||
<b>你需要一个可连接的 daemon</b>
|
||||
|
||||
本应用无法独立使用。请在存放代码和 agent CLI 的电脑上安装 Paseo daemon,然后在设置中将本应用与其配对。桌面应用已内置 daemon,你也可以通过 npm、Docker 或 Nix 运行。
|
||||
|
||||
安装说明:https://paseo.sh
|
||||
@@ -0,0 +1 @@
|
||||
随时随地监控和控制本地的 AI 编程 agents
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-gZ6egDKIDiDi9jmDlPamM+or2Tthwr17DbJ+P66t4RE=
|
||||
sha256-13Q44pEU+V2M/wnitVAlBarYxAqWkGkXrE+w5qUCf7o=
|
||||
|
||||
Generated
+68
-25
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -15070,6 +15070,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/await-lock": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
|
||||
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
@@ -21336,6 +21342,20 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-sqlite": {
|
||||
"version": "16.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz",
|
||||
"integrity": "sha512-tUOKxE9TpfneRG3eOfbNfhN9236SJ7IiUnP8gCqU7umd9DtgDGB/5PhYVVfl+U7KskgolgNoB9v9OZ9iwXN8Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"await-lock": "^2.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-system-ui": {
|
||||
"version": "6.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz",
|
||||
@@ -21781,6 +21801,16 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fake-indexeddb": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
@@ -36140,7 +36170,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36200,6 +36230,7 @@
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
@@ -36241,6 +36272,7 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "~22.19.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
@@ -36254,6 +36286,7 @@
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^20.0.3",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
@@ -36791,6 +36824,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@types/node": {
|
||||
"version": "22.19.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
|
||||
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"packages/app/node_modules/@xterm/addon-clipboard": {
|
||||
"version": "0.3.0-beta.213",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.213.tgz",
|
||||
@@ -37192,12 +37235,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.6.1",
|
||||
"@getpaseo/protocol": "0.6.1",
|
||||
"@getpaseo/server": "0.6.1",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/server": "0.5.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37444,10 +37487,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.6.1",
|
||||
"@getpaseo/relay": "0.6.1",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/relay": "0.5.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37458,7 +37501,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37701,7 +37744,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38597,7 +38640,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38626,12 +38669,12 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.6.1",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38640,14 +38683,14 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.6.1",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38660,7 +38703,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38878,17 +38921,17 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.6.1",
|
||||
"@getpaseo/highlight": "0.6.1",
|
||||
"@getpaseo/plugin": "0.6.1",
|
||||
"@getpaseo/protocol": "0.6.1",
|
||||
"@getpaseo/relay": "0.6.1",
|
||||
"@getpaseo/client": "0.5.1",
|
||||
"@getpaseo/highlight": "0.5.1",
|
||||
"@getpaseo/plugin": "0.5.1",
|
||||
"@getpaseo/protocol": "0.5.1",
|
||||
"@getpaseo/relay": "0.5.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39940,7 +39983,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -84,6 +84,8 @@
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
|
||||
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
|
||||
@@ -91,7 +93,7 @@
|
||||
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version": "npm run version:sync-internal && npm run fdroid:changelogs && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||
|
||||
@@ -66,21 +66,54 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
|
||||
test.describe("Add Project command-center flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("method selection shows the daemon's available project sources without search", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("method selection has no search field", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowMethod(page, "directory-search")).toBeVisible();
|
||||
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
|
||||
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
|
||||
await expect(addProjectFlowInput(page)).toHaveCount(0);
|
||||
await expect(addProjectFlow(page).getByRole("textbox")).toHaveCount(0);
|
||||
await expect(page.getByTestId("add-project-flow-page-host")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the back arrow, search input, and result glyph share one left edge", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await addProjectFlowInput(page).fill("/tmp");
|
||||
|
||||
const backGlyph = addProjectFlowBack(page).locator("svg");
|
||||
const resultGlyph = addProjectFlow(page)
|
||||
.locator('[data-testid^="add-project-flow-path-"]')
|
||||
.first()
|
||||
.locator("svg");
|
||||
await expect(resultGlyph).toBeVisible();
|
||||
|
||||
const [backBox, inputBox, resultBox, titleBox, resultsBox, footerBox] = await Promise.all([
|
||||
backGlyph.boundingBox(),
|
||||
addProjectFlowInput(page).boundingBox(),
|
||||
resultGlyph.boundingBox(),
|
||||
page.getByTestId("add-project-flow-title").boundingBox(),
|
||||
page.getByTestId("add-project-flow-results").boundingBox(),
|
||||
page.getByTestId("add-project-flow-footer").boundingBox(),
|
||||
]);
|
||||
expect(backBox).not.toBeNull();
|
||||
expect(inputBox).not.toBeNull();
|
||||
expect(resultBox).not.toBeNull();
|
||||
expect(titleBox).not.toBeNull();
|
||||
expect(resultsBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
if (!backBox || !inputBox || !resultBox || !titleBox || !resultsBox || !footerBox) return;
|
||||
|
||||
expect(Math.abs(backBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
||||
expect(Math.abs(resultBox.x - inputBox.x)).toBeLessThanOrEqual(2);
|
||||
expect(titleBox.height).toBeLessThanOrEqual(24);
|
||||
expect(resultsBox.y + resultsBox.height).toBeLessThanOrEqual(footerBox.y + 1);
|
||||
});
|
||||
|
||||
test("an offline extra host neither appears nor forces host selection", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
@@ -240,6 +273,14 @@ test.describe("Add Project command-center flow", () => {
|
||||
await expectProjectHasNoWorkspaces(projectId);
|
||||
});
|
||||
|
||||
test("the current daemon advertises Clone from GitHub and New directory", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openAddProjectFlow(page);
|
||||
|
||||
await expect(addProjectFlowMethod(page, "github")).toContainText("Clone from GitHub");
|
||||
await expect(addProjectFlowMethod(page, "new-directory")).toContainText("New directory");
|
||||
});
|
||||
|
||||
test("a complete repository URL remains selectable without a GitHub search result", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -442,40 +442,21 @@ function formatFrames(frames: TurnFrame[], centers: number | number[]): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function submittedRowContentTop(frame: TurnFrame | undefined): number | undefined {
|
||||
const viewportTop = frame?.userRow.rect?.top;
|
||||
const scrollTop = frame?.scroll.scrollTop;
|
||||
return viewportTop == null || scrollTop == null ? undefined : viewportTop + scrollTop;
|
||||
}
|
||||
|
||||
function expectRowContinuity(frames: TurnFrame[]): void {
|
||||
const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow));
|
||||
const baselineFrame = frames[first];
|
||||
const baselineContentTop = submittedRowContentTop(baselineFrame);
|
||||
const baseline = frames[first]?.userRow.rect?.top;
|
||||
const disappeared = frames.findIndex(
|
||||
(frame, index) => index > first && !hasPaintedLayout(frame.userRow),
|
||||
);
|
||||
const reappeared = frames.findIndex(
|
||||
(frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow),
|
||||
);
|
||||
const contentShifted = frames.findIndex((frame, index) => {
|
||||
const contentTop = submittedRowContentTop(frame);
|
||||
return (
|
||||
index > first &&
|
||||
hasPaintedLayout(frame.userRow) &&
|
||||
baselineContentTop !== undefined &&
|
||||
contentTop !== undefined &&
|
||||
Math.abs(contentTop - baselineContentTop) > 1
|
||||
);
|
||||
});
|
||||
const viewportChanged = frames.findIndex(
|
||||
const shifted = frames.findIndex(
|
||||
(frame, index) =>
|
||||
index > first &&
|
||||
[
|
||||
geometryChanged(frame.scroll.rect?.top, baselineFrame?.scroll.rect?.top),
|
||||
geometryChanged(frame.scroll.rect?.height, baselineFrame?.scroll.rect?.height),
|
||||
geometryChanged(frame.scroll.clientHeight, baselineFrame?.scroll.clientHeight),
|
||||
].some(Boolean),
|
||||
hasPaintedLayout(frame.userRow) &&
|
||||
baseline !== undefined &&
|
||||
Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1,
|
||||
);
|
||||
const attachmentMissing = frames.findIndex(
|
||||
(frame, index) => index >= first && !hasPaintedLayout(frame.attachment),
|
||||
@@ -487,18 +468,16 @@ function expectRowContinuity(frames: TurnFrame[]): void {
|
||||
`submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`,
|
||||
]
|
||||
: []),
|
||||
...(contentShifted >= 0
|
||||
...(shifted >= 0
|
||||
? [
|
||||
`submitted row moved in scroll content from ${baselineContentTop?.toFixed(1)} to ${submittedRowContentTop(frames[contentShifted])?.toFixed(1)}`,
|
||||
`submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`,
|
||||
]
|
||||
: []),
|
||||
...(viewportChanged >= 0 ? ["scroll viewport geometry changed"] : []),
|
||||
...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []),
|
||||
];
|
||||
let failure = Math.max(0, first);
|
||||
if (attachmentMissing >= 0) failure = attachmentMissing;
|
||||
if (viewportChanged >= 0) failure = viewportChanged;
|
||||
if (contentShifted >= 0) failure = contentShifted;
|
||||
if (shifted >= 0) failure = shifted;
|
||||
if (disappeared >= 0) failure = disappeared;
|
||||
expect(
|
||||
violations,
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||
import { cancelAgent, expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
cancelAgent,
|
||||
fillComposerDraft,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
} from "../support/helpers/composer";
|
||||
import { expectNearBottom, scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
|
||||
import { holdRewindCompletion } from "../support/helpers/agent-timeline-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function completeSubmittedTurn(
|
||||
page: Page,
|
||||
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
|
||||
prompt: string,
|
||||
): Promise<Locator> {
|
||||
await submitMessage(page, prompt);
|
||||
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(userMessage).toBeVisible();
|
||||
|
||||
const finish = await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
return userMessage;
|
||||
}
|
||||
|
||||
async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise<void> {
|
||||
await userMessage.getByText(prompt, { exact: true }).hover();
|
||||
await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click();
|
||||
@@ -21,6 +45,49 @@ async function expectTurnCompletesNormally(
|
||||
}
|
||||
|
||||
test.describe("Agent message rewind", () => {
|
||||
test("rewinds a submitted prompt without replaying history and preserves a human draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const retainedPrompt = "Keep this completed turn after rewind.";
|
||||
const rewoundPrompt = "Remove this completed turn after rewind.";
|
||||
const preservedDraft = "Keep this human draft after rewind.";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "message-rewind-roundtrip-",
|
||||
title: "Message rewind roundtrip",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
const rewindCompletion = await holdRewindCompletion(page, agent.agentId);
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await completeSubmittedTurn(page, agent, retainedPrompt);
|
||||
const userMessage = await completeSubmittedTurn(page, agent, rewoundPrompt);
|
||||
await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
await fillComposerDraft(page, preservedDraft);
|
||||
rewindCompletion.clearTimelineStreamCount();
|
||||
|
||||
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
|
||||
await rewindCompletion.waitForDelayedResponse();
|
||||
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
|
||||
expect(rewindCompletion.timelineStreamCount()).toBe(0);
|
||||
rewindCompletion.release();
|
||||
await rewind;
|
||||
|
||||
await expect(page.getByTestId("user-message").filter({ hasText: rewoundPrompt })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectNearBottom(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const prompt = "Delay synthetic user message by 2000ms.";
|
||||
|
||||
@@ -103,18 +103,6 @@ async function readProviderModePreference(page: Page, provider: string): Promise
|
||||
);
|
||||
}
|
||||
|
||||
async function readProviderFeaturePreferences(page: Page, provider: string): Promise<unknown> {
|
||||
return page.evaluate(
|
||||
({ preferencesKey, providerId }) => {
|
||||
const raw = localStorage.getItem(preferencesKey);
|
||||
if (!raw) return null;
|
||||
const preferences = JSON.parse(raw) as FormPreferences;
|
||||
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
|
||||
},
|
||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
@@ -219,9 +207,6 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
||||
|
||||
await openModelPicker(page);
|
||||
await applyProfileFromPicker(page, "Fast profile");
|
||||
await expect
|
||||
.poll(() => readProviderFeaturePreferences(page, featureProviderId), { timeout: 10_000 })
|
||||
.toEqual({ fast_mode: true });
|
||||
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
|
||||
|
||||
const createAgentRequest = await createAgentRecorder.waitForRequest();
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||
import { startRunningMockAgent } from "../support/helpers/composer";
|
||||
import {
|
||||
sampleStreamFrames,
|
||||
summarizeStreamSmoothness,
|
||||
} from "../support/helpers/stream-smoothness";
|
||||
|
||||
/**
|
||||
* Streaming smoothness, measured on the path the user actually sees.
|
||||
*
|
||||
* The mock provider's "bursty-stream" model emits tokens in uneven runs separated
|
||||
* by idle gaps, which is how real models arrive. Sampling and the statistics live
|
||||
* in ../support/helpers/stream-smoothness.ts, which also documents what the two
|
||||
* numbers mean.
|
||||
*
|
||||
* Budgets are deliberately loose. This spec exists to catch a regression in the
|
||||
* shape of the stream, not to pin exact numbers on shared CI hardware. For
|
||||
* reference, on a local dev daemon the paced reveal measures a CV around 1.3–1.5
|
||||
* against 2.7 when painting deltas as they arrive.
|
||||
*/
|
||||
|
||||
const SAMPLE_WINDOW_MS = 6_000;
|
||||
const CHARS_PER_FRAME_CV_BUDGET = 2;
|
||||
const UPDATE_GAP_P95_BUDGET_MS = 250;
|
||||
const REACT_COMMIT_P95_BUDGET_MS = 12;
|
||||
const REACT_COMMIT_COUNT_BUDGET = 600;
|
||||
const RUN_AGENT_STREAM_PERF = process.env.PASEO_AGENT_STREAM_PERF_E2E === "1";
|
||||
const agentStreamPerfDescribe = RUN_AGENT_STREAM_PERF ? test.describe : test.describe.skip;
|
||||
|
||||
interface ReactCommit {
|
||||
id: string;
|
||||
phase: "mount" | "update" | "nested-update";
|
||||
actualDuration: number;
|
||||
baseDuration: number;
|
||||
startTime: number;
|
||||
commitTime: number;
|
||||
}
|
||||
|
||||
agentStreamPerfDescribe("Agent stream smoothness", () => {
|
||||
test("reveals bursty model output at a steady rate", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.addInitScript(() => {
|
||||
Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
|
||||
});
|
||||
|
||||
const agent = await startRunningMockAgent(page, {
|
||||
prefix: "stream-smoothness-",
|
||||
model: "bursty-stream",
|
||||
prompt: "Stream bursty output for smoothness measurement.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await page.evaluate(() => {
|
||||
const reset = Reflect.get(globalThis, "__PASEO_RESET_RENDER_PROFILE__");
|
||||
if (typeof reset !== "function") {
|
||||
throw new Error("Render profiler did not initialize");
|
||||
}
|
||||
reset();
|
||||
});
|
||||
|
||||
const samples = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
|
||||
const report = summarizeStreamSmoothness(samples, SAMPLE_WINDOW_MS);
|
||||
const reactCommits = await readStreamReactCommits(page);
|
||||
const reactReport = summarizeReactCommits(reactCommits);
|
||||
|
||||
await testInfo.attach("agent-stream-smoothness-report", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await testInfo.attach("agent-stream-react-profile", {
|
||||
body: JSON.stringify(reactReport, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[perf] Agent stream React: ${JSON.stringify(reactReport)}`);
|
||||
console.log(`[perf] Agent stream smoothness: ${JSON.stringify(report)}`);
|
||||
|
||||
expect(
|
||||
report.growthFrames,
|
||||
"the stream must actually produce text during the sample window",
|
||||
).toBeGreaterThan(20);
|
||||
expect(
|
||||
report.charsPerFrameCv,
|
||||
`characters painted per frame should be steady (cv < ${CHARS_PER_FRAME_CV_BUDGET})`,
|
||||
).toBeLessThan(CHARS_PER_FRAME_CV_BUDGET);
|
||||
expect(
|
||||
report.updateGapP95Ms,
|
||||
`the text should not stall (p95 gap < ${UPDATE_GAP_P95_BUDGET_MS}ms)`,
|
||||
).toBeLessThan(UPDATE_GAP_P95_BUDGET_MS);
|
||||
expect(reactReport.count, "streaming should produce measured React commits").toBeGreaterThan(
|
||||
20,
|
||||
);
|
||||
expect(
|
||||
reactReport.count,
|
||||
`the reveal should cap commits below ${REACT_COMMIT_COUNT_BUDGET} per sample window`,
|
||||
).toBeLessThan(REACT_COMMIT_COUNT_BUDGET);
|
||||
expect(
|
||||
reactReport.actualDurationP95Ms,
|
||||
`React commits should stay below ${REACT_COMMIT_P95_BUDGET_MS}ms at p95`,
|
||||
).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function readStreamReactCommits(
|
||||
page: Parameters<typeof sampleStreamFrames>[0],
|
||||
): Promise<ReactCommit[]> {
|
||||
return await page.evaluate(() => {
|
||||
const samples = Reflect.get(globalThis, "__PASEO_RENDER_PROFILE__");
|
||||
if (!Array.isArray(samples)) {
|
||||
return [];
|
||||
}
|
||||
return samples.filter(
|
||||
(sample): sample is ReactCommit =>
|
||||
typeof sample === "object" &&
|
||||
sample !== null &&
|
||||
typeof Reflect.get(sample, "id") === "string" &&
|
||||
Reflect.get(sample, "id").startsWith("AgentStreamSection:"),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeReactCommits(commits: readonly ReactCommit[]) {
|
||||
const durations = commits
|
||||
.map((commit) => commit.actualDuration)
|
||||
.sort((left, right) => left - right);
|
||||
const total = durations.reduce((sum, duration) => sum + duration, 0);
|
||||
return {
|
||||
count: commits.length,
|
||||
actualDurationTotalMs: round2(total),
|
||||
actualDurationMeanMs: round2(commits.length > 0 ? total / commits.length : 0),
|
||||
actualDurationP50Ms: percentile(durations, 50),
|
||||
actualDurationP95Ms: percentile(durations, 95),
|
||||
actualDurationMaxMs: durations.at(-1) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function percentile(sorted: readonly number[], percentileValue: number): number {
|
||||
if (sorted.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return sorted[
|
||||
Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * (percentileValue / 100)))
|
||||
];
|
||||
}
|
||||
@@ -10,7 +10,6 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
repoPrefix: "bottom-sheet-reopen-",
|
||||
title: "Bottom sheet reopen e2e",
|
||||
initialPrompt: "Prepare a bottom sheet reopen test agent.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible({
|
||||
@@ -23,6 +22,16 @@ async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
return session;
|
||||
}
|
||||
|
||||
async function withMobileMockAgent(page: Page, run: () => Promise<void>) {
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function bottomSheetBackdrop(page: Page) {
|
||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
}
|
||||
@@ -99,36 +108,35 @@ async function openAndCloseModelSelectorTwice(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("mobile bottom sheet reopen", () => {
|
||||
test("sheets reopen and model search returns to configuration", async ({ page }) => {
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
try {
|
||||
await test.step("tab switcher opens, closes, and reopens", async () => {
|
||||
await openAndCloseTabSwitcherTwice(page);
|
||||
test("tab switcher can open, close, reopen, and close again", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openAndCloseTabSwitcherTwice(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("model selector can open, close, reopen, and close again", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openAndCloseModelSelectorTwice(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("search selection returns to configuration options", async ({ page }) => {
|
||||
await withMobileMockAgent(page, async () => {
|
||||
await openModelSelector(page);
|
||||
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||
|
||||
await page.getByTestId("model-search-all-input").click();
|
||||
const model = page.getByRole("button", { name: /^Ten second stream/ });
|
||||
await expect(model).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await test.step("model selector opens, closes, and reopens", async () => {
|
||||
await openAndCloseModelSelectorTwice(page);
|
||||
});
|
||||
await model.click();
|
||||
|
||||
await test.step("model search returns to configuration", async () => {
|
||||
await openModelSelector(page);
|
||||
const sheet = page.getByTestId("agent-controls-model-sheet");
|
||||
|
||||
await page.getByTestId("model-search-all-input").click();
|
||||
const model = page.getByRole("button", { name: /^Ten second stream/ });
|
||||
await expect(model).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await model.click();
|
||||
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-settings-list")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-controls-model")).toContainText("Ten second stream");
|
||||
await expect(page.getByTestId("agent-controls-model-browser-sheet")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,38 @@ test.describe("Branch switcher", () => {
|
||||
// releases stale sessions from the previous spec; one retry stabilizes it.
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("switches the workspace branch from the git diff panel for an opaque workspace id", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "branch-switch-",
|
||||
repo: { branches: ["main", "dev"] },
|
||||
});
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
|
||||
|
||||
await openChangesPanel(page);
|
||||
await expectWorkspaceBranch(page, "main");
|
||||
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
|
||||
await expectWorkspaceBranch(page, "dev");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await readWorktreeBranchInfo({ worktreePath: workspace.repoPath })).currentBranch,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("dev");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a custom workspace title stays in the header while the diff panel switches the real branch", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -30,7 +30,11 @@ const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-file-tree").filter({ visible: true });
|
||||
return page.getByTestId("changes-tree-rail-tree");
|
||||
}
|
||||
|
||||
function changesContent(page: Page) {
|
||||
return page.getByTestId("changes-tree-rail-content");
|
||||
}
|
||||
|
||||
async function readFileIfPresent(filePath: string): Promise<string | null> {
|
||||
@@ -260,8 +264,7 @@ test("Changes opens the populated committed comparison for a clean checkout", as
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -269,19 +272,19 @@ test("Changes expires a manual comparison when checkout dirtiness changes", asyn
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
||||
await expect(mode).toContainText("Uncommitted");
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
||||
|
||||
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -294,8 +297,7 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const tree = page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
const mode = tree.getByTestId("changes-diff-status-trigger");
|
||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
@@ -303,8 +305,8 @@ test("an empty Changes comparison links to the populated comparison", async ({ p
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(tree.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
const seeCommitted = tree.getByRole("button", { name: "See committed changes" });
|
||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
@@ -319,7 +321,7 @@ test("changes file actions open below the right-click without a reserved kebab",
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
const deletedFileName = page.getByTestId("diff-file-1-name");
|
||||
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
|
||||
await expect(deletedFileName).toHaveCSS("user-select", "none");
|
||||
await deletedFileName.dblclick();
|
||||
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
|
||||
@@ -430,6 +432,7 @@ test("changes context menus duplicate files and folders", async ({ page }) => {
|
||||
.poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts")))
|
||||
.toBe(AFTER);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-folder-src-duplicate").click();
|
||||
await expect
|
||||
@@ -444,6 +447,8 @@ test("changes tree aligns every file status after its diff stat", async ({ page
|
||||
});
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
|
||||
const tree = changesTree(page);
|
||||
const modifiedRow = tree.getByTestId("diff-tree-file-0");
|
||||
const deletedRow = tree.getByTestId("diff-tree-file-1");
|
||||
@@ -466,6 +471,7 @@ test("changes context menu recursively collapses descendant folders", async ({ p
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
const tree = changesTree(page);
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
|
||||
@@ -510,6 +516,7 @@ test("changes context menus expose folder revert and restore a file after confir
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
const tree = changesTree(page);
|
||||
await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" });
|
||||
const folderRevert = page.getByTestId("diff-folder-src-revert");
|
||||
@@ -642,11 +649,6 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0);
|
||||
await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0);
|
||||
@@ -656,30 +658,34 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible();
|
||||
|
||||
await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0);
|
||||
// Collapse-all and the layout switch are overflow-menu items, not toolbar icons.
|
||||
const diffOptions = visiblePanel.getByRole("button", { name: "Diff options" });
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
||||
await diffOptions.click();
|
||||
// A workspace tab has nowhere to open itself, so the menu withholds that item.
|
||||
await expect(page.getByTestId("changes-open-tab")).toHaveCount(0);
|
||||
const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all");
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files");
|
||||
await collapseFiles.click();
|
||||
await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files");
|
||||
await collapseFiles.click();
|
||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
||||
await diffOptions.click();
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Expand all files");
|
||||
await page.getByTestId("changes-toggle-collapse-all").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
const layout = visiblePanel.getByTestId("changes-toggle-layout");
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff");
|
||||
await layout.click();
|
||||
await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff");
|
||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
||||
await diffOptions.click();
|
||||
await expect(layoutItem).toContainText("Side-by-side diff");
|
||||
// Leading icon only: the layout is still unified, so no trailing check.
|
||||
await expect(layoutItem.locator("svg")).toHaveCount(1);
|
||||
await layoutItem.click();
|
||||
await diffOptions.click();
|
||||
await expect(layoutItem.locator("svg")).toHaveCount(2);
|
||||
|
||||
const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace");
|
||||
await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace");
|
||||
const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines");
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines");
|
||||
await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Refresh",
|
||||
);
|
||||
await wrapLines.click();
|
||||
await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines");
|
||||
await expect(page.getByText("Hide whitespace", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Wrap long lines", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
||||
await page.getByText("Wrap long lines", { exact: true }).click();
|
||||
await diffOptions.click();
|
||||
await expect(page.getByText("Scroll long lines", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
@@ -695,23 +701,151 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expectFlatFileList(page);
|
||||
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
||||
// The tree toggle is the one icon action that stays outside the overflow menu.
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
|
||||
const [changesToolbarBox, treeToggleBox, optionsBox, treeGlyphBox, optionsGlyphBox] =
|
||||
await Promise.all([
|
||||
page.getByTestId("changes-header").boundingBox(),
|
||||
page.getByTestId("changes-toggle-tree").boundingBox(),
|
||||
page.getByRole("button", { name: "Diff options" }).boundingBox(),
|
||||
page.getByTestId("changes-toggle-tree").locator("svg").boundingBox(),
|
||||
page.getByRole("button", { name: "Diff options" }).locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!changesToolbarBox || !treeToggleBox || !optionsBox || !treeGlyphBox || !optionsGlyphBox) {
|
||||
throw new Error("Changes toolbar geometry could not be measured");
|
||||
}
|
||||
expect(changesToolbarBox.height).toBe(36);
|
||||
expect(treeToggleBox.width).toBe(24);
|
||||
expect(treeToggleBox.height).toBe(24);
|
||||
expect(optionsBox.width).toBe(24);
|
||||
expect(optionsBox.height).toBe(24);
|
||||
expect(treeGlyphBox.width).toBe(14);
|
||||
expect(treeGlyphBox.height).toBe(14);
|
||||
expect(optionsGlyphBox.width).toBe(14);
|
||||
expect(optionsGlyphBox.height).toBe(14);
|
||||
const diffOptions = page.getByRole("button", { name: "Diff options" });
|
||||
const diffOptionsRestBackground = await diffOptions.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await diffOptions.hover();
|
||||
const diffOptionsHoverBackground = await diffOptions.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(diffOptionsHoverBackground).not.toBe(diffOptionsRestBackground);
|
||||
await page.getByTestId("changes-toggle-tree").hover();
|
||||
expect(
|
||||
await page
|
||||
.getByTestId("changes-toggle-tree")
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(diffOptionsHoverBackground);
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toContainText("Collapse all files");
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toContainText("Side-by-side diff");
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("changes-refresh")).toHaveText("Refresh");
|
||||
await page.getByTestId("changes-toggle-whitespace").click();
|
||||
await page.getByTestId("changes-options-menu").click();
|
||||
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "true");
|
||||
expect(
|
||||
await page
|
||||
.getByTestId("changes-toggle-tree")
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(diffOptionsHoverBackground);
|
||||
const tree = changesTree(page);
|
||||
const content = changesContent(page);
|
||||
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
|
||||
"user-select",
|
||||
"none",
|
||||
);
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
|
||||
await expect(tree.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
|
||||
const folderToggleBounds = await tree.getByTestId("diff-folder-src-toggle").boundingBox();
|
||||
const folderChevronBounds = await tree
|
||||
.getByTestId("diff-folder-src-toggle")
|
||||
.locator("svg")
|
||||
.boundingBox();
|
||||
expect(folderToggleBounds).not.toBeNull();
|
||||
expect(folderChevronBounds).not.toBeNull();
|
||||
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
|
||||
folderToggleBounds!.y + folderToggleBounds!.height / 2,
|
||||
0,
|
||||
);
|
||||
const folderLabelBounds = await tree
|
||||
.getByTestId("diff-folder-src")
|
||||
.getByText("src", { exact: true })
|
||||
.boundingBox();
|
||||
const fileLabelBounds = await tree
|
||||
.getByTestId("diff-tree-file-0")
|
||||
.getByText("use-mounted-tab-set.ts", { exact: true })
|
||||
.boundingBox();
|
||||
expect(folderLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds).not.toBeNull();
|
||||
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(12, 0);
|
||||
|
||||
const folderToggle = tree.getByTestId("diff-folder-src-toggle");
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
||||
await folderToggle.click();
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible();
|
||||
|
||||
const fileToggle = tree.getByTestId("diff-tree-file-0-toggle");
|
||||
await fileToggle.click({ button: "right" });
|
||||
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(page.getByTestId("diff-tree-file-0-context-menu")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await fileToggle.click();
|
||||
await expect(content.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
const contentBounds = await content.boundingBox();
|
||||
const focusedFileBounds = await content.getByTestId("diff-file-0").boundingBox();
|
||||
expect(contentBounds).not.toBeNull();
|
||||
expect(focusedFileBounds).not.toBeNull();
|
||||
expect(focusedFileBounds!.y).toBeGreaterThanOrEqual(contentBounds!.y);
|
||||
expect(focusedFileBounds!.y).toBeLessThan(contentBounds!.y + 80);
|
||||
|
||||
await tree.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
|
||||
await expectFlatFileList(page);
|
||||
const screenshot = testInfo.outputPath("changes-toolbar.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
|
||||
});
|
||||
|
||||
test("compact Changes keeps its actions compact and menu-only", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
|
||||
const compactChangesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
if (!(await compactChangesTab.isVisible())) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
}
|
||||
await expect(compactChangesTab).toBeVisible();
|
||||
await compactChangesTab.click();
|
||||
const compactExplorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
await expect(compactExplorer.getByTestId("changes-header")).toBeVisible();
|
||||
|
||||
const actions = compactExplorer.getByTestId("changes-actions-menu-trigger");
|
||||
const options = compactExplorer.getByRole("button", { name: "Diff options" });
|
||||
const actions = page.getByTestId("changes-actions-menu-trigger").filter({ visible: true });
|
||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||
const [actionsBox, optionsBox, glyphBox] = await Promise.all([
|
||||
actions.boundingBox(),
|
||||
options.boundingBox(),
|
||||
@@ -739,9 +873,7 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
await expect(wrapLines).toBeVisible();
|
||||
await wrapLines.click();
|
||||
await options.click();
|
||||
await expect(
|
||||
page.getByTestId("changes-options-menu-content").getByTestId("changes-toggle-wrap-lines"),
|
||||
).toContainText("Scroll long lines");
|
||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||
@@ -751,9 +883,7 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
||||
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const handle = page
|
||||
.getByTestId("workspace-explorer-sidebar-resize-handle")
|
||||
.getByRole("separator");
|
||||
const handle = page.getByTestId("workspace-split-resize-handle").getByRole("separator");
|
||||
await expect(handle).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -774,11 +904,11 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
||||
};
|
||||
}),
|
||||
]);
|
||||
if (!handleBounds) throw new Error("Explorer sidebar resize handle has no bounds");
|
||||
if (!handleBounds) throw new Error("Workspace split resize handle has no bounds");
|
||||
|
||||
await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120);
|
||||
await page.mouse.move(handleBounds.x - 120, handleBounds.y + 120);
|
||||
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||
|
||||
const duringDrag = await Promise.all([
|
||||
@@ -944,7 +1074,6 @@ test("canvas diff uses the overlay scrollbar and its thumb controls vertical scr
|
||||
if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds");
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
|
||||
await page.mouse.down();
|
||||
await expect(grab).toHaveCSS("cursor", "grabbing");
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180);
|
||||
await page.mouse.up();
|
||||
|
||||
@@ -1184,20 +1313,24 @@ async function setOpenChangesPresentation(
|
||||
page: Page,
|
||||
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
||||
): Promise<void> {
|
||||
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const layoutItem = diffPanel.getByTestId("changes-toggle-layout");
|
||||
const currentLayout =
|
||||
(await layoutItem.getAttribute("aria-label")) === "Switch to unified diff"
|
||||
? "split"
|
||||
: "unified";
|
||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||
|
||||
await options.click();
|
||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
||||
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
|
||||
if (currentLayout !== requestedPresentation.layout) {
|
||||
await layoutItem.click();
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines");
|
||||
const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines";
|
||||
await options.click();
|
||||
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
|
||||
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
|
||||
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
||||
await wrapItem.click();
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1232,6 +1365,12 @@ async function releaseBrowserFontLoads(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function expectFlatFileList(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
|
||||
await expect(page.getByTestId("diff-file-0")).toContainText("src");
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
await expect
|
||||
@@ -1352,7 +1491,6 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
@@ -1369,27 +1507,13 @@ async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspa
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(diffPanel.getByTestId("diff-file-0-name")).toHaveText("selection.ts", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("selection.ts")).toBeVisible({ timeout: 30_000 });
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
|
||||
await changesTab.click();
|
||||
const changedFile = explorer
|
||||
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await changedFile.click();
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const CHANGES_SHORTCUT = `${process.platform === "darwin" ? "Meta" : "Control"}+Shift+G`;
|
||||
|
||||
test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-explorer-" });
|
||||
function mainPane(page: Page) {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function sidePanel(page: Page) {
|
||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
test("Changes shortcut follows the default Side panel placement", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-side-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
@@ -15,12 +24,34 @@ test("Changes shortcut reveals the Changes tree in Explorer", async ({ page }) =
|
||||
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible({
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes shortcut uses the focused pane when Side panel placement is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "changes-shortcut-pane-" });
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await page.keyboard.press(CHANGES_SHORTCUT);
|
||||
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sidePanel(page)).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
clickChatOutlineRowEdge,
|
||||
disableChatOutlineFromAppearance,
|
||||
expectActiveChatOutlinePrompt,
|
||||
expectActiveChatOutlinePromptMovedFrom,
|
||||
expectChatOutlinePreview,
|
||||
expectChatOutlinePrompts,
|
||||
expectChatOutlinePromptToRemainBare,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
openAgentTimeline,
|
||||
scrollThroughOlderHistoryPages,
|
||||
scrollTimelineToNewestLoadedEdge,
|
||||
scrollTimelineToOldestLoadedEdge,
|
||||
seedLongMockAgentTimeline,
|
||||
type LongTimelineAgent,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
@@ -130,12 +130,11 @@ test.describe("desktop chat outline", () => {
|
||||
await expectOneActiveChatOutlinePrompt(page);
|
||||
|
||||
await clickChatOutlineRowEdge(page, 4);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.prompts[3]);
|
||||
await expectTimelinePromptVisible(page, agent.prompts[3]);
|
||||
await expectActiveChatOutlinePrompt(page, 4);
|
||||
|
||||
await scrollTimelineToNewestLoadedEdge(page);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectActiveChatOutlinePromptMovedFrom(page, 4);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expectActiveChatOutlinePrompt(page, 1);
|
||||
});
|
||||
|
||||
test("keeps a clicked prompt free of persistent selection chrome", async ({ page }) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman
|
||||
import {
|
||||
applyCommandCenterAgentControls,
|
||||
chooseCommandCenterAgentControl,
|
||||
openCommandCenterForAgent,
|
||||
expectCommandCenterAgentControlSelected,
|
||||
expectFocusedAgentControls,
|
||||
expectWorkspaceAgentConfiguration,
|
||||
@@ -13,9 +12,7 @@ import {
|
||||
} from "../support/helpers/command-center-agent-controls";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { buildSchedulesRoute } from "@/utils/host-routes";
|
||||
|
||||
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
|
||||
|
||||
@@ -79,107 +76,6 @@ test.describe("Command Center agent controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||
// model. Asserting both model and mode catches either row winning.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-relevance-",
|
||||
title: "Command Center relevance",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("matches query tokens in any order", async ({ page }) => {
|
||||
// "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never
|
||||
// a substring of it. Only per-token matching finds this row.
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-tokenized-",
|
||||
title: "Command Center tokenized search",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "legacy-mode");
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
await openCommandCenter(page);
|
||||
await chooseCommandCenterAgentControl({
|
||||
page,
|
||||
query: "test load",
|
||||
choice: "Mode › Load test",
|
||||
});
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps an action first when the action is the best match", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-action-priority-",
|
||||
title: "Command Center action priority",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("sched");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("drops an arrow-key selection once the query changes", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "command-center-active-reset-",
|
||||
title: "Command Center active reset",
|
||||
model: "five-minute-stream",
|
||||
});
|
||||
try {
|
||||
await workspace.client.setAgentMode(workspace.agentId, "approval-test");
|
||||
const input = await openCommandCenterForAgent(page, workspace);
|
||||
await input.fill("loa");
|
||||
// Moves the highlight onto a model row, which still matches after the next keystroke.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await input.fill("load");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectWorkspaceAgentConfiguration(workspace, {
|
||||
id: workspace.agentId,
|
||||
provider: "mock",
|
||||
model: "five-minute-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("applies draft model and setting choices to the created agent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" });
|
||||
await seedMockDraftPreferences(page);
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
const SECONDARY_HOST_ID = "host-command-center-secondary";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent results show the host they live on when more than one host exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-host-" });
|
||||
const title = `cc-host-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
// A second (offline) host makes the view multi-host, which is when the host label earns its space.
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: SECONDARY_HOST_ID,
|
||||
label: "Secondary Host",
|
||||
primaryLabel: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
|
||||
// The shared daemon may carry agents from other specs, so target this agent by its id.
|
||||
const row = panel.getByTestId(`command-center-agent-${getServerId()}:${agent.id}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(title);
|
||||
await expect(row).toContainText(PRIMARY_HOST_LABEL);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||
|
||||
@@ -15,7 +15,7 @@ test("commit history explains when the workspace has no commits ahead of its bas
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: workspace.repoPath, stdio: "ignore" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openChangesTreePanel(page);
|
||||
await openChangesPanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
@@ -35,7 +35,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openChangesTreePanel(page);
|
||||
await openChangesPanel(page);
|
||||
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||
await commitsSection.click();
|
||||
@@ -60,8 +60,8 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
throw new Error("Commit-diff toolbar geometry could not be measured");
|
||||
}
|
||||
expect(commitToolbarBox.height).toBe(36);
|
||||
expect(layoutToggleBox.width).toBe(20);
|
||||
expect(layoutToggleBox.height).toBe(20);
|
||||
expect(layoutToggleBox.width).toBe(24);
|
||||
expect(layoutToggleBox.height).toBe(24);
|
||||
expect(layoutToggleGlyphBox.width).toBe(14);
|
||||
expect(layoutToggleGlyphBox.height).toBe(14);
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
|
||||
@@ -38,10 +38,7 @@ async function seedChangedAgent(repoPrefix: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("composer diff stat opens Changes in the configured side pane", async ({ page }) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openInSidePane: { changesLinks: true } }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||
|
||||
try {
|
||||
@@ -57,24 +54,31 @@ test("composer diff stat opens Changes in the configured side pane", async ({ pa
|
||||
await expect(pill).toContainText("-0");
|
||||
await pill.click();
|
||||
|
||||
const sidePane = page
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.filter({ visible: true })
|
||||
.filter({ has: page.getByTestId("working-diff-panel") });
|
||||
await expect(sidePane.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
const sidePanel = page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(visibleMainPane(page).getByTestId("working-diff-panel")).toHaveCount(0);
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await test.step("Explorer navigation does not replace the side pane", async () => {
|
||||
await test.step("switching from another Side panel view reveals Changes", async () => {
|
||||
await openFilesPanel(page);
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toContainText("Files");
|
||||
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
|
||||
await pill.click();
|
||||
await expect(sidePane.getByTestId("working-diff-panel")).toBeVisible();
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
|
||||
await pill.click();
|
||||
await expect(sidePanel).toHaveCount(0);
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("clicking again restores Changes", async () => {
|
||||
await pill.click();
|
||||
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
|
||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -92,16 +96,19 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
|
||||
|
||||
await page.getByTestId("composer-diff-stat-pill").click();
|
||||
|
||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer diff stat opens Changes in the focused pane by default", async ({ page }) => {
|
||||
test("composer diff stat opens Changes in the focused pane when Side panel routing is off", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(settingsKey, JSON.stringify({ openSupportingTabsInSidePanel: false }));
|
||||
}, APP_SETTINGS_KEY);
|
||||
const workspace = await seedChangedAgent("composer-diff-stat-tab-");
|
||||
|
||||
try {
|
||||
@@ -118,9 +125,7 @@ test("composer diff stat opens Changes in the focused pane by default", async ({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(mainPane.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
page.locator('[data-testid^="workspace-pane-"]').filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-side-panel")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
||||
repoPrefix: "composer-bottom-anchor-",
|
||||
title: "Composer bottom anchor",
|
||||
initialPrompt: "Produce enough content to make the chat scrollable.",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Direct terminal shortcut pane placement", () => {
|
||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||
@@ -12,31 +11,21 @@ test.describe("Direct terminal shortcut pane placement", () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
const focusedPaneChild = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ has: page.getByTestId("workspace-new-tab-panel") });
|
||||
const originalPaneChild = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ hasNot: page.getByTestId("workspace-new-tab-panel") });
|
||||
await expect(focusedPaneChild).toBeVisible();
|
||||
const focusedPaneId = await focusedPaneChild
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getAttribute("data-testid");
|
||||
const originalPaneId = await originalPaneChild
|
||||
.locator('[data-testid^="workspace-pane-"]')
|
||||
.getAttribute("data-testid");
|
||||
expect(focusedPaneId).not.toBeNull();
|
||||
expect(originalPaneId).not.toBeNull();
|
||||
const focusedPane = page.getByTestId(focusedPaneId!);
|
||||
const originalPane = page.getByTestId(originalPaneId!);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const focusedPane = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
const primaryPane = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
await pressDirectNewTabShortcut(page, "t");
|
||||
|
||||
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(originalPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -13,25 +13,38 @@ async function expectLauncherSelection(launcher: Locator, name: string): Promise
|
||||
await expect(launcher.getByRole("button", { name, exact: true })).toBeFocused();
|
||||
}
|
||||
|
||||
async function openExplorerWithKeyboard(page: Page): Promise<Locator> {
|
||||
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+E`);
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
const launcher = page
|
||||
.getByTestId("workspace-side-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("workspace-new-tab-panel");
|
||||
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||
return launcher;
|
||||
}
|
||||
|
||||
test.describe("New tab keyboard launcher", () => {
|
||||
test("Cmd+E reveals the repository Changes tree", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-keyboard-" });
|
||||
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
const explorer = await openExplorerWithKeyboard(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
const launcher = await openSidePanelWithKeyboard(page);
|
||||
await expectLauncherSelection(launcher, "Changes");
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Files");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-side-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("workspace-tab-files"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -52,12 +65,14 @@ test.describe("New tab keyboard launcher", () => {
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Terminal");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Diff");
|
||||
await expectLauncherSelection(launcher, "Changes");
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expectLauncherSelection(launcher, "Files");
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(
|
||||
page.getByTestId("workspace-tab-working_diff").filter({ visible: true }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -8,10 +8,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
openChangesTreePanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
@@ -25,18 +22,23 @@ function draftTabChip(page: Page): Locator {
|
||||
return page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true });
|
||||
}
|
||||
|
||||
/** The Explorer has its own fixed tab rail, separate from workspace pane rows. */
|
||||
/** The explorer pane is the only tab row carrying the Changes tab. */
|
||||
function explorerTabRow(page: Page): Locator {
|
||||
return visible(page, "explorer-sidebar-tab-rail");
|
||||
return visible(page, "workspace-tabs-row").filter({
|
||||
has: page.getByTestId("workspace-tab-working_diff"),
|
||||
});
|
||||
}
|
||||
|
||||
function mainTabRow(page: Page): Locator {
|
||||
return visible(page, "workspace-tabs-row");
|
||||
return visible(page, "workspace-tabs-row").filter({
|
||||
hasNot: page.getByTestId("workspace-tab-working_diff"),
|
||||
});
|
||||
}
|
||||
|
||||
async function selectExplorerChanges(page: Page): Promise<void> {
|
||||
await openChangesTreePanel(page);
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
async function focusExplorerPane(page: Page): Promise<void> {
|
||||
await openChangesPanel(page);
|
||||
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
||||
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
||||
@@ -113,8 +115,8 @@ test.describe("explorer pane tab placement", () => {
|
||||
await expect(draftTabChip(page).first()).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step("select Changes in Explorer", async () => {
|
||||
await selectExplorerChanges(page);
|
||||
await test.step("focus the explorer pane", async () => {
|
||||
await focusExplorerPane(page);
|
||||
});
|
||||
|
||||
await test.step("an agent appearing now opens in the main pane, not the explorer pane", async () => {
|
||||
@@ -138,8 +140,11 @@ test.describe("explorer pane tab placement", () => {
|
||||
await expect(
|
||||
explorerTabRow(page).first().getByTestId(`workspace-tab-agent_${agentId}`),
|
||||
).toHaveCount(0);
|
||||
// The background open must not change the selected Explorer view.
|
||||
await expect(visible(page, "changes-tree-panel").first()).toBeVisible();
|
||||
// The background open must not steal the explorer pane's focus.
|
||||
await expect(visible(page, "workspace-tab-working_diff").first()).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
await test.step("close the draft; the agent is the main pane's only tab", async () => {
|
||||
@@ -165,9 +170,8 @@ test.describe("explorer pane tab placement", () => {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
// The split must have taken: agent pane + New main. Explorer keeps its own rail.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(2, { timeout: 10_000 });
|
||||
await expect(explorerTabRow(page)).toHaveCount(1);
|
||||
// The split must have taken: agent pane + New main + explorer = 3 tab rows.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
ensureExplorerSidebar,
|
||||
openFilesPanel,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
|
||||
test.describe("Explorer sidebar", () => {
|
||||
test("starts with Files and Changes, switches views, and toggles without changing main", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-sidebar-defaults-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const mainTabsBefore = await page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.locator('[data-testid^="workspace-tab-"]')
|
||||
.count();
|
||||
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(explorer.getByTestId("explorer-sidebar-tab-changes_tree")).toBeVisible();
|
||||
await expect(explorer.getByTestId("workspace-new-tab-button")).toHaveCount(0);
|
||||
|
||||
await openFilesPanel(page);
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
|
||||
await explorer.getByTestId("explorer-sidebar-tab-changes_tree").click();
|
||||
await expect(explorer.getByTestId("changes-tree-panel")).toBeVisible();
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("workspace-pane-main").locator('[data-testid^="workspace-tab-"]'),
|
||||
).toHaveCount(mainTabsBefore);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,14 @@ function readPersistedPanelState(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => localStorage.getItem("panel-state"));
|
||||
}
|
||||
|
||||
function explorerSidebar(page: Page): Locator {
|
||||
return visible(page, "workspace-explorer-sidebar");
|
||||
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
|
||||
function dockedSidebar(page: Page): Locator {
|
||||
return visible(page, "explorer-header");
|
||||
}
|
||||
|
||||
/** The desktop companion surface is a hideable side panel. */
|
||||
function sidePanel(page: Page): Locator {
|
||||
return visible(page, "workspace-side-panel");
|
||||
}
|
||||
|
||||
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
||||
@@ -59,18 +65,18 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await test.step("the retired flag does not reveal Explorer", async () => {
|
||||
await expect(explorerSidebar(page)).toHaveCount(0);
|
||||
await test.step("no docked sidebar, on load or after toggling", async () => {
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(
|
||||
explorerSidebar(page).getByTestId("explorer-sidebar-tab-changes_tree"),
|
||||
).toBeVisible();
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the retired keys are migrated out of storage", async () => {
|
||||
@@ -101,22 +107,22 @@ test.describe("explorer surface after upgrading from the docked sidebar", () =>
|
||||
|
||||
await test.step("open the explorer so both sides are showing", async () => {
|
||||
await toggle.click();
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
await expect(agentList).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("the shortcut collapses both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(explorerSidebar(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(agentList).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the shortcut restores both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(explorerSidebar(page).getByTestId("explorer-sidebar-tab-files")).toBeVisible();
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-new-tab-panel")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
const RED_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
@@ -220,12 +218,6 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
});
|
||||
|
||||
test("clicking the editor focuses its pane beside an agent", async ({ page }) => {
|
||||
await page.addInitScript((settingsKey) => {
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({ openInSidePane: { explorerFiles: true } }),
|
||||
);
|
||||
}, APP_SETTINGS_KEY);
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink({
|
||||
target,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// The file panel's tree rail had no way to close: the changes panel's toolbar
|
||||
// carries a folder-tree toggle, the file toolbar carried nothing, and the rail
|
||||
// was permanent on desktop.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
async function effectiveBackground(locator: import("@playwright/test").Locator): Promise<string> {
|
||||
return locator.evaluate((element) => {
|
||||
let current: Element | null = element;
|
||||
while (current) {
|
||||
const color = getComputedStyle(current).backgroundColor;
|
||||
if (color !== "rgba(0, 0, 0, 0)" && color !== "transparent") return color;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return "transparent";
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "file-tree-rail-toggle-",
|
||||
repo: { files: [{ path: "docs/guide.md", content: "# Guide\n" }] },
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test.describe("File panel tree rail", () => {
|
||||
test("the Files panel starts with an empty content pane beside the tree", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const rail = page.getByTestId("files-tree-rail").filter({ visible: true });
|
||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
||||
const content = rail.getByTestId("files-tree-rail-content");
|
||||
const tree = rail.getByTestId("files-tree-rail-tree");
|
||||
await expect(content).toHaveText("Choose a file");
|
||||
await expect(tree).toContainText("docs");
|
||||
expect(await effectiveBackground(tree.getByTestId("file-explorer-tree-scroll"))).toBe(
|
||||
await effectiveBackground(content),
|
||||
);
|
||||
});
|
||||
|
||||
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
|
||||
const rail = page.getByTestId("file-tree-rail-tree").filter({ visible: true });
|
||||
const toggle = page.getByTestId("file-toggle-tree").filter({ visible: true }).first();
|
||||
const editorToolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
||||
const filesToolbar = page.getByTestId("files-pane-header").filter({ visible: true });
|
||||
const refresh = page.getByRole("button", { name: "Refresh files" }).filter({ visible: true });
|
||||
const hiddenFiles = page
|
||||
.getByRole("button", { name: "Hide hidden files" })
|
||||
.filter({ visible: true });
|
||||
const shoot = async (name: string) => {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
};
|
||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||
const [
|
||||
editorToolbarBox,
|
||||
filesToolbarBox,
|
||||
treeToggleBox,
|
||||
refreshBox,
|
||||
treeGlyphBox,
|
||||
refreshGlyphBox,
|
||||
] = await Promise.all([
|
||||
editorToolbar.boundingBox(),
|
||||
filesToolbar.boundingBox(),
|
||||
toggle.boundingBox(),
|
||||
refresh.boundingBox(),
|
||||
toggle.locator("svg").boundingBox(),
|
||||
refresh.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (
|
||||
!editorToolbarBox ||
|
||||
!filesToolbarBox ||
|
||||
!treeToggleBox ||
|
||||
!refreshBox ||
|
||||
!treeGlyphBox ||
|
||||
!refreshGlyphBox
|
||||
) {
|
||||
throw new Error("Pane-content toolbar geometry could not be measured");
|
||||
}
|
||||
expect(editorToolbarBox.height).toBe(36);
|
||||
expect(filesToolbarBox.height).toBe(36);
|
||||
expect(editorToolbarBox.y + editorToolbarBox.height).toBe(
|
||||
filesToolbarBox.y + filesToolbarBox.height,
|
||||
);
|
||||
expect(treeToggleBox.width).toBe(24);
|
||||
expect(treeToggleBox.height).toBe(24);
|
||||
expect(refreshBox.width).toBe(24);
|
||||
expect(refreshBox.height).toBe(24);
|
||||
expect(treeGlyphBox.width).toBe(14);
|
||||
expect(treeGlyphBox.height).toBe(14);
|
||||
expect(refreshGlyphBox.width).toBe(14);
|
||||
expect(refreshGlyphBox.height).toBe(14);
|
||||
|
||||
const restBackground = await refresh.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
const treeToggleSelectedBackground = await toggle.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await refresh.hover();
|
||||
const refreshHoverBackground = await refresh.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(refreshHoverBackground).not.toBe(restBackground);
|
||||
expect(refreshHoverBackground).toBe(treeToggleSelectedBackground);
|
||||
await hiddenFiles.click();
|
||||
const hiddenFilesSelected = page
|
||||
.getByRole("button", { name: "Show hidden files" })
|
||||
.filter({ visible: true });
|
||||
expect(
|
||||
await hiddenFilesSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(refreshHoverBackground);
|
||||
await shoot("tree-open");
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(rail).toHaveCount(0);
|
||||
await shoot("tree-closed");
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(rail).toBeVisible();
|
||||
await shoot("tree-reopened");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectExplorerNodeRetained,
|
||||
hideExplorerSplit,
|
||||
measureExplorerSplit,
|
||||
openExplorerSplit,
|
||||
resizeExplorerSplit,
|
||||
stampExplorerNode,
|
||||
} from "../support/helpers/split-pane";
|
||||
import {
|
||||
createAgentTabFromMenu,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Hidden split pane retention", () => {
|
||||
test("releases all layout space without unmounting and restores its dragged width", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "hidden-split-pane-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await createAgentTabFromMenu(page);
|
||||
await openExplorerSplit(page);
|
||||
|
||||
// Drag to a non-default width first: restoring the default would prove nothing.
|
||||
await resizeExplorerSplit(page, 137);
|
||||
await stampExplorerNode(page);
|
||||
const open = await measureExplorerSplit(page);
|
||||
expect(open.explorerWidth).toBeGreaterThan(250);
|
||||
expect(open.separatorCount).toBe(1);
|
||||
|
||||
await hideExplorerSplit(page);
|
||||
const hidden = await measureExplorerSplit(page);
|
||||
expect(hidden.explorerWidth).toBe(0);
|
||||
// The sibling must absorb the whole row, not just collapse the hidden pane.
|
||||
expect(hidden.visibleWidth).toBeCloseTo(hidden.containerWidth, 5);
|
||||
expect(hidden.occupiedWidth).toBeCloseTo(hidden.containerWidth, 5);
|
||||
expect(hidden.separatorCount).toBe(0);
|
||||
// Same DOM node, so a teardown-and-rebuild cannot pass as retention.
|
||||
await expectExplorerNodeRetained(page);
|
||||
|
||||
await openExplorerSplit(page);
|
||||
await expectExplorerNodeRetained(page);
|
||||
const reopened = await measureExplorerSplit(page);
|
||||
expect(reopened.explorerWidth).toBeCloseTo(open.explorerWidth, 5);
|
||||
expect(reopened.separatorCount).toBe(1);
|
||||
|
||||
await testInfo.attach("split-pane-geometry", {
|
||||
body: JSON.stringify({ open, hidden, reopened }, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
countTabsOfKind,
|
||||
waitForTabBar,
|
||||
pressNewTabShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
@@ -178,11 +177,13 @@ test.describe("Tab creation", () => {
|
||||
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||
const agent = panel.getByRole("button", { name: /^Agent/ });
|
||||
const terminal = panel.getByRole("button", { name: /^Terminal/ });
|
||||
const diff = panel.getByRole("button", { name: /Diff/ });
|
||||
const changes = panel.getByRole("button", { name: /Changes/ });
|
||||
const files = panel.getByRole("button", { name: /Files/ });
|
||||
const shortcutPrefix = process.platform === "darwin" ? /⇧⌘/ : /Ctrl.*Shift/;
|
||||
await expect(agent).toContainText(new RegExp(`${shortcutPrefix.source}.*A`));
|
||||
await expect(terminal).toContainText(new RegExp(`${shortcutPrefix.source}.*T`));
|
||||
await expect(diff).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||
await expect(changes).toContainText(new RegExp(`${shortcutPrefix.source}.*G`));
|
||||
await expect(files).toContainText(new RegExp(`${shortcutPrefix.source}.*E`));
|
||||
await expect(agent).toBeFocused();
|
||||
|
||||
await page.keyboard.press("ArrowDown");
|
||||
@@ -190,12 +191,6 @@ test.describe("Tab creation", () => {
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(agent).toBeFocused();
|
||||
|
||||
await pressDirectNewTabShortcut(page, "e");
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-explorer-tree-scroll").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator("body").click({ position: { x: 1, y: 1 } });
|
||||
await panel.click({ position: { x: 20, y: 20 } });
|
||||
await expect(agent).toBeFocused();
|
||||
|
||||
@@ -64,7 +64,6 @@ test.describe("New workspace: launch target memory", () => {
|
||||
await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => {
|
||||
await openNewWorkspace();
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "remember me");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
@@ -89,7 +88,6 @@ test.describe("New workspace: launch target memory", () => {
|
||||
|
||||
await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => {
|
||||
await selectLaunchOption(page, PROMPT_PROFILE.id);
|
||||
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
|
||||
await fillTerminalPrompt(page, "second run");
|
||||
await submitTerminalLaunch(page);
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
|
||||
@@ -102,11 +102,11 @@ test.describe("PR pane", () => {
|
||||
await expectPrPaneTitle(page, "Work in progress");
|
||||
});
|
||||
|
||||
test("renders check pills with correct passed/failed/pending counts", async ({ page }) => {
|
||||
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
|
||||
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
|
||||
});
|
||||
|
||||
test("renders activity rows with correct count", async ({ page }) => {
|
||||
@@ -120,7 +120,7 @@ test.describe("PR pane", () => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
|
||||
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
|
||||
await expectPrPaneTitle(page, "PR with no checks");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
beginReplicaCacheObservation,
|
||||
finishReplicaCacheObservation,
|
||||
installReplicaCacheMeasurement,
|
||||
measureReplicaCacheRestore,
|
||||
type ReplicaCacheMeasurementReport,
|
||||
} from "../support/helpers/replica-cache-measurement";
|
||||
|
||||
const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1";
|
||||
const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL;
|
||||
const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT;
|
||||
const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000);
|
||||
const HYDRATED_SELECTOR =
|
||||
process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ??
|
||||
'[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]';
|
||||
|
||||
const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip;
|
||||
|
||||
measurementDescribe("Replica cache live measurement", () => {
|
||||
test("observes persistence and cached restore without mutating the daemon", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(OBSERVATION_MS + 90_000);
|
||||
expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy();
|
||||
expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy();
|
||||
|
||||
await installReplicaCacheMeasurement(page);
|
||||
await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
|
||||
const observation = await beginReplicaCacheObservation(page);
|
||||
await page.waitForTimeout(OBSERVATION_MS);
|
||||
const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!);
|
||||
const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR);
|
||||
const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs };
|
||||
|
||||
await mkdir(path.dirname(REPORT_PATH!), { recursive: true });
|
||||
await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await testInfo.attach("replica-cache-measurement", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`);
|
||||
|
||||
expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
|
||||
const MAX_STREAM_WRITES = 12;
|
||||
const MAX_SERIALIZED_CHARS = 190_000;
|
||||
const MAX_SERIALIZED_CHARS = 45_000;
|
||||
|
||||
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
|
||||
replicaCacheAgent: async ({ page: _page }, provide) => {
|
||||
|
||||
@@ -153,7 +153,6 @@ test.describe("Rewind sheet", () => {
|
||||
repoPrefix: "rewind-e2e-",
|
||||
title: "Rewind e2e",
|
||||
initialPrompt: firstPrompt,
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -70,55 +70,73 @@ test.describe("History search", () => {
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("searches, ranks, highlights, and clears session history", async ({ page }) => {
|
||||
test("typing narrows history to matching sessions and clearing restores them", async ({
|
||||
page,
|
||||
}) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await test.step("narrows history and restores chronological grouping", async () => {
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Seeded newest-first, and at rest history is chronological.
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await search(page, `${NONCE} billing`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
// Ranked results are one flat list — a day heading would claim an order
|
||||
// the list no longer has.
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await test.step("ranks whole-word matches above substrings", async () => {
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
await page.getByTestId("sessions-search-clear").click();
|
||||
await expect(page.getByTestId("sessions-search-input")).toHaveValue("");
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
await expect(page.getByText("Today", { exact: true })).toHaveCount(1, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step("highlights exact and typo-resolved matches", async () => {
|
||||
await search(page, `${NONCE} billing`);
|
||||
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
test("ranks a whole-word hit above one buried inside a word", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
// "bill" starts a word in "billing" and hides inside "unbilled", so the
|
||||
// stronger match leads even though both sessions are equally recent.
|
||||
await search(page, `${NONCE} bill`);
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled]);
|
||||
});
|
||||
|
||||
await test.step("distinguishes no matches from empty history", async () => {
|
||||
await search(page, `${NONCE} kubernetes`);
|
||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||
test("marks the characters each result matched on", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await page.getByText("Clear search").click();
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
});
|
||||
// The mark is a nested Text run, so the matched slice is its own element.
|
||||
await search(page, `${NONCE} billing`);
|
||||
const row = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// A typo has no characters in the text to point at, so the whole word it
|
||||
// resolved to is marked.
|
||||
await search(page, `${NONCE} bulling`);
|
||||
const typoRow = page.locator(AGENT_ROW).filter({ hasText: NONCE }).first();
|
||||
await expect(typoRow.getByText("billing", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("finds a session through a typo", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} bulling`);
|
||||
await expectVisibleTitles(page, [TITLES.billing]);
|
||||
});
|
||||
|
||||
test("says the query found nothing, not that history is empty", async ({ page }) => {
|
||||
await resetSeededPageState(page);
|
||||
await openSessions(page);
|
||||
|
||||
await search(page, `${NONCE} kubernetes`);
|
||||
await expect(page.getByTestId("sessions-empty")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("No sessions match")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByText("Clear search").click();
|
||||
await expectVisibleTitles(page, [TITLES.billing, TITLES.unbilled, TITLES.terminal]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,41 +121,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("pressing Escape closes settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await test.step("a dropdown owns Escape", async () => {
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel(/Theme:/).click();
|
||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByRole("menuitem", { name: "System", exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
await test.step("a modal owns Escape", async () => {
|
||||
await openAddHostFlow(page);
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
function sidePanel(page: Page): Locator {
|
||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
function mainPane(page: Page): Locator {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function newTabPanel(scope: Locator): Locator {
|
||||
return scope.getByTestId("workspace-new-tab-panel");
|
||||
}
|
||||
|
||||
async function launcherButtonLabels(launcher: Locator): Promise<(string | null)[]> {
|
||||
return launcher
|
||||
.getByRole("button")
|
||||
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")));
|
||||
}
|
||||
|
||||
function sidePanelToggle(page: Page): Locator {
|
||||
return page.getByTestId("workspace-explorer-toggle").first();
|
||||
}
|
||||
|
||||
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
|
||||
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
|
||||
const paneBox = await mainPane(page).boundingBox();
|
||||
const chipBox = await chip.boundingBox();
|
||||
expect(paneBox).not.toBeNull();
|
||||
expect(chipBox).not.toBeNull();
|
||||
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
|
||||
|
||||
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
|
||||
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Side panel", () => {
|
||||
test("reveals empty, launches a view, and hides back out of the way", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(sidePanel(page)).toHaveCount(0);
|
||||
|
||||
await test.step("revealing the Side panel seeds nothing", async () => {
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
||||
const launcher = newTabPanel(sidePanel(page));
|
||||
await expect(launcher).toBeVisible();
|
||||
expect((await launcherButtonLabels(launcher)).slice(0, 4)).toEqual([
|
||||
"Changes",
|
||||
"Files",
|
||||
"Terminal",
|
||||
"Agent",
|
||||
]);
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
||||
});
|
||||
|
||||
await test.step("Changes opens in the Side panel that offered it", async () => {
|
||||
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "02-changes-opens-in-side-panel");
|
||||
});
|
||||
|
||||
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
|
||||
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
|
||||
await changesTab.hover();
|
||||
await page
|
||||
.locator('[data-testid^="workspace-working-diff-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
|
||||
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(newTabPanel(sidePanel(page))).toBeVisible();
|
||||
await capture(page, testInfo, "04-side-panel-revealed-empty");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureSidePanel(page);
|
||||
|
||||
// Dragging the pane's only tab into its own left edge splits that tab into a
|
||||
// new pane and leaves the one it came from empty.
|
||||
const draftTab = page
|
||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(draftTab).toBeVisible({ timeout: 30_000 });
|
||||
await splitTabOutOfMainPane(page, draftTab);
|
||||
const draftPane = page
|
||||
.locator('[data-testid^="workspace-pane-pane_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(draftPane).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const emptyMain = newTabPanel(mainPane(page));
|
||||
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
|
||||
await capture(page, testInfo, "06-empty-main-pane-launcher");
|
||||
|
||||
const newTabChip = mainPane(page)
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
await newTabChip.hover();
|
||||
await mainPane(page)
|
||||
.locator('[data-testid^="workspace-new-tab-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
|
||||
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
|
||||
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(sidePanel(page)).toBeVisible();
|
||||
await expect(draftPane.getByTestId("workspace-new-tab-panel")).toHaveCount(0);
|
||||
await capture(page, testInfo, "07-empty-main-pane-removed");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("closing New cannot remove the last visible pane", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
// Park a tab in the Side panel, then put the panel away. The workspace still
|
||||
// holds that tab, so emptying Main does not look like an empty workspace and
|
||||
// nothing reseeds a draft into it.
|
||||
await ensureSidePanel(page);
|
||||
await newTabPanel(sidePanel(page))
|
||||
.getByRole("button", { name: "Files", exact: true })
|
||||
.click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
const draftTab = page
|
||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await draftTab.hover();
|
||||
await page
|
||||
.locator('[data-testid^="workspace-draft-close-"]')
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Main now contains New and is the only pane the user can see. Closing its
|
||||
// tab cannot remove the final visible pane.
|
||||
const lastPane = newTabPanel(mainPane(page));
|
||||
await expect(lastPane).toBeVisible({ timeout: 15_000 });
|
||||
await expect(lastPane.getByRole("button", { name: "Files", exact: true })).toBeVisible();
|
||||
const finalNewTab = mainPane(page)
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
await finalNewTab.hover();
|
||||
await mainPane(page)
|
||||
.locator('[data-testid^="workspace-new-tab-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(lastPane).toBeVisible();
|
||||
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureSidePanel(page);
|
||||
await newTabPanel(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "08-changes-in-side-panel");
|
||||
|
||||
await mainPane(page).getByTestId("workspace-new-tab-button").click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-changes")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
|
||||
await capture(page, testInfo, "09-independent-changes-in-both-panes");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -41,37 +41,46 @@ async function expectExternalPage(
|
||||
await popup.close();
|
||||
}
|
||||
|
||||
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
|
||||
test("opens troubleshooting tools from the sidebar help menu", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||
|
||||
await test.step("opens diagnostics and keyboard shortcuts", async () => {
|
||||
await openHelpMenu(page);
|
||||
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
|
||||
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
await closeSheet(page, "app-diagnostic-sheet");
|
||||
|
||||
await openHelpMenu(page);
|
||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
|
||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||
await openHelpMenu(page);
|
||||
const triggerBox = await page.getByTestId("sidebar-help").evaluate((element) => {
|
||||
const { y, height } = element.getBoundingClientRect();
|
||||
return { y, height };
|
||||
});
|
||||
|
||||
await test.step("opens support and release pages", async () => {
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
const menuBox = await page.getByTestId("sidebar-help-menu").evaluate((element) => {
|
||||
const { y, height } = element.getBoundingClientRect();
|
||||
return { y, height };
|
||||
});
|
||||
expect(menuBox.y + menuBox.height).toBeLessThanOrEqual(triggerBox.y);
|
||||
await expect(page.getByText("Help", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Report an issue", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("What's new", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-help-version")).toHaveText(APP_VERSION);
|
||||
|
||||
await page.getByTestId("sidebar-help-diagnostics").click();
|
||||
await expectDiagnosticReport(page);
|
||||
await closeSheet(page, "app-diagnostic-sheet");
|
||||
|
||||
await openHelpMenu(page);
|
||||
await page.getByTestId("sidebar-help-shortcuts").click();
|
||||
await expect(page.getByTestId("keyboard-shortcuts-dialog")).toBeVisible();
|
||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||
});
|
||||
|
||||
test("opens support and release destinations", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
|
||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
|
||||
test.use({ viewport: { width: 1600, height: 900 } });
|
||||
|
||||
async function expectBorderHighlight(
|
||||
page: Page,
|
||||
testID: string,
|
||||
hoverTarget: Locator,
|
||||
expectedWidth: string,
|
||||
) {
|
||||
const handle = page.getByTestId(testID);
|
||||
await expect(handle).toBeVisible();
|
||||
await expect(page.getByTestId(`${testID}-highlight`)).toHaveCount(0);
|
||||
|
||||
await hoverTarget.hover();
|
||||
|
||||
const highlight = page.getByTestId(`${testID}-highlight`);
|
||||
await expect(highlight).toBeVisible();
|
||||
await expect(highlight).toHaveCSS("width", expectedWidth);
|
||||
await expect(highlight).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
|
||||
test("sidebar and workspace pane borders highlight on hover", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "sidebar-resize-handle-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
const sidebarHandle = page.getByTestId("left-sidebar-resize-handle");
|
||||
await expectBorderHighlight(page, "left-sidebar-resize-handle", sidebarHandle, "1px");
|
||||
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const splitHandle = page.getByTestId("workspace-split-resize-handle");
|
||||
await expectBorderHighlight(
|
||||
page,
|
||||
"workspace-split-resize-handle",
|
||||
splitHandle.getByRole("separator"),
|
||||
"3px",
|
||||
);
|
||||
});
|
||||
@@ -146,37 +146,20 @@ async function installPinRpcGate(
|
||||
}
|
||||
|
||||
test.describe("Pin workspace shortcut", () => {
|
||||
test("dispatches pin once and keeps project creation reachable", async ({ page }) => {
|
||||
test("keeps project creation reachable after pinning its only workspace", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "pin-project-creation-",
|
||||
title: "Pinned workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
const gate = await installPinRpcGate(page);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await test.step("sends one RPC for each pin transition", async () => {
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(1);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(2);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("keeps the pinned project available to workspace creation", async () => {
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(3);
|
||||
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||
});
|
||||
await openNewWorkspaceComposer(page, workspace);
|
||||
await expectNewWorkspaceProjectSelected(page, workspace.projectDisplayName);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -224,6 +207,34 @@ test.describe("Pin workspace shortcut", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("sends exactly one pin RPC per press when the row is rendered and selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-expanded-" });
|
||||
|
||||
try {
|
||||
const gate = await installPinRpcGate(page);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toBeVisible({ timeout: 10_000 });
|
||||
// Counting frames catches a press that produces zero or two RPCs — a misfiring in-flight
|
||||
// guard, or a second dispatch path. It cannot detect a duplicate handler registration:
|
||||
// `keyboardActionDispatcher.dispatch` returns at the first handler that returns true, so a
|
||||
// shadowed second handler is unobservable from outside by design.
|
||||
expect(gate.sentCount()).toBe(1);
|
||||
|
||||
await page.keyboard.press(PIN_SHORTCUT);
|
||||
await expect(pinnedSection(page)).toHaveCount(0, { timeout: 10_000 });
|
||||
expect(gate.sentCount()).toBe(2);
|
||||
await expect(workspaceRow(page, workspace.workspaceId)).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pins the active workspace while its status group is collapsed", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "pin-shortcut-status-" });
|
||||
|
||||
|
||||
@@ -118,6 +118,19 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("project shows workspace under it", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
|
||||
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-git project shows directory name", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
||||
|
||||
@@ -306,7 +319,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(closedIcon).toBeVisible();
|
||||
const closedBounds = await closedIcon.boundingBox();
|
||||
expect(closedBounds).not.toBeNull();
|
||||
expect(closedBounds?.x).toBeCloseTo(9, 0);
|
||||
expect(closedBounds?.x).toBeCloseTo(12, 0);
|
||||
expect(closedBounds?.y).toBe(openBounds?.y);
|
||||
});
|
||||
|
||||
@@ -329,19 +342,16 @@ test.describe("Half-screen desktop layout", () => {
|
||||
|
||||
await openFilesPanel(page);
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("explorer-sidebar-tab-rail")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
|
||||
await explorerToggle.click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-sidebar-tab-files").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open Explorer sidebar");
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -11,37 +11,35 @@ test.describe("Workspace lifecycle", () => {
|
||||
// One retry is enough for the daemon to stabilize.
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("main checkout creates an agent chat and terminal in the project root", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await test.step("creates an agent chat", async () => {
|
||||
test.describe("Main checkout", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-chat-" });
|
||||
await workspace.navigateTo();
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
await test.step("creates a terminal in the project root", async () => {
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
});
|
||||
|
||||
test("worktree creates an agent chat and terminal in its directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await test.step("creates an agent chat", async () => {
|
||||
test.describe("Worktree workspace", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-chat-" });
|
||||
await workspace.navigateTo();
|
||||
await createAgentChatFromLauncher(page);
|
||||
});
|
||||
|
||||
await test.step("creates a terminal in the worktree", async () => {
|
||||
test("creates a terminal with correct CWD", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "lifecycle-wt-shell-" });
|
||||
await workspace.navigateTo();
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, workspace.repoPath);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
submitNewWorkspaceEmpty,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { selectSidebarStatusGrouping } from "../support/helpers/sidebar";
|
||||
import { killProcessTree, spawnTsx } from "../support/helpers/spawn-node";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs";
|
||||
|
||||
@@ -195,11 +195,9 @@ async function getAvailablePort(): Promise<number> {
|
||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
let lastConnectionError: unknown = null;
|
||||
while (Date.now() - startedAt < 90_000) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Restart test daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).`,
|
||||
);
|
||||
while (Date.now() - startedAt < 20_000) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -228,6 +226,21 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
try {
|
||||
await once(child, "exit");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function startRestartDaemon(input: {
|
||||
paseoHome: string;
|
||||
origin: string;
|
||||
@@ -238,7 +251,8 @@ async function startRestartDaemon(input: {
|
||||
}
|
||||
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const child = spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], {
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: withDisabledE2ESpeechEnv({
|
||||
...process.env,
|
||||
@@ -262,7 +276,7 @@ async function startRestartDaemon(input: {
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
} catch (error) {
|
||||
await killProcessTree(child);
|
||||
await stopProcess(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
@@ -271,7 +285,7 @@ async function startRestartDaemon(input: {
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () => killProcessTree(child),
|
||||
close: () => stopProcess(child),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -356,6 +370,22 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowHasOnlyIndicator(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; indicator: string },
|
||||
) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
|
||||
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
|
||||
if (indicator === input.indicator) {
|
||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
||||
} else {
|
||||
await expect(locator).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowDoesNotShowIndicator(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; indicator: string },
|
||||
@@ -428,6 +458,11 @@ test.describe("Workspace model restart regressions", () => {
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
||||
await waitForSidebarHydration(page);
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceA,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
@@ -35,22 +35,10 @@ async function expectSameRenderedNode(
|
||||
);
|
||||
}
|
||||
|
||||
async function expectNodeConnected(node: Awaited<ReturnType<typeof captureRenderedNode>>) {
|
||||
expect(await node.evaluate((candidate) => candidate.isConnected)).toBe(true);
|
||||
}
|
||||
|
||||
async function getSettingsShortcut(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForWorkspaceRoute(page: Page, route: string) {
|
||||
await page.waitForURL((url) => url.pathname === route);
|
||||
}
|
||||
|
||||
test.describe("Workspace pane mounting", () => {
|
||||
test("workspace navigation keeps the existing agent composer mounted", async ({ page }) => {
|
||||
test("opening the first split pane keeps the existing agent composer mounted", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -77,35 +65,55 @@ test.describe("Workspace pane mounting", () => {
|
||||
.first()
|
||||
.elementHandle();
|
||||
expect(originalComposer).not.toBeNull();
|
||||
await test.step("opening the first split pane preserves the composer", async () => {
|
||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-new-tab-panel").count();
|
||||
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||
emptyPanesBeforeSplit + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expectNodeConnected(originalComposer!);
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(page.getByTestId("workspace-new-tab-panel")).toHaveCount(
|
||||
emptyPanesBeforeSplit + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
|
||||
|
||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
||||
expect(originalStillConnected).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening Settings hides workspace overlays and keeps the agent composer mounted", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "settings-pane-retention-" });
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-pane-retention-${Date.now()}`,
|
||||
});
|
||||
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
await test.step("desktop Settings closes overlays and preserves the composer", async () => {
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||
await tab.click({ button: "right" });
|
||||
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
|
||||
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
|
||||
await expect(renameInput).toBeVisible();
|
||||
const originalComposer = await captureRenderedNode(composer);
|
||||
|
||||
const settingsShortcut = await getSettingsShortcut(page);
|
||||
await page.keyboard.press(settingsShortcut);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(renameInput).not.toBeVisible();
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalComposer!, composer);
|
||||
});
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||
await tab.click({ button: "right" });
|
||||
await page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`).click();
|
||||
const renameInput = renameModalInput(page, `workspace-tab-rename-modal-agent-${agent.id}`);
|
||||
await expect(renameInput).toBeVisible();
|
||||
|
||||
const settingsShortcut = await page.evaluate(() =>
|
||||
navigator.platform.toLowerCase().includes("mac") ? "Meta+," : "Control+,",
|
||||
);
|
||||
await page.keyboard.press(settingsShortcut);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(renameInput).not.toBeVisible();
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalComposer, composer);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -132,17 +140,18 @@ test.describe("Workspace pane mounting", () => {
|
||||
const composer = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
const originalComposer = await captureRenderedNode(composer);
|
||||
|
||||
const workspaceRoute = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
|
||||
await openCompactSettings(page, workspaceRoute);
|
||||
await openCompactSettings(page, buildHostWorkspaceRoute(serverId, workspace.workspaceId));
|
||||
await page.goBack();
|
||||
await waitForWorkspaceRoute(page, workspaceRoute);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === buildHostWorkspaceRoute(serverId, workspace.workspaceId),
|
||||
);
|
||||
await expectSameRenderedNode(originalComposer, composer);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace navigation keeps the terminal emulator mounted", async ({ page }) => {
|
||||
test("switching away from a terminal tab keeps its emulator mounted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-pane-retention-" });
|
||||
@@ -161,20 +170,39 @@ test.describe("Workspace pane mounting", () => {
|
||||
const terminalSurface = terminalSurfaceLocator(page);
|
||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
||||
|
||||
await test.step("switching tabs preserves the terminal", async () => {
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
||||
await expectComposerVisible(page);
|
||||
await expectNodeConnected(originalTerminal);
|
||||
await page.getByTestId(`workspace-tab-agent_${agent.id}`).click();
|
||||
await expectComposerVisible(page);
|
||||
expect(await originalTerminal.evaluate((node) => node.isConnected)).toBe(true);
|
||||
|
||||
await clickFirstTerminalTab(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
await clickFirstTerminalTab(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opening Settings and returning keeps the terminal emulator mounted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const serverId = getServerId();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "settings-terminal-retention-" });
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: `settings-terminal-retention-${Date.now()}`,
|
||||
});
|
||||
|
||||
await test.step("opening Settings preserves the terminal", async () => {
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
});
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewTerminal(page);
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
const terminalSurface = terminalSurfaceLocator(page);
|
||||
const originalTerminal = await captureRenderedNode(terminalSurface);
|
||||
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expectSameRenderedNode(originalTerminal, terminalSurface);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { daemonTest, test, expect } from "../support/fixtures";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
closeSetupTab,
|
||||
@@ -117,7 +117,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
test("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-success-", {
|
||||
paseoConfig: {
|
||||
@@ -204,7 +204,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
@@ -283,7 +283,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("launches workspace scripts through an explicit daemon request", async () => {
|
||||
test("launches workspace scripts through an explicit daemon request", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-scripts-", {
|
||||
paseoConfig: {
|
||||
|
||||
@@ -39,6 +39,28 @@ test.describe("Workspace archive with worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `archive-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
|
||||
@@ -32,8 +32,16 @@ const metroTest = base.extend({
|
||||
},
|
||||
});
|
||||
|
||||
const daemonTest = metroTest.extend<
|
||||
{ projectOwnership: void },
|
||||
const test = metroTest.extend<
|
||||
{
|
||||
paseoE2ESetup: void;
|
||||
projectOwnership: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
},
|
||||
{ e2eForkProviders: string[]; e2eWorker: void; e2eWorkerClient: SeedDaemonClient }
|
||||
>({
|
||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||
@@ -86,16 +94,6 @@ const daemonTest = metroTest.extend<
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
});
|
||||
|
||||
const test = daemonTest.extend<{
|
||||
paseoE2ESetup: void;
|
||||
outdatedDaemon: OutdatedDaemon;
|
||||
desktopManagedOutdatedDaemon: OutdatedDaemon;
|
||||
relayConfigOutdatedDaemon: OutdatedDaemon;
|
||||
projectPickerFixture: TrackedProjectPickerFixture;
|
||||
withWorkspace: WithWorkspace;
|
||||
}>({
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
const daemonPort = getE2EDaemonPort();
|
||||
@@ -229,4 +227,4 @@ const test = daemonTest.extend<{
|
||||
},
|
||||
});
|
||||
|
||||
export { daemonTest, test, metroTest, expect, type Page };
|
||||
export { test, metroTest, expect, type Page };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { killProcessTree } from "./helpers/spawn-node";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
@@ -148,11 +148,28 @@ export async function warmMetro(port: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
once(child, "exit"),
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 5_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
||||
const appDir = path.resolve(__dirname, "../..");
|
||||
const expoCli = require.resolve("expo/bin/cli");
|
||||
// Spawns Node directly to bypass Windows .cmd shim execution restrictions without shell: true.
|
||||
const child = spawn(process.execPath, [expoCli, "start", "--web", "--port", String(port)], {
|
||||
const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -179,6 +196,12 @@ async function loadHarnessEnvironment(repoRoot: string): Promise<void> {
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
if (process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1") {
|
||||
if (!process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL) {
|
||||
throw new Error("PASEO_REPLICA_CACHE_MEASUREMENT_URL must be set for live measurement");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const repoRoot = path.resolve(__dirname, "../../../..");
|
||||
await loadHarnessEnvironment(repoRoot);
|
||||
|
||||
@@ -199,11 +222,11 @@ export default async function globalSetup() {
|
||||
console.log(`[e2e] Metro warmed on port ${metroPort}`);
|
||||
|
||||
return async () => {
|
||||
await killProcessTree(metroProcess);
|
||||
await stopProcess(metroProcess);
|
||||
console.log("[e2e] Metro stopped");
|
||||
};
|
||||
} catch (error) {
|
||||
await killProcessTree(metroProcess);
|
||||
await stopProcess(metroProcess);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ export interface DaemonHydrationGate {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface RewindCompletionGate {
|
||||
clearTimelineStreamCount(): void;
|
||||
release(): void;
|
||||
timelineStreamCount(): number;
|
||||
waitForDelayedResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PromptJumpRequestTracker {
|
||||
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
|
||||
}
|
||||
@@ -255,6 +262,62 @@ export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGa
|
||||
};
|
||||
}
|
||||
|
||||
export async function holdRewindCompletion(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<RewindCompletionGate> {
|
||||
let released = false;
|
||||
let timelineStreamCount = 0;
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let resolveDelayedResponse: (() => void) | null = null;
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayedResponse = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
const streamEvent = payload?.event;
|
||||
if (
|
||||
sessionMessage?.type === "agent_stream" &&
|
||||
payload?.agentId === agentId &&
|
||||
streamEvent &&
|
||||
typeof streamEvent === "object" &&
|
||||
(streamEvent as { type?: unknown }).type === "timeline"
|
||||
) {
|
||||
timelineStreamCount += 1;
|
||||
}
|
||||
if (
|
||||
!released &&
|
||||
sessionMessage?.type === "agent.rewind.response" &&
|
||||
payload?.agentId === agentId
|
||||
) {
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
resolveDelayedResponse?.();
|
||||
return;
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
clearTimelineStreamCount() {
|
||||
timelineStreamCount = 0;
|
||||
},
|
||||
release() {
|
||||
released = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
timelineStreamCount: () => timelineStreamCount,
|
||||
waitForDelayedResponse: () => delayedResponse,
|
||||
};
|
||||
}
|
||||
|
||||
export async function delayCreatedAgentInitialTailResponse(
|
||||
page: Page,
|
||||
): Promise<CreatedAgentTimelineGate> {
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { openChangesTreePanel } from "./workspace-tabs";
|
||||
import { openChangesPanel as openWorkspaceChangesPanel } from "./workspace-tabs";
|
||||
|
||||
// The branch switcher lives in the Changes tab, not in the workspace header. It renders as a button whose
|
||||
// accessible name carries the current branch ("Current branch: <name>. Press to
|
||||
// switch branch."). Matching on the accessible name keeps these helpers tied to
|
||||
// what a screen reader user hears, and it proves the panel resolved a real
|
||||
// checkout directory from the opaque workspace id. Scoping to the repository
|
||||
// header keeps the matcher aligned with the row that owns repository identity.
|
||||
// checkout directory from the opaque workspace id. Scoping to the changes header
|
||||
// keeps the matcher unambiguous even when the header is shared with diff actions.
|
||||
function branchSwitcherTrigger(page: Page, branchName: string) {
|
||||
return page
|
||||
.getByTestId("changes-repository-header")
|
||||
.getByTestId("changes-header")
|
||||
.getByRole("button", { name: new RegExp(`Current branch: ${escapeRegex(branchName)}\\b`) })
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
}
|
||||
|
||||
// Opens the fixed Explorer Changes view, where the switcher lives.
|
||||
// Opens the fixed pane and lands on the Changes tab, where the switcher and diff live.
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
await openChangesTreePanel(page);
|
||||
await expect(
|
||||
page.getByTestId("changes-repository-header").filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await openWorkspaceChangesPanel(page);
|
||||
await expect(page.getByTestId("changes-header").filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceBranch(page: Page, branchName: string): Promise<void> {
|
||||
|
||||
@@ -140,15 +140,6 @@ export async function expectActiveChatOutlinePrompt(page: Page, position: number
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectActiveChatOutlinePromptMovedFrom(
|
||||
page: Page,
|
||||
position: number,
|
||||
): Promise<void> {
|
||||
const activePrompt = chatOutlineRail(page).getByRole("tab", { selected: true });
|
||||
await expect(activePrompt).toHaveCount(1);
|
||||
await expect(activePrompt).not.toHaveAccessibleName(new RegExp(`^${position} of `));
|
||||
}
|
||||
|
||||
export async function expectLiveTurnPromptAboveFoldAndActive(
|
||||
page: Page,
|
||||
prompt: string,
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
export interface CommandCenterAgentControlChoice {
|
||||
query: string;
|
||||
choice: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses a seeded mock agent — which is what makes its model and mode contributions register —
|
||||
* opens the Command Center over it, and hands back the query input. Seeding stays in the test so
|
||||
* the workspace lifecycle is owned by the `try`/`finally` that cleans it up.
|
||||
*/
|
||||
export async function openCommandCenterForAgent(
|
||||
page: Page,
|
||||
workspace: Pick<MockAgentWorkspace, "workspaceId" | "agentId">,
|
||||
): Promise<Locator> {
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
const panel = await openCommandCenter(page);
|
||||
return panel.getByTestId("command-center-input");
|
||||
}
|
||||
|
||||
export async function chooseCommandCenterAgentControl(input: {
|
||||
page: Page;
|
||||
query: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import path from "node:path";
|
||||
import { killProcessTree } from "./spawn-node";
|
||||
|
||||
export interface OutdatedDaemon {
|
||||
endpoint: string;
|
||||
@@ -55,10 +55,14 @@ export async function startOutdatedDaemon(options?: {
|
||||
endpoint: ready.endpoint,
|
||||
label: options?.desktopManaged === true ? "outdated Desktop host" : "outdated host",
|
||||
serverId: ready.serverId,
|
||||
close: () => killProcessTree(child),
|
||||
async close() {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
await once(child, "exit");
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await killProcessTree(child);
|
||||
child.kill("SIGTERM");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execSync } from "node:child_process";
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -19,19 +19,11 @@ async function createFakeEditorBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||
let realGhPath = "";
|
||||
try {
|
||||
const locator = process.platform === "win32" ? "where.exe" : "which";
|
||||
const candidates = execFileSync(locator, ["gh"], { encoding: "utf8" })
|
||||
.split(/\r?\n/u)
|
||||
.map((candidate) => candidate.trim())
|
||||
.filter(Boolean);
|
||||
realGhPath =
|
||||
candidates.find(
|
||||
(candidate) =>
|
||||
process.platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(path.extname(candidate)),
|
||||
) ?? "";
|
||||
realGhPath = execSync("which gh").toString().trim();
|
||||
} catch {
|
||||
// The local GitHub fixture remains usable without a system gh binary.
|
||||
}
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
@@ -49,9 +41,6 @@ if (recordPath) {
|
||||
const editorPath = path.join(binDir, editorCommand);
|
||||
await writeFile(editorPath, fakeEditorSource);
|
||||
await chmod(editorPath, 0o755);
|
||||
if (process.platform === "win32") {
|
||||
await writeFile(`${editorPath}.cmd`, `@node "%~dp0${editorCommand}" %*\r\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const fakeGhPath = path.join(binDir, "gh");
|
||||
@@ -117,9 +106,6 @@ process.exit(result.status ?? 1);
|
||||
`;
|
||||
await writeFile(fakeGhPath, fakeGhSource);
|
||||
await chmod(fakeGhPath, 0o755);
|
||||
if (process.platform === "win32") {
|
||||
await writeFile(`${fakeGhPath}.cmd`, '@node "%~dp0gh" %*\r\n');
|
||||
}
|
||||
return binDir;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import {
|
||||
spawn,
|
||||
execFileSync,
|
||||
execSync,
|
||||
type ChildProcess,
|
||||
type SpawnOptions,
|
||||
} from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { withDisabledE2ESpeechEnv } from "./speech-env";
|
||||
import { killProcessTree, spawnTsx } from "./spawn-node";
|
||||
|
||||
export interface IsolatedHostDaemon {
|
||||
serverId: string;
|
||||
@@ -42,14 +48,12 @@ async function getAvailablePort(): Promise<number> {
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
const deadline = Date.now() + 90_000;
|
||||
const deadline = Date.now() + 20_000;
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Isolated host daemon exited before listening (code ${String(child.exitCode)}, signal ${String(child.signalCode)})`,
|
||||
);
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Isolated host daemon exited before listening (exit ${child.exitCode})`);
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -77,6 +81,19 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, 5_000);
|
||||
try {
|
||||
await once(child, "exit");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startIsolatedHostDaemon(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
@@ -97,17 +114,11 @@ export async function startIsolatedHostDaemon(
|
||||
path.join(publishedPackageRoot, "package.json"),
|
||||
`${JSON.stringify({ private: true })}\n`,
|
||||
);
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
try {
|
||||
const npmCli = process.env.npm_execpath;
|
||||
if (!npmCli || path.basename(npmCli).toLowerCase() !== "npm-cli.js") {
|
||||
throw new Error(
|
||||
"Published-version E2E requires npm_execpath from npm. Start it through `npm run test:e2e`.",
|
||||
);
|
||||
}
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
npmCommand,
|
||||
[
|
||||
npmCli,
|
||||
"install",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
@@ -117,9 +128,6 @@ export async function startIsolatedHostDaemon(
|
||||
{ cwd: publishedPackageRoot, stdio: "ignore" },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
await rm(publishedPackageRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
@@ -147,6 +155,7 @@ export async function startIsolatedHostDaemon(
|
||||
const serverDir = publishedPackageRoot
|
||||
? path.join(publishedPackageRoot, "node_modules", "@getpaseo", "server")
|
||||
: path.resolve(__dirname, "../../../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const spawnDaemon = async (): Promise<ChildProcess> => {
|
||||
const spawnOptions: SpawnOptions = {
|
||||
cwd: serverDir,
|
||||
@@ -166,7 +175,7 @@ export async function startIsolatedHostDaemon(
|
||||
};
|
||||
const child = publishedPackageRoot
|
||||
? spawn(process.execPath, ["dist/scripts/supervisor-entrypoint.js"], spawnOptions)
|
||||
: spawnTsx("scripts/supervisor-entrypoint.ts", ["--dev"], spawnOptions);
|
||||
: spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], spawnOptions);
|
||||
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
@@ -178,7 +187,7 @@ export async function startIsolatedHostDaemon(
|
||||
await waitForServer(port, child);
|
||||
return child;
|
||||
} catch (error) {
|
||||
await killProcessTree(child);
|
||||
await stopProcess(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
@@ -207,13 +216,13 @@ export async function startIsolatedHostDaemon(
|
||||
getPid: () => child.pid,
|
||||
restart: async () => {
|
||||
if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`);
|
||||
await killProcessTree(child);
|
||||
await stopProcess(child);
|
||||
child = await spawnDaemon();
|
||||
},
|
||||
close: async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await killProcessTree(child);
|
||||
await stopProcess(child);
|
||||
if (!options.preserveHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export async function sampleTabsDuringTransition(
|
||||
function sample() {
|
||||
const tabs = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'[data-testid^="workspace-tab-"][role="button"][aria-selected]',
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
|
||||
),
|
||||
).filter((element) => element.getClientRects().length > 0);
|
||||
scope.__paseoTabTrackFrames?.push(
|
||||
@@ -188,10 +188,7 @@ export async function sampleTabsDuringTransition(
|
||||
requestAnimationFrame(sample);
|
||||
}
|
||||
}
|
||||
// Establish the known-good pre-action state synchronously. Starting on the
|
||||
// next animation frame lets the action race the first sample, which can
|
||||
// misclassify a not-yet-painted test harness frame as a transition blank.
|
||||
sample();
|
||||
requestAnimationFrame(sample);
|
||||
}, durationMs);
|
||||
await action();
|
||||
await page.waitForTimeout(durationMs + 100);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { existsSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { killProcessTree } from "./spawn-node";
|
||||
|
||||
export interface LocalElixirRelay {
|
||||
endpoint: string;
|
||||
@@ -27,6 +27,19 @@ async function availablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const force = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, 5_000);
|
||||
try {
|
||||
await once(child, "exit");
|
||||
} finally {
|
||||
clearTimeout(force);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilReady(
|
||||
port: number,
|
||||
child: ChildProcess,
|
||||
@@ -35,9 +48,9 @@ async function waitUntilReady(
|
||||
const deadline = Date.now() + 60_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(
|
||||
`Elixir relay exited before becoming ready (code ${String(child.exitCode)}, signal ${String(child.signalCode)}).\n${recentOutput()}`,
|
||||
`Elixir relay exited before becoming ready (exit ${child.exitCode}).\n${recentOutput()}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
@@ -57,12 +70,6 @@ async function waitUntilReady(
|
||||
}
|
||||
|
||||
export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
||||
if (process.platform === "win32") {
|
||||
throw new Error(
|
||||
"The local Elixir relay requires asdf, which is not available on Windows; the relay-deployment Playwright project is POSIX-only.",
|
||||
);
|
||||
}
|
||||
|
||||
const relayRoot =
|
||||
process.env.PASEO_RELAY_CHECKOUT ?? path.resolve(__dirname, "../../../../../..", "paseo-relay");
|
||||
if (!existsSync(path.join(relayRoot, "mix.exs"))) {
|
||||
@@ -100,14 +107,14 @@ export async function startLocalElixirRelay(): Promise<LocalElixirRelay> {
|
||||
try {
|
||||
await waitUntilReady(port, child, () => output.join("\n"));
|
||||
} catch (error) {
|
||||
await killProcessTree(child);
|
||||
await stopProcess(child);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
if (!child) return;
|
||||
await killProcessTree(child);
|
||||
await stopProcess(child);
|
||||
child = null;
|
||||
};
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function seedMockAgentWorkspace(
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: options.title,
|
||||
modeId: options.modeId ?? "load-test",
|
||||
model: options.model ?? "e2e-fast-stream",
|
||||
model: options.model ?? "ten-second-stream",
|
||||
initialPrompt: options.initialPrompt,
|
||||
featureValues: options.featureValues,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { CheckPresentationCounts } from "@/git/check-presentation";
|
||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||
import { openPullRequestPanel } from "./workspace-tabs";
|
||||
|
||||
@@ -29,10 +30,10 @@ async function assertCheckPill(page: Page, testId: string, count: number): Promi
|
||||
|
||||
export async function expectPrPaneCheckSummary(
|
||||
page: Page,
|
||||
counts: { passed: number; failed: number; pending: number },
|
||||
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
|
||||
): Promise<void> {
|
||||
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
|
||||
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
|
||||
await assertCheckPill(page, "pr-pane-check-success", counts.success);
|
||||
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
|
||||
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { CDPSession, Page } from "@playwright/test";
|
||||
|
||||
const REPLICA_CACHE_DATABASE = "paseo-replica-row-store";
|
||||
const REPLICA_CACHE_ROWS_STORE = "rows";
|
||||
const PERSIST_FRAME_NAMES = [
|
||||
"captureSessions",
|
||||
"captureHost",
|
||||
"queueReplicaChanges",
|
||||
"apply",
|
||||
"safeParse",
|
||||
"stringify",
|
||||
"setItem",
|
||||
"measuredPut",
|
||||
] as const;
|
||||
const PERSIST_STACK_ANCHORS = new Set([
|
||||
"captureSessions",
|
||||
"captureHost",
|
||||
"queueReplicaChanges",
|
||||
"persist",
|
||||
"flushPending",
|
||||
"measuredPut",
|
||||
]);
|
||||
|
||||
export interface ReplicaCacheMeasurementReport {
|
||||
targetUrl: string;
|
||||
observationMs: number;
|
||||
persistCpuMs: number;
|
||||
persistCpuFrames: Record<string, number>;
|
||||
flushes: number;
|
||||
bytesWritten: {
|
||||
total: number;
|
||||
perFlush: number[];
|
||||
};
|
||||
redundantWrites: number;
|
||||
storageDurationMs: number;
|
||||
longTasks: {
|
||||
count: number;
|
||||
totalDurationMs: number;
|
||||
maximumDurationMs: number;
|
||||
durationsMs: number[];
|
||||
};
|
||||
restoreMs: number | null;
|
||||
}
|
||||
|
||||
interface BrowserMeasurementState {
|
||||
flushes: Array<{ bytes: number; durationMs: number }>;
|
||||
longTasks: number[];
|
||||
redundantWrites: number;
|
||||
}
|
||||
|
||||
interface ProfileNode {
|
||||
id: number;
|
||||
callFrame: { functionName: string; url: string };
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface SamplingProfile {
|
||||
nodes: ProfileNode[];
|
||||
samples?: number[];
|
||||
timeDeltas?: number[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__replicaCacheMeasurement?: BrowserMeasurementState;
|
||||
__replicaCacheRestoreReadStartedAt?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installReplicaCacheMeasurement(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ databaseName, storeName }) => {
|
||||
const encoder = new TextEncoder();
|
||||
const transactionWrites = new WeakMap<
|
||||
IDBTransaction,
|
||||
Array<
|
||||
| { type: "put"; key: string; value: string; bytes: number; startedAt: number }
|
||||
| { type: "delete"; key: string; bytes: 0; startedAt: number }
|
||||
>
|
||||
>();
|
||||
const lastValues = new Map<string, string>();
|
||||
|
||||
const resolveStorageKey = (
|
||||
store: IDBObjectStore,
|
||||
value: unknown,
|
||||
explicitKey: IDBValidKey | undefined,
|
||||
): unknown => {
|
||||
if (explicitKey !== undefined) return explicitKey;
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
if (Array.isArray(store.keyPath)) {
|
||||
return store.keyPath.map((part) => Reflect.get(value, part));
|
||||
}
|
||||
return typeof store.keyPath === "string" ? Reflect.get(value, store.keyPath) : null;
|
||||
};
|
||||
|
||||
window.__replicaCacheMeasurement = {
|
||||
flushes: [],
|
||||
longTasks: [],
|
||||
redundantWrites: 0,
|
||||
};
|
||||
|
||||
const observeTransaction = (transaction: IDBTransaction) => {
|
||||
let writes = transactionWrites.get(transaction);
|
||||
if (writes) return writes;
|
||||
writes = [];
|
||||
transactionWrites.set(transaction, writes);
|
||||
transaction.addEventListener("complete", () => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state || writes!.length === 0) return;
|
||||
let bytes = 0;
|
||||
let startedAt = Number.POSITIVE_INFINITY;
|
||||
for (const write of writes!) {
|
||||
bytes += write.bytes;
|
||||
startedAt = Math.min(startedAt, write.startedAt);
|
||||
if (write.type === "put") {
|
||||
if (lastValues.get(write.key) === write.value) state.redundantWrites += 1;
|
||||
lastValues.set(write.key, write.value);
|
||||
} else {
|
||||
lastValues.delete(write.key);
|
||||
}
|
||||
}
|
||||
state.flushes.push({ bytes, durationMs: performance.now() - startedAt });
|
||||
});
|
||||
return writes;
|
||||
};
|
||||
|
||||
const originalPut = IDBObjectStore.prototype.put;
|
||||
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
|
||||
const request =
|
||||
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
|
||||
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
|
||||
const rowPayload =
|
||||
value && typeof value === "object" ? Reflect.get(value, "payload") : undefined;
|
||||
let serialized = JSON.stringify(value);
|
||||
if (typeof value === "string") serialized = value;
|
||||
if (typeof rowPayload === "string") serialized = rowPayload;
|
||||
const storageKey = JSON.stringify(resolveStorageKey(this, value, key));
|
||||
observeTransaction(this.transaction).push({
|
||||
type: "put",
|
||||
key: `${this.name}:${storageKey}`,
|
||||
value: serialized,
|
||||
bytes: encoder.encode(serialized).byteLength,
|
||||
startedAt: performance.now(),
|
||||
});
|
||||
return request;
|
||||
};
|
||||
const originalDelete = IDBObjectStore.prototype.delete;
|
||||
IDBObjectStore.prototype.delete = function measuredDelete(query: IDBValidKey | IDBKeyRange) {
|
||||
const request = originalDelete.call(this, query);
|
||||
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
|
||||
observeTransaction(this.transaction).push({
|
||||
type: "delete",
|
||||
key: `${this.name}:${JSON.stringify(query)}`,
|
||||
bytes: 0,
|
||||
startedAt: performance.now(),
|
||||
});
|
||||
return request;
|
||||
};
|
||||
|
||||
const markRestoreRead = (store: IDBObjectStore) => {
|
||||
if (
|
||||
store.transaction.db.name === databaseName &&
|
||||
store.name === storeName &&
|
||||
store.transaction.mode === "readonly" &&
|
||||
window.__replicaCacheRestoreReadStartedAt === undefined
|
||||
) {
|
||||
window.__replicaCacheRestoreReadStartedAt = performance.now();
|
||||
}
|
||||
};
|
||||
const originalGet = IDBObjectStore.prototype.get;
|
||||
IDBObjectStore.prototype.get = function measuredGet(query: IDBValidKey | IDBKeyRange) {
|
||||
markRestoreRead(this);
|
||||
return originalGet.call(this, query);
|
||||
};
|
||||
const originalGetAll = IDBObjectStore.prototype.getAll;
|
||||
IDBObjectStore.prototype.getAll = function measuredGetAll(
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
count?: number,
|
||||
) {
|
||||
markRestoreRead(this);
|
||||
return originalGetAll.call(this, query, count);
|
||||
};
|
||||
const originalOpenCursor = IDBObjectStore.prototype.openCursor;
|
||||
IDBObjectStore.prototype.openCursor = function measuredOpenCursor(
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
direction?: IDBCursorDirection,
|
||||
) {
|
||||
markRestoreRead(this);
|
||||
return originalOpenCursor.call(this, query, direction);
|
||||
};
|
||||
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
const observer = new PerformanceObserver((entries) => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state) return;
|
||||
for (const entry of entries.getEntries()) state.longTasks.push(entry.duration);
|
||||
});
|
||||
try {
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
} catch {
|
||||
// Chromium exposes Long Tasks; leave an empty list on runtimes that do not.
|
||||
}
|
||||
}
|
||||
},
|
||||
{ databaseName: REPLICA_CACHE_DATABASE, storeName: REPLICA_CACHE_ROWS_STORE },
|
||||
);
|
||||
}
|
||||
|
||||
export async function beginReplicaCacheObservation(
|
||||
page: Page,
|
||||
): Promise<{ cdp: CDPSession; startedAt: number }> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state) throw new Error("Replica cache measurement is not installed");
|
||||
state.flushes = [];
|
||||
state.longTasks = [];
|
||||
state.redundantWrites = 0;
|
||||
});
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Profiler.enable");
|
||||
await cdp.send("Profiler.setSamplingInterval", { interval: 100 });
|
||||
await cdp.send("Profiler.start");
|
||||
return { cdp, startedAt: Date.now() };
|
||||
}
|
||||
|
||||
export async function finishReplicaCacheObservation(
|
||||
page: Page,
|
||||
observation: { cdp: CDPSession; startedAt: number },
|
||||
targetUrl: string,
|
||||
): Promise<Omit<ReplicaCacheMeasurementReport, "restoreMs">> {
|
||||
const result = (await observation.cdp.send("Profiler.stop")) as { profile: SamplingProfile };
|
||||
await observation.cdp.detach();
|
||||
const browser = await page.evaluate(() => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state) throw new Error("Replica cache measurement is not installed");
|
||||
return state;
|
||||
});
|
||||
const cpu = summarizePersistCpu(result.profile);
|
||||
const bytesPerFlush = browser.flushes.map((flush) => flush.bytes);
|
||||
const longTaskDurations = browser.longTasks;
|
||||
return {
|
||||
targetUrl,
|
||||
observationMs: Date.now() - observation.startedAt,
|
||||
persistCpuMs: cpu.totalMs,
|
||||
persistCpuFrames: cpu.byFrame,
|
||||
flushes: browser.flushes.length,
|
||||
bytesWritten: {
|
||||
total: bytesPerFlush.reduce((sum, bytes) => sum + bytes, 0),
|
||||
perFlush: bytesPerFlush,
|
||||
},
|
||||
redundantWrites: browser.redundantWrites,
|
||||
storageDurationMs: browser.flushes.reduce((sum, flush) => sum + flush.durationMs, 0),
|
||||
longTasks: {
|
||||
count: longTaskDurations.length,
|
||||
totalDurationMs: longTaskDurations.reduce((sum, duration) => sum + duration, 0),
|
||||
maximumDurationMs: Math.max(0, ...longTaskDurations),
|
||||
durationsMs: longTaskDurations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function measureReplicaCacheRestore(
|
||||
page: Page,
|
||||
targetUrl: string,
|
||||
hydratedSelector: string,
|
||||
): Promise<number | null> {
|
||||
await page.routeWebSocket(/\/ws(?:\?|$)/, async (socket) => {
|
||||
await socket.close({ code: 1000, reason: "Hold reconnect until cached replica paints" });
|
||||
});
|
||||
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
|
||||
const restoredAt = await page.waitForFunction(
|
||||
(selector) => {
|
||||
const startedAt = window.__replicaCacheRestoreReadStartedAt;
|
||||
if (startedAt === undefined) return null;
|
||||
const element = document.querySelector(selector);
|
||||
if (!element || !(element instanceof HTMLElement) || element.offsetParent === null)
|
||||
return null;
|
||||
return performance.now();
|
||||
},
|
||||
hydratedSelector,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const restoredAtMs = await restoredAt.jsonValue();
|
||||
const startedAtMs = await page.evaluate(() => window.__replicaCacheRestoreReadStartedAt ?? null);
|
||||
return typeof restoredAtMs === "number" && startedAtMs !== null
|
||||
? restoredAtMs - startedAtMs
|
||||
: null;
|
||||
}
|
||||
|
||||
function summarizePersistCpu(profile: SamplingProfile): {
|
||||
totalMs: number;
|
||||
byFrame: Record<string, number>;
|
||||
} {
|
||||
const nodes = new Map(profile.nodes.map((node) => [node.id, node]));
|
||||
const byFrame: Record<string, number> = {};
|
||||
let totalMicroseconds = 0;
|
||||
for (const [index, sampleId] of (profile.samples ?? []).entries()) {
|
||||
const delta = profile.timeDeltas?.[index] ?? 0;
|
||||
const stackFunctions = new Set<string>();
|
||||
let node = nodes.get(sampleId);
|
||||
while (node) {
|
||||
stackFunctions.add(node.callFrame.functionName);
|
||||
node = node.parent === undefined ? undefined : nodes.get(node.parent);
|
||||
}
|
||||
if (![...stackFunctions].some((name) => PERSIST_STACK_ANCHORS.has(name))) continue;
|
||||
const matched = PERSIST_FRAME_NAMES.filter((name) => stackFunctions.has(name));
|
||||
if (matched.length === 0) continue;
|
||||
totalMicroseconds += delta;
|
||||
for (const pattern of matched) byFrame[pattern] = (byFrame[pattern] ?? 0) + delta / 1_000;
|
||||
}
|
||||
return { totalMs: totalMicroseconds / 1_000, byFrame };
|
||||
}
|
||||
@@ -1,26 +1,49 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const DATABASE_NAME = "paseo-replica-cache";
|
||||
const STORE_NAME = "key-value";
|
||||
const STORAGE_KEY = "@paseo:replica-cache";
|
||||
const DATABASE_NAME = "paseo-replica-row-store";
|
||||
const STORE_NAME = "rows";
|
||||
const SINGLETON_ID = "singleton";
|
||||
|
||||
type ReplicaRowKind = "agent" | "workspace" | "project" | "timeline" | "checkpoint";
|
||||
|
||||
interface ReplicaRowRecord {
|
||||
serverId: string;
|
||||
kind: ReplicaRowKind;
|
||||
id: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
interface ReplicaCacheHostRecord {
|
||||
serverId: string;
|
||||
agents: Array<Record<string, unknown>>;
|
||||
workspaces: Array<Record<string, unknown>>;
|
||||
projects: Array<Record<string, unknown>>;
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
range?: {
|
||||
endSeq?: number;
|
||||
epoch?: string;
|
||||
startSeq?: number;
|
||||
} | null;
|
||||
} | null;
|
||||
directorySync?: unknown;
|
||||
}
|
||||
|
||||
export interface ReplicaCacheRecord {
|
||||
version?: number;
|
||||
hosts?: Array<{
|
||||
timeline?: {
|
||||
agentId?: string;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
range?: {
|
||||
endSeq?: number;
|
||||
epoch?: string;
|
||||
startSeq?: number;
|
||||
} | null;
|
||||
} | null;
|
||||
}>;
|
||||
hosts?: ReplicaCacheHostRecord[];
|
||||
}
|
||||
|
||||
interface ReplicaCacheWriteObserverState {
|
||||
lastValue: string | null;
|
||||
lastValues: Record<string, string>;
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
writes: number;
|
||||
}
|
||||
|
||||
interface ReplicaCacheWriteObserverReport {
|
||||
redundantWrites: number;
|
||||
serializedChars: number;
|
||||
storageDurationMs: number;
|
||||
@@ -33,11 +56,10 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
async function readStoredValue(input: {
|
||||
async function readRows(input: {
|
||||
databaseName: string;
|
||||
storeName: string;
|
||||
storageKey: string;
|
||||
}): Promise<string | null> {
|
||||
}): Promise<ReplicaRowRecord[]> {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(input.databaseName, 1);
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
@@ -47,26 +69,103 @@ async function readStoredValue(input: {
|
||||
const request = database
|
||||
.transaction(input.storeName, "readonly")
|
||||
.objectStore(input.storeName)
|
||||
.get(input.storageKey);
|
||||
request.addEventListener("success", () =>
|
||||
resolve(typeof request.result === "string" ? request.result : null),
|
||||
);
|
||||
.getAll();
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
request.addEventListener("error", () => reject(request.error));
|
||||
});
|
||||
}
|
||||
|
||||
function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord {
|
||||
const hosts = new Map<string, ReplicaCacheHostRecord>();
|
||||
for (const row of rows) {
|
||||
const host = hosts.get(row.serverId) ?? {
|
||||
serverId: row.serverId,
|
||||
agents: [],
|
||||
workspaces: [],
|
||||
projects: [],
|
||||
timeline: null,
|
||||
};
|
||||
const payload = JSON.parse(row.payload) as Record<string, unknown>;
|
||||
switch (row.kind) {
|
||||
case "agent":
|
||||
host.agents.push(payload);
|
||||
break;
|
||||
case "workspace":
|
||||
host.workspaces.push(payload);
|
||||
break;
|
||||
case "project":
|
||||
host.projects.push(payload);
|
||||
break;
|
||||
case "timeline":
|
||||
host.timeline = payload;
|
||||
break;
|
||||
case "checkpoint":
|
||||
host.directorySync = payload;
|
||||
break;
|
||||
}
|
||||
hosts.set(row.serverId, host);
|
||||
}
|
||||
return { version: 6, hosts: [...hosts.values()] };
|
||||
}
|
||||
|
||||
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
|
||||
const raw = await page.evaluate(readStoredValue, {
|
||||
const rows = await page.evaluate(readRows, {
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
});
|
||||
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
|
||||
return rows.length > 0 ? assembleCache(rows) : null;
|
||||
}
|
||||
|
||||
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
||||
const rows: ReplicaRowRecord[] = [];
|
||||
for (const host of value.hosts ?? []) {
|
||||
for (const agent of host.agents) {
|
||||
const snapshot = agent.snapshot;
|
||||
if (!snapshot || typeof snapshot !== "object") continue;
|
||||
const id = Reflect.get(snapshot, "id");
|
||||
if (typeof id === "string") {
|
||||
rows.push({ serverId: host.serverId, kind: "agent", id, payload: JSON.stringify(agent) });
|
||||
}
|
||||
}
|
||||
for (const workspace of host.workspaces) {
|
||||
if (typeof workspace.id === "string") {
|
||||
rows.push({
|
||||
serverId: host.serverId,
|
||||
kind: "workspace",
|
||||
id: workspace.id,
|
||||
payload: JSON.stringify(workspace),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const project of host.projects) {
|
||||
if (typeof project.projectId === "string") {
|
||||
rows.push({
|
||||
serverId: host.serverId,
|
||||
kind: "project",
|
||||
id: project.projectId,
|
||||
payload: JSON.stringify(project),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (host.timeline) {
|
||||
rows.push({
|
||||
serverId: host.serverId,
|
||||
kind: "timeline",
|
||||
id: SINGLETON_ID,
|
||||
payload: JSON.stringify(host.timeline),
|
||||
});
|
||||
}
|
||||
if (host.directorySync !== undefined) {
|
||||
rows.push({
|
||||
serverId: host.serverId,
|
||||
kind: "checkpoint",
|
||||
id: SINGLETON_ID,
|
||||
payload: JSON.stringify(host.directorySync),
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.evaluate(
|
||||
async ({ databaseName, storeName, storageKey, raw }) => {
|
||||
async ({ databaseName, storeName, nextRows }) => {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName, 1);
|
||||
request.addEventListener("success", () => resolve(request.result));
|
||||
@@ -74,26 +173,23 @@ export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord):
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, "readwrite");
|
||||
transaction.objectStore(storeName).put(raw, storageKey);
|
||||
const store = transaction.objectStore(storeName);
|
||||
store.clear();
|
||||
for (const row of nextRows) store.put(row);
|
||||
transaction.addEventListener("complete", () => resolve());
|
||||
transaction.addEventListener("error", () => reject(transaction.error));
|
||||
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||
});
|
||||
},
|
||||
{
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
raw: JSON.stringify(value),
|
||||
},
|
||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, nextRows: rows },
|
||||
);
|
||||
}
|
||||
|
||||
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ databaseName, storeName, storageKey }) => {
|
||||
({ databaseName, storeName }) => {
|
||||
window.__replicaCacheWriteObserver = {
|
||||
lastValue: null,
|
||||
lastValues: {},
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
@@ -106,51 +202,71 @@ export async function observeReplicaCacheStorageWrites(page: Page): Promise<void
|
||||
if (
|
||||
this.transaction.db.name !== databaseName ||
|
||||
this.name !== storeName ||
|
||||
key !== storageKey ||
|
||||
typeof value !== "string"
|
||||
!value ||
|
||||
typeof value !== "object"
|
||||
) {
|
||||
return request;
|
||||
}
|
||||
const serverId = Reflect.get(value, "serverId");
|
||||
const kind = Reflect.get(value, "kind");
|
||||
const id = Reflect.get(value, "id");
|
||||
const payload = Reflect.get(value, "payload");
|
||||
if (
|
||||
typeof serverId !== "string" ||
|
||||
typeof kind !== "string" ||
|
||||
typeof id !== "string" ||
|
||||
typeof payload !== "string"
|
||||
) {
|
||||
return request;
|
||||
}
|
||||
const rowKey = `${serverId}:${kind}:${id}`;
|
||||
const startedAt = performance.now();
|
||||
request.addEventListener("success", () => {
|
||||
const state = window.__replicaCacheWriteObserver;
|
||||
if (!state) return;
|
||||
if (state.lastValue === value) state.redundantWrites += 1;
|
||||
state.lastValue = value;
|
||||
if (state.lastValues[rowKey] === payload) state.redundantWrites += 1;
|
||||
state.lastValues[rowKey] = payload;
|
||||
state.writes += 1;
|
||||
state.serializedChars += value.length;
|
||||
state.serializedChars += payload.length;
|
||||
state.storageDurationMs += performance.now() - startedAt;
|
||||
});
|
||||
return request;
|
||||
};
|
||||
},
|
||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
|
||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME },
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
|
||||
const lastValue = await page.evaluate(readStoredValue, {
|
||||
const rows = await page.evaluate(readRows, {
|
||||
databaseName: DATABASE_NAME,
|
||||
storeName: STORE_NAME,
|
||||
storageKey: STORAGE_KEY,
|
||||
});
|
||||
await page.evaluate((value) => {
|
||||
const lastValues = Object.fromEntries(
|
||||
rows.map((row) => [`${row.serverId}:${row.kind}:${row.id}`, row.payload]),
|
||||
);
|
||||
await page.evaluate((values) => {
|
||||
window.__replicaCacheWriteObserver = {
|
||||
lastValue: value,
|
||||
lastValues: values,
|
||||
redundantWrites: 0,
|
||||
serializedChars: 0,
|
||||
storageDurationMs: 0,
|
||||
writes: 0,
|
||||
};
|
||||
}, lastValue);
|
||||
}, lastValues);
|
||||
}
|
||||
|
||||
export async function readReplicaCacheStorageWriteObserver(
|
||||
page: Page,
|
||||
): Promise<ReplicaCacheWriteObserverState> {
|
||||
): Promise<ReplicaCacheWriteObserverReport> {
|
||||
return page.evaluate(() => {
|
||||
const state = window.__replicaCacheWriteObserver;
|
||||
if (!state) throw new Error("Replica cache write observer is not installed");
|
||||
return state;
|
||||
return {
|
||||
redundantWrites: state.redundantWrites,
|
||||
serializedChars: state.serializedChars,
|
||||
storageDurationMs: state.storageDurationMs,
|
||||
writes: state.writes,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
|
||||
/**
|
||||
* Runs a repo-local TypeScript entrypoint through Node's own tsx loader instead
|
||||
* of the `node_modules/.bin/tsx` shim.
|
||||
*
|
||||
* The shim is a platform trap, not a convenience: on Windows it is `tsx.cmd`,
|
||||
* and Node refuses to spawn `.cmd`/`.bat` without `shell: true` (EINVAL, from
|
||||
* the CVE-2024-27980 mitigation). Opting into `shell: true` to get around that
|
||||
* trades one bug for three — argv is concatenated unescaped (DEP0190), a path
|
||||
* containing spaces splits, and the kill signal lands on `cmd.exe` while the
|
||||
* real child keeps the port. Spawning `process.execPath` has none of those
|
||||
* properties and is byte-identical on POSIX, so there is no platform branch.
|
||||
*/
|
||||
export function spawnTsx(entrypoint: string, args: string[], options: SpawnOptions): ChildProcess {
|
||||
if (entrypoint.startsWith("-")) {
|
||||
throw new Error(`TypeScript entrypoint must be a path, received ${entrypoint}`);
|
||||
}
|
||||
return spawn(process.execPath, ["--import", "tsx", "--", entrypoint, ...args], options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminates a child and everything it forked, then waits for the exit.
|
||||
*
|
||||
* `child.kill()` on Windows maps to `TerminateProcess` against the direct child
|
||||
* only. Metro and the daemon supervisor both fork workers, so the signal leaves
|
||||
* grandchildren holding the listening port and the next run fails to bind.
|
||||
* `taskkill /T` walks the tree; POSIX keeps the SIGTERM-then-SIGKILL ladder.
|
||||
*/
|
||||
export async function killProcessTree(child: ChildProcess | null): Promise<void> {
|
||||
if (!child || hasExited(child)) return;
|
||||
const exited = once(child, "exit").then(() => undefined);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const pid = child.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error("Cannot terminate a Windows process tree without a PID");
|
||||
}
|
||||
|
||||
// Register the exit listener before taskkill to avoid missing a fast exit.
|
||||
// Bound taskkill itself so a stuck system utility cannot hang teardown.
|
||||
const completed = Promise.withResolvers<Error | null>();
|
||||
execFile("taskkill", ["/pid", String(pid), "/T", "/F"], { timeout: 5_000 }, (error) =>
|
||||
completed.resolve(error),
|
||||
);
|
||||
const taskkillError = await completed.promise;
|
||||
if (taskkillError && !hasExited(child)) {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// The child can exit between the state check and the direct kill.
|
||||
}
|
||||
await waitForExitOrTimeout(exited, 5_000);
|
||||
throw new Error(`Failed to terminate process tree for PID ${pid}`, { cause: taskkillError });
|
||||
}
|
||||
if (!hasExited(child) && !(await waitForExitOrTimeout(exited, 5_000))) {
|
||||
throw new Error(`Process tree for PID ${pid} did not exit after taskkill`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
if (await waitForExitOrTimeout(exited, 5_000)) return;
|
||||
|
||||
child.kill("SIGKILL");
|
||||
if (!(await waitForExitOrTimeout(exited, 5_000))) {
|
||||
throw new Error(`Process ${String(child.pid)} did not exit after SIGKILL`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasExited(child: ChildProcess): boolean {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
|
||||
async function waitForExitOrTimeout(exited: Promise<void>, timeoutMs: number): Promise<boolean> {
|
||||
const timedOut = Promise.withResolvers<boolean>();
|
||||
const timeout = setTimeout(() => timedOut.resolve(false), timeoutMs);
|
||||
try {
|
||||
return await Promise.race([exited.then(() => true), timedOut.promise]);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export interface SplitPaneGeometry {
|
||||
containerWidth: number;
|
||||
explorerWidth: number;
|
||||
visibleWidth: number;
|
||||
separatorCount: number;
|
||||
occupiedWidth: number;
|
||||
}
|
||||
|
||||
function splitSeparator(page: Page): Locator {
|
||||
return page.getByRole("separator", { name: "" });
|
||||
}
|
||||
|
||||
export async function openExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open side panel" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function hideExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Close side panel" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function resizeExplorerSplit(page: Page, deltaX: number): Promise<void> {
|
||||
const separator = splitSeparator(page);
|
||||
await expect(separator).toHaveCount(1);
|
||||
const box = await separator.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (!box) throw new Error("Split separator has no bounding box");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function stampExplorerNode(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByTestId("split-group-child")
|
||||
.last()
|
||||
.evaluate((node) => {
|
||||
Reflect.set(window, "__splitPaneExplorerNode", node);
|
||||
node.setAttribute("data-split-pane-identity", "retained");
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectExplorerNodeRetained(page: Page): Promise<void> {
|
||||
const stampedNode = page.locator('[data-split-pane-identity="retained"]');
|
||||
await expect(stampedNode).toHaveCount(1);
|
||||
expect(
|
||||
await stampedNode.evaluate((node) => Reflect.get(window, "__splitPaneExplorerNode") === node),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
export async function measureExplorerSplit(page: Page): Promise<SplitPaneGeometry> {
|
||||
const explorerChild = page.locator('[data-split-pane-identity="retained"]');
|
||||
await expect(explorerChild).toHaveCount(1);
|
||||
|
||||
return explorerChild.evaluate((explorerNode) => {
|
||||
const visibleNode = Array.from(explorerNode.parentElement?.children ?? [])
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate !== explorerNode &&
|
||||
(candidate as HTMLElement).dataset.testid === "split-group-child",
|
||||
)
|
||||
.sort(
|
||||
(left, right) => right.getBoundingClientRect().width - left.getBoundingClientRect().width,
|
||||
)[0];
|
||||
if (!(visibleNode instanceof HTMLElement)) throw new Error("Visible split child not found");
|
||||
const visible = visibleNode.getBoundingClientRect();
|
||||
const explorer = explorerNode.getBoundingClientRect();
|
||||
const container = explorerNode.parentElement?.getBoundingClientRect();
|
||||
if (!container) throw new Error("Split child has no container");
|
||||
const separatorCount =
|
||||
explorerNode.parentElement?.querySelectorAll('[role="separator"]').length ?? 0;
|
||||
return {
|
||||
containerWidth: container.width,
|
||||
explorerWidth: explorer.width,
|
||||
visibleWidth: visible.width,
|
||||
separatorCount,
|
||||
occupiedWidth: explorer.width + visible.width,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { summarizeStreamSmoothness } from "./stream-smoothness";
|
||||
|
||||
describe("summarizeStreamSmoothness", () => {
|
||||
it("reports canonical handover snaps separately from paced growth", () => {
|
||||
const report = summarizeStreamSmoothness(
|
||||
[
|
||||
{ timestampMs: 0, growthChars: 0, firstPaintChars: 0 },
|
||||
{ timestampMs: 10, growthChars: 4, firstPaintChars: 0 },
|
||||
{ timestampMs: 20, growthChars: 100, firstPaintChars: 30 },
|
||||
{ timestampMs: 30, growthChars: 4, firstPaintChars: 0 },
|
||||
{ timestampMs: 40, growthChars: 0, firstPaintChars: 0 },
|
||||
],
|
||||
40,
|
||||
);
|
||||
|
||||
expect(report).toMatchObject({
|
||||
frames: 3,
|
||||
growthFrames: 2,
|
||||
charsPainted: 8,
|
||||
maxCharsInOneFrame: 4,
|
||||
firstPaintFrames: 1,
|
||||
firstPaintChars: 30,
|
||||
handoverGrowthFrames: 1,
|
||||
handoverGrowthChars: 100,
|
||||
maxHandoverGrowthCharsInOneFrame: 100,
|
||||
updateGapP95Ms: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Frame-level sampling of the streaming assistant message, and the two numbers
|
||||
* that describe how it reads:
|
||||
*
|
||||
* - `charsPerFrameCv` — coefficient of variation of characters painted per frame.
|
||||
* This is the smoothness metric. Painting deltas as they land tracks the
|
||||
* arrival lumps, so it is high; pacing the reveal flattens it.
|
||||
* - `updateGapP95Ms` — how long the text can sit unchanged mid-stream. This is
|
||||
* the stall metric, and it is what stops smoothness being bought with lag.
|
||||
*
|
||||
* Both are needed. A stream that never updates is perfectly smooth.
|
||||
*/
|
||||
|
||||
export interface StreamFrameSample {
|
||||
timestampMs: number;
|
||||
growthChars: number;
|
||||
firstPaintChars: number;
|
||||
}
|
||||
|
||||
export interface StreamSmoothnessReport {
|
||||
sampleWindowMs: number;
|
||||
frames: number;
|
||||
growthFrames: number;
|
||||
charsPainted: number;
|
||||
meanCharsPerFrame: number;
|
||||
maxCharsInOneFrame: number;
|
||||
charsPerFrameCv: number;
|
||||
firstPaintFrames: number;
|
||||
firstPaintChars: number;
|
||||
maxFirstPaintCharsInOneFrame: number;
|
||||
handoverGrowthFrames: number;
|
||||
handoverGrowthChars: number;
|
||||
maxHandoverGrowthCharsInOneFrame: number;
|
||||
updateGapP50Ms: number;
|
||||
updateGapP95Ms: number;
|
||||
updateGapMaxMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record text growth on every mounted assistant message once per frame.
|
||||
*
|
||||
* Message occurrence identity separates paced growth from first paint. Completed markdown
|
||||
* blocks move into canonical timeline rows while a turn streams, remounting text
|
||||
* that was already visible. Counting the new node's full length as growth would
|
||||
* report that handover as a large reveal even though no text appeared on screen.
|
||||
* First paints stay in the report as a separate diagnostic.
|
||||
*
|
||||
* Runs entirely in the page so the sampling clock is the same clock the reveal
|
||||
* runs on. Waits for the text to start moving first, so a slow model's
|
||||
* time-to-first-token is not counted as a stall.
|
||||
*/
|
||||
export async function sampleStreamFrames(
|
||||
page: Page,
|
||||
options: { sampleWindowMs: number; startTimeoutMs?: number },
|
||||
): Promise<StreamFrameSample[]> {
|
||||
const startTimeoutMs = options.startTimeoutMs ?? 60_000;
|
||||
return await page.evaluate(
|
||||
async ({ sampleWindowMs, startTimeoutMs: waitMs }) => {
|
||||
const readLength = () => {
|
||||
let total = 0;
|
||||
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
||||
total += node.textContent?.length ?? 0;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const waitStart = performance.now();
|
||||
const base = readLength();
|
||||
await new Promise<void>((resolve) => {
|
||||
const poll = () => {
|
||||
if (readLength() > base + 20 || performance.now() - waitStart > waitMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(poll);
|
||||
};
|
||||
requestAnimationFrame(poll);
|
||||
});
|
||||
|
||||
let previousLengths = new Map<string, number>();
|
||||
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
||||
const key = node.getAttribute("data-reveal-key");
|
||||
const length = Number(node.getAttribute("data-reveal-length"));
|
||||
if (key !== null && Number.isFinite(length)) {
|
||||
previousLengths.set(key, length);
|
||||
}
|
||||
}
|
||||
|
||||
const samples: Array<{
|
||||
timestampMs: number;
|
||||
growthChars: number;
|
||||
firstPaintChars: number;
|
||||
}> = [];
|
||||
await new Promise<void>((resolve) => {
|
||||
const start = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const currentLengths = new Map<string, number>();
|
||||
let growthChars = 0;
|
||||
let firstPaintChars = 0;
|
||||
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
||||
const key = node.getAttribute("data-reveal-key");
|
||||
const currentLength = Number(node.getAttribute("data-reveal-length"));
|
||||
if (key === null || !Number.isFinite(currentLength)) {
|
||||
continue;
|
||||
}
|
||||
const previousLength = previousLengths.get(key);
|
||||
if (previousLength === undefined || currentLength < previousLength) {
|
||||
firstPaintChars += currentLength;
|
||||
} else {
|
||||
const nodeGrowth = currentLength - previousLength;
|
||||
growthChars += nodeGrowth;
|
||||
}
|
||||
currentLengths.set(key, currentLength);
|
||||
}
|
||||
samples.push({
|
||||
timestampMs: now,
|
||||
growthChars,
|
||||
firstPaintChars,
|
||||
});
|
||||
previousLengths = currentLengths;
|
||||
if (now - start >= sampleWindowMs) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
});
|
||||
return samples;
|
||||
},
|
||||
{ sampleWindowMs: options.sampleWindowMs, startTimeoutMs },
|
||||
);
|
||||
}
|
||||
|
||||
export function computePercentile(samples: readonly number[], percentile: number): number {
|
||||
if (samples.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
return sorted[Math.max(0, Math.ceil((percentile / 100) * sorted.length) - 1)];
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce frame samples to the report. Frames outside the streaming window are
|
||||
* dropped: leading idle is time-to-first-token, and trailing idle means the turn
|
||||
* ended mid-sample. First-paint frames are structural handovers. Existing blocks
|
||||
* may also snap complete on those frames, so their growth is reported separately
|
||||
* from the paced-growth CV.
|
||||
*/
|
||||
export function summarizeStreamSmoothness(
|
||||
samples: readonly StreamFrameSample[],
|
||||
sampleWindowMs: number,
|
||||
): StreamSmoothnessReport {
|
||||
const deltas: number[] = [];
|
||||
const gapsMs: number[] = [];
|
||||
let lastUpdateAtMs: number | null = null;
|
||||
let firstGrowth = -1;
|
||||
let lastGrowth = -1;
|
||||
const handoverGrowths: number[] = [];
|
||||
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const sample = samples[index];
|
||||
const isHandover = sample.firstPaintChars > 0;
|
||||
const growth = isHandover ? 0 : sample.growthChars;
|
||||
if (isHandover && sample.growthChars > 0) {
|
||||
handoverGrowths.push(sample.growthChars);
|
||||
lastUpdateAtMs = sample.timestampMs;
|
||||
}
|
||||
deltas.push(growth);
|
||||
if (growth > 0) {
|
||||
if (firstGrowth === -1) {
|
||||
firstGrowth = deltas.length - 1;
|
||||
}
|
||||
lastGrowth = deltas.length - 1;
|
||||
if (lastUpdateAtMs !== null) {
|
||||
gapsMs.push(samples[index].timestampMs - lastUpdateAtMs);
|
||||
}
|
||||
lastUpdateAtMs = samples[index].timestampMs;
|
||||
}
|
||||
}
|
||||
|
||||
const active = firstGrowth === -1 ? [] : deltas.slice(firstGrowth, lastGrowth + 1);
|
||||
const charsPainted = active.reduce((sum, delta) => sum + delta, 0);
|
||||
const mean = active.length > 0 ? charsPainted / active.length : 0;
|
||||
const variance =
|
||||
active.length > 0
|
||||
? active.reduce((sum, delta) => sum + (delta - mean) ** 2, 0) / active.length
|
||||
: 0;
|
||||
const firstPaints = samples.map((sample) => sample.firstPaintChars).filter((chars) => chars > 0);
|
||||
|
||||
return {
|
||||
sampleWindowMs,
|
||||
frames: active.length,
|
||||
growthFrames: active.filter((delta) => delta > 0).length,
|
||||
charsPainted,
|
||||
meanCharsPerFrame: round2(mean),
|
||||
maxCharsInOneFrame: active.length > 0 ? Math.max(...active) : 0,
|
||||
charsPerFrameCv: mean === 0 ? Number.POSITIVE_INFINITY : round2(Math.sqrt(variance) / mean),
|
||||
firstPaintFrames: firstPaints.length,
|
||||
firstPaintChars: firstPaints.reduce((sum, chars) => sum + chars, 0),
|
||||
maxFirstPaintCharsInOneFrame: firstPaints.length > 0 ? Math.max(...firstPaints) : 0,
|
||||
handoverGrowthFrames: handoverGrowths.length,
|
||||
handoverGrowthChars: handoverGrowths.reduce((sum, chars) => sum + chars, 0),
|
||||
maxHandoverGrowthCharsInOneFrame: handoverGrowths.length > 0 ? Math.max(...handoverGrowths) : 0,
|
||||
updateGapP50Ms: round2(computePercentile(gapsMs, 50)),
|
||||
updateGapP95Ms: round2(computePercentile(gapsMs, 95)),
|
||||
updateGapMaxMs: gapsMs.length > 0 ? round2(Math.max(...gapsMs)) : 0,
|
||||
};
|
||||
}
|
||||
@@ -42,8 +42,8 @@ export async function expectFailedSetupTabSeededInMainPane(
|
||||
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await expect(explorer.getByTestId(tabId)).toHaveCount(0);
|
||||
const panel = await ensureSidePanel(page);
|
||||
await expect(panel.getByTestId(tabId)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
|
||||
@@ -57,8 +57,8 @@ function visibleTestId(page: Page, testId: string) {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function explorerSidebar(page: Page) {
|
||||
return visibleTestId(page, "workspace-explorer-sidebar").first();
|
||||
function sidePanel(page: Page) {
|
||||
return visibleTestId(page, "workspace-side-panel").first();
|
||||
}
|
||||
|
||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
@@ -70,54 +70,53 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
/** Reveal the Explorer sidebar without changing its selected view. */
|
||||
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
|
||||
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
|
||||
export async function ensureSidePanel(page: Page): Promise<Locator> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const explorer = explorerSidebar(page);
|
||||
if ((await explorer.count()) === 0) {
|
||||
const panel = sidePanel(page);
|
||||
if ((await panel.count()) === 0) {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** Reveals the Explorer sidebar and selects one of its fixed navigation views. */
|
||||
async function openExplorerView(
|
||||
/**
|
||||
* Reveals the Side panel and brings one of its views up in it. Goes through the
|
||||
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
|
||||
*/
|
||||
async function openSidePanelView(
|
||||
page: Page,
|
||||
view: { tabTestId: string; contentTestId: string; timeout?: number },
|
||||
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
|
||||
): Promise<void> {
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
const tab = explorer.getByTestId(view.tabTestId);
|
||||
await tab.click();
|
||||
const panel = await ensureSidePanel(page);
|
||||
const tab = panel.getByTestId(view.tabTestId);
|
||||
if ((await tab.count()) === 0) {
|
||||
await panel.getByTestId("workspace-new-tab-button").click();
|
||||
await page
|
||||
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
}
|
||||
await selectWorkspaceTab(tab);
|
||||
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||
timeout: view.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openChangesTreePanel(page: Page): Promise<void> {
|
||||
await openExplorerView(page, {
|
||||
tabTestId: "explorer-sidebar-tab-changes_tree",
|
||||
contentTestId: "changes-tree-panel",
|
||||
});
|
||||
}
|
||||
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
await openChangesTreePanel(page);
|
||||
const changedFile = page
|
||||
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await changedFile.click();
|
||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-working_diff",
|
||||
launchItemId: "changes",
|
||||
contentTestId: "working-diff-panel",
|
||||
});
|
||||
}
|
||||
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
await openExplorerView(page, {
|
||||
tabTestId: "explorer-sidebar-tab-files",
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-files",
|
||||
launchItemId: "files",
|
||||
contentTestId: "file-explorer-tree-scroll",
|
||||
});
|
||||
}
|
||||
@@ -129,10 +128,12 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
return;
|
||||
}
|
||||
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||
await trigger.click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-pull_request",
|
||||
launchItemId: "pull-request",
|
||||
contentTestId: "pr-pane",
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
||||
const stableIosBuildSlot = 999;
|
||||
const FDROID_ABI_VERSION_CODE_SUFFIXES = {
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
x86: 3,
|
||||
x86_64: 4,
|
||||
};
|
||||
|
||||
function getNativeReleaseVersion(version) {
|
||||
const match = versionPattern.exec(version);
|
||||
@@ -21,7 +27,11 @@ function getNativeReleaseVersion(version) {
|
||||
}
|
||||
|
||||
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
|
||||
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
|
||||
if (
|
||||
!Number.isSafeInteger(versionCode) ||
|
||||
versionCode <= 0 ||
|
||||
versionCode * 10 + 9 > 2_100_000_000
|
||||
) {
|
||||
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
|
||||
}
|
||||
|
||||
@@ -38,4 +48,16 @@ function getNativeReleaseVersion(version) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getNativeReleaseVersion };
|
||||
function getFdroidVersionCodes(version) {
|
||||
const { androidVersionCode } = getNativeReleaseVersion(version);
|
||||
return Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES).map(([abi, suffix]) => ({
|
||||
abi,
|
||||
versionCode: androidVersionCode * 10 + suffix,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FDROID_ABI_VERSION_CODE_SUFFIXES,
|
||||
getFdroidVersionCodes,
|
||||
getNativeReleaseVersion,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const {
|
||||
FDROID_ABI_VERSION_CODE_SUFFIXES,
|
||||
getFdroidVersionCodes,
|
||||
getNativeReleaseVersion,
|
||||
} = require("./native-release-version");
|
||||
|
||||
describe("native release version", () => {
|
||||
it("reserves the final iOS build slot for a stable release", () => {
|
||||
@@ -24,4 +28,19 @@ describe("native release version", () => {
|
||||
"iOS beta number must be between 1 and 998",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives one F-Droid version code per published ABI", () => {
|
||||
expect(FDROID_ABI_VERSION_CODE_SUFFIXES).toEqual({
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
x86: 3,
|
||||
x86_64: 4,
|
||||
});
|
||||
expect(getFdroidVersionCodes("0.5.0")).toEqual([
|
||||
{ abi: "armeabi-v7a", versionCode: 50001 },
|
||||
{ abi: "arm64-v8a", versionCode: 50002 },
|
||||
{ abi: "x86", versionCode: 50003 },
|
||||
{ abi: "x86_64", versionCode: 50004 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.6.1",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project=browser",
|
||||
"test:e2e:diff-performance": "cross-env PASEO_DIFF_PERF_E2E=1 playwright test --project=browser e2e/browser/diff-performance.spec.ts",
|
||||
"test:e2e:stream-smoothness": "cross-env PASEO_AGENT_STREAM_PERF_E2E=1 playwright test --project=browser e2e/browser/agent-stream-smoothness.spec.ts",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:relay-deployment": "npm --prefix ../.. run build:server && npm --prefix ../.. run build:daemon-web-ui && cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=relay-deployment e2e/browser/relay-deployment-reconnect.real.spec.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
@@ -30,7 +31,6 @@
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
"profile:composer-typing": "node ./scripts/profile-composer-typing.mjs",
|
||||
"profile:explorer-toggle": "node ./scripts/profile-explorer-toggle.mjs",
|
||||
"profile:side-pane": "playwright test --config playwright.profile.config.ts side-pane-performance.spec.ts",
|
||||
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
@@ -96,6 +96,7 @@
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-sharing": "^14.0.8",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-sqlite": "~16.0.10",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"htmlparser2": "^12.0.0",
|
||||
@@ -137,6 +138,7 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "~22.19.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
@@ -150,6 +152,7 @@
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"expo-gradle-jvmargs": "^1.1.2",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^20.0.3",
|
||||
"material-icon-theme": "^5.32.0",
|
||||
"playwright": "^1.56.1",
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
|
||||
export interface ProfileTarget {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface RenderSummary {
|
||||
id: string;
|
||||
renders: number;
|
||||
mounts: number;
|
||||
updates: number;
|
||||
actualDurationMs: number;
|
||||
maximumActualDurationMs: number;
|
||||
}
|
||||
|
||||
export interface InteractionMeasurement {
|
||||
name: string;
|
||||
latencyMs: number;
|
||||
commits: number;
|
||||
domMutations: number;
|
||||
addedElements: number;
|
||||
removedElements: number;
|
||||
addedTestIds: string[];
|
||||
removedTestIds: string[];
|
||||
trackedNodes: Array<{
|
||||
selector: string;
|
||||
existedBefore: boolean;
|
||||
connected: boolean;
|
||||
sameNode: boolean;
|
||||
}>;
|
||||
renders: RenderSummary[];
|
||||
reasons: Record<string, Record<string, number>>;
|
||||
heapAfterGcBytes?: number;
|
||||
}
|
||||
|
||||
interface BrowserMeasurementState {
|
||||
startedAt: number;
|
||||
selectors: string[];
|
||||
originals: Array<Element | null>;
|
||||
observer?: MutationObserver;
|
||||
domMutations: number;
|
||||
addedElements: number;
|
||||
removedElements: number;
|
||||
addedTestIds: Set<string>;
|
||||
removedTestIds: Set<string>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PASEO_SIDE_PANE_MEASUREMENT__?: BrowserMeasurementState;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCliJson<T>(output: string): T {
|
||||
const arrayStart = output.indexOf("[\n");
|
||||
const objectStart = output.indexOf("{\n");
|
||||
const start = arrayStart >= 0 ? arrayStart : objectStart;
|
||||
if (start < 0) throw new Error(`Could not find JSON in CLI output: ${output}`);
|
||||
return JSON.parse(output.slice(start)) as T;
|
||||
}
|
||||
|
||||
export function resolveProfileTarget(): ProfileTarget {
|
||||
const repoRoot = resolve(process.cwd(), "../..");
|
||||
const serverId = (
|
||||
process.env.PASEO_PROFILE_SERVER_ID ??
|
||||
readFileSync(resolve(repoRoot, ".dev/paseo-home/server-id"), "utf8")
|
||||
).trim();
|
||||
const requestedName = process.env.PASEO_PROFILE_WORKSPACE_NAME?.trim() || "Paseo";
|
||||
const requestedWorkspaceId = process.env.PASEO_PROFILE_WORKSPACE_ID?.trim();
|
||||
if (requestedWorkspaceId) return { serverId, workspaceId: requestedWorkspaceId };
|
||||
|
||||
const output = execFileSync("npm", ["run", "cli", "--", "workspace", "ls", "--json"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
});
|
||||
const workspaces =
|
||||
parseCliJson<Array<{ workspaceId: string; name: string; cwd: string }>>(output);
|
||||
const workspace = workspaces.find(
|
||||
(candidate) => candidate.name === requestedName && candidate.cwd === repoRoot,
|
||||
);
|
||||
if (!workspace) {
|
||||
throw new Error(`No Paseo workspace named ${requestedName} points at ${repoRoot}`);
|
||||
}
|
||||
return { serverId, workspaceId: workspace.workspaceId };
|
||||
}
|
||||
|
||||
export async function openProfileWorkspace(page: Page, target: ProfileTarget): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const preserveRenderProfile = (original: History["pushState"]) =>
|
||||
function preserveProfileParam(
|
||||
this: History,
|
||||
state: unknown,
|
||||
unused: string,
|
||||
url?: string | URL | null,
|
||||
) {
|
||||
if (url == null) return Reflect.apply(original, this, [state, unused, url]);
|
||||
const nextUrl = new URL(String(url), location.href);
|
||||
nextUrl.searchParams.set("renderProfile", "1");
|
||||
return Reflect.apply(original, this, [
|
||||
state,
|
||||
unused,
|
||||
`${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`,
|
||||
]);
|
||||
};
|
||||
history.pushState = preserveRenderProfile(history.pushState);
|
||||
history.replaceState = preserveRenderProfile(history.replaceState);
|
||||
});
|
||||
await page.goto("/?renderProfile=1", { waitUntil: "domcontentloaded" });
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${target.serverId}:${target.workspaceId}`,
|
||||
);
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 60_000 });
|
||||
await workspaceRow.click();
|
||||
await expect(
|
||||
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-explorer-toggle").first()).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${target.workspaceId}\\?renderProfile=1$`));
|
||||
}
|
||||
|
||||
export async function enableChangesOpenInSidePane(page: Page): Promise<void> {
|
||||
await page.evaluate((settingsKey) => {
|
||||
const persisted = JSON.parse(localStorage.getItem(settingsKey) ?? "{}") as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const currentPreferences =
|
||||
typeof persisted.openInSidePane === "object" && persisted.openInSidePane !== null
|
||||
? (persisted.openInSidePane as Record<string, unknown>)
|
||||
: {};
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
...persisted,
|
||||
openInSidePane: { ...currentPreferences, explorerChanges: true },
|
||||
}),
|
||||
);
|
||||
}, APP_SETTINGS_KEY);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openChangesExplorer(page: Page): Promise<void> {
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
if ((await explorer.count()) === 0) {
|
||||
await page.keyboard.press("Meta+e");
|
||||
}
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first();
|
||||
await expect(changesTab).toBeVisible();
|
||||
if ((await changesTab.getAttribute("aria-selected")) !== "true") {
|
||||
await changesTab.click();
|
||||
}
|
||||
await expect(page.getByTestId("changes-tree-panel")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(changedFileRows(page).nth(1)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export function changedFileRows(page: Page): Locator {
|
||||
return page
|
||||
.locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]')
|
||||
.filter({ visible: true });
|
||||
}
|
||||
|
||||
export async function selectChangedFile(page: Page, index: number): Promise<void> {
|
||||
const row = changedFileRows(page).nth(index);
|
||||
await expect(row).toBeVisible();
|
||||
await row.click();
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function expectWorkingDiffVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(page.getByTestId("git-diff-canvas").filter({ visible: true })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeWorkingDiffPane(page: Page): Promise<void> {
|
||||
const tab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }).first();
|
||||
await expect(tab).toBeVisible();
|
||||
await tab.hover();
|
||||
await page
|
||||
.locator('[data-testid^="workspace-working-diff-close-"]')
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function measureHeapAfterGc(page: Page): Promise<number> {
|
||||
await page.requestGC();
|
||||
return page.evaluate(() => {
|
||||
const memory = performance as Performance & {
|
||||
memory?: { usedJSHeapSize: number };
|
||||
};
|
||||
return memory.memory?.usedJSHeapSize ?? 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForReactIdle(page: Page, quietMs = 250): Promise<void> {
|
||||
let previousCount = -1;
|
||||
let unchangedSince = Date.now();
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
const count = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__?.length ?? 0);
|
||||
if (count !== previousCount) {
|
||||
previousCount = count;
|
||||
unchangedSince = Date.now();
|
||||
} else if (Date.now() - unchangedSince >= quietMs) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
throw new Error("React profiler did not become idle");
|
||||
}
|
||||
|
||||
function summarizeRenders(
|
||||
samples: Array<{
|
||||
id: string;
|
||||
phase: "mount" | "update" | "nested-update";
|
||||
actualDuration: number;
|
||||
}>,
|
||||
): RenderSummary[] {
|
||||
const summaries = new Map<string, RenderSummary>();
|
||||
for (const sample of samples) {
|
||||
const summary = summaries.get(sample.id) ?? {
|
||||
id: sample.id,
|
||||
renders: 0,
|
||||
mounts: 0,
|
||||
updates: 0,
|
||||
actualDurationMs: 0,
|
||||
maximumActualDurationMs: 0,
|
||||
};
|
||||
summary.renders += 1;
|
||||
summary.mounts += sample.phase === "mount" ? 1 : 0;
|
||||
summary.updates += sample.phase === "mount" ? 0 : 1;
|
||||
summary.actualDurationMs += sample.actualDuration;
|
||||
summary.maximumActualDurationMs = Math.max(
|
||||
summary.maximumActualDurationMs,
|
||||
sample.actualDuration,
|
||||
);
|
||||
summaries.set(sample.id, summary);
|
||||
}
|
||||
for (const summary of summaries.values()) {
|
||||
summary.actualDurationMs = Math.round(summary.actualDurationMs * 100) / 100;
|
||||
summary.maximumActualDurationMs = Math.round(summary.maximumActualDurationMs * 100) / 100;
|
||||
}
|
||||
return [...summaries.values()].sort(
|
||||
(left, right) => right.actualDurationMs - left.actualDurationMs,
|
||||
);
|
||||
}
|
||||
|
||||
async function beginMeasurement(page: Page, selectors: string[]): Promise<void> {
|
||||
await waitForReactIdle(page);
|
||||
await page.evaluate((trackedSelectors) => {
|
||||
globalThis.__PASEO_RESET_RENDER_PROFILE__?.();
|
||||
const collectTestIds = (node: Node, output: Set<string>) => {
|
||||
if (!(node instanceof Element)) return;
|
||||
const ownTestId = node.getAttribute("data-testid");
|
||||
if (ownTestId) output.add(ownTestId);
|
||||
for (const element of node.querySelectorAll("[data-testid]")) {
|
||||
const testId = element.getAttribute("data-testid");
|
||||
if (testId) output.add(testId);
|
||||
}
|
||||
};
|
||||
const state: BrowserMeasurementState = {
|
||||
startedAt: performance.now(),
|
||||
selectors: trackedSelectors,
|
||||
originals: trackedSelectors.map((selector) => document.querySelector(selector)),
|
||||
domMutations: 0,
|
||||
addedElements: 0,
|
||||
removedElements: 0,
|
||||
addedTestIds: new Set(),
|
||||
removedTestIds: new Set(),
|
||||
};
|
||||
state.observer = new MutationObserver((records) => {
|
||||
state.domMutations += records.length;
|
||||
for (const record of records) {
|
||||
for (const node of record.addedNodes) {
|
||||
state.addedElements +=
|
||||
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
|
||||
collectTestIds(node, state.addedTestIds);
|
||||
}
|
||||
for (const node of record.removedNodes) {
|
||||
state.removedElements +=
|
||||
node instanceof Element ? 1 + node.querySelectorAll("*").length : 0;
|
||||
collectTestIds(node, state.removedTestIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
state.observer.observe(document.body, { childList: true, subtree: true });
|
||||
window.__PASEO_SIDE_PANE_MEASUREMENT__ = state;
|
||||
}, selectors);
|
||||
}
|
||||
|
||||
async function finishMeasurement(page: Page, name: string): Promise<InteractionMeasurement> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolveFrame) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolveFrame()));
|
||||
}),
|
||||
);
|
||||
await waitForReactIdle(page);
|
||||
const raw = await page.evaluate((measurementName) => {
|
||||
const state = window.__PASEO_SIDE_PANE_MEASUREMENT__;
|
||||
if (!state) throw new Error("Side-pane performance measurement was not started");
|
||||
state.observer?.disconnect();
|
||||
const samples = globalThis.__PASEO_RENDER_PROFILE__ ?? [];
|
||||
return {
|
||||
name: measurementName,
|
||||
latencyMs: Math.round((performance.now() - state.startedAt) * 100) / 100,
|
||||
commits: new Set(samples.map((sample) => sample.commitTime)).size,
|
||||
domMutations: state.domMutations,
|
||||
addedElements: state.addedElements,
|
||||
removedElements: state.removedElements,
|
||||
addedTestIds: [...state.addedTestIds].sort(),
|
||||
removedTestIds: [...state.removedTestIds].sort(),
|
||||
trackedNodes: state.selectors.map((selector, index) => {
|
||||
const original = state.originals[index] ?? null;
|
||||
const current = document.querySelector(selector);
|
||||
return {
|
||||
selector,
|
||||
existedBefore: original !== null,
|
||||
connected: original?.isConnected ?? false,
|
||||
sameNode: original !== null && original === current,
|
||||
};
|
||||
}),
|
||||
samples,
|
||||
reasons: globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {},
|
||||
};
|
||||
}, name);
|
||||
return {
|
||||
...raw,
|
||||
renders: summarizeRenders(raw.samples),
|
||||
};
|
||||
}
|
||||
|
||||
export async function measureInteraction(
|
||||
page: Page,
|
||||
input: {
|
||||
name: string;
|
||||
trackedSelectors: string[];
|
||||
interact: () => Promise<void>;
|
||||
expectComplete: () => Promise<void>;
|
||||
},
|
||||
): Promise<InteractionMeasurement> {
|
||||
await beginMeasurement(page, input.trackedSelectors);
|
||||
await input.interact();
|
||||
await input.expectComplete();
|
||||
return finishMeasurement(page, input.name);
|
||||
}
|
||||
|
||||
export function expectTrackedNodePreserved(
|
||||
measurement: InteractionMeasurement,
|
||||
selector: string,
|
||||
): void {
|
||||
const tracked = measurement.trackedNodes.find((candidate) => candidate.selector === selector);
|
||||
expect.soft(tracked).toEqual({
|
||||
selector,
|
||||
existedBefore: true,
|
||||
connected: true,
|
||||
sameNode: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function expectProfilerDidNotMount(
|
||||
measurement: InteractionMeasurement,
|
||||
idPrefix: string,
|
||||
): void {
|
||||
const summaries = measurement.renders.filter((summary) => summary.id.startsWith(idPrefix));
|
||||
expect.soft(summaries.length).toBeGreaterThan(0);
|
||||
expect.soft(summaries.every((summary) => summary.mounts === 0)).toBe(true);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
changedFileRows,
|
||||
closeWorkingDiffPane,
|
||||
enableChangesOpenInSidePane,
|
||||
expectProfilerDidNotMount,
|
||||
expectTrackedNodePreserved,
|
||||
expectWorkingDiffVisible,
|
||||
measureInteraction,
|
||||
measureHeapAfterGc,
|
||||
openChangesExplorer,
|
||||
openProfileWorkspace,
|
||||
resolveProfileTarget,
|
||||
selectChangedFile,
|
||||
type InteractionMeasurement,
|
||||
} from "./helpers/side-pane-performance";
|
||||
|
||||
const MAIN_PANE = '[data-testid="workspace-pane-main"]';
|
||||
const WORKING_DIFF = '[data-testid="working-diff-panel"]';
|
||||
const DIFF_CANVAS = '[data-testid="git-diff-canvas-root"]';
|
||||
const DIFF_TAB = '[data-testid="workspace-tab-working_diff"]';
|
||||
|
||||
test("side-pane diff transitions preserve mounted work and report their real React cost", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const target = resolveProfileTarget();
|
||||
const reports: InteractionMeasurement[] = [];
|
||||
|
||||
await test.step("open the real main workspace with Changes routed beside it", async () => {
|
||||
await openProfileWorkspace(page, target);
|
||||
await enableChangesOpenInSidePane(page);
|
||||
await openChangesExplorer(page);
|
||||
expect(await changedFileRows(page).count()).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
await test.step("creating the side pane keeps the main pane mounted", async () => {
|
||||
const report = await measureInteraction(page, {
|
||||
name: "create-side-pane",
|
||||
trackedSelectors: [MAIN_PANE],
|
||||
interact: () => selectChangedFile(page, 0),
|
||||
expectComplete: () => expectWorkingDiffVisible(page),
|
||||
});
|
||||
reports.push(report);
|
||||
await testInfo.attach(report.name, {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||
});
|
||||
|
||||
await test.step("changing the selected diff file updates the existing diff in place", async () => {
|
||||
const report = await measureInteraction(page, {
|
||||
name: "change-diff-focus",
|
||||
trackedSelectors: [MAIN_PANE, WORKING_DIFF, DIFF_CANVAS, DIFF_TAB],
|
||||
interact: () => selectChangedFile(page, 1),
|
||||
expectComplete: () => expectWorkingDiffVisible(page),
|
||||
});
|
||||
reports.push(report);
|
||||
await testInfo.attach(report.name, {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||
expectTrackedNodePreserved(report, WORKING_DIFF);
|
||||
expectTrackedNodePreserved(report, DIFF_CANVAS);
|
||||
expectTrackedNodePreserved(report, DIFF_TAB);
|
||||
expectProfilerDidNotMount(report, "WorkingDiffPanel:");
|
||||
});
|
||||
|
||||
await test.step("repeated cold reopenings measure retained workspace work", async () => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await closeWorkingDiffPane(page);
|
||||
const report = await measureInteraction(page, {
|
||||
name: `reopen-side-pane-${index + 1}`,
|
||||
trackedSelectors: [MAIN_PANE],
|
||||
interact: () => selectChangedFile(page, index % 2),
|
||||
expectComplete: () => expectWorkingDiffVisible(page),
|
||||
});
|
||||
report.heapAfterGcBytes = await measureHeapAfterGc(page);
|
||||
reports.push(report);
|
||||
await testInfo.attach(report.name, {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expectTrackedNodePreserved(report, MAIN_PANE);
|
||||
expect(
|
||||
report.renders.some(
|
||||
(summary) => summary.id.startsWith("WorkingDiffPanel:") && summary.mounts > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
const artifact = {
|
||||
appUrl: testInfo.project.use.baseURL,
|
||||
target,
|
||||
reports,
|
||||
};
|
||||
await testInfo.attach("side-pane-performance", {
|
||||
body: JSON.stringify(artifact, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(
|
||||
`[perf] Side pane: ${JSON.stringify(
|
||||
reports.map((report) => ({
|
||||
name: report.name,
|
||||
latencyMs: report.latencyMs,
|
||||
commits: report.commits,
|
||||
domMutations: report.domMutations,
|
||||
addedElements: report.addedElements,
|
||||
removedElements: report.removedElements,
|
||||
heapAfterGcBytes: report.heapAfterGcBytes,
|
||||
trackedNodes: report.trackedNodes,
|
||||
renders: report.renders.slice(0, 8),
|
||||
})),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
@@ -6,12 +6,6 @@ const baseURL =
|
||||
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
|
||||
const relayDeploymentSpec = "**/relay-deployment-reconnect.real.spec.ts";
|
||||
|
||||
function videoMode(): "on" | "on-first-retry" | "retain-on-failure" {
|
||||
if (process.env.E2E_RECORD_VIDEO === "1") return "on";
|
||||
if (process.env.CI) return "on-first-retry";
|
||||
return "retain-on-failure";
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e/browser",
|
||||
globalSetup: "./e2e/support/global-setup.ts",
|
||||
@@ -27,9 +21,9 @@ export default defineConfig({
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: process.env.CI ? "on-first-retry" : "retain-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: videoMode(),
|
||||
video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./perf",
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 30_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: process.env.PASEO_PROFILE_APP_URL ?? "http://127.0.0.1:8081",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { withAppBuildGradle, withDangerousMod, withSettingsGradle } = require("expo/config-plugins");
|
||||
const { FDROID_ABI_VERSION_CODE_SUFFIXES } = require("../native-release-version");
|
||||
|
||||
const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-camera",
|
||||
@@ -11,12 +12,15 @@ const EXCLUDED_ANDROID_MODULES = [
|
||||
"expo-dev-menu-interface",
|
||||
];
|
||||
|
||||
// Generated from the shared suffix table so the Groovy literal can never drift
|
||||
// from the version codes the F-Droid changelog filenames are derived from.
|
||||
const FDROID_ABI_VERSION_CODE_ENTRIES = Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES)
|
||||
.map(([abi, suffix]) => ` "${abi}": ${suffix},`)
|
||||
.join("\n");
|
||||
|
||||
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
|
||||
def paseoAbiVersionCodes = [
|
||||
"armeabi-v7a": 1,
|
||||
"arm64-v8a": 2,
|
||||
"x86": 3,
|
||||
"x86_64": 4,
|
||||
${FDROID_ABI_VERSION_CODE_ENTRIES}
|
||||
]
|
||||
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
|
||||
.toString()
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface BottomAnchorRouteRequest {
|
||||
}
|
||||
|
||||
export interface BottomAnchorLocalRequest {
|
||||
reason: "jump-to-bottom" | "message-sent";
|
||||
reason: "jump-to-bottom" | "message-sent" | "rewind";
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user