mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 08:42:39 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c32a43abc | ||
|
|
96dd11a053 | ||
|
|
ac65d19254 | ||
|
|
a295eaaf72 | ||
|
|
c18f13b47c | ||
|
|
89cb1bf34d |
+94
-174
@@ -3,18 +3,14 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Every base branch, so stacked PRs and PRs into `next` get the same checks.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same ref cancels the run still in progress, main included.
|
||||
# A full run costs about 200 runner-minutes against the org's 20 concurrent
|
||||
# jobs, and a superseded main run only answers a question the newer commit
|
||||
# answers again.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
@@ -58,10 +54,10 @@ jobs:
|
||||
- name: Validate CI contracts
|
||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
|
||||
|
||||
quality:
|
||||
name: quality
|
||||
format:
|
||||
name: format
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -72,67 +68,36 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
id: install
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
# Explicit status checks keep independent domains running after a failure.
|
||||
# A failed step still fails quality; no continue-on-error or success shim.
|
||||
- name: Check formatting
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
run: npm run format:check
|
||||
|
||||
lint:
|
||||
name: lint
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Lint lockfile
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||
|
||||
- name: Verify dependency signatures
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npm audit signatures
|
||||
|
||||
- name: Lint
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: npm run lint
|
||||
|
||||
- name: Test lint rules
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
run: node --test scripts/oxlint/paseo-plugin.test.mjs
|
||||
|
||||
- name: Build relay
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build client dependencies
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run build:relay && npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run build:plugin
|
||||
|
||||
- name: Run plugin SDK tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Run highlight tests
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run test --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Typecheck client examples
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
typecheck:
|
||||
name: typecheck
|
||||
needs: changes
|
||||
@@ -149,9 +114,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Verify dependency patch recovery
|
||||
run: node --test scripts/postinstall-patches.test.mjs
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
@@ -181,56 +143,43 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- &server_capacity
|
||||
name: Log runner capacity
|
||||
run: |
|
||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
||||
|
||||
- &server_checkout
|
||||
uses: actions/checkout@v4
|
||||
steps: &server_test_steps
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- &server_node
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- &server_fetch
|
||||
name: Fetch origin/main (worktree tests)
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- &server_install
|
||||
name: Install dependencies
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- &server_agent_clis
|
||||
name: Install agent CLIs for provider tests
|
||||
- name: Install agent CLIs for provider tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- &server_build
|
||||
name: Build server dependencies
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env: &server_test_env
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- &server_plugin_tests
|
||||
name: Run built-in plugin tests
|
||||
- name: Run built-in plugin tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test --workspace=@getpaseo/builtin-plugins
|
||||
|
||||
- &server_hub_contract
|
||||
name: Run Hub CLI/daemon contract
|
||||
- name: Run Hub CLI/daemon contract
|
||||
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
|
||||
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
|
||||
|
||||
@@ -239,41 +188,7 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- *server_capacity
|
||||
- *server_checkout
|
||||
- *server_node
|
||||
- *server_fetch
|
||||
- *server_install
|
||||
- *server_agent_clis
|
||||
- *server_build
|
||||
|
||||
- name: Run server unit tests
|
||||
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
|
||||
run: npm run test:unit --workspace=@getpaseo/server
|
||||
env: *server_test_env
|
||||
|
||||
- *server_plugin_tests
|
||||
- *server_hub_contract
|
||||
|
||||
# Keep the serial integration chain off the Windows unit critical path.
|
||||
server-integration-windows:
|
||||
name: server-integration (windows-latest)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- *server_capacity
|
||||
- *server_checkout
|
||||
- *server_node
|
||||
- *server_fetch
|
||||
- *server_install
|
||||
- *server_agent_clis
|
||||
- *server_build
|
||||
|
||||
- name: Run server integration tests
|
||||
run: npm run test:integration --workspace=@getpaseo/server
|
||||
env: *server_test_env
|
||||
steps: *server_test_steps
|
||||
|
||||
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
|
||||
# runner that exercises it. macOS — the platform the reported watcher bug was on —
|
||||
@@ -417,27 +332,52 @@ jobs:
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
sdk-tests:
|
||||
name: sdk-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run Android module tests
|
||||
run: npm run test:android --workspace=@getpaseo/app
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:relay && npm run build:client
|
||||
|
||||
- name: Build plugin SDK
|
||||
run: npm run build:plugin
|
||||
|
||||
- name: Run plugin SDK tests
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Run highlight tests
|
||||
run: npm run test --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Typecheck client examples
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright-1:
|
||||
name: playwright (shard 1/8)
|
||||
name: playwright (shard 1/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
PLAYWRIGHT_SHARD: "1/4"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
steps: &playwright_test_steps
|
||||
- name: Log runner capacity
|
||||
run: |
|
||||
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -465,11 +405,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
E2E_WORKERS: "2"
|
||||
# File groups are weighted by observed reporter durations.
|
||||
# Share of the test list per shard, rebalanced from the Playwright step
|
||||
# minutes of five eight-shard runs so the slowest and fastest shards meet.
|
||||
PWTEST_SHARD_WEIGHTS: "85:94:117:128:91:103:127:85"
|
||||
# Weight file groups by observed duration; raw test counts overload
|
||||
# early agent/Changes specs. Required check names remain unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -482,74 +420,56 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
playwright-2:
|
||||
name: playwright (shard 2/8)
|
||||
name: playwright (shard 2/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_SHARD: "2/4"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-3:
|
||||
name: playwright (shard 3/8)
|
||||
name: playwright (shard 3/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_SHARD: "3/4"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-4:
|
||||
name: playwright (shard 4/8)
|
||||
name: playwright (shard 4/4)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_SHARD: "4/4"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-5:
|
||||
name: playwright (shard 5/8)
|
||||
relay-tests:
|
||||
name: relay-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
steps: *playwright_test_steps
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
playwright-6:
|
||||
name: playwright (shard 6/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "6/8"
|
||||
PLAYWRIGHT_ARTIFACT: "6"
|
||||
steps: *playwright_test_steps
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
playwright-7:
|
||||
name: playwright (shard 7/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "7/8"
|
||||
PLAYWRIGHT_ARTIFACT: "7"
|
||||
steps: *playwright_test_steps
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
playwright-8:
|
||||
name: playwright (shard 8/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "8/8"
|
||||
PLAYWRIGHT_ARTIFACT: "8"
|
||||
steps: *playwright_test_steps
|
||||
- name: Build relay
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests-1:
|
||||
name: cli-tests (shard 1/3)
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
|
||||
# or how it is smoked still proves the real artifacts before merge.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "plugins/**"
|
||||
|
||||
-112
@@ -5,7 +5,6 @@
|
||||
},
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"jsPlugins": ["./scripts/oxlint/paseo-plugin.mjs"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "error",
|
||||
@@ -357,117 +356,6 @@
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/app/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"paseo/no-raw-pressable": "error"
|
||||
}
|
||||
},
|
||||
// Press feedback is owned by the primitives in components/ui. Raw pressable burn-down:
|
||||
// 94 files. They may only leave this list.
|
||||
{
|
||||
"files": [
|
||||
"packages/app/src/components/ui/**/*.{ts,tsx}",
|
||||
"packages/app/src/**/*.{test,spec}.{ts,tsx}",
|
||||
"packages/app/src/agent-profiles/settings/agent-profile-appearance-field.tsx",
|
||||
"packages/app/src/agent-skills/selection-sheet.tsx",
|
||||
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
|
||||
"packages/app/src/agent-stream/view.tsx",
|
||||
"packages/app/src/app/pair-scan.tsx",
|
||||
"packages/app/src/changelog/internal/changelog-sheet.tsx",
|
||||
"packages/app/src/command-center/command-center.tsx",
|
||||
"packages/app/src/components/adaptive-modal-sheet.tsx",
|
||||
"packages/app/src/components/add-host-method-modal.tsx",
|
||||
"packages/app/src/components/add-host-modal.tsx",
|
||||
"packages/app/src/components/add-project-flow.tsx",
|
||||
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
|
||||
"packages/app/src/components/agent-list.tsx",
|
||||
"packages/app/src/components/app-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/attachment-lightbox.tsx",
|
||||
"packages/app/src/components/attachment-pill.tsx",
|
||||
"packages/app/src/components/combined-model-selector.tsx",
|
||||
"packages/app/src/components/compact-explorer-sidebar.tsx",
|
||||
"packages/app/src/components/context-window-meter.tsx",
|
||||
"packages/app/src/components/desktop/window-controls.tsx",
|
||||
"packages/app/src/components/dictation-controls.tsx",
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/file-explorer-pane.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/highlighted-code-block.tsx",
|
||||
"packages/app/src/components/hosts/host-filter.tsx",
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
"packages/app/src/components/import-session-sheet.tsx",
|
||||
"packages/app/src/components/left-sidebar.tsx",
|
||||
"packages/app/src/components/markdown/fence/mermaid/host.native.tsx",
|
||||
"packages/app/src/components/markdown/fence/mermaid/host.web.tsx",
|
||||
"packages/app/src/components/message.tsx",
|
||||
"packages/app/src/components/model-browser.tsx",
|
||||
"packages/app/src/components/plan-card.tsx",
|
||||
"packages/app/src/components/provider-diagnostic-sheet.tsx",
|
||||
"packages/app/src/components/question-form-card.tsx",
|
||||
"packages/app/src/components/realtime-voice-overlay.tsx",
|
||||
"packages/app/src/components/schedules/schedule-row.tsx",
|
||||
"packages/app/src/components/settings/collapsible-row.tsx",
|
||||
"packages/app/src/components/settings/headings/settings-info-tip.tsx",
|
||||
"packages/app/src/components/sidebar-callout.tsx",
|
||||
"packages/app/src/components/sidebar-resize-handle.tsx",
|
||||
"packages/app/src/components/sidebar-workspace-list.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-header-row.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-section-header.tsx",
|
||||
"packages/app/src/components/sidebar/sidebar-status-list.tsx",
|
||||
"packages/app/src/components/sidebar/workspace-meta-row/index.tsx",
|
||||
"packages/app/src/components/terminal-copy-paste-actions.tsx",
|
||||
"packages/app/src/components/terminal-emulator-native-grid.native.tsx",
|
||||
"packages/app/src/components/terminal-pane.tsx",
|
||||
"packages/app/src/components/toast-host.tsx",
|
||||
"packages/app/src/components/tool-call-sheet.tsx",
|
||||
"packages/app/src/components/welcome-screen.tsx",
|
||||
"packages/app/src/components/workspace-hover-card.tsx",
|
||||
"packages/app/src/components/zoomable-viewport/toolbar.tsx",
|
||||
"packages/app/src/composer/agent-controls/index.tsx",
|
||||
"packages/app/src/composer/diff-stat-pill.tsx",
|
||||
"packages/app/src/composer/dock/internal/background.native.tsx",
|
||||
"packages/app/src/composer/draft/import-pill.tsx",
|
||||
"packages/app/src/composer/index.tsx",
|
||||
"packages/app/src/composer/tracks.tsx",
|
||||
"packages/app/src/desktop/browser/pane/index.electron.tsx",
|
||||
"packages/app/src/git/actions-split-button.tsx",
|
||||
"packages/app/src/git/commits-section/commit-row.tsx",
|
||||
"packages/app/src/git/commits-section/commits-section.tsx",
|
||||
"packages/app/src/git/diff-pane.tsx",
|
||||
"packages/app/src/git/forges/gitlab.view.tsx",
|
||||
"packages/app/src/git/pull-request-panel/checks-section.tsx",
|
||||
"packages/app/src/git/pull-request-panel/pane.tsx",
|
||||
"packages/app/src/git/pull-request-panel/section-kit.tsx",
|
||||
"packages/app/src/hosts/host-chooser.tsx",
|
||||
"packages/app/src/mobile-panels/presentation.tsx",
|
||||
"packages/app/src/panels/new-tab-panel.tsx",
|
||||
"packages/app/src/panels/setup-panel.tsx",
|
||||
"packages/app/src/plugins/buttons/view.tsx",
|
||||
"packages/app/src/review/surface.tsx",
|
||||
"packages/app/src/screens/open-project-screen.tsx",
|
||||
"packages/app/src/screens/project-settings-screen.tsx",
|
||||
"packages/app/src/screens/projects-screen.tsx",
|
||||
"packages/app/src/screens/settings-screen.tsx",
|
||||
"packages/app/src/screens/settings/host-appearance-section.tsx",
|
||||
"packages/app/src/screens/settings/host-page.tsx",
|
||||
"packages/app/src/screens/settings/providers-section.tsx",
|
||||
"packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx",
|
||||
"packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx",
|
||||
"packages/app/src/screens/workspace/workspace-tab-presentation.tsx",
|
||||
"packages/app/src/tool-calls/detail-level/overview/sheet.tsx",
|
||||
"packages/app/src/usage/sidebar-item.tsx",
|
||||
"packages/app/src/usage/window-bar.tsx",
|
||||
"packages/app/src/workspace-labels/manager-modal.tsx",
|
||||
"packages/app/src/workspace-labels/swatch.tsx",
|
||||
"packages/app/src/workspace/open-in-editor/button.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"paseo/no-raw-pressable": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/e2e/**/fixtures.ts"],
|
||||
"rules": {
|
||||
|
||||
-172
@@ -1,177 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0 - 2026-10-19
|
||||
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
**Before upgrading:** `paseo run --background` is deprecated in favor of `--no-wait`, and `paseo permit allow` and `deny` now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
### Added
|
||||
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on ([#5039](https://github.com/getpaseo/paseo/pull/5039), [#7141](https://github.com/getpaseo/paseo/pull/7141) by [@gpambrozio](https://github.com/gpambrozio), [@marvin-ambrozio](https://github.com/marvin-ambrozio))
|
||||
- Added a Send feedback button to the diff pane, so review comments reach an agent without opening its chat ([#6455](https://github.com/getpaseo/paseo/pull/6455))
|
||||
- Added a fullscreen composer on phones ([#6531](https://github.com/getpaseo/paseo/pull/6531), [#6902](https://github.com/getpaseo/paseo/pull/6902), [#6945](https://github.com/getpaseo/paseo/pull/6945), [#6987](https://github.com/getpaseo/paseo/pull/6987), [#7130](https://github.com/getpaseo/paseo/pull/7130), [#7170](https://github.com/getpaseo/paseo/pull/7170))
|
||||
- Added the sender's name to messages one agent sends another ([#6373](https://github.com/getpaseo/paseo/pull/6373))
|
||||
- Added Claude Mythos 5.1 when the installed Claude Code advertises it ([#6403](https://github.com/getpaseo/paseo/pull/6403))
|
||||
- Added option previews to Claude question cards ([#6171](https://github.com/getpaseo/paseo/pull/6171) by [@gabrielgiordan](https://github.com/gabrielgiordan))
|
||||
- Added `paseo usage` to show account quotas, balances, and reset times from the CLI ([#6474](https://github.com/getpaseo/paseo/pull/6474))
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed text selection in replies on iOS and Android to cross paragraphs, headings, and lists ([#6973](https://github.com/getpaseo/paseo/pull/6973))
|
||||
- Added a copy button to code blocks on iOS and Android ([#6973](https://github.com/getpaseo/paseo/pull/6973))
|
||||
- Changed wide Markdown tables on iOS and Android to scroll sideways ([#6973](https://github.com/getpaseo/paseo/pull/6973))
|
||||
- Changed phone layouts to a floating header ([#6431](https://github.com/getpaseo/paseo/pull/6431))
|
||||
- Moved New agent and New terminal on phones into the tab switcher ([#6431](https://github.com/getpaseo/paseo/pull/6431))
|
||||
- Moved workspace scripts on phones into ⋯ → Scripts, which shows what is running ([#7122](https://github.com/getpaseo/paseo/pull/7122))
|
||||
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
|
||||
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
|
||||
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
|
||||
- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
|
||||
- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
|
||||
- Made subagent rows in the Subagents panel taller on phones, and gave their archive and detach buttons the standard press highlight ([#7364](https://github.com/getpaseo/paseo/pull/7364))
|
||||
- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
|
||||
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
|
||||
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8), [#7342](https://github.com/getpaseo/paseo/pull/7342))
|
||||
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
|
||||
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
|
||||
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
|
||||
- Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam))
|
||||
|
||||
### Plugins
|
||||
|
||||
- Fixed plugins that import Paseo protocol types failing to install with "Could not resolve type dependency" ([#7023](https://github.com/getpaseo/paseo/pull/7023))
|
||||
- Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR))
|
||||
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
|
||||
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
|
||||
- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
|
||||
- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
|
||||
- Fixed late-loading composer plugin pills covering Copy and Fork after reopening a chat ([#7341](https://github.com/getpaseo/paseo/pull/7341) by [@sholiday](https://github.com/sholiday))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed iOS replies with list items that render blank, and text that keeps a stale width after a resize or rotation ([#6973](https://github.com/getpaseo/paseo/pull/6973))
|
||||
- Fixed plugin hosts, the CLI, and the desktop app growing to about 1 GB of memory after running for 30 to 75 minutes ([#6847](https://github.com/getpaseo/paseo/pull/6847) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
||||
- Fixed the daemon running out of memory during long OpenCode v2 reply or reasoning streams ([#6262](https://github.com/getpaseo/paseo/pull/6262))
|
||||
- Fixed archived ACP agents starting a provider process that kept running each time their timeline loaded ([#6766](https://github.com/getpaseo/paseo/pull/6766) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed oversized attachments disconnecting the host in a loop instead of failing with a size error ([#6285](https://github.com/getpaseo/paseo/pull/6285))
|
||||
- Fixed Download in Files failing on hosts connected over relay, SSH, or a socket ([#6595](https://github.com/getpaseo/paseo/pull/6595) by [@odoo-mvds](https://github.com/odoo-mvds), [@YASICJUNWOO](https://github.com/YASICJUNWOO), [@JrDw0](https://github.com/JrDw0))
|
||||
- Fixed Changes showing no diff lines with custom Git diff settings, such as `color.diff=always` or an external diff command ([#6536](https://github.com/getpaseo/paseo/pull/6536), [#6562](https://github.com/getpaseo/paseo/pull/6562) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
|
||||
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
|
||||
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed the project's New workspace button choosing the first host instead of the active workspace's host ([#7308](https://github.com/getpaseo/paseo/pull/7308))
|
||||
- Fixed the composer staying focused after switching tabs with keyboard shortcuts in touch browsers ([#7352](https://github.com/getpaseo/paseo/pull/7352))
|
||||
- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
|
||||
- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
|
||||
- Fixed new agent drafts staying on "Loading…" when the host is disconnected; they now say "Host is not connected" and keep your draft ([#7303](https://github.com/getpaseo/paseo/pull/7303) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed reloading a chat during a streaming reply showing a frozen copy above the live one and dropping the ending ([#7256](https://github.com/getpaseo/paseo/pull/7256))
|
||||
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
|
||||
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
|
||||
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
|
||||
- Fixed resumed Codex goals appearing idle and refusing Stop after a continuation ([#7339](https://github.com/getpaseo/paseo/pull/7339) by [@hemaaanth](https://github.com/hemaaanth))
|
||||
- Fixed Add Project directory search hanging the daemon on macOS when it entered `~/Library` ([#5789](https://github.com/getpaseo/paseo/pull/5789) by [@lardissone](https://github.com/lardissone))
|
||||
- Fixed New workspace failing with "Invalid mode" when the saved mode isn't offered by the provider ([#5847](https://github.com/getpaseo/paseo/pull/5847) by [@mcowger](https://github.com/mcowger))
|
||||
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
|
||||
- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
|
||||
- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
|
||||
- Fixed OpenCode agents receiving "HTTP 500" instead of a readable error when they call a Paseo tool with invalid arguments or an unknown name ([#7183](https://github.com/getpaseo/paseo/pull/7183))
|
||||
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
|
||||
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
|
||||
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
|
||||
- Fixed background Claude subagent cards staying empty while the subagent works, including after the parent finishes ([#6242](https://github.com/getpaseo/paseo/pull/6242), [#7266](https://github.com/getpaseo/paseo/pull/7266))
|
||||
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
|
||||
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
|
||||
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed the Claude context meter counting only output tokens when a proxy reports input usage at the end of the response ([#6703](https://github.com/getpaseo/paseo/pull/6703) by [@kywoo26](https://github.com/kywoo26))
|
||||
- Fixed the Claude context meter showing a 1M maximum after switching back to a 200k model ([#6814](https://github.com/getpaseo/paseo/pull/6814))
|
||||
- Fixed the context meter staying empty during the first turn on custom Claude `[1m]` models ([#6767](https://github.com/getpaseo/paseo/pull/6767))
|
||||
- Fixed a schedule never running again after one of its runs could not be recorded ([#6334](https://github.com/getpaseo/paseo/pull/6334))
|
||||
- Fixed schedules sharing a cron minute running a slot twice and skipping the next occurrence ([#6532](https://github.com/getpaseo/paseo/pull/6532) by [@keithpk](https://github.com/keithpk))
|
||||
- Fixed Committed showing no changes for worktrees created from an existing branch ([#6462](https://github.com/getpaseo/paseo/pull/6462) by [@cr-2takumax](https://github.com/cr-2takumax))
|
||||
- Fixed Discard and commit diffs in Changes for workspaces opened at a repository subdirectory ([#6777](https://github.com/getpaseo/paseo/pull/6777), [#6801](https://github.com/getpaseo/paseo/pull/6801), [#6882](https://github.com/getpaseo/paseo/pull/6882) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed renamed and edited files showing every line as added in Changes ([#6559](https://github.com/getpaseo/paseo/pull/6559))
|
||||
- Fixed renamed files opened from a commit showing as new files ([#6895](https://github.com/getpaseo/paseo/pull/6895))
|
||||
- Fixed Changes showing no diff for filenames containing quotes, backslashes, or tabs ([#6583](https://github.com/getpaseo/paseo/pull/6583) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Changes dropping removed lines that start with `--`, such as SQL comments ([#6569](https://github.com/getpaseo/paseo/pull/6569) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed the Diff tab staying on a spinner after the first edit in a clean workspace ([#6745](https://github.com/getpaseo/paseo/pull/6745))
|
||||
- Fixed worktree creation failing after running `git init` in a project added before Git ([#6785](https://github.com/getpaseo/paseo/pull/6785))
|
||||
- Fixed new worktrees not opening the terminals configured in `paseo.json` after setup ([#6999](https://github.com/getpaseo/paseo/pull/6999) by [@davidyang](https://github.com/davidyang))
|
||||
- Fixed a service newly added to `paseo.json` failing to start until the daemon restarted ([#6542](https://github.com/getpaseo/paseo/pull/6542))
|
||||
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
|
||||
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
|
||||
- Fixed MCP terminal key commands typing arrow and modifier key names instead of sending the keys ([#7343](https://github.com/getpaseo/paseo/pull/7343) by [@Aling33](https://github.com/Aling33))
|
||||
- Fixed CJK text gaining spaces between characters after returning to a workspace in the native terminal ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
|
||||
- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
|
||||
- Fixed long-pressing a Korean word in the terminal on iOS and Android selecting only one character instead of the whole word ([#7353](https://github.com/getpaseo/paseo/pull/7353))
|
||||
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
|
||||
- Fixed the Hub connection never reconnecting after a silent network failure ([#7315](https://github.com/getpaseo/paseo/pull/7315))
|
||||
- Fixed remote connections failing on slow networks when another resolved address is unreachable ([#7336](https://github.com/getpaseo/paseo/pull/7336))
|
||||
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
|
||||
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
|
||||
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed folder links in chats and Markdown previews opening an error tab or doing nothing; they now open Files with the folder's contents visible ([#7205](https://github.com/getpaseo/paseo/pull/7205), [#7210](https://github.com/getpaseo/paseo/pull/7210), [#7229](https://github.com/getpaseo/paseo/pull/7229))
|
||||
- Fixed Markdown links to files such as `[Capture](capture.png)` doing nothing for some file types ([#7233](https://github.com/getpaseo/paseo/pull/7233))
|
||||
- Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja))
|
||||
- Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
|
||||
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
|
||||
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
|
||||
- Fixed Page Up and Page Down not scrolling the chat after clicking a message or closing Find ([#7033](https://github.com/getpaseo/paseo/pull/7033), [#7263](https://github.com/getpaseo/paseo/pull/7263) by [@panta82](https://github.com/panta82))
|
||||
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
|
||||
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
|
||||
- Fixed shortcut recording saving the QWERTY key instead of the typed letter on layouts such as Dvorak ([#6652](https://github.com/getpaseo/paseo/pull/6652))
|
||||
- Fixed rebinding Jump to tab or Jump to workspace keeping only the pressed number instead of 1–9; record existing overrides again ([#7065](https://github.com/getpaseo/paseo/pull/7065) by [@dbunk903](https://github.com/dbunk903))
|
||||
- Fixed Previous and Next workspace shortcuts stopping at workspace 9 ([#6586](https://github.com/getpaseo/paseo/pull/6586) by [@IEatCodeDaily](https://github.com/IEatCodeDaily))
|
||||
- Fixed unreadable Android status bar icons in the Light theme and after the system theme changes ([#6707](https://github.com/getpaseo/paseo/pull/6707) by [@terayang](https://github.com/terayang))
|
||||
- Fixed the pairing QR code getting clipped on iOS and Android ([#6758](https://github.com/getpaseo/paseo/pull/6758) by [@fdkevin0](https://github.com/fdkevin0))
|
||||
- Fixed a `write EPIPE` error dialog on Linux desktop when the launcher stops reading console output ([#6639](https://github.com/getpaseo/paseo/pull/6639))
|
||||
- Fixed `paseo permit allow` and `deny` reporting success over SSH while the request stayed pending ([#6607](https://github.com/getpaseo/paseo/pull/6607) by [@budivoogt](https://github.com/budivoogt))
|
||||
- Fixed files and folders whose names start with two dots, like `..notes.txt`, being hidden and rejected ([#6604](https://github.com/getpaseo/paseo/pull/6604), [#6720](https://github.com/getpaseo/paseo/pull/6720) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Markdown previews dropping table rows after a `<` that isn't HTML, such as `List<Item>` ([#6370](https://github.com/getpaseo/paseo/pull/6370) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
- Fixed a literal heading tag appearing when an HTML heading contains a linked image ([#6650](https://github.com/getpaseo/paseo/pull/6650))
|
||||
- Fixed Astro diffs with an empty `{}` expression failing to render ([#6763](https://github.com/getpaseo/paseo/pull/6763))
|
||||
- Fixed slash command argument hints missing from autocomplete for ACP providers ([#6920](https://github.com/getpaseo/paseo/pull/6920) by [@bwestlund17](https://github.com/bwestlund17))
|
||||
- Fixed terminal profiles configured with the `agy` icon showing a generic bot instead of the Antigravity icon ([#6938](https://github.com/getpaseo/paseo/pull/6938) by [@noahg9](https://github.com/noahg9))
|
||||
- Fixed the Pi thinking level not updating when a Pi extension changes it ([#6741](https://github.com/getpaseo/paseo/pull/6741))
|
||||
- Fixed Pi subagents from `@tintinweb/pi-subagents` staying running after their turn limit ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi))
|
||||
- Fixed manual `/compact` on OMP agents showing no progress and leaving the context meter stale ([#6106](https://github.com/getpaseo/paseo/pull/6106))
|
||||
- Fixed follow-ups to idle OMP agents staying queued instead of starting the agent ([#7169](https://github.com/getpaseo/paseo/pull/7169) by [@nhthung](https://github.com/nhthung))
|
||||
- Fixed voice mode TTS rejecting model and voice names from OpenAI-compatible servers ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros))
|
||||
- Fixed the xhigh thinking option showing "Extra high" instead of its configured label ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj))
|
||||
- Fixed the Remove button going off screen for long custom model names in provider settings ([#6507](https://github.com/getpaseo/paseo/pull/6507))
|
||||
- Fixed the starting-ref picker not finding GitLab merge requests and issues by number ([#6525](https://github.com/getpaseo/paseo/pull/6525))
|
||||
- Fixed the starting-ref picker hiding change requests that match only in their description ([#6558](https://github.com/getpaseo/paseo/pull/6558))
|
||||
- Fixed @-mentions not finding plan files under `.cursor/plans` ([#6641](https://github.com/getpaseo/paseo/pull/6641) by [@chyendongnhanh338](https://github.com/chyendongnhanh338))
|
||||
- Fixed Paseo replacing a symlinked `config.json` with a regular file when saving settings ([#6023](https://github.com/getpaseo/paseo/pull/6023))
|
||||
- Fixed the published config schema rejecting valid settings such as `agentProfiles` and `plugins` ([#6621](https://github.com/getpaseo/paseo/pull/6621) by [@ragokan](https://github.com/ragokan))
|
||||
- Fixed duplicating a folder turning its relative symlinks into absolute links to the original ([#6771](https://github.com/getpaseo/paseo/pull/6771) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed renaming an untracked file like `[id].tsx` failing when a tracked file matches it as a pattern ([#6573](https://github.com/getpaseo/paseo/pull/6573) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed saving workspace files with long names failing with `ENAMETOOLONG` ([#6658](https://github.com/getpaseo/paseo/pull/6658) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed worktree creation and Changes failing for projects whose folder name ends in a space ([#6686](https://github.com/getpaseo/paseo/pull/6686))
|
||||
- Fixed merging a worktree when the base branch's worktree path contains a newline or ends in a space ([#6668](https://github.com/getpaseo/paseo/pull/6668) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `paseo agent ls` listing only the first 200 agents ([#6033](https://github.com/getpaseo/paseo/pull/6033) by [@hteo1337](https://github.com/hteo1337))
|
||||
- Fixed `paseo worktree ls` and `paseo worktree archive` failing with "cwd or repoRoot is required" ([#6467](https://github.com/getpaseo/paseo/pull/6467) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Fixed `paseo worktree ls` listing an archived worktree for up to 15 seconds ([#6545](https://github.com/getpaseo/paseo/pull/6545) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `paseo logs --since` printing activity from before the requested timestamp ([#6514](https://github.com/getpaseo/paseo/pull/6514) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed `paseo logs --follow` splitting words across lines as text streams in ([#6528](https://github.com/getpaseo/paseo/pull/6528) by [@fiorelorenzo](https://github.com/fiorelorenzo))
|
||||
- Fixed `paseo logs` printing reasoning after the reply that followed it ([#6546](https://github.com/getpaseo/paseo/pull/6546))
|
||||
- Fixed the daemon keeping files open after a canceled HTTP download ([#6567](https://github.com/getpaseo/paseo/pull/6567) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Linux desktop windows showing a generic icon instead of the Paseo icon ([#6645](https://github.com/getpaseo/paseo/pull/6645) by [@whme](https://github.com/whme), [@panta82](https://github.com/panta82))
|
||||
- Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339))
|
||||
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
|
||||
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
|
||||
- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
|
||||
- Fixed Usage merging distinct Cursor accounts into one card ([#7344](https://github.com/getpaseo/paseo/pull/7344))
|
||||
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
|
||||
- Fixed generic icons for Devin, Hermes, Kiro, and Antigravity in provider lists ([#7330](https://github.com/getpaseo/paseo/pull/7330) by [@OMEG-Lu](https://github.com/OMEG-Lu), [@kevenhu001-cyber](https://github.com/kevenhu001-cyber))
|
||||
- Fixed sidebar setup buttons overflowing the card with long translated labels ([#7312](https://github.com/getpaseo/paseo/pull/7312) by [@gentslava](https://github.com/gentslava))
|
||||
|
||||
## 0.11.4 - 2026-10-15
|
||||
|
||||
[Hoplite](https://hoplite.sh) joins Paseo as our first sponsor through the company sponsorship program. Hoplite builds cloud coding agents that turn tasks from GitHub, Linear, Slack and more into pull requests. Thank you to the Hoplite team for supporting Paseo.
|
||||
|
||||
-3238
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [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/website-i18n.md](docs/website-i18n.md) | Website languages: locale URLs, translated Markdown pairs, `{#id}` anchors, Chinese style |
|
||||
| [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 |
|
||||
@@ -107,8 +106,6 @@ npm run format:check # Check formatting without writing
|
||||
|
||||
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
|
||||
|
||||
When Mo requests an APK, use `npm run build:apk:arm64` for an ARM64 build: writes `artifacts/paseo-dogfood-arm64.apk` with diagnostics without starting a device; `npm run build:apk` builds all four Android architectures.
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Release branches
|
||||
|
||||
+7
-7
@@ -59,8 +59,8 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/zh-CN/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -80,7 +80,7 @@ Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding a
|
||||
|
||||
### 桌面 app(推荐)
|
||||
|
||||
从 [paseo.sh/download](https://paseo.sh/zh-CN/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
|
||||
从 [paseo.sh/download](https://paseo.sh/download) 或 [GitHub releases 页面](https://github.com/getpaseo/paseo/releases)下载。打开 app 后 daemon 会自动启动,不需要再安装其他东西。
|
||||
|
||||
如果要从手机连接,在 Settings 中扫描显示的二维码。
|
||||
|
||||
@@ -97,8 +97,8 @@ paseo
|
||||
|
||||
完整安装和配置见:
|
||||
|
||||
- [文档](https://paseo.sh/zh-CN/docs)
|
||||
- [配置参考](https://paseo.sh/zh-CN/docs/configuration)
|
||||
- [文档](https://paseo.sh/docs)
|
||||
- [配置参考](https://paseo.sh/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -116,7 +116,7 @@ paseo send abc123 "also add tests" # 发送后续任务
|
||||
paseo --host workstation.local:6767 run "run the full test suite"
|
||||
```
|
||||
|
||||
更多内容见[完整 CLI 参考](https://paseo.sh/zh-CN/docs/cli)。
|
||||
更多内容见[完整 CLI 参考](https://paseo.sh/docs/cli)。
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -164,7 +164,7 @@ npm run typecheck
|
||||
|
||||
## 支持 Paseo
|
||||
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/zh-CN/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
|
||||
|
||||
## 相关项目
|
||||
|
||||
|
||||
@@ -71,8 +71,6 @@ normal root agent immediately, and closing its still-open tab archives it.
|
||||
|
||||
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
|
||||
|
||||
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
|
||||
|
||||
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
|
||||
|
||||
+26
-68
@@ -61,81 +61,39 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
|
||||
|
||||
## Local build + install
|
||||
|
||||
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
|
||||
It backports
|
||||
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
|
||||
font size or another unhandled configuration can otherwise disable image selection
|
||||
until the app process restarts. Rebuild the native app after changing this patch;
|
||||
a Metro reload cannot update it. Remove the patch when the installed
|
||||
`expo-modules-core` includes the fix.
|
||||
|
||||
Run its native regression tests from `packages/app/android` after prebuild:
|
||||
|
||||
```bash
|
||||
./gradlew :expo-modules-core:testDebugUnitTest \
|
||||
--tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \
|
||||
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
|
||||
```
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
npm run build:apk # Universal dogfood APK: 32/64-bit ARM and x86
|
||||
npm run build:apk:arm64 # Smaller dogfood APK for ARM64 phones
|
||||
npm run dev:android # Build/install the debug app and start development
|
||||
npm run dev:ios # Build/install the iOS development app
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clear # Remove generated Android project
|
||||
```
|
||||
|
||||
`build:apk` writes `artifacts/paseo-dogfood-universal.apk`; `build:apk:arm64` writes
|
||||
`artifacts/paseo-dogfood-arm64.apk`. Neither command launches or installs on a device. Both enable
|
||||
`PASEO_WATCHDOG_BUILD=1`, the production React profiling renderer, and Android's shell-profileable
|
||||
flag. These builds keep the production package ID (`sh.paseo`) and release optimizations. Use this APK for
|
||||
personal dogfooding. Android system-trace spans require the separate `PASEO_PROFILE_BUILD=1` flag;
|
||||
watchdog recording does not send duplicate spans to that sink by default. Normal store/release
|
||||
workflows leave diagnostics disabled.
|
||||
|
||||
The older `android`, `android:development`, and `ios` commands remain aliases for development.
|
||||
`android:production` still builds and installs a release through Expo; use `build:apk` when you
|
||||
need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
In a dogfood build, open Android's Files app → Downloads → **Paseo diagnostics** and share all
|
||||
retained `.jsonl` files. No app screen, storage permission, or attached debugger is required.
|
||||
The recorder retains eight files of about 5 MiB each across reconnects and app restarts. Rotation
|
||||
replaces the oldest files, so collect them soon after reproducing a freeze. Uninstall/reinstall
|
||||
can leave older Downloads files that the new installation cannot manage.
|
||||
|
||||
Each file identifies its build and session. Events include application-transport frame sizes and
|
||||
message/request IDs, connection transitions, parse/validation/dispatch spans, named React subtree
|
||||
render timings, and foreground JS heartbeat gaps. Payloads, passwords, and message contents
|
||||
are excluded; connection identifiers include host addresses. Each size has a unit: **bytes** when
|
||||
already available, otherwise **UTF-16 code units** for text. These are
|
||||
client transport observations, after relay decryption where applicable, not packet arrival times.
|
||||
|
||||
The native recorder flags a heartbeat overdue by more than 500 ms (250 ms expected interval).
|
||||
A heartbeat gap bounds the unresponsive interval; it is not an exact blocked-stack duration.
|
||||
Backgrounding suspends detection. `js.stall.started` can be written while JS remains blocked;
|
||||
`js.stall.recovered` records the gap when JS resumes. Recovery can appear without a start event
|
||||
when JS resumes before the next native sampling tick. Process death leaves earlier files intact,
|
||||
but queued/unflushed records can be lost. A missing recovery event alone does not prove a crash.
|
||||
|
||||
`react.render.slow` records subtree rendering taking at least 16 ms; one-second summaries include
|
||||
faster renders. Nested profiler timings overlap: don't add parent and child times. React's render
|
||||
measurements do not measure native layout/paint or the complete commit phase. `recorder.dropped`
|
||||
means the bounded queue overflowed; missing events in that interval must not be treated as absence
|
||||
of activity. Storage failure stops recording rather than blocking the app.
|
||||
|
||||
For an ordinary release APK with system trace markers only:
|
||||
For a production-ID release APK that local Android profiling tools can attach to:
|
||||
|
||||
```bash
|
||||
PASEO_PROFILE_BUILD=1 npm run android:production
|
||||
```
|
||||
|
||||
Those markers emit only while a system trace records `sh.paseo` (`perfetto -a sh.paseo ...`);
|
||||
they do not produce Downloads files.
|
||||
This keeps the `sh.paseo` package id, release Hermes bundle, and release optimizations. It adds
|
||||
`<profileable android:shell="true" />` and enables local Android trace markers for workspace mounts
|
||||
and daemon WebSocket traffic. The markers contain message types and sizes, never payload contents,
|
||||
and emit only while a system trace records the `sh.paseo` app (`perfetto -a sh.paseo ...`).
|
||||
|
||||
Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
npx cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive
|
||||
npx cross-env APP_VARIANT=development expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
npx cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive
|
||||
npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
## Running on an emulator against a worktree daemon
|
||||
|
||||
@@ -247,12 +205,12 @@ adb exec-out screencap -p > screenshot.png
|
||||
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- The EAS GitHub app on Expo servers, using `packages/app/.eas/workflows/release-mobile.yml` (iOS + Android production builds and distribution).
|
||||
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||
|
||||
iOS distributes the same build to external TestFlight testers and App Store review; see [Mobile builds (EAS)](release.md#mobile-builds-eas) for the review paths. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` trigger the GitHub APK workflow and EAS `Release iOS Beta`. They publish a GitHub prerelease APK and distribute iOS through TestFlight without submitting to production stores.
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
|
||||
|
||||
+1
-2
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A pressable wrapping an icon is wrong; the icon action is `<ToolbarButton>` (`packages/app/src/components/ui/pane-content-toolbar.tsx`), which owns the hitbox, the rounded `interactionHighlight` fill on hover and press, the compact size, and the tooltip. Rows on a menu surface are `<MenuItem>`, or `<ComposerTrackRow>` in a track panel; other rows are `<PressHighlight>` (`packages/app/src/components/ui/press-highlight.tsx`), which flashes on native the moment a finger lands. A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
@@ -249,7 +249,6 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
|
||||
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Importing `Pressable` or a `Touchable*` outside `components/ui/`. A raw pressable has no standard press or hover feedback, so every one drifts. `paseo/no-raw-pressable` reports it; the files on its burn-down list in `.oxlintrc.json` may only leave it.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
|
||||
+1
-3
@@ -535,9 +535,7 @@ For tighter loops, you can rebuild a single workspace:
|
||||
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
|
||||
runs when its package is actually present, so add the package to that script's `patchedPackages`
|
||||
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
|
||||
patch-package cannot apply a patch that changed after an earlier version was applied, so the
|
||||
script removes that package and fails the install; the next `npm install` reinstalls it clean
|
||||
with the current patch. Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||
Regenerate a patch with `npx patch-package <package>` after editing `node_modules/<package>`, and
|
||||
patch every build the consumers use: Metro resolves the `react-native` field of a package
|
||||
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
|
||||
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
|
||||
|
||||
+16
-42
@@ -11,7 +11,7 @@ contracts.
|
||||
|
||||
## Panel host contract
|
||||
|
||||
Every panel registers its supported `PaneHost` values and presentation. Launchers derive
|
||||
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.
|
||||
@@ -21,10 +21,7 @@ so a pane’s + menu omits each while that pane already contains it. Closing one
|
||||
item available again. 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. The shared Explorer owns its tab rail; its shared frame owns visibility, activity and
|
||||
close-gesture lifetime. Platform adapters own resizing and inset geometry.
|
||||
The host keeps three recently used tabs plus modified tabs; unmodified tabs beyond that budget
|
||||
can remount. Native retained content stays live, while web chat uses its existing deferred Freeze.
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
## Sheets
|
||||
|
||||
@@ -43,11 +40,11 @@ remounts its panel. Revealing a target that already has a sheet uncovers that sh
|
||||
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
|
||||
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
|
||||
|
||||
Sheets are modal and cover the whole compact workspace, including the header and Explorer. A sheet's header is its drag
|
||||
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
|
||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
||||
Close, so the title keeps its width. The sheets present
|
||||
from the shared workspace's `SheetHostScope`, so their panels read the navigator and every
|
||||
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
|
||||
workspace provider an ordinary pane has without a context bridge
|
||||
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
|
||||
|
||||
@@ -59,16 +56,12 @@ the sheet's tab into a pane, and edits the autosave has not started writing are
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
One Explorer host stays beside the center column across compact overlays and docks. Keep its
|
||||
providers and gesture roots at the same React position when width or appearance changes. The
|
||||
workspace route preserves panel ancestry during appearance updates. Appearance-dependent leaves
|
||||
subscribe to theme changes; the file editor reconfigures its existing editor theme compartment.
|
||||
Ordinary leaf routes retain the stack appearance policy.
|
||||
The shell is 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
|
||||
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.
|
||||
|
||||
`packages/app/src/workspace-tabs/open-supporting-view.ts` owns semantic Changes and pull-request
|
||||
opens. Compact and wide native layouts select the matching workspace tab in Explorer. Desktop Changes opens
|
||||
opens. Compact and wide native layouts select the matching Explorer tab. Desktop Changes opens
|
||||
follow the shared diff preference. Desktop pull requests use their Main panel, On the side, or
|
||||
Explorer sidebar setting. Automatic PR discovery follows that preference once per workspace without
|
||||
interrupting the user's work. Closing the tab opts that workspace out of future automatic opens,
|
||||
@@ -83,37 +76,18 @@ literal `"explorer"` pane id and `explorerPaneIdByWorkspace` key for compatibili
|
||||
|
||||
Explorer uses the shared workspace tab row and ordinary tab context menus. Files and Changes
|
||||
hide their close buttons through the panel manifest; close them from the tab context menu.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock, including Agent and terminal profiles.
|
||||
Web also supports dragging agents and terminals into Explorer.
|
||||
Other tabs reveal the close control on hover. The + menu opens compatible panels in the dock and
|
||||
omits Agent and terminal profiles. Agents and terminals can still be dragged into Explorer.
|
||||
Bulk-close actions apply only to the dock's tabs. Explorer tabs can be reordered and dragged
|
||||
between compatible panes, but the dock cannot be split or maximized. Selecting an Explorer tab
|
||||
does not change workspace focus. Web dragging shares the workspace drag scope; compact layouts
|
||||
accept only reordering inside the source pane.
|
||||
does not change workspace focus.
|
||||
|
||||
Cmd+E shows or hides Explorer without changing its selected view. All layouts use the same
|
||||
workspace tabs, launcher, and panel host. Below 600 points the shell is the swipeable Explorer
|
||||
overlay; at 600 points and above it reserves width beside the workspace. Native docking keeps its
|
||||
touch resize handle. Full workspace splits remain a separate web-only capability.
|
||||
|
||||
The dock reserves at least 240 points and the workspace 360. App navigation yields when it cannot
|
||||
share the remaining width with both. This keeps a portrait tablet from rendering three unusably
|
||||
narrow columns; widening restores navigation without changing its saved open state.
|
||||
|
||||
Compact panel gestures own overlay visibility; the workspace layout owns its tab selection and
|
||||
contents on every platform. Explicit pane destinations bypass compact sheet placement, so choosing
|
||||
a panel in Explorer's + menu keeps it there. Ordinary compact file and subagent look-ups still use
|
||||
the sheet policy. Resolving an existing main tab reveals center immediately; a new file sheet
|
||||
keeps Explorer underneath and closing the sheet uncovers it. Semantic navigation resolves the
|
||||
actual destination before revealing it, including deduplicated targets.
|
||||
|
||||
Compact overlay selection and saved dock visibility are independent. Folding changes geometry,
|
||||
not either mode’s visibility intent. Native workspace retention remains one workspace; web retains
|
||||
up to ten with its existing inactive TTL.
|
||||
|
||||
PR discovery belongs to the active workspace: split layouts discover while active, and other
|
||||
layouts discover while Explorer is active. The rail requests summary status; selected visible PR
|
||||
content also requests timeline data. Inactive consumers read cached data without request demand.
|
||||
Discovery uses ordinary PR tabs and their existing once-per-workspace close opt-out.
|
||||
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
|
||||
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
|
||||
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
|
||||
|
||||
|
||||
+1
-3
@@ -157,9 +157,7 @@ a small `withUnistyles` boundary without subscribing the route tree itself to
|
||||
every Unistyles runtime update.
|
||||
|
||||
Navigators keep their identity across appearance changes. `ThemedStack` remounts
|
||||
each ordinary screen's content below the native stack when appearance tokens change.
|
||||
Workspace screens own retained panel lifetimes and update appearance at their
|
||||
leaves through `screenOwnedAppearance` ([Explorer](explorer-sidebar.md)). A
|
||||
each screen's content below the native stack when appearance tokens change; a
|
||||
keyed wrapper above a stack remounts the stack itself, and Android crashes when
|
||||
that happens while a FragmentManager transaction is running, which settings
|
||||
hydration at startup makes likely. A screen that mounts a nested navigator (the
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# I18n
|
||||
|
||||
Paseo client UI translations live in `packages/app/src/i18n`. Website translations are in [website-i18n.md](website-i18n.md).
|
||||
Paseo client UI translations live in `packages/app/src/i18n`.
|
||||
|
||||
## Supported Locales
|
||||
|
||||
|
||||
@@ -4,15 +4,11 @@ Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and
|
||||
|
||||
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat owns paced reveal, the web render cap, message identity and list measurement. Native renders completed messages together. While streaming, it keeps closed blocks in one head view and updates a separate tail, then reunites the text on completion. See [native parsing cost](agent-stream-performance.md#native-parsing-cost) for the boundary and pacing constraints.
|
||||
|
||||
## Block spacing
|
||||
|
||||
Every block is separated by one gap, `density.blockGap`: 12 on compact layouts, 16 wider. Inside the library's text, every element style ends with that gap and the library drops it after the last block. Views stacked outside the library (Mermaid segments, streaming head and tail) use the same gap through `MarkdownBlockStack`. Do not give a block type its own margins; change the gap.
|
||||
|
||||
## Links and images
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
||||
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory.
|
||||
|
||||
## Engine constraints
|
||||
|
||||
@@ -22,7 +18,7 @@ Math and video are disabled. Task lists and admonitions are enabled; task checkb
|
||||
|
||||
Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; see [native selection limitations](native-text-selection.md) for diagram grouping and leading inline images. Streaming keeps the last valid diagram visible while the next revision renders.
|
||||
|
||||
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also keeps the library's block spacing around the web code block override, preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
|
||||
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
|
||||
|
||||
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
|
||||
|
||||
|
||||
@@ -32,9 +32,7 @@ The UI worklet owns transient motion:
|
||||
|
||||
React publishes the active panel only when the canonical target and the UI-thread position agree at
|
||||
the final anchor. Retained content never observes gesture previews, progress, or an unsettled target.
|
||||
The gesture hosts stay mounted. The Gesture Handler web patch keeps initially disabled hosts
|
||||
from capturing paragraph pointers; that capture confines browser selection to its starting paragraph.
|
||||
Native worklets own overlay opacity and pointer events while
|
||||
The gesture hosts stay mounted. Native worklets own overlay opacity and pointer events while
|
||||
React owns settled panel activity and accessibility. Keep hidden native overlays laid out: collapsing
|
||||
their scroll ranges makes Android spring retained offsets back to zero during touch cancellation or
|
||||
release. Native panel hosts and their dependent draggable lists
|
||||
|
||||
+5
-22
@@ -300,33 +300,16 @@ done
|
||||
|
||||
### Android audio focus interruptions
|
||||
|
||||
Use native instrumentation for audio interruptions. JS-only tests cannot prove Android focus,
|
||||
input silencing, or route recovery. The module's `android/src/androidTest` suite runs through
|
||||
Gradle's `connectedDebugAndroidTest` task and uses real `AudioManager` and `AudioRecord` instances.
|
||||
Run the microphone-privacy cases on Android 12 or newer.
|
||||
|
||||
Verify these distinct outcomes:
|
||||
|
||||
- Dictation continues when another media owner takes permanent, temporary, or ducking focus.
|
||||
- Disabling microphone access pauses audio delivery without discarding the dictation. Enabling
|
||||
access resumes it. Starting dictation while access is disabled waits for the same recovery.
|
||||
- Returning from music controls resumes dictation without pausing music again. Cancelling a
|
||||
backgrounded recording must prevent the lifecycle callback from restarting it.
|
||||
- Two-way voice still stops coherently on focus loss. Keep the incoming-call check below: it
|
||||
exercises denied focus during lifecycle resume, beyond an ordinary media takeover.
|
||||
Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
|
||||
|
||||
```bash
|
||||
# Start voice mode in an existing composer, then background Paseo.
|
||||
adb shell am start -n sh.paseo/.MainActivity
|
||||
# Start voice mode in an existing composer, then background Paseo with Home.
|
||||
adb emu gsm call 5551234
|
||||
# Foreground Paseo while the call is still ringing, then end the simulated call.
|
||||
adb emu gsm cancel 5551234
|
||||
# Foreground Paseo while the call is still ringing.
|
||||
```
|
||||
|
||||
Expected result: no `RuntimeException: Audio focus request failed`, and voice mode stops or pauses
|
||||
coherently. Repeat with dictation: it must retain captured audio and recover microphone access.
|
||||
Use browser/device automation to operate the app; the commands above only control emulator telephony.
|
||||
|
||||
This fix requires a rebuilt native app. An OTA update on an older binary retains legacy capture.
|
||||
Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
|
||||
|
||||
### Releasing the audio session when idle
|
||||
|
||||
|
||||
+17
-59
@@ -86,12 +86,6 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
|
||||
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
|
||||
never deletes it. The global `pluginsEnabled` switch remains available.
|
||||
|
||||
## Client translations
|
||||
|
||||
Plugin runtime translations follow the connected app, so never send a daemon locale through RPCs or add plugin resources to the app catalog. The installation owns the translation engine; native portal bridges must carry it alongside RPC and client-state contexts. Keep host label resolution at presentation time so language changes preserve the installation lifetime.
|
||||
|
||||
The author contract is in [runtime translations](../public-docs/plugins/reference.md#runtime-translations).
|
||||
|
||||
## Built-in plugins
|
||||
|
||||
Built-in plugins ship from `plugins/<id>/`, with `paseo-plugin.json`, `index.server.ts`,
|
||||
@@ -246,9 +240,8 @@ Do not put any other code modules in the plugin root.
|
||||
|
||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
`@getpaseo/plugin/client/ui`. Its `Icon` draws a Lucide name from the client's installed
|
||||
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
|
||||
so it cannot break the plugin surface.
|
||||
`@getpaseo/plugin/client/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
|
||||
[host UI contract](../public-docs/plugins/reference.md#host-ui).
|
||||
@@ -263,14 +256,14 @@ Classify every SDK export before adding it. All client entry points and implemen
|
||||
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
|
||||
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
|
||||
|
||||
| Entry | Owns | May depend on |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
| Entry | Owns | May depend on |
|
||||
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
|
||||
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
|
||||
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
|
||||
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
|
||||
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
|
||||
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
|
||||
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
|
||||
|
||||
Server code imports shared helpers from the root and server capabilities from `/server`. Client
|
||||
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
|
||||
@@ -339,7 +332,7 @@ created by plugin code. Paseo removes registered contributions, unmounts surface
|
||||
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
|
||||
errors are logged and do not interrupt host teardown.
|
||||
|
||||
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
|
||||
Paseo owns the route, screen header, Lucide icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
The plugin owns the header's text through the screen's `title` (`resolvePluginScreenTitle` in
|
||||
`plugins/surface-contribution.ts`). A legacy `addSidebarItem` route (`/sidebar/<id>`) shows the
|
||||
@@ -407,25 +400,6 @@ See the public [navigation fields](../public-docs/plugins/reference.md#screens-a
|
||||
and [external links and workspace browsers](../public-docs/plugins/reference.md#external-links-and-workspace-browsers)
|
||||
for the author-facing contract.
|
||||
|
||||
### Plugin icons
|
||||
|
||||
The [public reference](../public-docs/plugins/reference.md#icons) owns the author API: a contribution
|
||||
`icon` is a Lucide name, `{ svg }`, or a component.
|
||||
|
||||
- `findSvgIconProblem` in the SDK root (`packages/plugin/src/icons.ts`) is the only SVG rule set.
|
||||
The daemon applies it to provider and usage-source icon files; the app applies it to inline
|
||||
icons. Change the rules there, never at a call site.
|
||||
- The app validates each icon once, at registration, through `parsePluginIcon` in
|
||||
`packages/app/src/plugins/icons.ts`. Rendering trusts the stored icon.
|
||||
- That module is the only code that branches on the icon form. Surfaces draw with
|
||||
`PluginIconView`, or take a stable component from `pluginIconComponent` when their slot expects
|
||||
one. Lucide and SVG icons receive only size and color, so the host props a button passes to its
|
||||
component icon never reach an SVG element.
|
||||
- Attachment sources take `PluginSerializableIcon`: composer drafts persist the source's icon with
|
||||
each attachment, and a component cannot be stored.
|
||||
- The plugin compiler loads `.svg` imports as text, so `import icon from "./client/icon.svg"` is the
|
||||
markup string.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Server entries register lifecycle observers with `server.on()` and request transforms with
|
||||
@@ -517,13 +491,15 @@ provider implementation. The public [provider plugin guide](../public-docs/plugi
|
||||
owns author workflow, lifecycle, testing, and distribution guidance.
|
||||
|
||||
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
|
||||
It must resolve inside that directory to a regular file that passes the shared SVG icon rules
|
||||
(see [Plugin icons](#plugin-icons)). Paseo reads and validates the file when the plugin starts; the
|
||||
string is never inline SVG or a URL.
|
||||
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
|
||||
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
|
||||
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
|
||||
|
||||
@@ -562,24 +538,6 @@ one section's order from built-ins, plugin groups, and the section's preference
|
||||
remembers under its own key. It shares header ids and the `plugin:<pluginId>:<id>` settings key
|
||||
with new-API items.
|
||||
|
||||
## Contribute file panels
|
||||
|
||||
Client `addFilePanel` registrations become the body of a file tab. The tab stats the file, picks a
|
||||
panel, and mounts it with a `PluginFile` handle; the panel reads, shows its own `Loading`, writes,
|
||||
and resolves conflicts. The bar only switches panels and shows the dot the panel sets through
|
||||
`tab.setModified`. Built-in panels (Markdown, HTML, Image, and Source, Paseo's editor) take the
|
||||
same handle as plugin panels, so a fix to reading, watching, or writing lands in one place:
|
||||
`packages/app/src/file-panels/file/`. Do not add a host-side read or loading gate for one panel;
|
||||
make that panel faster instead. Source highlights a large file on native in idle-time slices,
|
||||
the lines around the opening line first.
|
||||
|
||||
The handle checks every write against the version it last read or wrote, so two panels or a panel
|
||||
and an agent cannot silently overwrite each other. The daemon reports the file's kind (text,
|
||||
image, binary) with its stat from a bounded sniff, which is how Source claims text files without a
|
||||
read. See the [public file panel reference](../public-docs/plugins/reference.md#file-panels) for
|
||||
the author API, selection, `DocumentFrame`, and the version requirement. The examples live in
|
||||
`plugin-examples/pdf-panel` and `plugin-examples/markdown-panel`.
|
||||
|
||||
## Contribute buttons
|
||||
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
|
||||
@@ -74,14 +74,6 @@ The adapter keys legacy slots by physical socket, so an old connection cannot re
|
||||
sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted
|
||||
for parsing compatibility; modern requests cannot select their subscription ID.
|
||||
|
||||
Workspace setup details negotiate `workspace_setup_observation` with
|
||||
`server_info.features.workspaceSetupObservation`. Capable clients cannot request details through
|
||||
the legacy session event category. The setup panel owns a workspace-scoped observation while
|
||||
active and visible; directory metadata signals failures without requesting output. Older clients
|
||||
retain their legacy event delivery. A new app makes no setup event demand on older hosts and
|
||||
requires a host update to view details. Subscription-capable older hosts honor that absence of
|
||||
demand; broadcast-era hosts require an update before wire silence is guaranteed.
|
||||
|
||||
## Every shim is tagged and dated
|
||||
|
||||
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ Use OpenCode v2 execution events to trigger turn completion, with active-state a
|
||||
|
||||
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
|
||||
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. The global manifest must use agent-scoped schemas before a session is bound, because every bridge call executes in an agent session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
|
||||
|
||||
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
+13
-25
@@ -62,10 +62,9 @@ release. This applies to both beta and stable releases.
|
||||
the changelog. Retarget remaining PRs based on `next` to `main` and delete the integrated
|
||||
`next`. Create it fresh when needed again.
|
||||
|
||||
**Setup still needed:** PR checks run for every base branch, but CI, Docker, and Nix
|
||||
push runs target only `main`, and GitHub permits only squash merges. Enable
|
||||
required-check protection for `next`, CI on its pushes, and merge commits for the
|
||||
integration PR. Handle PR
|
||||
**Setup still needed:** CI, Docker, and Nix PR checks currently target only `main`,
|
||||
and GitHub permits only squash merges. Enable checks and required-check protection
|
||||
for `next`, CI on its pushes, and merge commits for the integration PR. Handle PR
|
||||
base changes (`edited` events) so retargeting runs checks against the new base;
|
||||
GitHub's default PR events do not cover this. Deployment triggers stay unchanged.
|
||||
|
||||
@@ -306,14 +305,12 @@ macOS 13 maps to Darwin 22. The two values use different version domains; do not
|
||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads once through a `testflight` job, distributes the build to the `Paseo Beta` external group with Beta App Review submission, and a Fastlane lane submits that same build for App Store review.
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment.
|
||||
|
||||
Stable tags run `Release Mobile`; beta tags run `Release iOS Beta`. Both distribute to the persistent `Paseo Beta` external group and submit for Beta App Review, so testers receive stable releases as well as betas. Testers, the group, and required TestFlight test information are managed once in App Store Connect.
|
||||
|
||||
Stable iOS releases use one binary for two Apple review paths: Beta App Review for external TestFlight distribution and App Store review. [Apple says](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers) the first submitted build requires a full TestFlight review; later builds for the same version might not. Do not assume a stable patch skips review.
|
||||
Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action.
|
||||
|
||||
There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly.
|
||||
|
||||
@@ -337,7 +334,7 @@ npx eas build:list --platform android --limit 5 --non-interactive --json
|
||||
# Inspect a specific build.
|
||||
npx eas build:view <build-id>
|
||||
|
||||
# Inspect the full release workflow, including distribute_ios, submit_android,
|
||||
# Inspect the full release workflow, including submit_ios, submit_android,
|
||||
# and submit_ios_for_review.
|
||||
npx eas workflow:view <workflow-run-id> --json
|
||||
|
||||
@@ -350,17 +347,17 @@ npx eas build:view <build-id> --json | jq '.logFiles[]'
|
||||
|
||||
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
|
||||
|
||||
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must distribute to the external TestFlight group with Beta App Review submission **and** submit the same build for App Store review. The release is not done until all platforms are on their way through the stores.
|
||||
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
|
||||
|
||||
For the `Release Mobile` EAS workflow, these jobs must pass:
|
||||
|
||||
- `build_ios` — iOS binary built
|
||||
- `distribute_ios` — iOS binary uploaded once, added to `Paseo Beta`, and submitted for Beta App Review
|
||||
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
|
||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||
- `build_android` — Android store binary built
|
||||
- `submit_android` — Android binary submitted to the Play Store
|
||||
|
||||
Do not treat `build_ios: SUCCESS` or `distribute_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
|
||||
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
|
||||
|
||||
@@ -383,9 +380,8 @@ Windows, and Android APK assets are present along with the channel manifests
|
||||
(`latest-mac.yml`, `latest-linux.yml`, and `latest.yml` for stable;
|
||||
`beta-mac.yml`, `beta-linux.yml`, and `beta.yml` for beta).
|
||||
|
||||
For stable releases, also confirm every required mobile build, external
|
||||
TestFlight distribution, Beta App Review submission, and store submission job
|
||||
for the release commit. For betas, confirm
|
||||
For stable releases, also confirm every required mobile build, upload, store
|
||||
submission, and review-submission job for the release commit. For betas, confirm
|
||||
the beta EAS workflow completed its TestFlight distribution and Beta App Review
|
||||
path. Delete the heartbeat only after every applicable checklist item passes,
|
||||
then report the release as shipped.
|
||||
@@ -400,7 +396,7 @@ Pattern:
|
||||
"timezone": "UTC",
|
||||
"maxRuns": 120,
|
||||
"expiresIn": "24h",
|
||||
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, distribute_ios (Paseo Beta external distribution and Beta App Review submission), submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
|
||||
"prompt": "Resume the vX.Y.Z release babysit for commit <sha>. Check npm tags; every GitHub Actions run for the release branch and tag; the published GitHub Release body, expected desktop/APK assets, and channel manifests; the Docker image; and the matching EAS workflow. Completion requires every applicable checklist item. For stable, require build_ios, submit_ios, submit_ios_for_review, build_android, and submit_android to succeed. For beta, require the beta TestFlight distribution and Beta App Review path. If work is pending, wait for the next heartbeat. If a failure can be retried safely for the same version, follow the failed-release procedure; otherwise report the blocker. When every applicable completion-checklist item passes, delete THIS heartbeat, report shipped, and stop.",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -534,14 +530,6 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
|
||||
|
||||
Older prerelease bodies on GitHub keep the notes they were synced with; `Release Notes Sync` only writes the release whose tag matches the top entry.
|
||||
|
||||
## Changelog translations
|
||||
|
||||
`CHANGELOG.zh-CN.md` holds the Chinese notes for `paseo.sh/zh-CN/changelog`. Only the website reads it; the app's **What's new** sheet, GitHub release bodies and F-Droid read `CHANGELOG.md`.
|
||||
|
||||
- Translations never gate a release. Translate a new entry in a follow-up PR, or in the changelog commit when you can.
|
||||
- A translated entry uses the English heading unchanged: `## X.Y.Z - YYYY-MM-DD`. The website uses it only while the version and date both match the English entry. Otherwise it shows that release whole in English under a notice, so a beta rewritten in place drops its old translation until the new notes are translated. Delete Chinese entries whose English entry was renamed.
|
||||
- Keep the English sections, bullets and links one to one. Section titles: Added 新增, Improved 改进, Changed 变更, Fixed 修复, Security 安全, Breaking 破坏性变更, Plugins 插件, Notes 说明. Wording follows [website-i18n.md](website-i18n.md#chinese-style).
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
|
||||
@@ -692,7 +680,7 @@ Every entry covers `previous stable release → release source`: first beta, lat
|
||||
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
- [ ] EAS iOS `build_ios` completes for the same tag
|
||||
- [ ] EAS iOS `distribute_ios` succeeds, uploading the binary once, distributing it to `Paseo Beta`, and submitting it for Beta App Review
|
||||
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
|
||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||
- [ ] EAS Android `build_android` completes for the same tag
|
||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es
|
||||
- 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. Automated Metro launches set `EXPO_UNSTABLE_HEADLESS=1` so Expo does not download the native DevTools shell into temporary test homes; its read-only cache breaks teardown. Browser fixtures and the standalone Electron browser runner share the developer-toast dismissal helper while retaining console logs. A pointer click moves hover away from the tested control and stalls behind app modal backdrops.
|
||||
- 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.
|
||||
|
||||
@@ -251,8 +251,7 @@ do not imply local ownership or authorization. Capture the sender title at deliv
|
||||
removed senders must remain understandable without a live directory lookup.
|
||||
|
||||
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
|
||||
presentation (`protocol/paseo-tool-presentation`) owns its heading and expanded details, next to the
|
||||
labels for every Paseo MCP tool. The daemon
|
||||
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
|
||||
must not construct UI labels or prepend sender labels to the semantic message body. The existing
|
||||
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
|
||||
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Website translations
|
||||
|
||||
paseo.sh is served in English and Simplified Chinese (`zh-CN`). This doc covers the website in `packages/website`. App UI translations are in [i18n.md](i18n.md).
|
||||
|
||||
## URLs own the language
|
||||
|
||||
- English pages keep their unprefixed paths. Every other language lives under its tag: `/docs/cli` and `/zh-CN/docs/cli` are the same page. `/en/...` is not a page.
|
||||
- The server renders the language in the URL. There is no browser-language detection or redirect, so a shared link always opens in the language it was shared in.
|
||||
- The language selector moves to the same page in the other language and keeps the query and hash. Every internal link on a page carries the page's language, so the choice sticks while the reader navigates. An explicit English URL always renders English.
|
||||
- The choice is not stored. A stored choice could only take effect by redirecting unprefixed URLs, which are the English pages. First-party links into the site carry the reader's language instead: `README.zh-CN.md` links to `/zh-CN/...`.
|
||||
- Routes live under `src/routes/{-$locale}/`. Build internal links with `localizePath` or `useLocalizePath` from `~/i18n`. Links inside Markdown are localized by `DocsMarkdown`. Files, `.md` sources, `llms.txt` and API endpoints keep one path for every language (`isPagePath`).
|
||||
- Give each page's `head` the language-neutral path through `pageMeta` (`src/meta.ts`). It writes the self canonical, the `hreflang` alternates with English as `x-default`, and `og:locale`. The sitemap in `vite.config.ts` lists the same alternates.
|
||||
|
||||
## Where copy lives
|
||||
|
||||
| Content | English | Chinese |
|
||||
| ---------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Interface copy | `src/i18n/resources/en.json` | `src/i18n/resources/zh-CN.json` |
|
||||
| Docs | `public-docs/<page>.md` | `public-docs/<page>.zh-CN.md` |
|
||||
| Blog posts | `posts/<slug>.md` | `posts/<slug>.zh-CN.md` |
|
||||
| Comparison pages | `src/content/alternatives/<slug>.md` | `src/content/alternatives/<slug>.zh-CN.md` |
|
||||
| Legal pages | `legal/<page>.md` | `legal/<page>.zh-CN.md` |
|
||||
| Changelog | `CHANGELOG.md` | `CHANGELOG.zh-CN.md`, see [release.md](release.md#changelog-translations) |
|
||||
| Plugin listings | the plugin's `paseo-plugin.json` and `OVERVIEW.md` | `translations` and `OVERVIEW.zh-CN.md`, see `public-docs/plugins/publishing.md` |
|
||||
|
||||
`en.json` types the keys, so typecheck fails when `zh-CN.json` misses one. A translation file is grouped with its English original by name (`src/markdown-content.ts`). A translation without an English original fails the build.
|
||||
|
||||
Every first-party page has a translation. The changelog and plugin listings are the exceptions: a release or listing without one renders whole in English under a notice. Pages never mix languages paragraph by paragraph. Navigation order, categories and slugs always come from the English file's frontmatter.
|
||||
|
||||
## Editing a translated doc
|
||||
|
||||
Edit the English file and its `.zh-CN.md` in the same PR, and add the `.zh-CN.md` with any new page. Code blocks, commands, flags, config keys and links are copied verbatim, so code-only changes are a copy. Never delete a translation to get around an edit, and never leave one that describes behavior the English no longer has.
|
||||
|
||||
Translated headings pin the English anchor with a trailing `{#id}`, so `/zh-CN/docs/cli#running-agents` lands on the same section as the English link:
|
||||
|
||||
```md
|
||||
## 运行 Agent {#running-agents}
|
||||
|
||||
:::example[回复 Slack 提及 {#answer-a-slack-mention}]
|
||||
```
|
||||
|
||||
When you rename an English heading, change the `{#id}` in the translation to the new slug. `src/content-translations.test.ts` fails when a docs, blog, comparison or legal page has no translation, and when a translation's headings differ from the English in count, level, order or anchor. It cannot tell whether the prose under a heading is current. The `.md` source endpoints serve files verbatim, so `{#id}` shows there as written.
|
||||
|
||||
## Chinese style
|
||||
|
||||
- Keep code, commands, file paths, IDs, brand and product names, and command output in English.
|
||||
- Keep these terms in English: Agent, worktree, daemon, Provider, PR, diff, Composer, Hub, workflow, heartbeat, slug.
|
||||
- Translate these: workspace 工作区, host 主机, relay 中继, plugin 插件, skill 技能, review 审查, schedule 定时任务, orchestration 编排, configuration 配置, prompt 提示词, subagent 子 Agent.
|
||||
- Quote app UI labels with the app's Chinese wording from `packages/app/src/i18n/resources/zh-CN.ts`, such as **设置 → 插件**. Labels the app has not translated stay in English, as do labels in Hub, GitHub, Slack and Discord. Add the English label in parentheses only where readers search for it, such as an error message.
|
||||
- Put a space between Chinese and Latin text or numbers: `启动 Agent`, `共 3 个`.
|
||||
- Bold that ends in full-width punctuation does not render when Chinese text follows it directly: `**注意:**这里` shows the asterisks. Move the punctuation outside the bold (`**注意**:这里`).
|
||||
@@ -1,8 +0,0 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,8 +0,0 @@
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and more than 120 fixes.
|
||||
|
||||
Before upgrading: paseo run --background is deprecated in favor of --no-wait, and paseo permit allow and deny now exit non-zero when the daemon doesn't confirm within 15 seconds
|
||||
|
||||
Added
|
||||
- Added background workspaces, hidden from the sidebar unless Show background is on
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -45,21 +45,6 @@
|
||||
}
|
||||
);
|
||||
|
||||
checks = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
packages = self.packages.${system};
|
||||
license = (builtins.fromJSON (builtins.readFile ./package.json)).license;
|
||||
in
|
||||
{
|
||||
license-metadata =
|
||||
assert packages.paseo.meta.license.spdxId == license;
|
||||
assert packages.desktop.meta.license.spdxId == license;
|
||||
pkgs.runCommand "paseo-license-metadata" { } "touch $out";
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
nixosModules.paseo =
|
||||
{ pkgs, lib, ... }:
|
||||
|
||||
@@ -257,7 +257,7 @@ buildNpmPackage {
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.asl20;
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-Et1ofRqkqFoGETnbeqXuy2RerFT0kkk5F+VwDXak+fk=
|
||||
sha256-Sw9WSOnvebpXgty0WACJrRq+pkKuynLMxJg6D/SnyRk=
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ buildNpmPackage rec {
|
||||
meta = {
|
||||
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.asl20;
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
|
||||
Generated
+3451
-6663
File diff suppressed because it is too large
Load Diff
+8
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0",
|
||||
"version": "0.11.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -41,16 +41,12 @@
|
||||
"dev:server:watch": "concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
|
||||
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
|
||||
"dev:android": "npm run dev:android --workspace=@getpaseo/app",
|
||||
"dev:ios": "npm run dev:ios --workspace=@getpaseo/app",
|
||||
"test:e2e:mobile": "./scripts/test-mobile-agent-device.sh",
|
||||
"test:e2e:composer-keyboard:android": "./packages/app/e2e/mobile/composer-keyboard/android.sh",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:apk": "node scripts/build-apk.mjs",
|
||||
"build:apk:arm64": "node scripts/build-apk.mjs arm64",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
|
||||
"build:plugin": "npm run build --workspace=@getpaseo/plugin",
|
||||
@@ -84,14 +80,15 @@
|
||||
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
|
||||
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
|
||||
"start": "npm run start --workspace=@getpaseo/server",
|
||||
"android": "npm run dev:android",
|
||||
"android": "npm run android --workspace=@getpaseo/app",
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||
"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 dev:ios",
|
||||
"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",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
@@ -140,21 +137,16 @@
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"react-dom": "19.2.3",
|
||||
"typescript": "^5.9.3",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"expo": "~57.0.30",
|
||||
"expo-router": "~57.0.28",
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.1",
|
||||
"react-native-worklets": "0.10.1"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-worklets": "0.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +23,13 @@ jobs:
|
||||
platform: android
|
||||
profile: production
|
||||
|
||||
distribute_ios:
|
||||
name: Distribute iOS to TestFlight
|
||||
submit_ios:
|
||||
name: Submit iOS
|
||||
needs: [build_ios]
|
||||
type: testflight
|
||||
type: submit
|
||||
params:
|
||||
build_id: ${{ needs.build_ios.outputs.build_id }}
|
||||
profile: production
|
||||
external_groups:
|
||||
- Paseo Beta
|
||||
submit_beta_review: true
|
||||
|
||||
submit_android:
|
||||
name: Submit Android
|
||||
@@ -44,7 +41,7 @@ jobs:
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [build_ios, distribute_ios]
|
||||
needs: [build_ios, submit_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1296.0)
|
||||
aws-sdk-core (3.257.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
rexml (~> 3.4, >= 3.4.2)
|
||||
aws-sdk-kms (1.132.0)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.233.2)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.3)
|
||||
cgi (0.5.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.6)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.7.20261007)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
erb (6.0.7)
|
||||
excon (1.7.3)
|
||||
logger
|
||||
faraday (2.15.0)
|
||||
faraday-net_http (>= 2.0, < 3.6)
|
||||
json
|
||||
logger
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.5.0)
|
||||
net-http (~> 0.5)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.241.1)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
cgi (~> 0.4)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
erb (>= 4.0, < 7.0)
|
||||
excon (>= 1.0.0, < 2.0.0)
|
||||
faraday (~> 2.7)
|
||||
faraday-cookie_jar (~> 0.0.8)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-retry (~> 2.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.99)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.5.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
irb (>= 1.8)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 3.4.0, < 4.0.0)
|
||||
security (~> 0.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 3.1.0, < 4.0.0)
|
||||
terminal-table (~> 4)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.109.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (1.3.1)
|
||||
addressable (~> 2.9)
|
||||
faraday (~> 2.13)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
googleauth (~> 1.14)
|
||||
mini_mime (~> 1.1)
|
||||
multi_json (~> 1.11)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 3.1, < 5.0)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.69.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.4.1)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.1)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.1)
|
||||
googleauth (1.17.5)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.1.6)
|
||||
domain_name (~> 0.5)
|
||||
io-console (0.9.4)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.2)
|
||||
jwt (3.3.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.2)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
net-http (0.10.0)
|
||||
net-protocol (>= 0.3.0)
|
||||
uri (>= 0.11.1)
|
||||
net-protocol (0.4.0)
|
||||
timeout
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.1)
|
||||
prism (1.9.0)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.1.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
representable (3.3.1)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (4.2.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rubyzip (3.7.0)
|
||||
security (0.3.0)
|
||||
signet (0.22.1)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (3.1.0)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
tsort (0.2.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.3.0)
|
||||
unicode-emoji (~> 4.3)
|
||||
unicode-emoji (4.3.0)
|
||||
uri (1.1.2)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (~> 2.234)
|
||||
multi_json
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1296.0) sha256=70cc7a44fa4824a9df5652b853dc96aaaf09ec20642cd9d42ed7335a36cc3567
|
||||
aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
|
||||
aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
|
||||
aws-sdk-s3 (1.233.2) sha256=b138737a0de8b02f163ae47a9fc184ac98fc9b90926237c7d7af716a14ea1221
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
|
||||
bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b
|
||||
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
|
||||
excon (1.7.3) sha256=ea13fabcab2b5f5bb64e2952a0ac018bf9882832fe7a58206630be18ba888fa9
|
||||
faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
|
||||
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.241.1) sha256=e8e97f3274d678c74d9e473b98d202621262c40dfce2b43b7a05afde7caa2ea5
|
||||
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
|
||||
google-apis-core (1.3.1) sha256=20ce9ee1fde0be87fe30a135dfdbd4b9ee33ba32172794c1f2770e7a45361b2f
|
||||
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
|
||||
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
|
||||
google-apis-storage_v1 (0.69.0) sha256=ca5faad4a244c609bb434f81884432c449e34b31a87623f7c32b582e464ca7c8
|
||||
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
|
||||
google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
|
||||
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
|
||||
google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
|
||||
google-logging-utils (0.2.1) sha256=73945c3ac6800eba802c26b1744765d5057cc4d89182c7255ee1347b0ca96dc7
|
||||
googleauth (1.17.5) sha256=92498efc6883630a7e011ce4e667f887e314e588d584410ba18df49f1303f3d7
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
|
||||
io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
|
||||
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
||||
jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
|
||||
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
|
||||
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
|
||||
prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
|
||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||
rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
|
||||
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
|
||||
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
|
||||
representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
|
||||
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
|
||||
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
|
||||
signet (0.22.1) sha256=6e73e9f2bdc2f038a5fdc8a89d248792b1b55e9ca50ee1ba458d8f1d9f0a2151
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
|
||||
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
|
||||
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
|
||||
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
|
||||
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.23
|
||||
@@ -10,7 +10,6 @@ const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
|
||||
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||
const isWatchdogBuild = process.env.PASEO_WATCHDOG_BUILD === "1";
|
||||
const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1";
|
||||
|
||||
const buildProfile = isFdroidBuild
|
||||
@@ -172,7 +171,6 @@ export default {
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
ios: { enableSceneSupport: true },
|
||||
android: {
|
||||
minSdkVersion: 29,
|
||||
kotlinVersion: "2.1.20",
|
||||
@@ -182,7 +180,7 @@ export default {
|
||||
},
|
||||
],
|
||||
...buildProfile.fdroidPlugins,
|
||||
...(isProfileBuild || isWatchdogBuild ? [withAndroidProfileable] : []),
|
||||
...(isProfileBuild ? [withAndroidProfileable] : []),
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
@@ -192,9 +190,6 @@ export default {
|
||||
extra: {
|
||||
fdroidBuild: isFdroidBuild,
|
||||
profileBuild: isProfileBuild,
|
||||
watchdogBuild: isWatchdogBuild,
|
||||
diagnosticRevision: process.env.PASEO_DIAGNOSTIC_REVISION,
|
||||
diagnosticBuildTime: process.env.PASEO_DIAGNOSTIC_BUILD_TIME,
|
||||
router: {},
|
||||
eas: {
|
||||
projectId: "0e7f65ce-0367-46c8-a238-2b65963d235a",
|
||||
|
||||
@@ -2,56 +2,48 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { openChangesPanel, openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visibleComposer(page: Page) {
|
||||
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`adds a changed file to the focused chat without replacing its composer draft at ${width}px`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
if (width < 600) await openChangesTreePanel(page);
|
||||
else await openChangesPanel(page);
|
||||
const changedFile = page.getByTestId("diff-file-0-toggle");
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
if (width < 600)
|
||||
await expect(
|
||||
page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
if (width >= 600) await expect(agentComposer).toBeFocused();
|
||||
else await expect(agentComposer).toBeEditable();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
}
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
await openChangesPanel(page);
|
||||
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
await expect(agentComposer).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ test("agent list age advances and command center says just now for a fresh agent
|
||||
const agentResult = commandCenter.getByTestId(
|
||||
`command-center-agent-${getServerId()}:${session.agentId}`,
|
||||
);
|
||||
await expect(agentResult.getByTestId("command-center-result-timestamp")).toContainText(
|
||||
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
|
||||
"just now",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
@@ -832,19 +832,14 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
// The shell survives the breakpoint, so the wide Explorer can still be on screen right after the
|
||||
// resize. Check for the compact tab only once the shell reports the compact layout.
|
||||
await expect(page.getByTestId("compact-shell")).toBeVisible();
|
||||
|
||||
const compactChangesTab = page
|
||||
.getByTestId("workspace-tab-changes_tree")
|
||||
.filter({ visible: true });
|
||||
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("workspace-explorer-sidebar").filter({ visible: true });
|
||||
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");
|
||||
@@ -881,27 +876,6 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
|
||||
).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
test("compact Explorer renders inline hunks with a saved tree-only desktop preference", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await page.addInitScript(
|
||||
({ key }) => {
|
||||
localStorage.setItem(key, JSON.stringify({ inlineDiff: false, layout: "split" }));
|
||||
},
|
||||
{ key: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
const explorer = await openCompactChanges(page, workspace);
|
||||
await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible();
|
||||
await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByTestId("diff-file-0-revert").click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
|
||||
.toBe(BEFORE);
|
||||
});
|
||||
|
||||
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -1700,10 +1674,10 @@ async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promis
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true });
|
||||
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
}
|
||||
@@ -1847,14 +1821,9 @@ async function changeCodeTypographyFromSettings(
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
// A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts
|
||||
// from the stored size. Edit the size only in the remounted form, or the remount drops it.
|
||||
const sizeField = page.getByLabel("Code font size");
|
||||
await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", ""));
|
||||
await page.getByLabel("Code font family").fill(typography.fontFamily);
|
||||
await page.getByLabel("Code font family").press("Enter");
|
||||
await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0);
|
||||
await sizeField.fill(String(typography.fontSize));
|
||||
await page.getByLabel("Code font size").fill(String(typography.fontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily);
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue(String(typography.fontSize));
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
@@ -20,10 +19,6 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
|
||||
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
expectPromptsDrawnByPlugin,
|
||||
withPromptTimelinePlugin,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
const MAC_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||
@@ -125,49 +120,6 @@ async function closeFind(page: Page) {
|
||||
.toEqual([]);
|
||||
}
|
||||
|
||||
async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") {
|
||||
// The scrolling surface has no accessible name; use its existing test id.
|
||||
const viewport = page.getByTestId("agent-chat-scroll");
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
const before = await viewport.evaluate((element) => element.scrollTop);
|
||||
await page.keyboard.press(key);
|
||||
const direction = key === "PageUp" ? -1 : 1;
|
||||
await expect
|
||||
.poll(
|
||||
async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before),
|
||||
)
|
||||
.toBeGreaterThan(100);
|
||||
}
|
||||
|
||||
test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-scroll-focus-",
|
||||
title: "Chat Find keyboard scrolling",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: TALL_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await searchChat(page, "Closing paragraph");
|
||||
await expectHighlight(page, "Closing paragraph");
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
await closeFind(page);
|
||||
await expectChatKeyboardScroll(page, "PageUp");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") });
|
||||
await expectChatKeyboardScroll(page, "PageDown");
|
||||
await searchChat(page, "Closing paragraph");
|
||||
await expectHighlight(page, "Closing paragraph");
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
await page.getByRole("button", { name: "Close Find", exact: true }).click();
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await expectChatKeyboardScroll(page, "PageUp");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -222,7 +174,7 @@ async function highlightedMessage(page: Page) {
|
||||
const range = entry && Array.from(entry[1])[0];
|
||||
if (!(range instanceof Range)) return null;
|
||||
const message = range.startContainer.parentElement?.closest<HTMLElement>(
|
||||
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
|
||||
'[data-testid="user-message"], [data-testid="assistant-message"]',
|
||||
);
|
||||
return message?.dataset.testid ?? null;
|
||||
});
|
||||
@@ -267,34 +219,6 @@ test("counts and steps through every match in the chat, not just the selected me
|
||||
}
|
||||
});
|
||||
|
||||
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-plugin-prompt-",
|
||||
title: "Chat Find plugin prompt",
|
||||
initialPrompt: ACROSS_PROMPT,
|
||||
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await withPromptTimelinePlugin(async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectPromptsDrawnByPlugin(page);
|
||||
await openChatFind(page);
|
||||
await query(page).fill("scope-needle");
|
||||
await expect(status(page)).toHaveText("1 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "plugin-prompt");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
|
||||
await expectNextMatch(page, "2 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await closeFind(page);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invisible source matches and ignores a late reply after closing", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -45,8 +45,7 @@ test("opening the command center keeps its first result fully visible", async ({
|
||||
await expectCommandCenterToRemainAtTheTop(page);
|
||||
await expectPrimaryCommandCenterActions(panel);
|
||||
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
|
||||
// The empty query lists the most recent workspaces, so the last one seeded leads it.
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 08");
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo, resolveTempRoot } from "../support/helpers/workspace";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
// The compact toolbar flattens every git action into one menu, and the policy keeps the actions
|
||||
// it can promote to `primary` in `secondary` as well. A promoted action must still show once.
|
||||
const cleanupTasks: Array<() => Promise<void>> = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const task of cleanupTasks.splice(0)) {
|
||||
await task();
|
||||
}
|
||||
});
|
||||
|
||||
async function createRemoteRepo(prefix: string): Promise<string> {
|
||||
const repo = await createTempGitRepo(prefix, {
|
||||
withRemote: true,
|
||||
files: [{ path: "src/app.ts", content: "export const app = 1;\n" }],
|
||||
});
|
||||
cleanupTasks.push(repo.cleanup);
|
||||
// The helper puts the bare remote inside the checkout; keep it out of the status.
|
||||
await writeFile(path.join(repo.path, ".git/info/exclude"), "remote.git/\n");
|
||||
return repo.path;
|
||||
}
|
||||
|
||||
async function registerWorkspace(repoPath: string): Promise<string> {
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push(async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
});
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repoPath } });
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to create workspace ${repoPath}`);
|
||||
}
|
||||
return created.workspace.id;
|
||||
}
|
||||
|
||||
async function openCompactActionsMenu(page: Page, workspaceId: string) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
||||
await page.reload();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true });
|
||||
await expect(changesTab).toBeVisible({ timeout: 30_000 });
|
||||
await changesTab.click();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const trigger = explorer.getByTestId("changes-actions-menu-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
const menu = page.getByTestId("changes-primary-cta-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
return menu;
|
||||
}
|
||||
|
||||
test("compact git actions menu lists a promoted Pull once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-pull-");
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", repoPath]);
|
||||
});
|
||||
|
||||
// Advance origin so the clean checkout is behind it and Pull is the promoted action.
|
||||
const other = await mkdtemp(path.join(await resolveTempRoot(), "compact-menu-other-"));
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", other]);
|
||||
});
|
||||
execFileSync("git", ["clone", path.join(repoPath, "remote.git"), other], { stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "e2e@paseo.test"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Paseo E2E"], { cwd: other, stdio: "ignore" });
|
||||
await writeFile(path.join(other, "src/incoming.ts"), "export const incoming = 1;\n");
|
||||
execFileSync("git", ["add", "--all"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Incoming commit"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["push", "origin", "main"], { cwd: other, stdio: "ignore" });
|
||||
execFileSync("git", ["fetch", "origin"], { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const workspaceId = await registerWorkspace(repoPath);
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-pull")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Pull",
|
||||
"Push",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
});
|
||||
|
||||
test("compact git actions menu lists a promoted Push once", async ({ page }) => {
|
||||
const repoPath = await createRemoteRepo("compact-menu-push-");
|
||||
cleanupTasks.push(async () => {
|
||||
execFileSync("rm", ["-rf", repoPath]);
|
||||
});
|
||||
const workspaceId = await registerWorkspace(repoPath);
|
||||
|
||||
// Commit so the clean checkout is ahead of origin and Push is the promoted action.
|
||||
await writeFile(path.join(repoPath, "src/mine.ts"), "export const mine = 1;\n");
|
||||
execFileSync("git", ["add", "--all"], { cwd: repoPath, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Local commit"], { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const menu = await openCompactActionsMenu(page, workspaceId);
|
||||
|
||||
await expect(menu.getByTestId("changes-menu-push")).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem")).toHaveText([
|
||||
"Push",
|
||||
"Pull",
|
||||
"Pull and push",
|
||||
"Archive workspace",
|
||||
]);
|
||||
});
|
||||
@@ -71,14 +71,6 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await closeSheet(page, "linked.ts");
|
||||
});
|
||||
|
||||
await test.step("a subagent's row shows the facts its sheet shows", async () => {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
await expect(page.getByTestId(`subagents-track-row-facts-${child.id}`)).toHaveText(
|
||||
"Ten second stream · Low · Load test",
|
||||
);
|
||||
await tapSheetBackdrop(page);
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet over the subagents list", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ test("composer diff stat opens the compact explorer instead of a Changes tab", a
|
||||
});
|
||||
|
||||
const closeExplorer = page
|
||||
.getByTestId("workspace-explorer-sidebar")
|
||||
.getByTestId("explorer-header")
|
||||
.getByRole("button", { name: "Close Explorer sidebar" });
|
||||
await expect(closeExplorer).not.toBeInViewport();
|
||||
|
||||
@@ -809,7 +809,7 @@ async function expectUnmatchedFeedback(page: Page) {
|
||||
}
|
||||
|
||||
function compactReviewSurface(page: Page) {
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
return page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||
}
|
||||
async function openCompactReviewExplorer(
|
||||
page: Page,
|
||||
@@ -847,10 +847,7 @@ async function scrollReviewToLastLine(page: Page) {
|
||||
|
||||
async function revealCompactChanges(page: Page) {
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await page
|
||||
.getByTestId("workspace-explorer-sidebar")
|
||||
.getByTestId("workspace-tab-changes_tree")
|
||||
.click();
|
||||
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
|
||||
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||
}
|
||||
async function expectReviewEditorBody(page: Page, body: string) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
blurComposerInput,
|
||||
roundTripComposerTabBeforePaint,
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
exitComposerFullscreen,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchComposerTabWithKeyboard } from "../support/helpers/composer";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
closeMobileExplorer,
|
||||
@@ -205,21 +205,19 @@ test.describe("touch screen wide enough for the desktop layout", () => {
|
||||
title: "Second touch chat",
|
||||
});
|
||||
await openWorkspaceWithAgents(page, [first, second]);
|
||||
// Repeat after an explicit edit so retained focus from an earlier visit is
|
||||
// also dismissed when the same mounted editor becomes inactive again.
|
||||
for (const draft of ["Keep this touch draft", "Edit this touch draft again"]) {
|
||||
await composerLocator(page).fill(draft);
|
||||
await expectComposerFocused(page);
|
||||
// An external keyboard changes tabs without a pointer click blurring the
|
||||
// editor first. The device still has a touch-primary focus policy.
|
||||
await roundTripComposerTabBeforePaint(page, "Second touch chat");
|
||||
await expectComposerDraft(page, draft);
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, " after return");
|
||||
await expectComposerDraft(page, `${draft} after return`);
|
||||
}
|
||||
await composerLocator(page).fill("Keep this touch draft");
|
||||
await expectComposerFocused(page);
|
||||
// An external keyboard changes tabs without a pointer click blurring the
|
||||
// editor first. The device still has a touch-primary focus policy.
|
||||
await switchComposerTabWithKeyboard(page, "previous");
|
||||
await expectComposerDraft(page, "");
|
||||
await switchComposerTabWithKeyboard(page, "next");
|
||||
await expectComposerDraft(page, "Keep this touch draft");
|
||||
await expectComposerNotFocused(page);
|
||||
await composerLocator(page).tap();
|
||||
await expectComposerFocused(page);
|
||||
await typeIntoFocusedComposer(page, " after return");
|
||||
await expectComposerDraft(page, "Keep this touch draft after return");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ for (const layout of [
|
||||
await expect(details.getByText(/tokens$/)).toHaveCount(0);
|
||||
const account = usageCard(details, "login-journey:account");
|
||||
await expect(account.getByText("Session", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31% used", { exact: true })).toBeVisible();
|
||||
await expect(account.getByText("31%", { exact: true })).toBeVisible();
|
||||
await qaScreenshot(page, `context-without-data-${layout.name}`);
|
||||
await layout.close(page);
|
||||
});
|
||||
@@ -161,7 +161,7 @@ for (const layout of [
|
||||
await expect(details.getByText("32k / 128k tokens", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("No context data", { exact: true })).toHaveCount(0);
|
||||
await expect(
|
||||
usageCard(details, "login-journey:account").getByText("31% used", { exact: true }),
|
||||
usageCard(details, "login-journey:account").getByText("31%", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||
|
||||
interface DragMeasurement {
|
||||
observer: MutationObserver;
|
||||
stylesheetMutations: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PASEO_EXPLORER_DRAG_MEASUREMENT__?: DragMeasurement;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForResizePaint(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function beginResizeMeasurement(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const stylesheet = document.getElementById("unistyles-web");
|
||||
if (!stylesheet) throw new Error("Shared stylesheet is missing");
|
||||
globalThis.__PASEO_RESET_RENDER_PROFILE__!();
|
||||
const measurement: DragMeasurement = {
|
||||
stylesheetMutations: 0,
|
||||
observer: new MutationObserver((records) => {
|
||||
measurement.stylesheetMutations += records.length;
|
||||
}),
|
||||
};
|
||||
measurement.observer.observe(stylesheet, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
window.__PASEO_EXPLORER_DRAG_MEASUREMENT__ = measurement;
|
||||
});
|
||||
}
|
||||
|
||||
async function previewExplorerResize(page: Page, distance: number) {
|
||||
const handle = page
|
||||
.getByTestId("workspace-explorer-sidebar-resize-handle")
|
||||
.getByRole("separator");
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
const bounds = await handle.boundingBox();
|
||||
if (!bounds) throw new Error("Explorer resize handle is missing");
|
||||
const before = (await explorer.boundingBox())!.width;
|
||||
const x = bounds.x + bounds.width / 2;
|
||||
const y = bounds.y + 100;
|
||||
await page.mouse.move(x, y);
|
||||
await page.mouse.down();
|
||||
await waitForResizePaint(page);
|
||||
await beginResizeMeasurement(page);
|
||||
for (let move = 1; move <= 20; move++) {
|
||||
await page.mouse.move(x + (distance * move) / 20, y);
|
||||
await waitForResizePaint(page);
|
||||
}
|
||||
const during = (await explorer.boundingBox())!.width;
|
||||
const measurement = await page.evaluate(() => {
|
||||
const state = window.__PASEO_EXPLORER_DRAG_MEASUREMENT__!;
|
||||
state.observer.disconnect();
|
||||
return {
|
||||
stylesheetMutations: state.stylesheetMutations,
|
||||
paneRenders: Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {})
|
||||
.filter(([id]) => id.startsWith("SplitPaneView:"))
|
||||
.reduce((sum, [, reasons]) => sum + (reasons.render ?? 0), 0),
|
||||
};
|
||||
});
|
||||
await page.mouse.up();
|
||||
await waitForResizePaint(page);
|
||||
const after = (await explorer.boundingBox())!.width;
|
||||
expect(during).toBeCloseTo(before - distance, 0);
|
||||
expect(after).toBeCloseTo(during, 0);
|
||||
return { ...measurement, width: after };
|
||||
}
|
||||
|
||||
async function expectExplorerWidthSurvivesReload(page: Page, width: number): Promise<void> {
|
||||
await page.reload();
|
||||
const explorer = page.getByTestId("workspace-explorer-sidebar");
|
||||
await expect(explorer).toBeVisible();
|
||||
await expect.poll(async () => (await explorer.boundingBox())?.width).toBeCloseTo(width, 0);
|
||||
}
|
||||
|
||||
test("Explorer drag previews without rerendering panes or rewriting the shared stylesheet", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||
});
|
||||
const workspace = await withWorkspace();
|
||||
await workspace.navigateTo();
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
// Verify that direct pane renders are being counted before resetting them.
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
Object.keys(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).some((id) =>
|
||||
id.startsWith("SplitPaneView:"),
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
const grown = await previewExplorerResize(page, -100);
|
||||
const shrunk = await previewExplorerResize(page, 50);
|
||||
expect(grown.stylesheetMutations).toBe(0);
|
||||
expect(grown.paneRenders).toBe(0);
|
||||
expect(shrunk.stylesheetMutations).toBe(0);
|
||||
expect(shrunk.paneRenders).toBe(0);
|
||||
await expectExplorerWidthSurvivesReload(page, shrunk.width);
|
||||
});
|
||||
@@ -1,7 +1,3 @@
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { openSettingsSection, clickSettingsBackToWorkspace } from "../support/helpers/settings";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
@@ -140,9 +136,10 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Files/ })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Changes/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Terminal/ })).toBeVisible();
|
||||
const entries = await menu.getByRole("menuitem").allTextContents();
|
||||
expect(entries.findIndex((entry) => entry.startsWith("Terminal"))).toBeLessThan(
|
||||
@@ -194,11 +191,13 @@ test("Explorer keeps Files and Changes close actions in the context menu", async
|
||||
}
|
||||
});
|
||||
|
||||
async function dragTabIntoExplorer(
|
||||
async function dragAgentIntoExplorer(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
tabId: string,
|
||||
agentId: string,
|
||||
) {
|
||||
const source = page.getByTestId("workspace-pane-main").getByTestId(`workspace-tab-${tabId}`);
|
||||
const source = page
|
||||
.getByTestId("workspace-pane-main")
|
||||
.getByTestId(`workspace-tab-agent_${agentId}`);
|
||||
const destination = explorerSidebar(page).getByTestId("workspace-tab-files");
|
||||
const sourceBox = await source.boundingBox();
|
||||
const destinationBox = await destination.boundingBox();
|
||||
@@ -214,7 +213,7 @@ async function dragTabIntoExplorer(
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test("moves agents into Explorer and keeps the shared Agent launcher available", async ({
|
||||
test("agents can still be dragged into Explorer when absent from its + menu", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-agent-drag-" });
|
||||
@@ -231,13 +230,14 @@ test("moves agents into Explorer and keeps the shared Agent launcher available",
|
||||
});
|
||||
const main = page.getByTestId("workspace-pane-main");
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await dragTabIntoExplorer(page, `agent_${agent.id}`);
|
||||
await dragAgentIntoExplorer(page, agent.id);
|
||||
await expect(explorer.getByTestId(`workspace-tab-agent_${agent.id}`)).toBeVisible();
|
||||
await expect(main.getByTestId(`workspace-tab-agent_${agent.id}`)).toHaveCount(0);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(1);
|
||||
await expect(menu.getByRole("menuitem", { name: /^Agent/ })).toHaveCount(0);
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await testInfo.attach("agent-in-explorer", {
|
||||
body: await page.screenshot(),
|
||||
@@ -247,273 +247,3 @@ test("moves agents into Explorer and keeps the shared Agent launcher available",
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("tablet prototype keeps Explorer tabs when resizing down to a phone", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "tablet-explorer-" });
|
||||
try {
|
||||
await page.setViewportSize({ width: 951, height: 669 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureExplorerSidebar(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
|
||||
await openExplorerAgentDraft(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await revealPhoneExplorer(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("phone-shared-explorer.png") });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openExplorerAgentDraft(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
await explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Agent/ }).click();
|
||||
await expect(explorerSidebar(page).getByRole("textbox").first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function revealPhoneExplorer(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
await page.getByRole("button", { name: "Open Explorer sidebar", exact: true }).click();
|
||||
await expect(explorerSidebar(page)).toBeVisible();
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "Close Explorer sidebar", exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
async function cycleWorkspaceTheme(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
await page.keyboard.press("Control+Alt+t");
|
||||
}
|
||||
|
||||
test("retains the Explorer host through appearance changes and folding", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-lifetime-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
if (!host) throw new Error("Explorer must be mounted before changing appearance");
|
||||
const previousColor = await explorer.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await cycleWorkspaceTheme(page);
|
||||
await expect
|
||||
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(previousColor);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await ensureExplorerSidebar(page);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await expect(explorer).toBeVisible();
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function reorderCompactExplorerTabs(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
const explorer = explorerSidebar(page);
|
||||
const files = explorer.getByTestId("workspace-tab-files");
|
||||
const changes = explorer.getByTestId("workspace-tab-changes_tree");
|
||||
const source = await changes.boundingBox();
|
||||
const target = await files.boundingBox();
|
||||
if (!source || !target) throw new Error("Explorer tabs must be visible to reorder");
|
||||
await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(source.x + source.width / 2 - 12, source.y + source.height / 2);
|
||||
await page.mouse.move(target.x + 3, target.y + target.height / 2, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const afterFiles = await files.boundingBox();
|
||||
const afterChanges = await changes.boundingBox();
|
||||
return Boolean(afterFiles && afterChanges && afterChanges.x < afterFiles.x);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("reorders compact Explorer tabs with a pointer without changing selection", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openFilesPanel(page);
|
||||
await reorderCompactExplorerTabs(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "Browse workspace files", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Exercise appearance at its consumers while the real workspace navigator retains the panels.
|
||||
test("updates populated code and diff appearance without losing the editor instance or draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-populated-appearance-" });
|
||||
const source = Array.from(
|
||||
{ length: 120 },
|
||||
(_, index) => `export const line${index} = "retained ${index}";`,
|
||||
).join("\n");
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/appearance.ts`, source);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ codeFontSize: 13, theme: "light" }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"@paseo:changes-preferences",
|
||||
JSON.stringify({ inlineDiff: true, layout: "unified" }),
|
||||
);
|
||||
});
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFilesPanel(page);
|
||||
await openFileFromExplorer(page, "appearance.ts");
|
||||
const editor = page.getByTestId("file-source-editor").filter({ visible: true });
|
||||
const text = editor.locator(".cm-content");
|
||||
await expect(text).toBeVisible();
|
||||
await text.fill(`${source}\n// unsaved local edit`);
|
||||
const instance = await editor.elementHandle();
|
||||
const scroller = editor.locator(".cm-scroller");
|
||||
await scroller.evaluate((node) => {
|
||||
node.scrollTop = 400;
|
||||
});
|
||||
const scrollBefore = await scroller.evaluate((node) => node.scrollTop);
|
||||
await explorerSidebar(page).getByTestId("workspace-tab-changes_tree").click();
|
||||
const diff = explorerSidebar(page).getByTestId("git-diff-canvas");
|
||||
await expect(diff).toBeVisible();
|
||||
await page.keyboard.press("Control+,");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel("Code font size", { exact: true }).fill("19");
|
||||
await page.getByLabel("Code font size", { exact: true }).press("Tab");
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(text).toHaveCSS("font-size", "19px");
|
||||
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("unsaved local edit");
|
||||
expect(await scroller.evaluate((node) => node.scrollTop)).toBe(scrollBefore);
|
||||
await expect(diff).toHaveCSS("font-size", "19px");
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await instance!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("unsaved local edit");
|
||||
await dragTabIntoExplorer(page, "file_appearance.ts");
|
||||
await expect(explorerSidebar(page).getByTestId("file-source-editor")).toBeVisible();
|
||||
const explorerEditor = await explorerSidebar(page)
|
||||
.getByTestId("file-source-editor")
|
||||
.elementHandle();
|
||||
await text.fill(`${source}\n// retained Explorer edit`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("retained Explorer edit");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("retains populated Explorer chat and terminal attachments through appearance, hide and fold", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "explorer-chat-appearance-",
|
||||
title: "Retained chat",
|
||||
initialPrompt: "Show the retention marker",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "Explorer populated paragraph.\n\n```ts\nconst retained = 42;\n```",
|
||||
},
|
||||
});
|
||||
const attaches: string[] = [];
|
||||
page.on("websocket", (socket) =>
|
||||
socket.on("framesent", (frame) => {
|
||||
if (typeof frame.payload !== "string") return;
|
||||
try {
|
||||
const message = JSON.parse(frame.payload).message;
|
||||
if (message?.type === "subscribe_terminal_request") attaches.push(message.terminalId);
|
||||
} catch {
|
||||
/* Binary terminal traffic is not a JSON request. */
|
||||
}
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await openAgentRoute(page, workspace);
|
||||
await ensureExplorerSidebar(page);
|
||||
await dragTabIntoExplorer(page, `agent_${workspace.agentId}`);
|
||||
const explorer = explorerSidebar(page);
|
||||
const paragraph = explorer.getByText("Explorer populated paragraph.", { exact: true });
|
||||
await expect(paragraph).toBeVisible();
|
||||
const composer = explorer.getByRole("textbox", { name: "Message agent..." });
|
||||
await composer.fill("Unsubmitted Explorer draft");
|
||||
const composerNode = await composer.elementHandle();
|
||||
await page.keyboard.press("Control+,");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await page.getByLabel("Content font size", { exact: true }).fill("20");
|
||||
await page.getByLabel("Content font size", { exact: true }).press("Tab");
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(paragraph).toHaveCSS("font-size", "20px");
|
||||
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
|
||||
const previousColor = await paragraph.evaluate((node) => getComputedStyle(node).color);
|
||||
await cycleWorkspaceTheme(page);
|
||||
await expect
|
||||
.poll(() => paragraph.evaluate((node) => getComputedStyle(node).color))
|
||||
.not.toBe(previousColor);
|
||||
await explorer.getByRole("button", { name: "New tab", exact: true }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: /^Terminal/ })
|
||||
.first()
|
||||
.click();
|
||||
const terminal = explorer.getByTestId("terminal-surface");
|
||||
await expect(terminal).toBeVisible();
|
||||
await expect.poll(() => attaches.length).toBe(1);
|
||||
const terminalNode = await terminal.elementHandle();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
expect(attaches).toHaveLength(1);
|
||||
await explorer.getByTestId(`workspace-tab-agent_${workspace.agentId}`).click();
|
||||
expect(await composerNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(composer).toHaveValue("Unsubmitted Explorer draft");
|
||||
await expect(paragraph).toHaveCSS("font-size", "20px");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("compact file sheets cover Explorer and return to its retained file list", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-sheet-layer-" });
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
await openFileFromExplorer(page, "sheet-marker.txt");
|
||||
await expect(page.getByText("Explorer sheet marker", { exact: true })).toBeVisible();
|
||||
// A real click also proves the sheet draws above the retained Explorer overlay.
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(page.getByText("Explorer sheet marker", { exact: true })).toHaveCount(0);
|
||||
await expect(explorer.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
expect(await host!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,12 +56,45 @@ async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||
await editor.type(content);
|
||||
}
|
||||
|
||||
async function replaceFileAfterDeletionWasObserved(input: {
|
||||
page: Page;
|
||||
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
content: string;
|
||||
}): Promise<void> {
|
||||
const { page, gate, filePath, relativePath, content } = input;
|
||||
// Recreate the observed race without faking file state: the daemon publishes the
|
||||
// deletion, then a real read succeeds after the replacement while its ready event waits.
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
gate.holdFileReads(relativePath);
|
||||
await unlink(filePath);
|
||||
await gate.waitForHeldFileRead();
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
gate.releaseHeldFileRead();
|
||||
await expectOnlyFileCallout(page, "File deleted on disk");
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
await writeFile(filePath, content, "utf8");
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
await expect.poll(() => readFile(filePath, "utf8")).toBe(content);
|
||||
}
|
||||
|
||||
async function expectOnlyFileCallout(page: Page, title: string): Promise<void> {
|
||||
await expect(fileCallout(page)).toHaveCount(1);
|
||||
await expect(fileCallout(page)).toContainText(title);
|
||||
await expect(filePane(page).getByText(title, { exact: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
async function expectCleanReplacementCanReload(page: Page): Promise<void> {
|
||||
await expectOnlyFileCallout(page, "Changed on disk");
|
||||
await expect(filePane(page).getByText("File deleted on disk", { exact: true })).toHaveCount(0);
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toBeEnabled();
|
||||
await fileCallout(page).getByRole("button", { name: "Reload" }).click();
|
||||
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function expectDeletedFileNotice(page: Page): Promise<void> {
|
||||
await expectOnlyFileCallout(page, "File deleted on disk");
|
||||
await expect(fileCallout(page)).toContainText("The open copy is preserved.");
|
||||
@@ -107,7 +140,7 @@ test.describe("Workspace file change conflicts", () => {
|
||||
relativePath,
|
||||
content: "const preserved = true;\n",
|
||||
});
|
||||
await expect(visibleEditor(page)).toContainText("const preserved = true;");
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await recordFileCallouts(page);
|
||||
|
||||
await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath });
|
||||
@@ -156,79 +189,162 @@ test.describe("Workspace file change conflicts", () => {
|
||||
relativePath,
|
||||
content: "const before = true;\n",
|
||||
});
|
||||
await expect(visibleEditor(page)).toContainText("const before = true;");
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await recordFileCallouts(page);
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
gate.holdFileReads(relativePath);
|
||||
|
||||
await replaceEditorText(page, "const saved = true;\n");
|
||||
await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;");
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
await gate.waitForHeldFileRead();
|
||||
|
||||
await expect(visibleEditor(page)).toContainText("const saved = true;");
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
await expectNoFileCalloutWasRendered(page);
|
||||
gate.releaseHeldFileRead();
|
||||
});
|
||||
|
||||
test("a viewed file deleted and written back shows the new file", async ({
|
||||
test("a clean file replaced on disk offers one working reload action", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-clean-replacement-",
|
||||
relativePath: "inventory.md",
|
||||
content: "# Before\n",
|
||||
});
|
||||
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
|
||||
|
||||
await unlink(filePath);
|
||||
await expect(filePane(page).getByText("The file was deleted.", { exact: true })).toBeVisible();
|
||||
await writeFile(filePath, "# After\n", "utf8");
|
||||
|
||||
await expect(filePane(page).getByText("After", { exact: true })).toBeVisible();
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
await replaceFileAfterDeletionWasObserved({
|
||||
page,
|
||||
gate,
|
||||
filePath,
|
||||
relativePath: "inventory.md",
|
||||
content: "# After\n",
|
||||
});
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
await expectCleanReplacementCanReload(page);
|
||||
});
|
||||
|
||||
test("a deleted file keeps the open copy with one notice and no resolution actions", async ({
|
||||
test("a deleted file shows one explanatory notice and no resolution actions", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-deleted-",
|
||||
relativePath: "deleted.ts",
|
||||
content: "const present = true;\n",
|
||||
relativePath: "deleted.md",
|
||||
content: "# Present\n",
|
||||
});
|
||||
await expect(visibleEditor(page)).toContainText("const present = true;");
|
||||
await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible();
|
||||
await recordFileCallouts(page);
|
||||
await unlink(filePath);
|
||||
await expectDeletedFileNotice(page);
|
||||
await expectFileCalloutWasRendered(page, "File deleted on disk");
|
||||
await expect(visibleEditor(page)).toContainText("const present = true;");
|
||||
});
|
||||
|
||||
test("a path that stops being a file keeps the open copy and follows the file back", async ({
|
||||
test("a file check error offers a working retry", async ({ page, withWorkspace }) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const relativePath = "retry.md";
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-check-retry-",
|
||||
relativePath,
|
||||
content: "# Before\n",
|
||||
});
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await expect(filePane(page).getByText("Before", { exact: true })).toBeVisible();
|
||||
|
||||
await unlink(filePath);
|
||||
await mkdir(filePath);
|
||||
await expectOnlyFileCallout(page, "Couldn't check file on disk");
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Reload" })).toHaveCount(0);
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Overwrite" })).toHaveCount(0);
|
||||
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
await rm(filePath, { recursive: true });
|
||||
await writeFile(filePath, "# After\n", "utf8");
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
gate.holdFileReads(relativePath);
|
||||
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
|
||||
await gate.waitForHeldFileRead();
|
||||
await expect(fileCallout(page).getByRole("button", { name: "Retry" })).toBeDisabled();
|
||||
gate.releaseHeldFileRead();
|
||||
await expectCleanReplacementCanReload(page);
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
});
|
||||
|
||||
test("retry clears a transient error when the file is unchanged", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const relativePath = "retry.ts";
|
||||
const relativePath = "unchanged.md";
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-check-error-",
|
||||
prefix: "file-check-unchanged-",
|
||||
relativePath,
|
||||
content: "const before = true;\n",
|
||||
content: "# Unchanged\n",
|
||||
});
|
||||
await expect(visibleEditor(page)).toContainText("const before = true;");
|
||||
const parkedPath = `${filePath}.parked`;
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await expect(filePane(page).getByText("Unchanged", { exact: true })).toBeVisible();
|
||||
|
||||
await rename(filePath, parkedPath);
|
||||
await mkdir(filePath);
|
||||
await expectOnlyFileCallout(page, "Couldn't check file on disk");
|
||||
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
await rm(filePath, { recursive: true });
|
||||
await rename(parkedPath, filePath);
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
await fileCallout(page).getByRole("button", { name: "Retry" }).click();
|
||||
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
await expect(filePane(page).getByText("Changed on disk", { exact: true })).toHaveCount(0);
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
});
|
||||
|
||||
test("a read-only file error preserves its message and offers retry", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const relativePath = "artifact.bin";
|
||||
const binaryContent = new Uint8Array([0, 1, 2, 3, 255]);
|
||||
const filePath = await openTrackedFile(page, withWorkspace, {
|
||||
prefix: "file-read-only-retry-",
|
||||
relativePath,
|
||||
content: binaryContent,
|
||||
});
|
||||
await gate.waitForFileSubscription(relativePath);
|
||||
await expect(
|
||||
filePane(page).getByText("Binary preview unavailable", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await unlink(filePath);
|
||||
await mkdir(filePath);
|
||||
await gate.waitForFileUpdate(relativePath, "error");
|
||||
await expect(visibleEditor(page)).toContainText("const before = true;");
|
||||
await expect(
|
||||
filePane(page).getByText("Requested path is not a file", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(filePane(page).getByRole("button", { name: "Retry" })).toBeEnabled();
|
||||
|
||||
gate.holdNextReadyFileUpdate(relativePath);
|
||||
await rm(filePath, { recursive: true });
|
||||
await writeFile(filePath, "const after = true;\n", "utf8");
|
||||
await writeFile(filePath, binaryContent);
|
||||
await gate.waitForHeldReadyFileUpdate();
|
||||
gate.holdFileReads(relativePath);
|
||||
await filePane(page).getByRole("button", { name: "Retry" }).click();
|
||||
await gate.waitForHeldFileRead();
|
||||
await expect(
|
||||
filePane(page).getByText("Requested path is not a file", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
gate.releaseHeldFileRead();
|
||||
|
||||
await expect(visibleEditor(page)).toContainText("const after = true;");
|
||||
await expect(fileCallout(page)).toHaveCount(0);
|
||||
await expect(
|
||||
filePane(page).getByText("Binary preview unavailable", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
filePane(page).getByText("Requested path is not a file", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
gate.releaseHeldReadyFileUpdate();
|
||||
});
|
||||
|
||||
test("a changed file with local edits offers overwrite and a working reload", async ({
|
||||
|
||||
@@ -55,10 +55,6 @@ async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
await expectFileTabOpen(page, filename);
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
}
|
||||
|
||||
function htmlPreview(page: Page) {
|
||||
return {
|
||||
host: page.getByTestId("file-html-preview"),
|
||||
@@ -66,9 +62,11 @@ function htmlPreview(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Switches the file tab to one of its panels from the bar's switcher. */
|
||||
async function selectFilePanel(page: Page, title: string): Promise<void> {
|
||||
const option = filePanelSwitcher(page).getByRole("button", { name: title, exact: true });
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||
name: view,
|
||||
exact: true,
|
||||
});
|
||||
await option.click();
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
@@ -107,37 +105,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "screenshot-markdown-link-",
|
||||
title: "Screenshot file links",
|
||||
initialPrompt: "Show screenshots",
|
||||
featureValues: {
|
||||
mockAssistantResponse:
|
||||
"[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
|
||||
await openAgentRoute(page, session);
|
||||
await test.step("Open the linked screenshot", async () => {
|
||||
await openAssistantMarkdownLink(page, "Capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
|
||||
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
|
||||
});
|
||||
await test.step("Explain why the missing screenshot cannot open", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "Missing capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
|
||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
@@ -319,7 +286,11 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
title: "Unsupported large source",
|
||||
initialPrompt: "Generate a title and a git branch name. Return JSON only.",
|
||||
});
|
||||
await writeFile(path.join(session.cwd, "too-large.txt"), "x".repeat(51 * 1024 * 1024), "utf8");
|
||||
await writeFile(
|
||||
path.join(session.cwd, "too-large.txt"),
|
||||
Buffer.alloc(51 * 1024 * 1024),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
@@ -482,9 +453,9 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
relativePath,
|
||||
);
|
||||
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||
const modeControl = filePanelSwitcher(page);
|
||||
const modeControl = page.getByTestId("file-preview-mode");
|
||||
await expect(modeControl).toBeVisible();
|
||||
await selectFilePanel(page, "Source");
|
||||
await selectFileView(page, "Source");
|
||||
|
||||
const editorHost = page.getByTestId("file-source-editor");
|
||||
const content = editor(page);
|
||||
@@ -546,7 +517,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
await selectFilePanel(page, "Source");
|
||||
await selectFileView(page, "Source");
|
||||
|
||||
const markdownScroller = page
|
||||
.getByTestId("file-source-editor")
|
||||
@@ -678,7 +649,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refreshes Markdown and images while switching between Markdown and Source", async ({
|
||||
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
@@ -693,15 +664,15 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(filePanelSwitcher(page).getByRole("button", { name: "Markdown" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Preview", exact: true })).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(visibleFilePane.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await selectFilePanel(page, "Source");
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await selectFilePanel(page, "Markdown");
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(visibleFilePane.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
@@ -784,10 +755,10 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
);
|
||||
await expect(preview.document.getByRole("heading", { name: "Updated plan" })).toBeVisible();
|
||||
|
||||
await selectFilePanel(page, "Source");
|
||||
await selectFileView(page, "Source");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(preview.host).toHaveCount(0);
|
||||
await selectFilePanel(page, "HTML");
|
||||
await selectFileView(page, "Preview");
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
expectFileTabOpen,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
async function openMarkdownPreview(page: Page) {
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "README.md");
|
||||
await expectFileTabOpen(page, "README.md");
|
||||
await page
|
||||
.getByTestId("file-panel-switcher")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Markdown", exact: true })
|
||||
.click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
}
|
||||
|
||||
async function followPreviewLink(page: Page, name: string) {
|
||||
await page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByRole("link", { name, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test("Markdown preview folder links reveal Files and file links still open a file", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "preview-folder-links-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs/guides/guide.md"),
|
||||
"# Folder link destination\n",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "README.md"),
|
||||
"[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Guide");
|
||||
await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible();
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Dot");
|
||||
await expectFolderContents(page);
|
||||
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Bare");
|
||||
await expectFolderContents(page);
|
||||
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") });
|
||||
});
|
||||
|
||||
async function openReportedFolderLink(page: Page, name: string) {
|
||||
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
|
||||
await link.click();
|
||||
}
|
||||
|
||||
async function expectFolderContents(page: Page) {
|
||||
await expect(page.getByText("Requested path is not a file", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-explorer-tree-scroll").getByText("guide.md", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
for (const folder of [
|
||||
"./docs/guides",
|
||||
"./docs/guides/",
|
||||
"./docs/reports.v1",
|
||||
"./.github/workflows",
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "folder-link-repro-",
|
||||
title: "Folder link reproduction",
|
||||
initialPrompt: "Show the folder link",
|
||||
featureValues: { mockAssistantResponse: `[Open folder](${folder})` },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"panel-state",
|
||||
JSON.stringify({ state: { explorerShowHiddenFiles: false }, version: 0 }),
|
||||
);
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await openReportedFolderLink(page, "Open folder");
|
||||
await expectFolderContents(page);
|
||||
} finally {
|
||||
await page.screenshot({ path: test.info().outputPath("folder-link-result.png") });
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { test, expect } from "../support/fixtures";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
assertNewTabMenuTriggerVisible,
|
||||
assertSingleNewTabButton,
|
||||
openNewTabMenuWithShortcut,
|
||||
clickNewChat,
|
||||
clickNewTerminal,
|
||||
@@ -124,7 +126,6 @@ test.describe("Tab creation", () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
await pressNewTabShortcut(page);
|
||||
const newTabs = page
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
@@ -189,18 +190,11 @@ test.describe("Tab creation", () => {
|
||||
await expect(agent).toBeFocused();
|
||||
});
|
||||
|
||||
test("the Agent menu creates a draft and shows its composer within the visual budget", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
await clickNewChat(page);
|
||||
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
@@ -208,17 +202,12 @@ test.describe("Tab creation", () => {
|
||||
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => {
|
||||
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
@@ -306,6 +295,13 @@ test.describe("Tab creation", () => {
|
||||
await profileSeed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -343,4 +339,37 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
|
||||
// Terminal surface should appear within a reasonable budget.
|
||||
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
|
||||
// Draft creation is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
// is that no blank/flash frame appears (tested above).
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
|
||||
async function openNestedGuide(page: Page): Promise<void> {
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
}
|
||||
|
||||
async function followDetailsLink(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByTestId("markdown-preview-frame")
|
||||
.getByRole("link", { name: "Details", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test("Markdown preview resolves a relative link from the document directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "markdown-relative-links-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs/guide"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs/guide.md"),
|
||||
"# Guide\n\n[Details](guide/details.md)\n",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs/guide/details.md"),
|
||||
"# Nested details destination\n",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openNestedGuide(page);
|
||||
await followDetailsLink(page);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("markdown-preview-frame")
|
||||
.getByText("Nested details destination", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -247,7 +247,9 @@ test.describe("New workspace: fullscreen composer across the compact breakpoint"
|
||||
await expectNewWorkspaceDraft(page, `${draft} wide`);
|
||||
});
|
||||
|
||||
// Exercise the terminal prompt's fullscreen and compact launch sheet on the phone.
|
||||
// Terminal mode and its prompt are screen state, and the shell remounts the screen
|
||||
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
|
||||
// layout only.
|
||||
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
// The compact launch control opens a sheet, not the desktop menu.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerModel } from "../support/helpers/agent-profiles";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
|
||||
async function restoreHostConnection(gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>) {
|
||||
gate.holdNextServerMessage("status");
|
||||
gate.restore();
|
||||
await gate.waitForHeldServerMessage("status");
|
||||
gate.releaseHeldServerMessage("status");
|
||||
}
|
||||
|
||||
test("an offline workspace draft reports the disconnected host instead of loading models", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace();
|
||||
await workspace.navigateTo();
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
await createAgentTabFromMenu(page);
|
||||
await submitMessage(page, "hello");
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveText(
|
||||
"Host is not connected",
|
||||
);
|
||||
await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0);
|
||||
await restoreHostConnection(gate);
|
||||
await expectComposerModel(page, "Ten second stream");
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello");
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
|
||||
async function openProjectHome(page: Page) {
|
||||
await page.goto("/open-project");
|
||||
await expect(page.getByText("Add a project", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function scrollToHomeFooter(page: Page) {
|
||||
await page.mouse.move(160, 350);
|
||||
await page.mouse.wheel(0, 1500);
|
||||
}
|
||||
|
||||
async function expectHomeActionsReachable(page: Page) {
|
||||
const providers = page.getByTestId("open-project-setup-providers");
|
||||
const community = page.getByRole("button", { name: "Community", exact: true });
|
||||
await expect(providers).toBeInViewport({ ratio: 1 });
|
||||
await expect(community).toBeInViewport({ ratio: 1 });
|
||||
const providerBounds = await providers.boundingBox();
|
||||
const communityBounds = await community.boundingBox();
|
||||
expect(providerBounds).not.toBeNull();
|
||||
expect(communityBounds).not.toBeNull();
|
||||
expect(communityBounds!.y).toBeGreaterThanOrEqual(providerBounds!.y + providerBounds!.height);
|
||||
}
|
||||
|
||||
test("home actions remain reachable on a short phone screen", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await openProjectHome(page);
|
||||
await scrollToHomeFooter(page);
|
||||
await expectHomeActionsReachable(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("home-scrolled.png") });
|
||||
});
|
||||
|
||||
test("home actions fit on a desktop screen without overlap", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await openProjectHome(page);
|
||||
await expectHomeActionsReachable(page);
|
||||
await expect(page.getByTestId("open-project-setup-providers")).toHaveCSS(
|
||||
"-webkit-app-region",
|
||||
"none",
|
||||
);
|
||||
});
|
||||
@@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => {
|
||||
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
|
||||
});
|
||||
await captureToolCall(page, testInfo, {
|
||||
label: "Send message",
|
||||
label: "Send agent prompt",
|
||||
artifact: "send-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Agent", "Result"],
|
||||
});
|
||||
|
||||
@@ -188,121 +188,3 @@ async function measureInlineCode(message: Locator) {
|
||||
return { codes, right: paragraph.getBoundingClientRect().right };
|
||||
});
|
||||
}
|
||||
|
||||
test("inline-code paths preserve stable recognition and file navigation", async ({
|
||||
page,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
const { writeFile, mkdir } = await import("node:fs/promises");
|
||||
const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"];
|
||||
const source =
|
||||
paths.map((path) => `Open \`${path}\`.`).join("\n\n") +
|
||||
"\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "inline-path-regression-",
|
||||
title: "Inline path regression",
|
||||
initialPrompt: "Show file paths",
|
||||
featureValues: { mockAssistantResponse: source },
|
||||
});
|
||||
try {
|
||||
await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true });
|
||||
for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`);
|
||||
await writeFile(
|
||||
`${session.cwd}/src/index.ts`,
|
||||
Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"),
|
||||
);
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible();
|
||||
await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0);
|
||||
await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0);
|
||||
for (const path of paths) {
|
||||
await openInlineRegressionFile(page, path);
|
||||
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText(
|
||||
`Opened ${path}`,
|
||||
);
|
||||
}
|
||||
await openInlineRegressionFile(page, "src/index.ts#L10-L20");
|
||||
await expect(page.getByLabel("Line 20, column 26")).toBeVisible();
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-content")
|
||||
.focus();
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(
|
||||
Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"),
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-selectionBackground"),
|
||||
).not.toHaveCount(0);
|
||||
await testInfo.attach("inline-path-line-selection", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openInlineRegressionFile(page: Page, path: string) {
|
||||
await page.getByRole("button", { name: "Inline path regression", exact: true }).click();
|
||||
await page
|
||||
.getByTestId("assistant-message")
|
||||
.getByRole("link", { name: path, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
// Web links are styled inline by the library, so hover state has to restyle the anchor.
|
||||
test("markdown links underline on hover", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "markdown-link-hover-",
|
||||
title: "Markdown link hover",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).",
|
||||
},
|
||||
initialPrompt: "Show links",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
for (const name of ["the docs", "README"]) {
|
||||
const link = message.getByRole("link", { name, exact: true });
|
||||
await expect(link).toHaveCSS("text-decoration-line", "none");
|
||||
await link.hover();
|
||||
await expect(link).toHaveCSS("text-decoration-line", "underline");
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(link).toHaveCSS("text-decoration-line", "none");
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
const PIXEL_PNG =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
|
||||
test("Markdown images show a zoom-in cursor", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "markdown-image-cursor-",
|
||||
title: "Markdown image cursor",
|
||||
featureValues: { mockAssistantResponse: `Before.\n\n\n\nAfter.` },
|
||||
initialPrompt: "Show image",
|
||||
});
|
||||
try {
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const image = page.getByTestId("assistant-message").getByRole("button", { name: "pixel" });
|
||||
await expect(image).toHaveCSS("cursor", "zoom-in");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { verifyPluginToolTranslations } from "../support/helpers/plugin-translations";
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
|
||||
|
||||
@@ -14,10 +13,3 @@ test("plugin header and composer buttons share actions, menus, content, and upda
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin tooltips and menu labels follow app language with a panel draft open", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(180_000);
|
||||
await verifyPluginToolTranslations(page, info);
|
||||
});
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Frame, TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
|
||||
// Full Chromium includes the PDF viewer; the default headless shell does not.
|
||||
test.use({ channel: "chromium" });
|
||||
|
||||
function samplePdf() {
|
||||
const stream = "BT /F1 24 Tf 72 720 Td (Paseo PDF panel example) Tj ET";
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
|
||||
];
|
||||
let pdf = "%PDF-1.4\n%\u0000\u00ff\n";
|
||||
const offsets = objects.map((object, index) => {
|
||||
const offset = Buffer.byteLength(pdf);
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
return offset;
|
||||
});
|
||||
const xref = Buffer.byteLength(pdf);
|
||||
pdf += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}`;
|
||||
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return pdf;
|
||||
}
|
||||
|
||||
async function openFile(page: Page, name: string) {
|
||||
await openFileFromExplorer(page, name);
|
||||
await expectFileTabOpen(page, name);
|
||||
}
|
||||
|
||||
const WIDE = { width: 1280, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string) {
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
/** Captures the current state wide, then compact once `ready` holds there, and returns to wide. */
|
||||
async function captureWideAndCompact(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
ready: () => Promise<void>,
|
||||
) {
|
||||
await capture(page, testInfo, `${name}-wide`);
|
||||
// The shell survives the breakpoint, so the previous layout's copy can still be on screen right
|
||||
// after a resize. Check `ready` only once the shell reports the new layout.
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("compact-shell")).toBeVisible();
|
||||
await ready();
|
||||
await capture(page, testInfo, `${name}-compact`);
|
||||
await page.setViewportSize(WIDE);
|
||||
await expect(page.getByTestId("wide-shell")).toBeVisible();
|
||||
await ready();
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
}
|
||||
|
||||
/** Turns on vim keybindings without discarding settings the test writes later. */
|
||||
async function enableVimKeybindings(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const key = "@paseo:app-settings";
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, vimKeybindings: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseRichMarkdown(page: Page) {
|
||||
await page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }).click();
|
||||
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Use", exact: true }).click();
|
||||
}
|
||||
|
||||
async function replaceMarkdown(page: Page, text: string) {
|
||||
await page.getByRole("textbox", { name: "Rich Markdown editor", exact: true }).fill(text);
|
||||
}
|
||||
|
||||
async function selectPanel(page: Page, title: string) {
|
||||
await filePanelSwitcher(page).getByRole("button", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPanels(page: Page, titles: string[]) {
|
||||
for (const title of titles) {
|
||||
await expect(
|
||||
filePanelSwitcher(page).getByRole("button", { name: title, exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/** The browser's PDF viewer in the mounted frame; a remounted pane leaves old frames detached. */
|
||||
async function pdfViewer(page: Page): Promise<Frame> {
|
||||
const viewerUrl = /^chrome-extension:\/\/[^/]+\/index\.html$/;
|
||||
const find = () =>
|
||||
page.frames().find((frame) => !frame.isDetached() && viewerUrl.test(frame.url())) ?? null;
|
||||
await expect.poll(() => find() !== null).toBe(true);
|
||||
return find()!;
|
||||
}
|
||||
|
||||
async function expectPdfViewerReady(page: Page) {
|
||||
// A narrow viewer collapses its toolbar to Print and More actions.
|
||||
const viewer = await pdfViewer(page);
|
||||
await expect(viewer.getByRole("button", { name: "Print", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
async function expectMarkdownEditor(page: Page, text: string) {
|
||||
const editor = page.getByRole("textbox", { name: "Rich Markdown editor", exact: true });
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor).toContainText(text);
|
||||
await expect(editor).toBeInViewport({ ratio: 1 });
|
||||
// Side by side on a wide screen; on a phone three panels do not fit, so the switcher is a menu.
|
||||
await expect(filePanelSwitcher(page)).toContainText("Rich Markdown");
|
||||
await expect(filePanelSwitcher(page)).toBeInViewport({ ratio: 1 });
|
||||
}
|
||||
|
||||
test("directory examples mount the PDF frame and write Markdown through the file handle", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const pdf = await copyPluginExample("pdf-panel");
|
||||
const markdown = await copyPluginExample("markdown-panel");
|
||||
execFileSync(
|
||||
process.platform === "win32" ? "npm.cmd" : "npm",
|
||||
["install", "--ignore-scripts", "--no-audit", "--no-fund"],
|
||||
{ cwd: markdown.directory, stdio: "pipe" },
|
||||
);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panel-examples-",
|
||||
repo: {
|
||||
files: [
|
||||
{
|
||||
path: "notes.md",
|
||||
content: "# Markdown panel example\n\nEdit **Markdown** through Paseo.\n",
|
||||
},
|
||||
{ path: "second.md", content: "# Remembered panel\n" },
|
||||
{ path: "report.pdf", content: samplePdf() },
|
||||
],
|
||||
},
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(pdf.directory);
|
||||
await client.installDirectoryPlugin(markdown.directory);
|
||||
await enableVimKeybindings(page);
|
||||
await page.setViewportSize(WIDE);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFile(page, "report.pdf");
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
|
||||
"src",
|
||||
/^blob:/,
|
||||
);
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).not.toHaveAttribute(
|
||||
"sandbox",
|
||||
);
|
||||
const documentUrl = await page
|
||||
.getByTitle("Document preview", { exact: true })
|
||||
.getAttribute("src");
|
||||
expect(
|
||||
await page.evaluate(
|
||||
async (url) => (await fetch(url!)).blob().then((blob) => blob.type),
|
||||
documentUrl,
|
||||
),
|
||||
).toBe("application/pdf");
|
||||
await expectPdfViewerReady(page);
|
||||
await expect(
|
||||
(await pdfViewer(page)).getByRole("textbox", { name: "Page number", exact: true }),
|
||||
).toHaveValue("1");
|
||||
await captureWideAndCompact(page, testInfo, "pdf-example", async () => {
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toBeInViewport();
|
||||
await expectPdfViewerReady(page);
|
||||
});
|
||||
|
||||
await openFile(page, "notes.md");
|
||||
await expect(page.getByRole("radio", { name: "Markdown — Paseo", exact: true })).toBeChecked();
|
||||
await expect(
|
||||
page.getByRole("radio", { name: "Rich Markdown — markdown-panel", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: "Source — Paseo", exact: true })).toBeVisible();
|
||||
await captureWideAndCompact(page, testInfo, "markdown-example-picker", async () => {
|
||||
// The previous layout's picker animates out (the sheet slides down, the dialog fades), so
|
||||
// both pickers are mounted for a moment after a resize.
|
||||
await expect(page.getByRole("button", { name: "Use", exact: true })).toHaveCount(1);
|
||||
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
});
|
||||
await chooseRichMarkdown(page);
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
|
||||
"Markdown panel example",
|
||||
);
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await replaceMarkdown(page, "# Edited by Rich Markdown\n\nSaved by the panel.\n");
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-dirty-wide");
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
|
||||
.toBe("# Edited by Rich Markdown\n\nSaved by the panel.\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
|
||||
await expectPanels(page, ["Rich Markdown", "Markdown", "Source"]);
|
||||
await captureWideAndCompact(page, testInfo, "markdown-example-rich-markdown", async () => {
|
||||
await expectMarkdownEditor(page, "Edited by Rich Markdown");
|
||||
});
|
||||
|
||||
// Source is Paseo's editor; its cursor and vim mode are in its own status row.
|
||||
await selectPanel(page, "Source");
|
||||
const source = page.getByTestId("file-source-editor").filter({ visible: true });
|
||||
await expect(source.locator(".cm-content")).toContainText("Edited by Rich Markdown");
|
||||
await source.locator(".cm-line").nth(2).click();
|
||||
const bar = page.getByTestId("file-source-status").filter({ visible: true });
|
||||
await expect(bar.getByText("NORMAL", { exact: true })).toBeVisible();
|
||||
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-source-wide");
|
||||
// The phone layout mounts the tab anew, so it opens the remembered panel; its menu offers Source.
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
|
||||
await filePanelSwitcher(page).click();
|
||||
await page.getByRole("menuitem", { name: "Source", exact: true }).click();
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Source");
|
||||
await source.locator(".cm-line").nth(2).click();
|
||||
await expect(bar.getByText("NORMAL", { exact: true })).toBeInViewport();
|
||||
await expect(bar.getByText(/^Ln 3, Col \d+$/)).toBeInViewport();
|
||||
await capture(page, testInfo, "markdown-example-source-compact");
|
||||
await page.setViewportSize(WIDE);
|
||||
await selectPanel(page, "Rich Markdown");
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), "# External update\n");
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toHaveText(
|
||||
"# External update",
|
||||
);
|
||||
await replaceMarkdown(page, "# Local edit\n");
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Conflicting external edit\n");
|
||||
await expect(page.getByTestId("rich-markdown-conflict")).toBeVisible();
|
||||
await capture(page, testInfo, "markdown-example-conflict-wide");
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
|
||||
.toBe("# Local edit\n");
|
||||
|
||||
await openFile(page, "second.md");
|
||||
await expect(page.getByRole("textbox", { name: "Rich Markdown editor" })).toContainText(
|
||||
"Remembered panel",
|
||||
);
|
||||
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expectMarkdownEditor(page, "Remembered panel");
|
||||
await expect(filePanelSwitcher(page)).toHaveText("Rich Markdown");
|
||||
} finally {
|
||||
await client.removePlugin("pdf-panel");
|
||||
await client.removePlugin("markdown-panel");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await pdf.cleanup();
|
||||
await markdown.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { expectFileTabOpen, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import {
|
||||
BUILTIN_MARKDOWN_OPTION,
|
||||
MARKDOWN_PANEL_ID,
|
||||
PLUGIN_MARKDOWN_OPTION,
|
||||
expectBuiltinMarkdown,
|
||||
expectPluginMarkdown,
|
||||
filePanelPicker,
|
||||
installMarkdownPanelPlugin,
|
||||
openMarkdown,
|
||||
storedFilePanelAssociations,
|
||||
} from "../support/helpers/markdown-file-panel-plugin";
|
||||
|
||||
async function choosePanel(page: Page, option: string, options: { remember: boolean }) {
|
||||
await page.getByRole("radio", { name: option, exact: true }).click();
|
||||
if (options.remember) {
|
||||
await page.getByRole("checkbox", { name: "Don't ask me again", exact: true }).click();
|
||||
}
|
||||
await page.getByRole("button", { name: "Use", exact: true }).click();
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
}
|
||||
|
||||
async function openWithFromTabMenu(page: Page, name: string, option: string) {
|
||||
await page.getByTestId(`workspace-tab-file_${name}`).first().click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Open with…" }).hover();
|
||||
await page.getByRole("menuitem", { name: option }).click();
|
||||
}
|
||||
|
||||
async function toggleAlwaysUseFromFileBar(page: Page) {
|
||||
await page.getByTestId("file-panel-bar-menu").filter({ visible: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Always use for .md" }).click();
|
||||
}
|
||||
|
||||
function filePanelSwitcher(page: Page) {
|
||||
return page.getByTestId("file-panel-switcher").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectSwitcherPanels(page: Page, titles: string[]) {
|
||||
await expect(filePanelSwitcher(page).getByRole("button")).toHaveText(titles);
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string) {
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
test("opens Markdown with the built-in panel and Source beside it without asking", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panel-builtins-",
|
||||
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
|
||||
});
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openMarkdown(page, "notes.md");
|
||||
await expectBuiltinMarkdown(page, "notes heading");
|
||||
await expectSwitcherPanels(page, ["Markdown", "Source"]);
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
await capture(page, testInfo, "file-panel-builtins-wide");
|
||||
|
||||
await filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }).click();
|
||||
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toBeVisible();
|
||||
await filePanelSwitcher(page).getByRole("button", { name: "Markdown", exact: true }).click();
|
||||
await expectBuiltinMarkdown(page, "notes heading");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expectBuiltinMarkdown(page, "notes heading");
|
||||
await expect(
|
||||
filePanelSwitcher(page).getByRole("button", { name: "Source", exact: true }),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
await capture(page, testInfo, "file-panel-builtins-compact");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("asks once which panel opens Markdown and remembers it when told to", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panel-picker-",
|
||||
repo: {
|
||||
files: ["first", "second", "third", "fourth"].map((name) => ({
|
||||
path: `${name}.md`,
|
||||
content: `# ${name} heading\n`,
|
||||
})),
|
||||
},
|
||||
});
|
||||
const plugin = await installMarkdownPanelPlugin();
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
// First open: the built-in renders under the picker.
|
||||
await openMarkdown(page, "first.md");
|
||||
await expect(filePanelPicker(page)).toBeVisible();
|
||||
await expect(page.getByRole("radio", { name: BUILTIN_MARKDOWN_OPTION })).toBeChecked();
|
||||
await expect(page.getByRole("radio", { name: PLUGIN_MARKDOWN_OPTION })).not.toBeChecked();
|
||||
await expectBuiltinMarkdown(page, "first heading");
|
||||
await capture(page, testInfo, "file-panel-picker-wide");
|
||||
|
||||
// Don't ask me again: the plugin renders and later opens use it silently.
|
||||
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: true });
|
||||
await expectPluginMarkdown(page, "first.md");
|
||||
expect(await storedFilePanelAssociations(page)).toEqual({ "ext:md": MARKDOWN_PANEL_ID });
|
||||
await openMarkdown(page, "second.md");
|
||||
await expectPluginMarkdown(page, "second.md");
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
|
||||
// Forgetting from the file bar keeps the tab on the plugin.
|
||||
await toggleAlwaysUseFromFileBar(page);
|
||||
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
|
||||
await expectPluginMarkdown(page, "second.md");
|
||||
|
||||
// An unremembered choice applies to this tab only; the next open asks again.
|
||||
await openMarkdown(page, "third.md");
|
||||
await expect(filePanelPicker(page)).toBeVisible();
|
||||
await choosePanel(page, PLUGIN_MARKDOWN_OPTION, { remember: false });
|
||||
await expectPluginMarkdown(page, "third.md");
|
||||
expect(await storedFilePanelAssociations(page)).toEqual({});
|
||||
|
||||
// "Open with…" switches the open tab back to the built-in in place.
|
||||
await openWithFromTabMenu(page, "third.md", BUILTIN_MARKDOWN_OPTION);
|
||||
await expectBuiltinMarkdown(page, "third heading");
|
||||
await expectFileTabOpen(page, "third.md");
|
||||
|
||||
// Dismissing keeps the built-in; reopening the tab after a reload asks again.
|
||||
await openMarkdown(page, "fourth.md");
|
||||
await expect(filePanelPicker(page)).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
await expectBuiltinMarkdown(page, "fourth heading");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.reload();
|
||||
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Use", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
await capture(page, testInfo, "file-panel-picker-compact");
|
||||
} finally {
|
||||
await plugin.uninstall();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
BUILTIN_MARKDOWN_OPTION,
|
||||
MARKDOWN_PANEL_ID,
|
||||
PLUGIN_MARKDOWN_OPTION,
|
||||
SOURCE_OPTION,
|
||||
expectPluginMarkdown,
|
||||
filePanelPicker,
|
||||
installMarkdownPanelPlugin,
|
||||
openMarkdown,
|
||||
storedFilePanelAssociations,
|
||||
} from "../support/helpers/markdown-file-panel-plugin";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
const ASK_EVERY_TIME = "Ask every time";
|
||||
|
||||
function markdownRow(page: Page) {
|
||||
return page.getByTestId("file-panel-settings-row-ext:md");
|
||||
}
|
||||
|
||||
function markdownDropdown(page: Page, value: string) {
|
||||
return markdownRow(page).getByRole("button", { name: `.md: ${value}`, exact: true });
|
||||
}
|
||||
|
||||
function askAgain(page: Page) {
|
||||
return markdownRow(page).getByRole("button", { name: "Ask again for .md", exact: true });
|
||||
}
|
||||
|
||||
async function openFilesSettings(page: Page) {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "files");
|
||||
await expect(page.getByTestId("file-panel-settings")).toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string) {
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function expectMenuOpen(page: Page) {
|
||||
await expect(page.getByTestId("file-panel-settings-menu")).toHaveCSS("opacity", "1");
|
||||
}
|
||||
|
||||
test("Settings > Files chooses which panel opens .md and asks again on request", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panel-settings-",
|
||||
repo: { files: [{ path: "notes.md", content: "# notes heading\n" }] },
|
||||
});
|
||||
const plugin = await installMarkdownPanelPlugin();
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFilesSettings(page);
|
||||
|
||||
// Nothing remembered: the row asks every time and has nothing to forget.
|
||||
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(askAgain(page)).toBeDisabled();
|
||||
await capture(page, testInfo, "file-panel-settings-wide");
|
||||
|
||||
await markdownDropdown(page, ASK_EVERY_TIME).click();
|
||||
await expect(page.getByRole("menuitem", { name: BUILTIN_MARKDOWN_OPTION })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
|
||||
await expectMenuOpen(page);
|
||||
await capture(page, testInfo, "file-panel-settings-wide-menu");
|
||||
await page.getByRole("menuitem", { name: PLUGIN_MARKDOWN_OPTION }).click();
|
||||
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible();
|
||||
await expect
|
||||
.poll(() => storedFilePanelAssociations(page))
|
||||
.toEqual({
|
||||
"ext:md": MARKDOWN_PANEL_ID,
|
||||
});
|
||||
await capture(page, testInfo, "file-panel-settings-wide-chosen");
|
||||
|
||||
// The next open uses the plugin without asking.
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openMarkdown(page, "notes.md");
|
||||
await expectPluginMarkdown(page, "notes.md");
|
||||
await expect(filePanelPicker(page)).toBeHidden();
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/settings/files");
|
||||
await expect(markdownDropdown(page, PLUGIN_MARKDOWN_OPTION)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(askAgain(page)).toBeInViewport({ ratio: 1 });
|
||||
await capture(page, testInfo, "file-panel-settings-compact");
|
||||
await markdownDropdown(page, PLUGIN_MARKDOWN_OPTION).click();
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
|
||||
await expectMenuOpen(page);
|
||||
await capture(page, testInfo, "file-panel-settings-compact-menu");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
|
||||
|
||||
// Ask again forgets the choice; the next open asks.
|
||||
await askAgain(page).click();
|
||||
await expect(markdownDropdown(page, ASK_EVERY_TIME)).toBeVisible();
|
||||
await expect.poll(() => storedFilePanelAssociations(page)).toEqual({});
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await expect(filePanelPicker(page)).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await plugin.uninstall();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Settings > Files explains where file panels come from when no plugin adds one", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto("/settings/files");
|
||||
const empty = page.getByTestId("file-panel-settings-empty");
|
||||
await expect(empty).toHaveText("File panels appear here when a plugin adds one", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "file-panel-settings-empty-wide");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(empty).toBeVisible();
|
||||
await capture(page, testInfo, "file-panel-settings-empty-compact");
|
||||
});
|
||||
|
||||
test("Settings > Files offers Source in place of a panel no host has, and forgets it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ filePanelAssociations: { "ext:pdf": "plugin:none/none" } }),
|
||||
);
|
||||
});
|
||||
await page.goto("/settings/files");
|
||||
const row = page.getByTestId("file-panel-settings-row-ext:pdf");
|
||||
const dropdown = row.getByRole("button", { name: ".pdf: Not on a connected host" });
|
||||
await expect(dropdown).toBeEnabled({ timeout: 30_000 });
|
||||
await dropdown.click();
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menuitem", { name: SOURCE_OPTION })).toBeHidden();
|
||||
await row.getByRole("button", { name: "Ask again for .pdf", exact: true }).click();
|
||||
await expect(page.getByTestId("file-panel-settings-empty")).toBeVisible();
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import {
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
} from "../support/helpers/file-explorer";
|
||||
|
||||
const PLUGIN_ID = "file-panel-e2e";
|
||||
const PDF = Buffer.from("%PDF-1.4\n%\x00\xff\n1 0 obj<</Type/Catalog>>endobj\n%%EOF", "binary");
|
||||
const SOURCE = `import React, { useEffect, useState } from "react";
|
||||
import { Text, View, Pressable } from "react-native";
|
||||
import {
|
||||
PluginFileConflictError,
|
||||
useFileBytes,
|
||||
useFileUrl,
|
||||
usePaseo,
|
||||
useWorkspace,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
|
||||
function Pdf({ file, workspaceId }) {
|
||||
usePaseo();
|
||||
useQueryClient();
|
||||
const workspace = useWorkspace(workspaceId, value => ({ id: value.id }));
|
||||
const bytes = useFileBytes(file);
|
||||
const url = useFileUrl(file);
|
||||
const [urlBytes, setUrlBytes] = useState(-1);
|
||||
const href = url.status === "ready" ? url.url : null;
|
||||
useEffect(() => {
|
||||
if (href) fetch(href).then(response => response.arrayBuffer()).then(body => setUrlBytes(body.byteLength));
|
||||
}, [href]);
|
||||
if (bytes.status !== "ready" || !href) return <Loading />;
|
||||
return <View style={{ flex: 1 }}>
|
||||
<Text>PDF plugin: {file.name}</Text>
|
||||
<Text>Path {file.path}</Text>
|
||||
<Text>Bytes {bytes.bytes.length}; URL bytes {urlBytes}</Text>
|
||||
<Text>Workspace {workspace?.id}</Text>
|
||||
<DocumentFrame url={href} testID="plugin-pdf-frame" />
|
||||
</View>;
|
||||
}
|
||||
// Reads once and writes on demand, so a change on disk since that read rejects the write.
|
||||
function Markdown({ file, tab }) {
|
||||
const [text, setText] = useState(null);
|
||||
const [status, setStatus] = useState("");
|
||||
useEffect(() => { file.readText().then(setText); }, [file]);
|
||||
const save = (next) => {
|
||||
tab.setModified(true);
|
||||
file.writeText(next).then(
|
||||
() => { tab.setModified(false); setText(next); },
|
||||
(error) => setStatus(error instanceof PluginFileConflictError ? "Write rejected: changed on disk" : String(error)),
|
||||
);
|
||||
};
|
||||
if (text === null) return <Loading />;
|
||||
return <View>
|
||||
<Text>Plugin Markdown: {text}</Text>
|
||||
<Pressable accessibilityRole="button" onPress={() => save("# Saved by plugin\\n")}>
|
||||
<Text>Edit Markdown through plugin</Text>
|
||||
</Pressable>
|
||||
<Text>{status}</Text>
|
||||
</View>;
|
||||
}
|
||||
export default function contribute(client) {
|
||||
client.addFilePanel({ id: "pdf", title: "PDF", icon: "FileText",
|
||||
files: { extensions: ["pdf", "txt"] }, Component: Pdf });
|
||||
client.addFilePanel({ id: "markdown", title: "Plugin Markdown", icon: "FileText",
|
||||
files: { extensions: ["md"] }, Component: Markdown });
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
async function openWorkspaceFile(page: Page, name: string) {
|
||||
await openFileFromExplorer(page, name);
|
||||
await expectFileTabOpen(page, name);
|
||||
}
|
||||
async function associateMarkdownWithPlugin(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const key = "@paseo:app-settings";
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
settings.filePanelAssociations = { "ext:md": "plugin:file-panel-e2e/markdown" };
|
||||
localStorage.setItem(key, JSON.stringify(settings));
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
async function editMarkdownThroughPlugin(page: Page) {
|
||||
await page.getByRole("button", { name: "Edit Markdown through plugin", exact: true }).click();
|
||||
}
|
||||
async function selectSource(page: Page) {
|
||||
await page
|
||||
.getByTestId("file-panel-switcher")
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Source", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test("directory plugins render PDF bytes and write Markdown through the file handle", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-file-panels-plugin-"));
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "file-panels-",
|
||||
repo: {
|
||||
files: [
|
||||
{ path: "notes.md", content: "# Built-in Markdown\n" },
|
||||
{ path: "bytes.txt", content: "Bytes panel text document" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(workspace.repoPath, "report.pdf"), PDF);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), SOURCE);
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openWorkspaceFile(page, "report.pdf");
|
||||
await expect(page.getByText("PDF plugin: report.pdf", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`Bytes ${PDF.length}; URL bytes ${PDF.length}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`Workspace ${workspace.workspaceId}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
|
||||
"src",
|
||||
/^blob:/,
|
||||
);
|
||||
await expect(
|
||||
page.getByText(`Path ${path.join(workspace.repoPath, "report.pdf")}`, { exact: true }),
|
||||
).toBeVisible();
|
||||
const pdfScreenshot = testInfo.outputPath("plugin-pdf-web.png");
|
||||
await page.screenshot({ path: pdfScreenshot });
|
||||
await testInfo.attach("plugin-pdf-web", { path: pdfScreenshot, contentType: "image/png" });
|
||||
|
||||
// A plugin panel claiming a text file competes with Source, so Paseo asks over the plugin.
|
||||
await openWorkspaceFile(page, "bytes.txt");
|
||||
await expect(page.getByText("Open .txt files with", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("Open .txt files with", { exact: true })).toBeHidden();
|
||||
await expect(page.getByText("PDF plugin: bytes.txt", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTitle("Document preview", { exact: true })).toHaveAttribute(
|
||||
"sandbox",
|
||||
"",
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('[title="Document preview"]')
|
||||
.getByText("Bytes panel text document", { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
await expect(page.getByText("Open .md files with", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("Open .md files with", { exact: true })).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Built-in Markdown", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Edit Markdown through plugin" })).toHaveCount(0);
|
||||
await associateMarkdownWithPlugin(page);
|
||||
await expect(
|
||||
page.getByText("Plugin Markdown: # Built-in Markdown", { exact: true }),
|
||||
).toBeVisible();
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await editMarkdownThroughPlugin(page);
|
||||
await gate.waitForHeldClientRequest();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect(
|
||||
page.getByText("Plugin Markdown: # Saved by plugin", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() => readFile(path.join(workspace.repoPath, "notes.md"), "utf8"))
|
||||
.toBe("# Saved by plugin\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeHidden();
|
||||
|
||||
// The disk changed since the plugin's last read or write: the write is rejected, not applied.
|
||||
await writeFile(path.join(workspace.repoPath, "notes.md"), "# Changed outside\n");
|
||||
await editMarkdownThroughPlugin(page);
|
||||
await expect(page.getByText("Write rejected: changed on disk", { exact: true })).toBeVisible();
|
||||
expect(await readFile(path.join(workspace.repoPath, "notes.md"), "utf8")).toBe(
|
||||
"# Changed outside\n",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_notes.md")).toBeVisible();
|
||||
let closePrompt = "";
|
||||
page.once("dialog", async (dialog) => {
|
||||
closePrompt = dialog.message();
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page
|
||||
.getByTestId("workspace-tab-file_notes.md")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Close", exact: true }).click();
|
||||
await expect.poll(() => closePrompt).toContain("discard");
|
||||
|
||||
await selectSource(page);
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"),
|
||||
).toHaveText("# Changed outside");
|
||||
const markdownScreenshot = testInfo.outputPath("plugin-markdown-source-web.png");
|
||||
await page.screenshot({ path: markdownScreenshot });
|
||||
await testInfo.attach("plugin-markdown-source-web", {
|
||||
path: markdownScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await client.patchDaemonConfig({
|
||||
pluginsEnabled: previousConfig.config.pluginsEnabled ?? false,
|
||||
});
|
||||
await client.close();
|
||||
await workspace.cleanup();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -6,11 +5,15 @@ import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
const PLUGIN_ID = "plugin-host-ui-e2e";
|
||||
|
||||
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
|
||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
@@ -88,6 +91,12 @@ async function useCompactLayout(page: Page): Promise<void> {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
}
|
||||
|
||||
async function reopenHostUiPluginFromCompactSidebar(page: Page): Promise<void> {
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Host UI", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openPluginModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open plugin modal", exact: true }).click();
|
||||
}
|
||||
@@ -149,6 +158,7 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
||||
|
||||
await test.step("compact layouts preserve host contexts inside a sheet", async () => {
|
||||
await useCompactLayout(page);
|
||||
await reopenHostUiPluginFromCompactSidebar(page);
|
||||
await openPluginModal(page);
|
||||
await expectCompactPluginSheet(page);
|
||||
await savePluginIssue(page);
|
||||
@@ -167,10 +177,3 @@ async function playPluginAudio(page: Page, button: string, result: string) {
|
||||
await page.getByRole("button", { name: button, exact: true }).click();
|
||||
await expect(page.getByText(result, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("plugin content and host labels follow app language without losing an open draft", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(180_000);
|
||||
await verifyPluginTranslations(page, info);
|
||||
});
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
const PLUGIN_ID = "plugin-icons-e2e";
|
||||
const SVG_PATH = "M4 12l5 5L20 6";
|
||||
|
||||
const pluginClientSource = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
const checkIcon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="${SVG_PATH}" /></svg>';
|
||||
|
||||
function BadgeIcon({ size, color }) {
|
||||
return <View testID="plugin-component-icon" style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
function Panel() {
|
||||
return <View><Text>Plugin icon panel</Text></View>;
|
||||
}
|
||||
|
||||
const panels = [
|
||||
{ id: "lucide", title: "Lucide icon panel", icon: "Scan" },
|
||||
{ id: "svg", title: "SVG icon panel", icon: { svg: checkIcon } },
|
||||
{ id: "component", title: "Component icon panel", icon: BadgeIcon },
|
||||
];
|
||||
|
||||
export default function contribute(client) {
|
||||
for (const panel of panels) {
|
||||
client.addWorkspacePanel({ ...panel, context: "workspace", Component: Panel });
|
||||
client.addCommandCenterItem({
|
||||
id: panel.id,
|
||||
title: "Open " + panel.title,
|
||||
icon: panel.icon,
|
||||
context: "workspace",
|
||||
onSelect({ openPanel }) { openPanel(panel.id); },
|
||||
});
|
||||
}
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
function pluginTab(page: Page, panelId: string): Locator {
|
||||
const identity = `${PLUGIN_ID.length}_${PLUGIN_ID}_${panelId.length}_${panelId}`;
|
||||
return page.getByTestId(`workspace-tab-plugin_workspace_${identity}`).filter({ visible: true });
|
||||
}
|
||||
|
||||
async function runCommand(page: Page, title: string): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const screenshot = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
|
||||
}
|
||||
|
||||
test.describe("plugin icons", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("renders a Lucide name, inline SVG, and a component", async ({ page }, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icons-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-icons-" });
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginClientSource);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
const target = { serverId: getServerId(), workspaceId: workspace.workspaceId };
|
||||
await waitForWorkspaceInSidebar(page, target);
|
||||
await switchWorkspaceViaSidebar({ page, ...target });
|
||||
|
||||
await test.step("Command Center rows draw each icon form", async () => {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill("icon panel");
|
||||
for (const title of ["Lucide icon panel", "SVG icon panel", "Component icon panel"]) {
|
||||
await expect(
|
||||
panel.getByRole("button", { name: `Open ${title}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
panel
|
||||
.getByRole("button", { name: "Open SVG icon panel", exact: true })
|
||||
.locator(`path[d="${SVG_PATH}"]`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel
|
||||
.getByRole("button", { name: "Open Component icon panel", exact: true })
|
||||
.getByTestId("plugin-component-icon"),
|
||||
).toBeVisible();
|
||||
await capture(page, testInfo, "command-center-icons");
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await test.step("workspace panel tabs draw each icon form", async () => {
|
||||
await runCommand(page, "Open Lucide icon panel");
|
||||
await runCommand(page, "Open SVG icon panel");
|
||||
await runCommand(page, "Open Component icon panel");
|
||||
await expect(pluginTab(page, "lucide").locator("svg")).toBeVisible();
|
||||
await expect(pluginTab(page, "svg").locator(`path[d="${SVG_PATH}"]`)).toBeVisible();
|
||||
await expect(
|
||||
pluginTab(page, "component").getByTestId("plugin-component-icon"),
|
||||
).toBeVisible();
|
||||
await capture(page, testInfo, "workspace-tab-icons");
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await workspace.cleanup();
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -317,47 +316,6 @@ export default function contribute(plugin) {
|
||||
return directory;
|
||||
}
|
||||
|
||||
/** Build command that holds the plugin lifecycle queue until the test writes the release file. */
|
||||
const gatedBuildCommand = [
|
||||
'const fs = require("node:fs");',
|
||||
'fs.writeFileSync(process.argv[1], "started");',
|
||||
"const wait = () => {",
|
||||
" if (fs.existsSync(process.argv[2])) return;",
|
||||
" setTimeout(wait, 50);",
|
||||
"};",
|
||||
"wait();",
|
||||
].join("\n");
|
||||
|
||||
async function createGatedBuildRepository(
|
||||
root: string,
|
||||
gate: { started: string; release: string },
|
||||
): Promise<string> {
|
||||
const repository = path.join(root, "gated-repository");
|
||||
await mkdir(repository);
|
||||
await writeFile(
|
||||
path.join(repository, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "gated-build-plugin",
|
||||
description: "Holds the plugin lifecycle queue while its build waits",
|
||||
requirements: pluginRequirements,
|
||||
build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]],
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin"));
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Paseo Tests"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.email", "paseo@example.test"], {
|
||||
cwd: repository,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" });
|
||||
return repository;
|
||||
}
|
||||
|
||||
async function installFailedPlugin(
|
||||
page: Page,
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
@@ -722,155 +680,3 @@ async function installPluginWithUntrustedDescription(
|
||||
);
|
||||
await environment.client.installPluginSource({ source: directory });
|
||||
}
|
||||
|
||||
/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */
|
||||
function observePluginReload(page: Page) {
|
||||
const requests: number[] = [];
|
||||
const responses: number[] = [];
|
||||
const isSessionMessage = (payload: unknown, type: string): boolean => {
|
||||
if (typeof payload !== "string") return false;
|
||||
try {
|
||||
const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } };
|
||||
const message = envelope.type === "session" ? envelope.message : envelope;
|
||||
return message?.type === type;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
page.on("websocket", (socket) => {
|
||||
// The reload request travels client -> daemon (outbound); the answer comes back inbound.
|
||||
socket.on("framesent", ({ payload }) => {
|
||||
if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now());
|
||||
});
|
||||
socket.on("framereceived", ({ payload }) => {
|
||||
if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now());
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1),
|
||||
waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1),
|
||||
requestedAt: () => requests[0]!,
|
||||
requestCount: () => requests.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */
|
||||
async function holdPluginLifecycleQueue(
|
||||
client: PluginEnvironment["client"],
|
||||
root: string,
|
||||
): Promise<{ release: () => Promise<void>; settle: () => Promise<void> }> {
|
||||
const gate = {
|
||||
started: path.join(root, "build-started"),
|
||||
release: path.join(root, "build-release"),
|
||||
};
|
||||
const repository = await createGatedBuildRepository(root, gate);
|
||||
const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` });
|
||||
// The marker wait below can outlive an early failure: a refused preparation, a dropped
|
||||
// connection, or a build command that exits before writing its marker. Handle that rejection now
|
||||
// so it never surfaces as an unhandled one, and drain the same promise in finish().
|
||||
const installDrained = install.catch(() => undefined);
|
||||
// Every path releases the gate and drains the install: a build still polling for its gate holds
|
||||
// the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall.
|
||||
const finish = async () => {
|
||||
await writeFile(gate.release, "go").catch(() => undefined);
|
||||
await installDrained;
|
||||
};
|
||||
try {
|
||||
// Whichever comes first decides the scenario: the build reports that it started, or the setup
|
||||
// fails with the daemon's own error instead of a bare marker timeout.
|
||||
await Promise.race([
|
||||
expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true),
|
||||
install,
|
||||
]);
|
||||
} catch (error) {
|
||||
// Setup that never reached the build must not hand the caller a queue it cannot settle.
|
||||
await finish();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
release: () => writeFile(gate.release, "go"),
|
||||
settle: finish,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginRowActions(page: Page, pluginId: string) {
|
||||
return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true });
|
||||
}
|
||||
|
||||
async function expectPluginRowPending(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeDisabled();
|
||||
}
|
||||
|
||||
async function expectPluginRowSettled(page: Page, pluginId: string): Promise<void> {
|
||||
await expect(pluginRowActions(page, pluginId)).toBeEnabled();
|
||||
await expect(page.getByLabel(`${pluginId} running`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath(name),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
}
|
||||
|
||||
/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */
|
||||
async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise<void> {
|
||||
const remaining = 66_000 - (Date.now() - requestedAt);
|
||||
if (remaining > 0) await page.waitForTimeout(remaining);
|
||||
}
|
||||
|
||||
test("keeps Reload pending past a minute while the plugin queue is busy", async ({
|
||||
page,
|
||||
pluginEnvironment,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { client, directory } = pluginEnvironment;
|
||||
const pluginDirectory = await createDirectoryPlugin(
|
||||
directory,
|
||||
"e2e-plugin",
|
||||
"Exercises the complete local plugin lifecycle",
|
||||
"Plugin v1",
|
||||
);
|
||||
|
||||
// Observe before the app loads: its daemon socket comes up with the page.
|
||||
const reload = observePluginReload(page);
|
||||
await gotoAppShell(page);
|
||||
await openPluginSettings(page);
|
||||
// Plugins on is a precondition here, not the subject: set it through the daemon instead of the
|
||||
// switch, whose transient "Plugins enabled" confirmation this scenario must not depend on.
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installPluginSource({ source: pluginDirectory });
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
|
||||
const queue = await holdPluginLifecycleQueue(client, directory);
|
||||
try {
|
||||
await selectPluginAction(page, "e2e-plugin", "Reload");
|
||||
await reload.waitForRequest();
|
||||
// Pending: the row's actions stay disabled, so a second reload cannot be submitted.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-pending.png");
|
||||
|
||||
await waitPastFormerReloadDeadline(page, reload.requestedAt());
|
||||
|
||||
// Past the former deadline the reload is still pending, the row shows its previous state, and
|
||||
// no timeout error reached the surface.
|
||||
await expectPluginRowPending(page, "e2e-plugin");
|
||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0);
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png");
|
||||
|
||||
await queue.release();
|
||||
// The daemon's answer only arrives once the plugin really started again; a request timeout
|
||||
// would have shown up as feedback during the wait above.
|
||||
await reload.waitForResponse();
|
||||
expect(reload.requestCount()).toBe(1);
|
||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
|
||||
"Reloaded e2e-plugin",
|
||||
);
|
||||
await expectPluginRowSettled(page, "e2e-plugin");
|
||||
await capturePluginSurface(page, testInfo, "plugin-reload-completed.png");
|
||||
} finally {
|
||||
await queue.settle();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
openMobileAgentSidebar,
|
||||
expectMobileAgentSidebarVisible,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function openExample(page: Page, example: string) {
|
||||
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
|
||||
@@ -118,7 +122,9 @@ async function openWideExamples(page: Page) {
|
||||
|
||||
async function openCompactExamples(page: Page) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectDeniedCopyFeedback(page: Page) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
withTimelinePlugin,
|
||||
viewInlineThinkingExample,
|
||||
requestPluginTimeline,
|
||||
interactWithStreamingCard,
|
||||
expectWholeCompletedCard,
|
||||
@@ -34,9 +33,3 @@ test("Overview preserves both consecutive tool plugin cards", async ({ page }, i
|
||||
await expectBothConsecutiveTools(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("inline-thinking example renders a streamed thought through host UI", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await viewInlineThinkingExample(page, info);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ function pluginClientSource(input: {
|
||||
}): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon } from "@getpaseo/plugin/client/ui";
|
||||
import { Icon } from "@getpaseo/plugin/client/react-native";
|
||||
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
|
||||
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
||||
import { recordComposerOpen } from "./shared/rpc";
|
||||
|
||||
@@ -223,10 +223,7 @@ test.describe("Projects settings", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("edits worktree setup, navigates within the host, and removes a script", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
@@ -239,22 +236,15 @@ test.describe("Projects settings", () => {
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
|
||||
await test.step("navigation stays inside the selected host", async () => {
|
||||
await expectProjectHostContextHidden(page);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
|
||||
});
|
||||
|
||||
await test.step("removing a script leaves the committed setup unchanged", async () => {
|
||||
await expectScriptRowCount(page, 1);
|
||||
await removeProjectScript(page, "dev");
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectHostContextHidden(page);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
|
||||
});
|
||||
|
||||
test("user edits worktree setup on a non-GitHub remote project", async ({
|
||||
@@ -269,10 +259,7 @@ test.describe("Projects settings", () => {
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
|
||||
test("renames a project, reopens the saved edit sheet, and adds an icon", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
@@ -281,23 +268,37 @@ test.describe("Projects settings", () => {
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
});
|
||||
|
||||
await test.step("reopening seeds the saved name with no unsaved changes", async () => {
|
||||
await openProjectEditSheet(page);
|
||||
await expectProjectEditName(page, "Renamed project");
|
||||
await expectProjectEditsSaveDisabled(page);
|
||||
});
|
||||
test("reopening the edit sheet seeds from the saved project", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectName(page, "Renamed project");
|
||||
await saveProjectEdits(page);
|
||||
await expectProjectEditSaved(page);
|
||||
|
||||
await test.step("an icon can be saved without another name change", async () => {
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
await openProjectEditSheet(page);
|
||||
|
||||
await expectProjectEditName(page, "Renamed project");
|
||||
await expectProjectEditsSaveDisabled(page);
|
||||
});
|
||||
|
||||
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
});
|
||||
|
||||
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
|
||||
@@ -458,4 +459,31 @@ test.describe("Projects settings — error UX", () => {
|
||||
|
||||
await expectNoEditableTarget(page);
|
||||
});
|
||||
|
||||
test("project detail does not render a second host selector", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectHostContextHidden(page);
|
||||
});
|
||||
|
||||
test("script removal via kebab menu removes the row from the form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectScriptRowCount(page, 1);
|
||||
|
||||
await removeProjectScript(page, "dev");
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
findAcpCatalogProvider,
|
||||
openAddProviderArea,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
async function openProviderCatalog(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await openAddProviderArea(page);
|
||||
}
|
||||
|
||||
async function expectProviderArtwork(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
id: "devin" | "hermes" | "kiro",
|
||||
name: string,
|
||||
) {
|
||||
await findAcpCatalogProvider(page, name);
|
||||
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
|
||||
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
|
||||
await expect(icon).toBeVisible();
|
||||
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
|
||||
await expect.soft(icon).toHaveAttribute("width", "24");
|
||||
const expectedPaths = await page.evaluate((xml) => {
|
||||
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
|
||||
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
|
||||
}, ACP_PROVIDER_ICON_SVGS[id]);
|
||||
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
|
||||
for (const [index, path] of expectedPaths.entries()) {
|
||||
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) });
|
||||
}
|
||||
|
||||
test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await openProviderCatalog(page);
|
||||
await expectProviderArtwork(page, testInfo, "devin", "Devin CLI");
|
||||
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
|
||||
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
|
||||
});
|
||||
@@ -116,12 +116,28 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectProviderSettingsVisible(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("provider settings overlay stack", () => {
|
||||
test("provider model update age says just now", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-relative-time-",
|
||||
title: "Provider relative time",
|
||||
});
|
||||
try {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings covers the desktop model selector without closing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { test } from "../support/fixtures";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
import { buildSeededHost } from "../support/helpers/daemon-registry";
|
||||
import { killProcessTree } from "../support/helpers/spawn-node";
|
||||
import {
|
||||
buildAgentRoute,
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
chooseQuestionOption,
|
||||
continueToNextQuestion,
|
||||
@@ -141,204 +130,3 @@ test.describe("Question prompt pagination", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const questionDaemonMessage = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("ready"), port: z.number(), serverId: z.string() }),
|
||||
z.object({
|
||||
type: z.literal("attempt"),
|
||||
requestId: z.string(),
|
||||
response: z.unknown(),
|
||||
sessionId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resolved"),
|
||||
requestId: z.string(),
|
||||
response: z.unknown(),
|
||||
sessionId: z.string(),
|
||||
pendingRequestIds: z.array(z.string()),
|
||||
}),
|
||||
z.object({ type: z.literal("error"), error: z.string() }),
|
||||
]);
|
||||
|
||||
type QuestionResponseAttempt = Extract<z.infer<typeof questionDaemonMessage>, { type: "attempt" }>;
|
||||
|
||||
type QuestionResolution = Extract<z.infer<typeof questionDaemonMessage>, { type: "resolved" }>;
|
||||
|
||||
interface QuestionRetryControl {
|
||||
attempts: QuestionResponseAttempt[];
|
||||
resolutions: QuestionResolution[];
|
||||
releaseFirstResponse(): void;
|
||||
}
|
||||
|
||||
const retryTest = test.extend<{
|
||||
questionClient: QuestionRetryControl;
|
||||
}>({
|
||||
questionClient: async ({ page }, provide, testInfo) => {
|
||||
const serverDir = path.resolve(__dirname, "../../../server");
|
||||
const child = fork(path.join(serverDir, "src/server/test-utils/question-response-daemon.ts"), {
|
||||
cwd: serverDir,
|
||||
execArgv: ["--import", "tsx"],
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
const output: string[] = [];
|
||||
child.stdout?.on("data", (data: Buffer) => output.push(data.toString()));
|
||||
child.stderr?.on("data", (data: Buffer) => output.push(data.toString()));
|
||||
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
|
||||
const attempts: QuestionResponseAttempt[] = [];
|
||||
const resolutions: QuestionResolution[] = [];
|
||||
child.on("message", (message: unknown) => {
|
||||
const parsed = questionDaemonMessage.parse(message);
|
||||
switch (parsed.type) {
|
||||
case "ready":
|
||||
ready.resolve(parsed);
|
||||
break;
|
||||
case "attempt":
|
||||
attempts.push(parsed);
|
||||
break;
|
||||
case "resolved":
|
||||
resolutions.push(parsed);
|
||||
break;
|
||||
case "error":
|
||||
ready.reject(new Error(parsed.error));
|
||||
break;
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => ready.reject(new Error(`Question daemon exited with ${code}`)));
|
||||
const timeout = setTimeout(
|
||||
() => ready.reject(new Error("Question daemon startup timed out")),
|
||||
20_000,
|
||||
);
|
||||
try {
|
||||
const daemon = await ready.promise;
|
||||
clearTimeout(timeout);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
port: daemon.port,
|
||||
repoPrefix: "question-retry-",
|
||||
title: "Question response retry",
|
||||
initialPrompt: "Emit synthetic questions.",
|
||||
});
|
||||
try {
|
||||
await page.goto("/", { waitUntil: "commit" });
|
||||
const serverId = daemon.serverId;
|
||||
const host = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
label: "Question retry host",
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
await page.evaluate((extraHost) => {
|
||||
localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([extraHost]));
|
||||
}, host);
|
||||
await page.goto(buildAgentRoute(session.workspaceId, session.agentId, serverId), {
|
||||
waitUntil: "commit",
|
||||
});
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
await provide({ attempts, resolutions, releaseFirstResponse: () => child.send("release") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
await killProcessTree(child);
|
||||
if (testInfo.status !== testInfo.expectedStatus) {
|
||||
await testInfo.attach("question-daemon", {
|
||||
body: output.join(""),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
retryTest(
|
||||
"failed question submission preserves answers and allows retry",
|
||||
async ({ page, questionClient }, testInfo) => {
|
||||
retryTest.setTimeout(180_000);
|
||||
const card = page.getByTestId("question-form-card");
|
||||
await chooseQuestionOption(page, "App");
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Preserve my answers after a failed submission.",
|
||||
});
|
||||
await card.getByRole("button", { name: "Submit" }).click();
|
||||
await expect(card.getByRole("progressbar")).toBeVisible();
|
||||
await expectQuestionPrimaryActionDisabled(page, "Submit");
|
||||
await expect(card.getByRole("button", { name: "Dismiss" })).toBeDisabled();
|
||||
await expect(card.getByRole("tab").first()).toBeDisabled();
|
||||
await expect(card.getByRole("textbox")).not.toBeEditable();
|
||||
questionClient.releaseFirstResponse();
|
||||
await expectQuestionPrimaryActionEnabled(page, "Submit");
|
||||
await card.screenshot({ path: testInfo.outputPath("submission-failed.png") });
|
||||
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
|
||||
await expect(card.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(card.getByRole("button", { name: "Submit" })).toHaveText("Submit");
|
||||
await expectQuestionDismissEnabled(page);
|
||||
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionSelected(page, "Behind feature flag");
|
||||
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
|
||||
await expect(card.getByRole("textbox")).toHaveValue(
|
||||
"Preserve my answers after a failed submission.",
|
||||
);
|
||||
await submitQuestionAnswers(page);
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expect.poll(() => questionClient.attempts.length).toBe(2);
|
||||
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
|
||||
await expect
|
||||
.poll(() => questionClient.resolutions)
|
||||
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
|
||||
expect(questionClient.attempts[1].response).toMatchObject({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
answers: {
|
||||
surface: "App",
|
||||
rollout: "Behind feature flag",
|
||||
success: "Preserve my answers after a failed submission.",
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("submission-retried.png") });
|
||||
},
|
||||
);
|
||||
|
||||
retryTest(
|
||||
"failed question dismissal allows an immediate retry",
|
||||
async ({ page, questionClient }, testInfo) => {
|
||||
retryTest.setTimeout(180_000);
|
||||
const card = page.getByTestId("question-form-card");
|
||||
questionClient.releaseFirstResponse();
|
||||
await chooseQuestionOption(page, "App");
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Start with the internal team.",
|
||||
});
|
||||
await card.getByRole("button", { name: "Dismiss" }).click();
|
||||
await expect(card.getByRole("alert")).toHaveText("Could not send your response. Try again.");
|
||||
await expect(card.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(card.getByRole("button", { name: "Dismiss" })).toHaveText("Dismiss");
|
||||
await expectQuestionDismissEnabled(page);
|
||||
await expectQuestionPrimaryActionEnabled(page, "Submit");
|
||||
await card.screenshot({ path: testInfo.outputPath("dismissal-failed.png") });
|
||||
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
await openQuestion(page, { index: 3, total: TOTAL_QUESTIONS });
|
||||
await expect(card.getByRole("textbox")).toHaveValue("Start with the internal team.");
|
||||
await expect(card.getByRole("alert")).toBeVisible();
|
||||
await card.getByRole("button", { name: "Dismiss" }).click();
|
||||
await expect(card).toHaveCount(0);
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expect.poll(() => questionClient.attempts.length).toBe(2);
|
||||
expect(questionClient.attempts[1]).toEqual(questionClient.attempts[0]);
|
||||
await expect
|
||||
.poll(() => questionClient.resolutions)
|
||||
.toEqual([{ ...questionClient.attempts[1], type: "resolved", pendingRequestIds: [] }]);
|
||||
expect(questionClient.attempts[1].response).toEqual({
|
||||
behavior: "deny",
|
||||
message: "Dismissed by user",
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("dismissal-retried.png") });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ async function closeMenuSheet(page: Page): Promise<void> {
|
||||
|
||||
test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" });
|
||||
const secondProject = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-filter-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
@@ -27,7 +26,6 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
/^Grouping/,
|
||||
/^Title/,
|
||||
"Show",
|
||||
"Project",
|
||||
"Show background",
|
||||
]);
|
||||
await closeMenuSheet(page);
|
||||
@@ -55,7 +53,6 @@ test("project and workspace kebabs open action sheets on compact layouts", async
|
||||
});
|
||||
await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await secondProject.cleanup();
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { openAddProjectFlow, addProjectFlow } from "../support/helpers/add-project-flow";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
import {
|
||||
@@ -32,28 +31,6 @@ import {
|
||||
setSidebarNavItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
|
||||
async function showDeveloperToast(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.getElementById("error-toast")?.remove();
|
||||
const toast = document.createElement("div");
|
||||
toast.id = "error-toast";
|
||||
toast.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:white";
|
||||
// Expo renders its unnamed dismiss button in an open shadow root.
|
||||
const button = document.createElement("button");
|
||||
button.style.cssText = "width:24px;height:24px";
|
||||
button.addEventListener("click", () => toast.remove());
|
||||
toast.attachShadow({ mode: "open" }).appendChild(button);
|
||||
document.body.appendChild(toast);
|
||||
});
|
||||
}
|
||||
|
||||
test("developer toasts leave concurrent sidebar layout checks uninterrupted", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await showDeveloperToast(page);
|
||||
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
|
||||
await expect(page.locator("#error-toast")).toBeHidden();
|
||||
});
|
||||
|
||||
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
expectProjectWorkspaceCountForHost,
|
||||
expectSeparateProjects,
|
||||
openGroupedProjectSettings,
|
||||
openGroupedProjectWorkspace,
|
||||
expectWorkspaceHost,
|
||||
openProjectsForSettingsHost,
|
||||
openProjectDirectory,
|
||||
openProjectDirectoryWithHosts,
|
||||
@@ -420,29 +418,4 @@ test.describe("Sidebar project grouping", () => {
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
test("creates grouped project workspaces on the active workspace host", async ({
|
||||
page,
|
||||
crossHostProject,
|
||||
}) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace");
|
||||
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
|
||||
await expectWorkspaceHost(page, SECONDARY_HOST_LABEL);
|
||||
await createWorkspaceWithoutAgent(page);
|
||||
await expectProjectWorkspaceCountForHost(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace");
|
||||
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
|
||||
await expectWorkspaceHost(page, PRIMARY_HOST_LABEL);
|
||||
await createWorkspaceWithoutAgent(page);
|
||||
await expectProjectWorkspaceCountForHost(page, {
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
hostName: PRIMARY_HOST_LABEL,
|
||||
count: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -504,17 +504,6 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
|
||||
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
|
||||
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
|
||||
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
|
||||
});
|
||||
|
||||
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -624,9 +613,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("yields app navigation to Explorer and restores it when Explorer closes", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("keeps app navigation beside the Explorer pane", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
|
||||
|
||||
try {
|
||||
@@ -638,7 +625,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close Explorer sidebar");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-explorer-sidebar")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -266,22 +266,14 @@ test("keeps overview tool calls inline on desktop", async ({ page }) => {
|
||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
|
||||
|
||||
try {
|
||||
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||
await gate.waitForFirstCompleted();
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
|
||||
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
|
||||
const header = group.getByRole("button").first();
|
||||
await header.click();
|
||||
await group.click();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
|
||||
gate.release(1);
|
||||
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
} from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installLoginUsage,
|
||||
expectLoginUsageFreshness,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
refreshLoginUsage,
|
||||
@@ -132,7 +130,7 @@ async function refreshUsageAndWaitForReports(page: Page, usage: UsageReportsFixt
|
||||
await refreshAllUsage(page);
|
||||
await usage.waitForListRequests(requestCount + 1);
|
||||
const group = page.getByTestId(`usage-host-${getServerId()}`);
|
||||
await expect(group.getByText("41% used", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("41%", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
@@ -179,7 +177,7 @@ test.describe("usage modal", () => {
|
||||
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(hostFilter(page)).toHaveCount(0);
|
||||
await qaScreenshot(page, "usage-modal-one-host");
|
||||
await expect(group.getByText("31% used")).toBeVisible();
|
||||
await expect(group.getByText("31%")).toBeVisible();
|
||||
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
|
||||
|
||||
@@ -241,15 +239,15 @@ test.describe("usage modal", () => {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromItem(page);
|
||||
const group = page.getByTestId(`usage-host-${serverId}`);
|
||||
await expect(group.getByText("31% used")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const alphaRefresh = group.getByTestId("usage-refresh").first();
|
||||
await alphaRefresh.click();
|
||||
await expect
|
||||
.poll(() => forcedRefreshes(usage))
|
||||
.toEqual([{ forceRefresh: true, reportIds: ["alpha:a"] }]);
|
||||
await expect(group.getByText("58% used")).toBeVisible();
|
||||
await expect(group.getByText("12% used")).toBeVisible();
|
||||
await expect(group.getByText("58%")).toBeVisible();
|
||||
await expect(group.getByText("12%")).toBeVisible();
|
||||
|
||||
await group.getByTestId("usage-refresh").nth(1).hover();
|
||||
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
|
||||
@@ -333,7 +331,7 @@ test.describe("usage modal", () => {
|
||||
await hostFilter(page).click();
|
||||
await page.getByTestId(`usage-host-filter-item-${secondary.serverId}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(hostFilter(page)).toContainText("Secondary box");
|
||||
await qaScreenshot(page, "usage-modal-picked-host");
|
||||
@@ -348,7 +346,7 @@ test.describe("usage modal", () => {
|
||||
await expectPinnedUsage(page, ["12% Weekly"]);
|
||||
await openUsageFromItem(page);
|
||||
await expect(
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12% used"),
|
||||
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await secondary.close().catch(() => undefined);
|
||||
@@ -514,7 +512,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
await expect(screen.getByText("69% left")).toBeVisible();
|
||||
await showUsageAs(page, "used");
|
||||
await expect(pinRow(screen, "Claude", "Session")).toHaveAccessibleName(
|
||||
/^Pin Claude Session, 31% used · resets /,
|
||||
/^Pin Claude Session, 31% · resets /,
|
||||
);
|
||||
await openUsageOptions(page);
|
||||
await closeUsageOptions(page);
|
||||
@@ -577,7 +575,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
|
||||
},
|
||||
]);
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42% used");
|
||||
await expectLoginUsageOnly(page, "42%");
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
@@ -590,156 +588,6 @@ async function expectLoginErrors(page: Page, lines: string[]) {
|
||||
|
||||
async function expectLoginUsageOnly(page: Page, percentage: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible();
|
||||
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
|
||||
}
|
||||
|
||||
for (const compact of [false, true]) {
|
||||
test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
|
||||
try {
|
||||
await page.clock.install();
|
||||
await page.setViewportSize(
|
||||
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
|
||||
);
|
||||
await gotoAppShell(page);
|
||||
if (compact) await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expectLoginErrors(page, ["Claude: Initial failure"]);
|
||||
await test.step("developer errors do not interrupt freshness inspection", async () => {
|
||||
await page.evaluate(() => console.error("E2E: developer toast while Usage is open"));
|
||||
await expectLoginUsageFreshness(page, "No successful update", compact);
|
||||
});
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }],
|
||||
});
|
||||
// Move only the browser clock; real daemon fetches continue using the host clock.
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "42% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({ status: "error", error: "Rate limited" });
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: Rate limited");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No active quota" },
|
||||
});
|
||||
await refreshLoginUsage(page);
|
||||
await expectRetainedLoginUsage(page, "Claude: No active quota");
|
||||
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
|
||||
|
||||
await fixture.setReport({
|
||||
status: "available",
|
||||
windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }],
|
||||
});
|
||||
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
|
||||
await refreshLoginUsage(page);
|
||||
await expectLoginUsageOnly(page, "43% used");
|
||||
await advanceUsageClock(page, 60_000);
|
||||
await expect(page.getByText("42% used", { exact: true })).toHaveCount(0);
|
||||
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function expectRetainedLoginUsage(page: Page, message: string) {
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByText(/^42% used(?:$| ·)/)).toBeVisible();
|
||||
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
|
||||
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
|
||||
}
|
||||
|
||||
async function switchToJapanese(page: Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "general");
|
||||
await page.getByRole("button", { name: "System", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "日本語 - Japanese", exact: true }).click();
|
||||
await page.getByRole("button", { name: "戻る", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openJapaneseUsage(page: Page) {
|
||||
await page.getByRole("button", { name: "使用状況", exact: true }).click();
|
||||
await expect(page.getByTestId("usage-modal").first()).toBeVisible();
|
||||
}
|
||||
|
||||
test("usage percentages state their meaning and follow the selected language", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [{ id: "session", label: "Session", usedPct: 70, summary: true }],
|
||||
});
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await openUsageFromIcon(page);
|
||||
await expect(page.getByText("70% used", { exact: true })).toBeVisible();
|
||||
await switchToJapanese(page);
|
||||
await expect(usageItem(page)).toHaveAccessibleName(/Claude セッション 70% 使用済み/);
|
||||
await openJapaneseUsage(page);
|
||||
await expect(page.getByText("70% 使用済み", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "すべて更新", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "設定", exact: true }).last().click();
|
||||
await expect(page.getByText("割合", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("使用済み", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("残り", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("open usage clocks advance without refreshing or reopening", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const now = new Date();
|
||||
const reset = new Date(now.getTime() + 30.5 * 60_000).toISOString();
|
||||
const login = await installLoginUsage({
|
||||
status: "available",
|
||||
windows: [
|
||||
{ id: "session", label: "Session", usedPct: 42, resetsAt: reset },
|
||||
{ id: "weekly", label: "Weekly", usedPct: 80, runsOutAt: reset, shortfallPct: 10 },
|
||||
],
|
||||
balances: [{ id: "credits", label: "Credits", unit: "usd", remaining: 12, resetsAt: reset }],
|
||||
});
|
||||
try {
|
||||
await openCompactUsageWithClock(page, now);
|
||||
const card = page.getByTestId("usage-report-login-journey:account");
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText("Updated just now");
|
||||
await expect(card.getByText("resets 30m", { exact: false }).first()).toBeVisible();
|
||||
// The shared minute clock is wall-aligned, and the daemon timestamps use real time.
|
||||
// After two browser minutes the latest displayed tick is one or two minutes old.
|
||||
await advanceUsageClock(page, 120_000);
|
||||
await expect(card.getByTestId("usage-freshness")).toHaveText(/^Updated [12]m ago$/);
|
||||
await expect(card.getByText(/resets (28|29)m/).first()).toBeVisible();
|
||||
await expect(card.getByText(/runs out (28|29)m/)).toBeVisible();
|
||||
await expect(card.getByTestId("provider-usage-balance-credits-reset")).toHaveText(
|
||||
/^resets (28|29)m$/,
|
||||
);
|
||||
} finally {
|
||||
await login.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openCompactUsageWithClock(page: Page, now: Date) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.clock.install({ time: now });
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
await openUsageFromIcon(page);
|
||||
}
|
||||
|
||||
async function advanceUsageClock(page: Page, milliseconds: number) {
|
||||
await page.clock.fastForward(milliseconds);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ test.describe("usage settings", () => {
|
||||
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("70% used")).toBeVisible();
|
||||
await expect(card.getByText("70%")).toBeVisible();
|
||||
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
|
||||
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
|
||||
@@ -131,11 +131,11 @@ test.describe("usage settings", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, serverId, "usage");
|
||||
const card = page.getByTestId("usage-card");
|
||||
await expect(card.getByText("23% used")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(card.getByText("23%")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await refreshAllUsage(page);
|
||||
await expect.poll(() => forcedRefreshCount(usage)).toBe(1);
|
||||
await expect(card.getByText("64% used")).toBeVisible();
|
||||
await expect(card.getByText("64%")).toBeVisible();
|
||||
});
|
||||
|
||||
test("asks to update a host without usage support and never calls it", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
typeInTerminal,
|
||||
setupDeterministicPrompt,
|
||||
waitForTerminalContent,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
|
||||
interface CreatedAgentCwdAssertion {
|
||||
workspaceId: string;
|
||||
cwd: string | null;
|
||||
}
|
||||
|
||||
async function fetchSingleAgentForWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<CreatedAgentCwdAssertion | null> {
|
||||
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId);
|
||||
if (agents.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const [agent] = agents;
|
||||
return {
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: agent.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
test("main checkout workspace opens terminals in the project root", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const message = `cwd draft create ${Date.now()}`;
|
||||
await expect(composer).toBeEditable({ timeout: 15_000 });
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
|
||||
.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
createAgentChatFromLauncher,
|
||||
createStandaloneTerminalFromLauncher,
|
||||
expectTerminalCwd,
|
||||
submitAgentChatAndExpectCwd,
|
||||
} from "../support/helpers/workspace-lifecycle";
|
||||
|
||||
test.describe("Workspace lifecycle", () => {
|
||||
@@ -20,9 +19,8 @@ test.describe("Workspace lifecycle", () => {
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await test.step("submitting a draft creates an agent in the project root", async () => {
|
||||
await test.step("creates an agent chat", async () => {
|
||||
await createAgentChatFromLauncher(page);
|
||||
await submitAgentChatAndExpectCwd(page, workspace);
|
||||
});
|
||||
|
||||
await test.step("creates a terminal in the project root", async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { test, expect } from "../support/fixtures";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
setupCommandOutput,
|
||||
createWorkspaceThroughDaemon,
|
||||
openWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
@@ -34,9 +33,7 @@ test("scripts menu resizes when a service row grows after launch", async ({ page
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
setupCommandOutput,
|
||||
createWorkspaceThroughDaemon,
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
closeWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
waitForWorkspaceSetupProgress,
|
||||
observeSetupPanelTraffic,
|
||||
} from "../support/helpers/workspace-setup";
|
||||
|
||||
interface WorkspaceScriptStarter {
|
||||
@@ -93,8 +91,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
@@ -119,45 +116,6 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({
|
||||
page,
|
||||
}) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-membership-", {
|
||||
paseoConfig: { worktree: { setup: ["echo setup complete"] } },
|
||||
});
|
||||
const traffic = observeSetupPanelTraffic(page);
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed",
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "setup-membership",
|
||||
});
|
||||
await completed;
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
await traffic.expectNoDemand();
|
||||
await expectSetupPanel(page);
|
||||
await traffic.expectSubscribed(workspace.id, 1);
|
||||
await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete");
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await traffic.expectReleased(0);
|
||||
await expectSetupPanel(page);
|
||||
await traffic.expectSubscribed(workspace.id, 2);
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await traffic.expectReleased(1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
daemonTest("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-success-", {
|
||||
@@ -172,19 +130,16 @@ test.describe("Workspace setup streaming", () => {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const initialRunning = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.commands.length === 0,
|
||||
(payload) => payload.status === "running" && payload.detail.log === "",
|
||||
);
|
||||
const runningWithOutput = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "running" &&
|
||||
setupCommandOutput(payload.detail).includes("starting setup"),
|
||||
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
|
||||
);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
@@ -197,8 +152,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completedPayload = await completed;
|
||||
|
||||
expect(initialPayload.detail.log).toBe("");
|
||||
expect(setupCommandOutput(runningPayload.detail)).toContain("starting setup");
|
||||
expect(setupCommandOutput(completedPayload.detail)).toContain("setup complete");
|
||||
expect(runningPayload.detail.log).toContain("starting setup");
|
||||
expect(completedPayload.detail.log).toContain("setup complete");
|
||||
expect(completedPayload.error).toBeNull();
|
||||
} finally {
|
||||
await client.close();
|
||||
@@ -220,9 +175,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "failed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup failed"),
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
);
|
||||
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
@@ -231,8 +184,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
});
|
||||
|
||||
const failedPayload = await failed;
|
||||
expect(setupCommandOutput(failedPayload.detail)).toContain("starting setup");
|
||||
expect(setupCommandOutput(failedPayload.detail)).toContain("setup failed");
|
||||
expect(failedPayload.detail.log).toContain("starting setup");
|
||||
expect(failedPayload.detail.log).toContain("setup failed");
|
||||
expect(failedPayload.error).toMatch(/failed/i);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
@@ -338,8 +291,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
@@ -385,8 +337,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
setupCommandOutput(payload.detail).includes("setup complete"),
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
|
||||
const result = await client.createPaseoWorktree({
|
||||
|
||||
@@ -4,8 +4,8 @@ env APP_ID=sh.paseo.debug
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-explorer-toggle\""
|
||||
wait "id=\"workspace-tab-files\"" 10000
|
||||
press "id=\"workspace-tab-files\""
|
||||
wait "id=\"explorer-tab-files\"" 10000
|
||||
press "id=\"explorer-tab-files\""
|
||||
wait "id=\"file-explorer-row-0\"" 10000
|
||||
longpress "id=\"file-explorer-row-0\"" 600
|
||||
wait "id=\"file-explorer-row-0-context-menu-content\"" 10000
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Prerequisite: a native terminal showing the output of echo '한글 문장'
|
||||
# on its first row, with no active selection. Use a dedicated QA workspace.
|
||||
# Coordinates target the first half of 문 on a 1080x1920 phone at default
|
||||
# code size. Override WORD_X/WORD_Y for other device geometry.
|
||||
context platform=android timeout=30000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
env WORD_X=105
|
||||
env WORD_Y=251
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"Terminal input\"" 10000
|
||||
wait "label=\"한글 문장\"" 10000
|
||||
longpress "${WORD_X}" "${WORD_Y}" 650
|
||||
wait "id=\"terminal-copy\"" 5000
|
||||
press "id=\"terminal-copy\""
|
||||
# Copy clears the selection; only Android's clipboard preview has this exact
|
||||
# text, since the unselected terminal row contains the full sentence.
|
||||
wait "text=\"문장\"" 3000
|
||||
close
|
||||
@@ -1,145 +0,0 @@
|
||||
// Run against an Android dev client serving this checkout, with a real software keyboard.
|
||||
// node packages/app/e2e/mobile/appearance-keyboard/android.mjs <session> <metro-url> <server-id> [app-id]
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, metroUrl, serverId, appId = "sh.paseo.debug"] = process.argv;
|
||||
assert(
|
||||
session && metroUrl && serverId,
|
||||
"Pass the agent-device session, Metro URL and connected host ID",
|
||||
);
|
||||
const artifacts = resolve(".dev/agent-device-artifacts/appearance-keyboard-android");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function evaluate(expression) {
|
||||
return ad("cdp", "runtime", "eval", "--expr", expression, "--json");
|
||||
}
|
||||
|
||||
// Read the mounted app's public React Native APIs through Metro. Accessibility
|
||||
// snapshots with Gboard open can block while waiting for an idle hierarchy.
|
||||
const reactNative = `Array.from(__r.getModules().values()).find(
|
||||
m => m.verboseName?.endsWith("/react-native/index.js")
|
||||
).publicModule.exports`;
|
||||
|
||||
function measureFocusedInput() {
|
||||
evaluate(`(() => {
|
||||
const rn = ${reactNative};
|
||||
globalThis.__appearanceKeyboardMeasurement = null;
|
||||
rn.TextInput.State.currentlyFocusedInput()?.measureInWindow((x, y, width, height) => {
|
||||
globalThis.__appearanceKeyboardMeasurement = JSON.stringify({
|
||||
x, y, width, height,
|
||||
statusBarHeight: rn.StatusBar.currentHeight,
|
||||
scale: rn.PixelRatio.get(),
|
||||
keyboard: rn.Keyboard.metrics(),
|
||||
});
|
||||
});
|
||||
})()`);
|
||||
const value = JSON.parse(evaluate("globalThis.__appearanceKeyboardMeasurement"));
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
async function waitForFocusedInput() {
|
||||
let measurement;
|
||||
let previous;
|
||||
let stable = 0;
|
||||
const deadline = Date.now() + 30_000;
|
||||
do {
|
||||
measurement = measureFocusedInput();
|
||||
const current = measurement?.keyboard
|
||||
? [measurement.y, measurement.height, measurement.keyboard.screenY].map(Math.round).join(",")
|
||||
: null;
|
||||
stable = measurement?.keyboard && current === previous ? stable + 1 : 0;
|
||||
previous = current;
|
||||
if (stable >= 3) break;
|
||||
await delay(150);
|
||||
} while (Date.now() < deadline);
|
||||
|
||||
assert(stable >= 3, `Focused input and keyboard must settle: ${JSON.stringify(measurement)}`);
|
||||
return measurement;
|
||||
}
|
||||
|
||||
ad("open", appId, "--platform", "android", "--no-test-ime");
|
||||
const targets = ad("cdp", "target", "list", "--url", metroUrl)
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(appId));
|
||||
assert.equal(targets.length, 1, "Connect only the test app to this Metro server");
|
||||
ad("cdp", "target", "select", targets[0].split(/\s+/)[0]);
|
||||
|
||||
try {
|
||||
evaluate(`(${reactNative}).Keyboard.dismiss()`);
|
||||
ad("open", appId, "paseo://settings", "--platform", "android", "--no-test-ime");
|
||||
ad("wait", "text", "Appearance", "5000");
|
||||
ad("open", appId, "paseo://settings/appearance", "--platform", "android", "--no-test-ime");
|
||||
ad("wait", "text", "Code size", "5000");
|
||||
const snapshot = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const numericFields = snapshot.data.nodes.filter(
|
||||
(node) => node.type === "android.widget.EditText" && /^\d+$/.test(node.value ?? ""),
|
||||
);
|
||||
assert.equal(numericFields.length, 3, "All three font sizes must be visible without scrolling");
|
||||
ad("screenshot", resolve(artifacts, "before-keyboard.png"));
|
||||
// Appearance orders Interface size, Content size, then Code size.
|
||||
ad("press", `@${numericFields[2].ref}`);
|
||||
|
||||
const measurement = await waitForFocusedInput();
|
||||
// Android measureInWindow excludes the status bar, Keyboard.metrics uses screen coordinates.
|
||||
const inputBottom = measurement.y + measurement.height + measurement.statusBarHeight;
|
||||
const keyboardTop = measurement.keyboard.screenY;
|
||||
console.log(JSON.stringify({ inputBottom, keyboardTop, measurement }));
|
||||
ad("screenshot", resolve(artifacts, "keyboard.png"));
|
||||
assert(
|
||||
inputBottom <= keyboardTop + 1 / measurement.scale,
|
||||
"Code size must be fully visible above the number keyboard (within one physical pixel)",
|
||||
);
|
||||
console.log("PASS: focusing Code size reveals the whole field above the keyboard");
|
||||
|
||||
evaluate(`(${reactNative}).Keyboard.dismiss()`);
|
||||
ad(
|
||||
"open",
|
||||
appId,
|
||||
`paseo://settings/hosts/${encodeURIComponent(serverId)}/plugins`,
|
||||
"--platform",
|
||||
"android",
|
||||
"--no-test-ime",
|
||||
);
|
||||
ad("wait", "text", "Plugin source", "5000");
|
||||
const plugins = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const source = plugins.data.nodes.find((node) => node.type === "android.widget.EditText");
|
||||
assert(source, "The connected host must support installing plugins from source");
|
||||
ad("press", `@${source.ref}`);
|
||||
ad("type", "qa-source");
|
||||
const pluginMeasurement = await waitForFocusedInput();
|
||||
// A FormTextInput draws its border and padding on the native input's parent.
|
||||
// Measure that visible frame, rather than accepting only the text as visible.
|
||||
const hierarchy = JSON.parse(ad("snapshot", "--json")).data.nodes;
|
||||
const focused = hierarchy.find((node) => node.type === "android.widget.EditText" && node.focused);
|
||||
assert(focused, "Plugin source must remain focused after typing");
|
||||
assert.equal(focused.value, "qa-source", "Typed source must remain in the focused field");
|
||||
const frame = hierarchy.find((node) => node.index === focused.parentIndex);
|
||||
assert(frame, "The source input's visible frame must be present in the native hierarchy");
|
||||
assert(
|
||||
frame.rect.height > focused.rect.height,
|
||||
"Measure the surrounding frame, including padding",
|
||||
);
|
||||
const frameBottom = frame.rect.y + frame.rect.height;
|
||||
const pluginKeyboardTop = pluginMeasurement.keyboard.screenY * pluginMeasurement.scale;
|
||||
console.log(JSON.stringify({ frameBottom, keyboardTop: pluginKeyboardTop, frame: frame.rect }));
|
||||
ad("screenshot", resolve(artifacts, "plugin-source-keyboard.png"));
|
||||
assert(
|
||||
frameBottom <= pluginKeyboardTop + 1,
|
||||
"Plugin source frame must be fully visible above the keyboard (within one physical pixel)",
|
||||
);
|
||||
console.log("PASS: focusing Plugin source reveals the whole field frame above the keyboard");
|
||||
} finally {
|
||||
evaluate(`(${reactNative}).Keyboard.dismiss()`);
|
||||
evaluate("delete globalThis.__appearanceKeyboardMeasurement");
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# Late composer pill clearance
|
||||
|
||||
This regression opens a completed conversation, relaunches the Android app, and
|
||||
registers a composer pill five seconds after the client plugin loads. It checks
|
||||
that the completed turn's Copy and Fork buttons remain fully above the pill.
|
||||
The daemon, plugin API, native app, layout, and accessibility bounds are real;
|
||||
no provider calls are needed during the test.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Build the server and app dependencies with `npm run build:server` and
|
||||
`npm run build --workspace=@getpaseo/expo-two-way-audio`.
|
||||
- Run an isolated development daemon on a non-default port.
|
||||
- Run this checkout's Metro server and open the connected Android dev client
|
||||
with `agent-device`. The app must already know the isolated daemon.
|
||||
- Open a completed conversation on a compact portrait Android emulator, at the
|
||||
latest turn, with no other composer tracks visible. Keep the software keyboard
|
||||
closed. Note the workspace and agent IDs from the daemon.
|
||||
|
||||
Set `WORKSPACE_ID` and `AGENT_ID` to that conversation, then run from the
|
||||
repository root, substituting the session and ports:
|
||||
|
||||
```sh
|
||||
PASEO_OVERLAY_ARTIFACTS_DIR=.dev/composer-overlay-evidence \
|
||||
node packages/app/e2e/mobile/composer-overlay/android.mjs \
|
||||
composer-overlay ws://127.0.0.1:6843/ws "$WORKSPACE_ID" "$AGENT_ID" sh.paseo.debug 8193
|
||||
```
|
||||
|
||||
The runner installs and removes its delayed plugin and leaves the conversation
|
||||
intact. It preserves the daemon's previous plugins-enabled setting. The evidence directory
|
||||
contains the cold-reopen screenshot and raw accessibility snapshot. Without an
|
||||
explicit directory, temporary evidence is removed with the fixture.
|
||||
|
||||
To check an existing capture independently:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs \
|
||||
.dev/composer-overlay-evidence/cold-reopen.json 'Overlay regression pill'
|
||||
```
|
||||
|
||||
On the failing baseline, the Copy button extends below the pill's top edge. With
|
||||
the correction, both actions are clear before the composer receives focus.
|
||||
|
||||
Additional checks on the same conversation: opening the keyboard keeps the footer
|
||||
clear; after scrolling up, a newly registered pill must not jump back to the latest
|
||||
turn. Native iOS uses a different keyboard implementation and needs its own device
|
||||
verification.
|
||||
@@ -1,95 +0,0 @@
|
||||
// Start on a completed conversation in the connected Android dev client.
|
||||
// node android.mjs <session> <daemon-ws-url> <workspace-id> <agent-id> [app-id] [metro-port]
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { WebSocket } from "ws";
|
||||
import { DaemonClient } from "../../../../client/dist/daemon-client.js";
|
||||
|
||||
const [, , session, daemonUrl, workspaceId, agentId, appId = "sh.paseo.debug", metroPort = "8081"] =
|
||||
process.argv;
|
||||
assert(
|
||||
session && daemonUrl && workspaceId && agentId,
|
||||
"Pass a session, isolated daemon URL, workspace ID, and agent ID",
|
||||
);
|
||||
const url = new URL(daemonUrl);
|
||||
assert(["127.0.0.1", "localhost"].includes(url.hostname), "Use an isolated loopback daemon");
|
||||
assert(!["", "6767", "6768"].includes(url.port), "Do not use the installed or development daemon");
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-overlay-"));
|
||||
const artifacts = process.env.PASEO_OVERLAY_ARTIFACTS_DIR ?? path.join(directory, "evidence");
|
||||
await mkdir(artifacts, { recursive: true });
|
||||
const pluginId = "composer-overlay-mobile-repro";
|
||||
const pillLabel = "Overlay regression pill";
|
||||
const client = new DaemonClient({
|
||||
url: daemonUrl,
|
||||
clientId: "composer-overlay-mobile-regression",
|
||||
clientType: "cli",
|
||||
appVersion: JSON.parse(await readFile(new URL("../../../package.json", import.meta.url))).version,
|
||||
webSocketFactory: (address, options) => new WebSocket(address, options),
|
||||
});
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
let installed = false;
|
||||
let previousConfig;
|
||||
try {
|
||||
ad("wait", 'label="Copy turn"', "20000");
|
||||
await client.connect();
|
||||
previousConfig = (await client.getDaemonConfig()).config;
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: pluginId,
|
||||
requirements: { paseo: ">=0.11.4" },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`export default function contribute(client) {
|
||||
let pill;
|
||||
const timer = setTimeout(() => {
|
||||
pill = client.addComposerPill({
|
||||
id: "late-pill", workspaceId: ${JSON.stringify(workspaceId)}, agentId: ${JSON.stringify(agentId)},
|
||||
button: { title: ${JSON.stringify(pillLabel)}, icon: "Clock", behavior: { kind: "action", onPress() {} } },
|
||||
});
|
||||
}, 5000);
|
||||
return () => { clearTimeout(timer); pill?.remove(); };
|
||||
}`,
|
||||
);
|
||||
await client.installDirectoryPlugin(directory);
|
||||
installed = true;
|
||||
ad("wait", `label="${pillLabel}"`, "20000");
|
||||
// Match the report: reopen a completed conversation, then let the plugin register late.
|
||||
ad(
|
||||
"open",
|
||||
appId,
|
||||
"--platform",
|
||||
"android",
|
||||
"--metro-port",
|
||||
metroPort,
|
||||
"--no-test-ime",
|
||||
"--relaunch",
|
||||
);
|
||||
ad("wait", `label="${pillLabel}"`, "20000");
|
||||
const capture = path.join(artifacts, "cold-reopen.json");
|
||||
await writeFile(capture, ad("snapshot", "--raw", "--json"));
|
||||
ad("screenshot", path.join(artifacts, "cold-reopen.png"));
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[fileURLToPath(new URL("./assert-footer-clear.mjs", import.meta.url)), capture, pillLabel],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
} finally {
|
||||
if (installed) await client.removePlugin(pluginId);
|
||||
if (previousConfig)
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previousConfig.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// Assert geometry from a fresh agent-device snapshot --raw --json capture.
|
||||
// node assert-footer-clear.mjs <snapshot.json> <composer-pill-label>
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [, , snapshotPath, pillLabel] = process.argv;
|
||||
assert(snapshotPath && pillLabel, "Pass a snapshot JSON path and composer pill label");
|
||||
const { data } = JSON.parse(readFileSync(snapshotPath, "utf8"));
|
||||
const buttons = data.nodes.filter((node) => node.type === "android.widget.Button");
|
||||
const pill = buttons.find((node) => node.label === pillLabel);
|
||||
assert(pill?.rect, `Composer pill '${pillLabel}' must be visible`);
|
||||
for (const label of ["Copy turn", "Fork chat from here"]) {
|
||||
const button = buttons.find((node) => node.label === label);
|
||||
assert(button?.rect, `Completed-turn action '${label}' must be visible`);
|
||||
const bottom = button.rect.y + button.rect.height;
|
||||
assert(
|
||||
bottom <= pill.rect.y,
|
||||
`${label} ends at ${bottom}, below composer pill top ${pill.rect.y}`,
|
||||
);
|
||||
}
|
||||
console.log("PASS: completed-turn actions remain fully above the composer pill");
|
||||
@@ -1,45 +0,0 @@
|
||||
# Native retained status-ring regression
|
||||
|
||||
This runs the production native ring and clock inside real retained panels with
|
||||
Reanimated on Android. A probe consumes the same public rotation hook and counts
|
||||
UI-runtime value changes. No Reanimated mocks or provider processes are used.
|
||||
|
||||
Use an Android development client compatible with the checkout's native dependencies.
|
||||
In a disposable checkout, temporarily replace `packages/app/index.ts` with:
|
||||
|
||||
```tsx
|
||||
import "./src/polyfills/install-early";
|
||||
import "./src/styles/unistyles";
|
||||
import { registerRootComponent } from "expo";
|
||||
import NativeRingRegression from "./e2e/mobile/status-ring/native";
|
||||
registerRootComponent(NativeRingRegression);
|
||||
```
|
||||
|
||||
Start Metro in `packages/app`:
|
||||
|
||||
```sh
|
||||
APP_VARIANT=development npx expo start --localhost --port 8096
|
||||
```
|
||||
|
||||
With one Android emulator running, open that bundle:
|
||||
|
||||
```sh
|
||||
agent-device open sh.paseo.debug 'exp+voice-mobile://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8096' --platform android --session status-ring --metro-port 8096
|
||||
```
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/status-ring/android.mjs status-ring http://127.0.0.1:8096
|
||||
```
|
||||
|
||||
The test checks visible rotation, hidden-panel inactivity while another ring is
|
||||
visible, inactivity with both panels hidden, and synchronized resumption. Each
|
||||
sample reads counters on the UI runtime; elapsed time includes device-command
|
||||
latency. The baseline fails at the first hidden-panel assertion.
|
||||
|
||||
Restore the original `packages/app/index.ts`, close the agent-device session,
|
||||
and stop Metro afterward. The fixture substitutes running rings for a live agent
|
||||
and toggles retained panels directly; it does not exercise workspace navigation
|
||||
or measure whole-app CPU usage. It is excluded from routine unit tests because
|
||||
it requires a native runtime.
|
||||
@@ -1,71 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, metroUrl] = process.argv;
|
||||
assert(session && metroUrl, "Pass an agent-device session and Metro URL serving native.tsx");
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
function evaluate(expression) {
|
||||
const value = ad("cdp", "runtime", "eval", "--expr", expression, "--json").trim();
|
||||
return value === "undefined" ? undefined : JSON.parse(value);
|
||||
}
|
||||
let target;
|
||||
const deadline = Date.now() + 30_000;
|
||||
do {
|
||||
target = ad("cdp", "target", "list", "--url", metroUrl)
|
||||
.split("\n")
|
||||
.find((line) => line.includes("sh.paseo.debug"));
|
||||
if (!target) await delay(250);
|
||||
} while (!target && Date.now() < deadline);
|
||||
assert(target, "Open the Android dev client on this Metro bundle first");
|
||||
ad("cdp", "target", "select", target.split(/\s+/)[0]);
|
||||
let ready = false;
|
||||
do {
|
||||
ready = evaluate("Object.keys(globalThis.__ringReaders ?? {}).length === 2");
|
||||
if (!ready) await delay(250);
|
||||
} while (!ready && Date.now() < deadline);
|
||||
assert(ready, "The two native probes must mount before sampling");
|
||||
async function sample() {
|
||||
evaluate("Object.values(globalThis.__ringReaders).forEach(read => read())");
|
||||
await delay(100);
|
||||
return JSON.parse(evaluate("JSON.stringify(globalThis.__ringSamples)"));
|
||||
}
|
||||
async function observe(label) {
|
||||
const before = await sample();
|
||||
await delay(600);
|
||||
const after = await sample();
|
||||
const deltas = Object.fromEntries(
|
||||
Object.keys(before).map((key) => [key, after[key].updates - before[key].updates]),
|
||||
);
|
||||
console.log(JSON.stringify({ label, deltas, after }));
|
||||
return { deltas, after };
|
||||
}
|
||||
const visible = await observe("both visible");
|
||||
assert(visible.deltas.first > 0 && visible.deltas.second > 0);
|
||||
assert.equal(visible.after.first.rotation, visible.after.second.rotation);
|
||||
ad("press", 'label="HIDE FIRST"');
|
||||
await delay(200);
|
||||
const hidden = await observe("first hidden, second visible");
|
||||
assert.equal(hidden.deltas.first, 0, "A retained hidden ring must stop UI-thread rotation updates");
|
||||
assert(hidden.deltas.second > 0, "Visible rings must continue rotating");
|
||||
ad("press", 'label="HIDE SECOND"');
|
||||
await delay(200);
|
||||
const allHidden = await observe("both hidden");
|
||||
assert.equal(allHidden.deltas.first, 0);
|
||||
assert.equal(allHidden.deltas.second, 0);
|
||||
ad("press", 'label="SHOW FIRST"');
|
||||
ad("press", 'label="SHOW SECOND"');
|
||||
await delay(200);
|
||||
const restored = await observe("both shown again");
|
||||
assert(restored.deltas.first > 0 && restored.deltas.second > 0);
|
||||
assert.equal(
|
||||
restored.after.first.rotation,
|
||||
restored.after.second.rotation,
|
||||
"Restored rings must share the current phase",
|
||||
);
|
||||
console.log("PASS native retained-ring lifecycle");
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useState } from "react";
|
||||
import { Button, Text, View } from "react-native";
|
||||
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
||||
import { RetainedPanel } from "@/components/retained-panel";
|
||||
import { useStatusRingRotation } from "@/components/status-ring/clock";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
|
||||
interface Sample {
|
||||
updates: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __ringSamples: Record<string, Sample>;
|
||||
var __ringReaders: Record<string, () => void>;
|
||||
}
|
||||
|
||||
function recordSample(name: string, value: { updates: number; rotation: number }) {
|
||||
globalThis.__ringSamples[name] = value;
|
||||
}
|
||||
|
||||
function Probe({ name }: { name: string }) {
|
||||
const rotation = useStatusRingRotation();
|
||||
const updates = useSharedValue(0);
|
||||
useAnimatedReaction(
|
||||
() => rotation.value,
|
||||
() => {
|
||||
updates.value += 1;
|
||||
},
|
||||
);
|
||||
// Read the real UI runtime, without scheduling a continuous JS observer.
|
||||
useLayoutEffect(() => {
|
||||
globalThis.__ringSamples ??= {};
|
||||
globalThis.__ringReaders ??= {};
|
||||
globalThis.__ringReaders[name] = () =>
|
||||
scheduleOnUI(() => {
|
||||
"worklet";
|
||||
const sample = { updates: updates.value, rotation: rotation.value };
|
||||
scheduleOnRN(recordSample, name, sample);
|
||||
});
|
||||
return () => {
|
||||
delete globalThis.__ringReaders[name];
|
||||
};
|
||||
}, [name, rotation, updates]);
|
||||
return <StatusRing />;
|
||||
}
|
||||
|
||||
const containerStyle = { flex: 1, padding: 60, backgroundColor: "white" };
|
||||
|
||||
export default function NativeRingRegression() {
|
||||
const [first, setFirst] = useState(true);
|
||||
const [second, setSecond] = useState(true);
|
||||
const toggleFirst = useCallback(() => setFirst((value) => !value), []);
|
||||
const toggleSecond = useCallback(() => setSecond((value) => !value), []);
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text>Native retained status rings</Text>
|
||||
<Button title={first ? "Hide first" : "Show first"} onPress={toggleFirst} />
|
||||
<Button title={second ? "Hide second" : "Show second"} onPress={toggleSecond} />
|
||||
<RetainedPanel active={first}>
|
||||
<Probe name="first" />
|
||||
</RetainedPanel>
|
||||
<RetainedPanel active={second}>
|
||||
<Probe name="second" />
|
||||
</RetainedPanel>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
const HIDE_DEVELOPER_TOASTS = "#error-toast { display: none !important; }";
|
||||
|
||||
/** @param {import('@playwright/test').Page} page */
|
||||
export async function hideDeveloperToasts(page) {
|
||||
// Developer chrome must not cover app actions or move the test's pointer/focus.
|
||||
// Console errors remain in the runner's logs. Hiding the toast, instead of clicking it away,
|
||||
// leaves no race with a toast that closes or is replaced between matching and clicking.
|
||||
await page.addInitScript((css) => {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = css;
|
||||
document.addEventListener("DOMContentLoaded", () => document.head.append(style));
|
||||
}, HIDE_DEVELOPER_TOASTS);
|
||||
// A page attached after it loaded (Electron over CDP) runs no init script until it navigates.
|
||||
await page.addStyleTag({ content: HIDE_DEVELOPER_TOASTS });
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { hideDeveloperToasts } from "./developer-ui.mjs";
|
||||
import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update";
|
||||
import { startE2EWorker } from "./helpers/e2e-worker";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
@@ -131,8 +130,6 @@ const test = daemonTest.extend<{
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
await hideDeveloperToasts(page);
|
||||
|
||||
const entries: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
|
||||
@@ -134,8 +134,6 @@ function startMetro(port: number, buffer: ReturnType<typeof createLineBuffer>):
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: "none",
|
||||
// Automation never opens native DevTools or installs its desktop shell.
|
||||
EXPO_UNSTABLE_HEADLESS: "1",
|
||||
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_WEB_PLATFORM: "electron" } : {}),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
|
||||
@@ -203,8 +203,7 @@ export async function expectOneLineTruncatedFileResult(
|
||||
expect(lineBox).not.toBeNull();
|
||||
expect(filenameBox).not.toBeNull();
|
||||
expect(pathBox).not.toBeNull();
|
||||
// COMMAND_CENTER_LIST_METRICS.desktop.singleLineRow (list-metrics imports react-native).
|
||||
expect(rowBox?.height).toBe(32);
|
||||
expect(rowBox?.height).toBe(36);
|
||||
expect(Math.abs((filenameBox?.y ?? 0) - (pathBox?.y ?? 0))).toBeLessThanOrEqual(1);
|
||||
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth);
|
||||
expect(overflow.overflow).toBe("hidden");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user