diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml new file mode 100644 index 000000000..c156ec0a1 --- /dev/null +++ b/.github/ci-paths.yml @@ -0,0 +1,71 @@ +# Pull-request checks are routed by the behavior they prove. Package consumers +# do not inherit their dependencies' test suites; main and manual runs execute +# every contract regardless of these paths. +routing: + - ".github/ci-paths.yml" + +workspace: + - ".mise.toml" + - ".tool-versions" + - "package.json" + - "package-lock.json" + - "patches/**" + - "scripts/**" + - "tsconfig.json" + - "tsconfig.base.json" + - "vitest.config.ts" + +ci: + - ".github/actions/**" + - ".github/workflows/ci.yml" + +format: + - ".agents/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - ".github/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - "**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - "packages/expo-two-way-audio/**" + +quality: + - "**/*.{cjs,js,json,jsx,mjs,ts,tsx}" + - "packages/expo-two-way-audio/**" + +hub: + - "packages/cli/src/commands/hub/**" + - "packages/server/src/server/hub/**" + +server: + - "packages/server/**" + - "packages/app/e2e/support/fixtures/recording.*" + +desktop: + - "packages/desktop/**" + - "packages/app/src/desktop/**" + - "packages/server/src/server/browser-tools/**" + - "packages/app/e2e/support/**" + - "packages/app/*config.{cjs,js,ts}" + - "packages/app/package.json" + +app: + - "packages/app/**" + - "packages/expo-two-way-audio/**" + +sdk: + - "packages/client/**" + - "packages/highlight/**" + - "packages/protocol/**" + +browser: + - "packages/app/src/!(desktop)/**" + - "packages/app/e2e/browser/**" + - "packages/app/e2e/support/**" + - "packages/app/assets/**" + - "packages/app/public/**" + - "packages/app/index.ts" + - "packages/app/*config.{cjs,js,ts}" + - "packages/app/package.json" + +relay: + - "packages/relay/**" + +cli: + - "packages/cli/**" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f0214819..e7fa5c814 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,110 +19,47 @@ env: jobs: changes: + name: changes runs-on: ubuntu-latest permissions: contents: read pull-requests: read outputs: - quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }} - server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }} - desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }} - desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }} - app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }} - sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }} - playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }} - relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }} - cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }} + full: ${{ github.event_name != 'pull_request' || steps.filter.outputs.routing != 'false' || steps.filter.outputs.workspace != 'false' || steps.filter.outputs.ci != 'false' }} + format: ${{ steps.filter.outputs.format }} + quality: ${{ steps.filter.outputs.quality }} + hub: ${{ steps.filter.outputs.hub }} + server: ${{ steps.filter.outputs.server }} + desktop: ${{ steps.filter.outputs.desktop }} + app: ${{ steps.filter.outputs.app }} + sdk: ${{ steps.filter.outputs.sdk }} + browser: ${{ steps.filter.outputs.browser }} + relay: ${{ steps.filter.outputs.relay }} + cli: ${{ steps.filter.outputs.cli }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - - name: Detect affected CI jobs + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Detect affected CI contracts id: filter uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 with: - filters: | - shared: - - '.github/workflows/ci.yml' - - '.github/actions/**' - - '.mise.toml' - - '.tool-versions' - - 'package.json' - - 'package-lock.json' - - 'patches/**' - - 'scripts/**' - - 'tsconfig.json' - - 'tsconfig.base.json' - - 'vitest.config.ts' - quality: - - 'packages/**' - - '*.cjs' - - '*.js' - - '*.json' - - '*.mjs' - - '*.ts' - server: - - 'packages/app/e2e/fixtures/recording.*' - - 'packages/client/**' - - 'packages/cli/src/**' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - - 'packages/server/**' - desktop: - - 'packages/app/**' - - 'packages/cli/**' - - 'packages/client/**' - - 'packages/desktop/**' - - 'packages/expo-two-way-audio/**' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - - 'packages/server/**' - desktop_package: - - '.github/workflows/ci.yml' - - 'packages/desktop/**' - app: - - 'packages/app/**' - - 'packages/client/**' - - 'packages/expo-two-way-audio/**' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - sdk: - - 'packages/client/**' - - 'packages/protocol/**' - - 'packages/relay/**' - playwright: - - 'packages/app/**' - - 'packages/client/**' - - 'packages/expo-two-way-audio/**' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - - 'packages/server/**' - relay: - - 'packages/relay/**' - cli: - - 'nix/**' - - 'packages/app/e2e/global-setup.ts' - - 'packages/cli/**' - - 'packages/client/**' - - 'packages/desktop/src/daemon/runtime-paths.ts' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - - 'packages/server/**' + filters: .github/ci-paths.yml - - name: Validate CI workflow - run: node --test scripts/ci-workflow.test.mjs + - name: Validate CI contracts + run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs format: + name: format + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} runs-on: ubuntu-latest env: - # This job never executes Electron. Skipping the hosted binary avoids - # unrelated npm ci failures when Electron's CDN returns 504. ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -136,15 +73,12 @@ jobs: run: node scripts/npm-retry.mjs ci - name: Check formatting - run: npx oxfmt --check . + run: npm run format:check lint: + name: lint needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.quality != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -169,12 +103,9 @@ jobs: run: npm run lint typecheck: + name: typecheck needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.quality != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -188,6 +119,7 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Build server stack run: npm run build:server @@ -200,130 +132,112 @@ jobs: npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server - server-tests: + server-tests-ubuntu: + name: server-tests (ubuntu-latest) needs: changes - if: ${{ !cancelled() }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - name: server-tests (${{ matrix.os }}) + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} + runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - RUN_TESTS: >- - ${{ github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.server != 'false' }} - steps: - - name: Skip unaffected server tests - if: env.RUN_TESTS != 'true' - run: echo "No server changes detected." - + steps: &server_test_steps - uses: actions/checkout@v4 - if: env.RUN_TESTS == 'true' with: fetch-depth: 0 - uses: actions/setup-node@v4 - if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Fetch origin/main (worktree tests) - if: env.RUN_TESTS == 'true' + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: git fetch --no-tags origin main:refs/remotes/origin/main - name: Install dependencies - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci + - name: Install agent CLIs for provider tests - if: env.RUN_TESTS == 'true' + 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 - name: Build server dependencies - if: env.RUN_TESTS == 'true' run: npm run build:server-deps - name: Run server tests - if: env.RUN_TESTS == 'true' + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: npm run test --workspace=@getpaseo/server env: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - desktop-tests: + - 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 + + server-tests-windows: + name: server-tests (windows-latest) needs: changes - if: ${{ !cancelled() }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} + runs-on: windows-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: *server_test_steps + + desktop-tests-ubuntu: + name: desktop-tests (ubuntu-latest) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }} + runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read - env: - RUN_TESTS: >- - ${{ github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.desktop != 'false' }} - steps: - - name: Skip unaffected desktop tests - if: env.RUN_TESTS != 'true' - run: echo "No desktop changes detected." - + steps: &desktop_test_steps - uses: actions/checkout@v4 - if: env.RUN_TESTS == 'true' - uses: actions/setup-node@v4 - if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Install dependencies with retry - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci + - name: Build server stack - if: env.RUN_TESTS == 'true' run: npm run build:server - name: Run desktop tests - if: env.RUN_TESTS == 'true' run: npm run test --workspace=@getpaseo/desktop - - name: Build app dependencies for desktop E2E - if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' - run: npm run build:app-deps - - name: Install virtual display - if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' + if: ${{ runner.os == 'Linux' }} run: sudo apt-get update && sudo apt-get install -y xvfb xauth - - name: Run real Electron browser tab bridge E2E - if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' - run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop - env: - PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e + - name: Install Playwright browser + if: ${{ runner.os == 'Linux' }} + run: npx playwright install chromium - - name: Upload browser tab bridge diagnostics + - name: Run desktop renderer E2E + if: ${{ runner.os == 'Linux' }} + run: npm run test:e2e:renderer --workspace=@getpaseo/desktop + + - name: Run desktop browser E2E + if: ${{ runner.os == 'Linux' }} + run: npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop + env: + PASEO_DESKTOP_BROWSER_E2E_ARTIFACT_DIR: ${{ runner.temp }}/desktop-browser-e2e + + - name: Upload desktop browser diagnostics uses: actions/upload-artifact@v4 - if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' + if: ${{ failure() && runner.os == 'Linux' }} with: - name: browser-tab-bridge-e2e - path: ${{ runner.temp }}/browser-tab-bridge-e2e + name: desktop-browser-e2e + path: ${{ runner.temp }}/desktop-browser-e2e if-no-files-found: ignore retention-days: 7 - name: Build and smoke unpacked desktop app - if: >- - env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.desktop_package != 'false') + if: ${{ runner.os == 'Linux' }} run: npm run build:desktop -- --publish never --linux --x64 --dir env: EP_GH_IGNORE_TIME: true @@ -331,25 +245,28 @@ jobs: PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke - name: Upload packaged smoke diagnostics - if: >- - env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.desktop_package != 'false') uses: actions/upload-artifact@v4 + if: ${{ failure() && runner.os == 'Linux' }} with: name: desktop-packaged-smoke-linux-x64 path: ${{ runner.temp }}/desktop-smoke if-no-files-found: ignore retention-days: 7 - app-tests: + desktop-tests-windows: + name: desktop-tests (windows-latest) needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.app != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }} + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: read + steps: *desktop_test_steps + + app-tests: + name: app-tests + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -363,6 +280,7 @@ jobs: - name: Install dependencies with retry run: node scripts/npm-retry.mjs ci + - name: Install Playwright browsers timeout-minutes: 10 run: npx playwright install chromium @@ -374,12 +292,9 @@ jobs: run: npm run test --workspace=@getpaseo/app sdk-tests: + name: sdk-tests needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.sdk != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -393,6 +308,7 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Build client dependencies run: npm run build:client @@ -402,90 +318,97 @@ jobs: - 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: + playwright-1: + name: playwright (shard 1/4) needs: changes - if: ${{ !cancelled() }} - strategy: - fail-fast: false - matrix: - include: - - { label: "shard 1/4", shard: 1, desktop: false } - - { label: "shard 2/4", shard: 2, desktop: false } - - { label: "shard 3/4", shard: 3, desktop: false } - - { label: "shard 4/4", shard: 4, desktop: false } - - { label: "desktop overlay", shard: "desktop", desktop: true } - name: playwright (${{ matrix.label }}) + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - RUN_TESTS: >- - ${{ github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.playwright != 'false' }} - steps: - - name: Skip unaffected Playwright tests - if: env.RUN_TESTS != 'true' - run: echo "No Playwright changes detected." - + PLAYWRIGHT_SHARD: "1/4" + PLAYWRIGHT_ARTIFACT: "1" + steps: &playwright_test_steps - uses: actions/checkout@v4 - if: env.RUN_TESTS == 'true' - uses: actions/setup-node@v4 - if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Install dependencies with retry - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci + - name: Install Playwright browsers - if: env.RUN_TESTS == 'true' timeout-minutes: 10 run: npx playwright install chromium - name: Build app dependencies - if: env.RUN_TESTS == 'true' run: npm run build:app-deps - name: Build server stack - if: env.RUN_TESTS == 'true' run: npm run build:server - name: Install agent CLIs for provider tests - if: env.RUN_TESTS == 'true' && !matrix.desktop run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - name: Run Playwright E2E tests - if: env.RUN_TESTS == 'true' && !matrix.desktop - run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4 + run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - - name: Run desktop-overlay Playwright tests - if: env.RUN_TESTS == 'true' && matrix.desktop - run: npm run test:e2e:desktop --workspace=@getpaseo/app - - name: Upload test artifacts uses: actions/upload-artifact@v4 - if: env.RUN_TESTS == 'true' && failure() + if: failure() with: - name: playwright-results-${{ matrix.shard }} + name: playwright-results-${{ env.PLAYWRIGHT_ARTIFACT }} path: | packages/app/test-results/ packages/app/playwright-report/ retention-days: 7 - relay-tests: + playwright-2: + name: playwright (shard 2/4) needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.relay != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PLAYWRIGHT_SHARD: "2/4" + PLAYWRIGHT_ARTIFACT: "2" + steps: *playwright_test_steps + + playwright-3: + name: playwright (shard 3/4) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PLAYWRIGHT_SHARD: "3/4" + PLAYWRIGHT_ARTIFACT: "3" + steps: *playwright_test_steps + + playwright-4: + name: playwright (shard 4/4) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PLAYWRIGHT_SHARD: "4/4" + PLAYWRIGHT_ARTIFACT: "4" + steps: *playwright_test_steps + + relay-tests: + name: relay-tests + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -506,53 +429,62 @@ jobs: - name: Run relay tests run: npm run test --workspace=@getpaseo/relay - cli-tests: + cli-tests-1: + name: cli-tests (shard 1/3) needs: changes - if: ${{ !cancelled() }} - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest - name: cli-tests (shard ${{ matrix.shard }}/3) env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - RUN_TESTS: >- - ${{ github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.cli != 'false' }} - steps: - - name: Skip unaffected CLI tests - if: env.RUN_TESTS != 'true' - run: echo "No CLI changes detected." - + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" + PASEO_DICTATION_ENABLED: "0" + PASEO_VOICE_MODE_ENABLED: "0" + PASEO_CLI_TEST_SHARD: "1" + PASEO_CLI_TEST_SHARD_TOTAL: "3" + steps: &cli_test_steps - uses: actions/checkout@v4 - if: env.RUN_TESTS == 'true' - uses: actions/setup-node@v4 - if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Install dependencies - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci - name: Build server stack - if: env.RUN_TESTS == 'true' run: npm run build:server - name: Install agent CLIs for provider tests - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - name: Run CLI tests - if: env.RUN_TESTS == 'true' run: npm run test --workspace=@getpaseo/cli - env: - PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" - PASEO_DICTATION_ENABLED: "0" - PASEO_VOICE_MODE_ENABLED: "0" - PASEO_CLI_TEST_SHARD: ${{ matrix.shard }} - PASEO_CLI_TEST_SHARD_TOTAL: "3" + + cli-tests-2: + name: cli-tests (shard 2/3) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" + PASEO_DICTATION_ENABLED: "0" + PASEO_VOICE_MODE_ENABLED: "0" + PASEO_CLI_TEST_SHARD: "2" + PASEO_CLI_TEST_SHARD_TOTAL: "3" + steps: *cli_test_steps + + cli-tests-3: + name: cli-tests (shard 3/3) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" + PASEO_DICTATION_ENABLED: "0" + PASEO_VOICE_MODE_ENABLED: "0" + PASEO_CLI_TEST_SHARD: "3" + PASEO_CLI_TEST_SHARD_TOTAL: "3" + steps: *cli_test_steps diff --git a/.oxlintrc.json b/.oxlintrc.json index 6948690ca..5fe29499a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -188,9 +188,9 @@ "packages/app/src/components/agent-list.tsx", "packages/app/src/components/agent-status-dot.tsx", "packages/app/src/components/attachment-lightbox.tsx", - "packages/app/src/components/browser-pane.electron.tsx", - "packages/app/src/components/browser-pane.tsx", - "packages/app/src/components/browser-pane.web.tsx", + "packages/app/src/desktop/browser/pane/index.electron.tsx", + "packages/app/src/desktop/browser/pane/index.tsx", + "packages/app/src/desktop/browser/pane/index.web.tsx", "packages/app/src/components/command-center.tsx", "packages/app/src/components/context-window-meter.tsx", "packages/app/src/components/dictation-controls.tsx", @@ -279,7 +279,7 @@ } }, { - "files": ["**/e2e/fixtures.ts"], + "files": ["**/e2e/**/fixtures.ts"], "rules": { "no-empty-pattern": "off" } diff --git a/CLAUDE.md b/CLAUDE.md index 4ae9ebe89..44e1a7ae8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,12 +162,12 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically. - **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`. ``` - components/ - browser-pane.electron.tsx ← Electron implementation - browser-pane.web.tsx ← plain web fallback - browser-pane.tsx ← native fallback + desktop/browser/pane/ + index.electron.tsx ← Electron implementation + index.web.tsx ← plain web fallback + index.tsx ← native fallback ``` - Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation. + Import as `@/desktop/browser/pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation. - **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only. - **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS. - **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web. diff --git a/docs/terminal-performance.md b/docs/terminal-performance.md index 1a5f4f9bd..6a6eba020 100644 --- a/docs/terminal-performance.md +++ b/docs/terminal-performance.md @@ -30,7 +30,7 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev - **Node-only benchmark (fast iteration, server pipeline):** `npx tsx scripts/benchmark-terminal-latency.ts`. Boots an isolated daemon (fresh `PASEO_HOME`, random port — never 6767), measures echo latency percentiles, burst jitter, and snapshot counts under ramped mock-agent load. Writes JSON to `/tmp/paseo-terminal-bench/`. Healthy numbers (2026-06): echo p50 ~2.3ms, p95 ~3.3ms, a 2MB burst fully streamed with `snap=0`. - **Browser perf specs (user-perceived path):** gated behind `PASEO_TERMINAL_PERF_E2E=1` — - `packages/app/e2e/terminal-performance.spec.ts` and `packages/app/e2e/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst. + `packages/app/e2e/browser/terminal-performance.spec.ts` and `packages/app/e2e/browser/terminal-keystroke-stress.spec.ts` (per-stage keydown→xterm-commit breakdown under mock-agent load). Healthy: keydown→commit p50 ~18ms under 600-key burst. - **Production:** grep `daemon.log` for `ws_runtime_metrics` and read `eventLoopDelay` + `bufferedAmount`. - **Git pressure:** the same log line includes `git.commands` (limiter occupancy, queue age, queue wait, execution time, failures, timeouts, and top operations), diff --git a/docs/testing.md b/docs/testing.md index 876971979..782442dbf 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -124,14 +124,14 @@ PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \ npm run build:desktop -- --publish never --linux --x64 --dir ``` -### Browser tab bridge regression +### Desktop browser regression -The desktop browser tab bridge E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click. +The desktop browser E2E launches an isolated real daemon, Metro, and Electron app. It forces workspace LRU eviction to reparent the original tab and replace its guest `WebContents`, then makes one MCP call each for tab listing, snapshot, and click against that original browser id. A final MCP wait proves the real target page received the click. -Run it locally with the same command owned by the Ubuntu leg of the existing `desktop-tests` CI check: +Run it locally with the same command owned by the Ubuntu `desktop-tests` required check: ```bash -npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop +npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop ``` ## Test organization @@ -154,7 +154,7 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b | `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` | | `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` | -App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials. +Browser Playwright specs live in `packages/app/e2e/browser/`. Desktop Playwright and real-Electron E2E live in `packages/desktop/e2e/`. Harness code shared by both suites lives in `packages/app/e2e/support/`; neither suite may place specs there. App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default browser project ignores that suffix so CI does not need provider credentials. Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI. @@ -175,9 +175,18 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es - Never re-run a suite another agent already reported green. - For full-suite confidence, push to CI and check GitHub Actions. - Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow. -- App Playwright specs share one isolated daemon per run. Helpers that create projects or workspaces must remove the daemon project record during cleanup, not only delete the temp directory. Agent helpers must pass the intended `workspaceId` through to agent creation; never infer ownership from `cwd`. -- CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads. -- Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence. +- App Playwright shares one warmed Metro server per run and gives every Playwright worker its own isolated daemon and `PASEO_HOME`. Spec files run concurrently without exposing one file's projects, agents, terminals, history, or provider configuration to another worker; tests within a file remain together so file-level setup is not repeated. +- 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. + +## Pull-request test routing + +PR checks are routed by the behavior each suite proves, using `.github/ci-paths.yml`. A package does not inherit every test suite of its runtime consumers: app changes do not run CLI or Electron-wrapper tests, and protocol changes do not run every package that imports the protocol. Cross-package static compatibility belongs to `typecheck`; full integration coverage runs after merge on main and in manual CI runs. + +Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name. + +The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer, real-Electron, and packaged-app coverage together in the Ubuntu leg. CLI-side Hub changes select one focused test inside the existing required server jobs. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs. ## Agent authentication in tests diff --git a/packages/app/e2e/00-sessions-empty.spec.ts b/packages/app/e2e/00-sessions-empty.spec.ts deleted file mode 100644 index 48cbb9e87..000000000 --- a/packages/app/e2e/00-sessions-empty.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -// "00-" prefix is intentional: this file must sort before every other spec. -// Sessions history is daemon-global — any agent created by a prior spec hides the empty state. -// If the beforeAll probe below fails, a spec sorted before this file is creating agents. -import { test } from "./fixtures"; -import { connectSeedClient } from "./helpers/seed-client"; -import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab"; - -test.describe("Sessions screen empty state", () => { - test.beforeAll(async () => { - const client = await connectSeedClient(); - try { - const history = await client.fetchAgentHistory({ page: { limit: 1 } }); - if (history.entries.length > 0) { - throw new Error( - `Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` + - `Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` + - `or the daemon has stale history from a previous run.`, - ); - } - } finally { - await client.close().catch(() => undefined); - } - }); - - test("shows empty placeholder when there is no session history", async ({ - page, - withWorkspace, - }) => { - const workspace = await withWorkspace({ prefix: "sessions-empty-" }); - await workspace.navigateTo(); - await openSessions(page); - await expectSessionsEmptyState(page); - }); -}); diff --git a/packages/app/e2e/acp-provider-catalog.spec.ts b/packages/app/e2e/acp-provider-catalog.spec.ts deleted file mode 100644 index 7671d325c..000000000 --- a/packages/app/e2e/acp-provider-catalog.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { test } from "./fixtures"; -import { gotoAppShell, openSettings } from "./helpers/app"; -import { getServerId } from "./helpers/server-id"; -import { - expectProviderInstalledInSettings, - installAcpCatalogProvider, - openAddProviderArea, - openSettingsHost, - openSettingsHostSection, -} from "./helpers/settings"; - -const ACP_PROVIDER = { - id: "hermes", - name: "Hermes", -}; - -test.describe("ACP provider catalog", () => { - test("adds a catalog provider from settings", async ({ page }) => { - await gotoAppShell(page); - await openSettings(page); - await openSettingsHost(page, getServerId()); - // Providers moved to their own host section; add-provider lives there now. - await openSettingsHostSection(page, getServerId(), "providers"); - await openAddProviderArea(page); - - await installAcpCatalogProvider(page, ACP_PROVIDER.name); - await expectProviderInstalledInSettings(page, ACP_PROVIDER.name); - }); -}); diff --git a/packages/app/e2e/browser/acp-provider-catalog.spec.ts b/packages/app/e2e/browser/acp-provider-catalog.spec.ts new file mode 100644 index 000000000..52f433799 --- /dev/null +++ b/packages/app/e2e/browser/acp-provider-catalog.spec.ts @@ -0,0 +1,43 @@ +import { test } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { getServerId } from "../support/helpers/server-id"; +import { + expectProviderInstalledInSettings, + installAcpCatalogProvider, + openAddProviderArea, + openSettingsHost, + openSettingsHostSection, +} from "../support/helpers/settings"; + +const ACP_PROVIDER = { + id: "hermes", + name: "Hermes", +}; + +interface ProviderCatalogDaemonClient { + connect(): Promise; + close(): Promise; + patchDaemonConfig(config: { removeProviders?: string[] }): Promise; +} + +test.describe("ACP provider catalog", () => { + test("adds a catalog provider from settings", async ({ page }) => { + const client = await connectDaemonClient({ + clientIdPrefix: "provider-catalog-e2e", + }); + try { + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await openAddProviderArea(page); + + await installAcpCatalogProvider(page, ACP_PROVIDER.name); + await expectProviderInstalledInSettings(page, ACP_PROVIDER.name); + } finally { + await client.patchDaemonConfig({ removeProviders: [ACP_PROVIDER.id] }).catch(() => undefined); + await client.close().catch(() => undefined); + } + }); +}); diff --git a/packages/app/e2e/add-changed-file-to-chat.spec.ts b/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts similarity index 92% rename from packages/app/e2e/add-changed-file-to-chat.spec.ts rename to packages/app/e2e/browser/add-changed-file-to-chat.spec.ts index 6b5f55001..9307aef33 100644 --- a/packages/app/e2e/add-changed-file-to-chat.spec.ts +++ b/packages/app/e2e/browser/add-changed-file-to-chat.spec.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { test, expect, type Page } from "./fixtures"; -import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent"; +import { test, expect, type Page } from "../support/fixtures"; +import { seedMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent"; function visibleComposer(page: Page) { return page.locator("textarea[data-composer-input]").filter({ visible: true }).first(); diff --git a/packages/app/e2e/add-project-flow.spec.ts b/packages/app/e2e/browser/add-project-flow.spec.ts similarity index 96% rename from packages/app/e2e/add-project-flow.spec.ts rename to packages/app/e2e/browser/add-project-flow.spec.ts index 51ef13f78..3d5bea938 100644 --- a/packages/app/e2e/add-project-flow.spec.ts +++ b/packages/app/e2e/browser/add-project-flow.spec.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { test, expect } from "./fixtures"; +import { test, expect } from "../support/fixtures"; import { addProjectFlow, addProjectFlowBack, @@ -13,17 +13,20 @@ import { expectAddProjectPage, expectNewWorkspaceForAddedProject, openAddProjectFlow, -} from "./helpers/add-project-flow"; -import { gotoAppShell } from "./helpers/app"; +} from "../support/helpers/add-project-flow"; +import { gotoAppShell } from "../support/helpers/app"; import { addConnectedHostAndReload, addOfflineHostAndReload, waitForConnectedHost, -} from "./helpers/hosts"; -import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; -import { expectOpenedProject } from "./helpers/project-picker-ui"; -import { connectSeedClient } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +} from "../support/helpers/hosts"; +import { + type IsolatedHostDaemon, + startIsolatedHostDaemon, +} from "../support/helpers/isolated-host-daemon"; +import { expectOpenedProject } from "../support/helpers/project-picker-ui"; +import { connectSeedClient } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; const SECONDARY_HOST_ID = "add-project-flow-secondary"; const SECONDARY_HOST_LABEL = "Secondary Host"; diff --git a/packages/app/e2e/add-project-github.real.spec.ts b/packages/app/e2e/browser/add-project-github.real.spec.ts similarity index 87% rename from packages/app/e2e/add-project-github.real.spec.ts rename to packages/app/e2e/browser/add-project-github.real.spec.ts index acb7eaab4..4285f05e7 100644 --- a/packages/app/e2e/add-project-github.real.spec.ts +++ b/packages/app/e2e/browser/add-project-github.real.spec.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "./fixtures"; +import { expect, test } from "../support/fixtures"; import { addProjectFlow, addProjectFlowBack, @@ -10,12 +10,16 @@ import { expectAddProjectPage, expectNewWorkspaceForAddedProject, openAddProjectFlow, -} from "./helpers/add-project-flow"; -import { gotoAppShell } from "./helpers/app"; -import { createTempGithubRepo, hasGithubAuth, type GhRepoFixture } from "./helpers/github-fixtures"; -import { expectOpenedProject } from "./helpers/project-picker-ui"; -import { connectSeedClient } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +} from "../support/helpers/add-project-flow"; +import { gotoAppShell } from "../support/helpers/app"; +import { + createTempGithubRepo, + hasGithubAuth, + type GhRepoFixture, +} from "../support/helpers/github-fixtures"; +import { expectOpenedProject } from "../support/helpers/project-picker-ui"; +import { connectSeedClient } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; test.describe("Add Project GitHub flow", () => { test.describe.configure({ timeout: 300_000 }); diff --git a/packages/app/e2e/agent-consecutive-turns.spec.ts b/packages/app/e2e/browser/agent-consecutive-turns.spec.ts similarity index 98% rename from packages/app/e2e/agent-consecutive-turns.spec.ts rename to packages/app/e2e/browser/agent-consecutive-turns.spec.ts index 7e291f06f..26ee605ea 100644 --- a/packages/app/e2e/agent-consecutive-turns.spec.ts +++ b/packages/app/e2e/browser/agent-consecutive-turns.spec.ts @@ -1,20 +1,20 @@ import type { Page } from "@playwright/test"; -import { expect, test as baseTest } from "./fixtures"; -import { expectAgentIdle } from "./helpers/agent-stream"; +import { expect, test as baseTest } from "../support/fixtures"; +import { expectAgentIdle } from "../support/helpers/agent-stream"; import { attachImageFromMenu, expectAttachmentPill, expectComposerVisible, submitMessage, -} from "./helpers/composer"; -import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; -import { seedWorkspace } from "./helpers/seed-client"; +} from "../support/helpers/composer"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace, -} from "./helpers/mock-agent"; -import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +} from "../support/helpers/mock-agent"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; interface FrameRect { top: number; diff --git a/packages/app/e2e/agent-message-rewind.spec.ts b/packages/app/e2e/browser/agent-message-rewind.spec.ts similarity index 94% rename from packages/app/e2e/agent-message-rewind.spec.ts rename to packages/app/e2e/browser/agent-message-rewind.spec.ts index b0797786f..78752a353 100644 --- a/packages/app/e2e/agent-message-rewind.spec.ts +++ b/packages/app/e2e/browser/agent-message-rewind.spec.ts @@ -1,13 +1,13 @@ import type { Locator } from "@playwright/test"; -import { expect, test, type Page } from "./fixtures"; -import { expectAgentIdle, expectAgentReadyToInterrupt } from "./helpers/agent-stream"; +import { expect, test, type Page } from "../support/fixtures"; +import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream"; import { cancelAgent, expectComposerDraft, expectComposerVisible, submitMessage, -} from "./helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +} from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; async function completeSubmittedTurn( page: Page, diff --git a/packages/app/e2e/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts similarity index 98% rename from packages/app/e2e/agent-message-submission.spec.ts rename to packages/app/e2e/browser/agent-message-submission.spec.ts index fccf211fb..6a8d60e9c 100644 --- a/packages/app/e2e/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -1,5 +1,5 @@ import type { Locator, Page } from "@playwright/test"; -import { expect, test as baseTest } from "./fixtures"; +import { expect, test as baseTest } from "../support/fixtures"; import { awaitToolCall, expectAgentIdle, @@ -7,8 +7,8 @@ import { expectAgentSurfacesIdle, expectRunningAgentChrome, expectVisibleAgentSurfacesIdle, -} from "./helpers/agent-stream"; -import { gateNextAgentMessage } from "./helpers/agent-message-gate"; +} from "../support/helpers/agent-stream"; +import { gateNextAgentMessage } from "../support/helpers/agent-message-gate"; import { attachImageFromMenu, expectComposerDraft, @@ -21,26 +21,26 @@ import { sendDraftToQueue, startRunningMockAgent, submitMessage, -} from "./helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; -import { seedWorkspace } from "./helpers/seed-client"; -import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; -import { getServerId } from "./helpers/server-id"; +} from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { getServerId } from "../support/helpers/server-id"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; -import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; -import { selectModel } from "./helpers/app"; +import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { selectModel } from "../support/helpers/app"; import { scrollTimelineToNewestLoadedEdge, scrollTimelineUntilOlderHistoryIsReachable, -} from "./helpers/timeline-pagination"; +} from "../support/helpers/timeline-pagination"; import { appendSettledTimelineTurns, createSettledMockAgent, createSmallAssistantPng, emitSettledAssistantImage, expectAssistantImageRendered, -} from "./helpers/assistant-images"; +} from "../support/helpers/assistant-images"; const IMAGE = { name: "message-submission.png", diff --git a/packages/app/e2e/agent-stream-ui.spec.ts b/packages/app/e2e/browser/agent-stream-ui.spec.ts similarity index 95% rename from packages/app/e2e/agent-stream-ui.spec.ts rename to packages/app/e2e/browser/agent-stream-ui.spec.ts index 45f9cb9df..229536893 100644 --- a/packages/app/e2e/agent-stream-ui.spec.ts +++ b/packages/app/e2e/browser/agent-stream-ui.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "./fixtures"; +import { test, expect } from "../support/fixtures"; import { awaitAssistantMessage, expectAgentIdle, @@ -6,7 +6,7 @@ import { expectRunningAgentChrome, expectTurnCopyButton, expectScrollFollowsNewContent, -} from "./helpers/agent-stream"; +} from "../support/helpers/agent-stream"; import { expectScrollStaysFixed, clickToolCallBesideScrollToBottomButton, @@ -14,16 +14,16 @@ import { scrollAgentChatToBottom, scrollChatAwayFromBottom, waitForScrollableChat, -} from "./helpers/agent-bottom-anchor"; -import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate"; -import { selectModel } from "./helpers/app"; -import { clickNewChat } from "./helpers/launcher"; -import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer"; +} from "../support/helpers/agent-bottom-anchor"; +import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate"; +import { selectModel } from "../support/helpers/app"; +import { clickNewChat } from "../support/helpers/launcher"; +import { expectComposerVisible, startRunningMockAgent } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace, seedRunningMockAgentWorkspace, -} from "./helpers/mock-agent"; +} from "../support/helpers/mock-agent"; const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360; diff --git a/packages/app/e2e/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts similarity index 94% rename from packages/app/e2e/agent-tab-image-stability.spec.ts rename to packages/app/e2e/browser/agent-tab-image-stability.spec.ts index 6ad5d0550..08014cfe4 100644 --- a/packages/app/e2e/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -1,4 +1,4 @@ -import { test as base } from "./fixtures"; +import { test as base } from "../support/fixtures"; import { appendSettledTimelineTurns, createNearTenMegabyteAssistantPng, @@ -13,8 +13,8 @@ import { sendFollowUpAndExpectVisibleResponse, switchAwayAndBackWithoutImageInstability, userPagesUntilAssistantImageRenders, -} from "./helpers/assistant-images"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +} from "../support/helpers/assistant-images"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ imageWorkspace: async ({ page: _page }, provide) => { diff --git a/packages/app/e2e/agent-timeline-pagination.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts similarity index 98% rename from packages/app/e2e/agent-timeline-pagination.spec.ts rename to packages/app/e2e/browser/agent-timeline-pagination.spec.ts index 8cb1fdca6..86ca233f5 100644 --- a/packages/app/e2e/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "./fixtures"; +import { expect, test } from "../support/fixtures"; import { expectSameOlderHistoryLoadingOperation, expectStableHistoryStartGutter, @@ -26,7 +26,7 @@ import { expectTimelineViewportAnchoredAfterPrepend, userNavigatesTimelineToHistoryStartWithKeyboard, userScrollsTimelineToHistoryStart, -} from "./helpers/timeline-pagination"; +} from "../support/helpers/timeline-pagination"; test.describe("Agent timeline pagination", () => { test("keeps a fixed history-start gutter before, during, and after pagination", async ({ diff --git a/packages/app/e2e/agent-timeline-resume.spec.ts b/packages/app/e2e/browser/agent-timeline-resume.spec.ts similarity index 91% rename from packages/app/e2e/agent-timeline-resume.spec.ts rename to packages/app/e2e/browser/agent-timeline-resume.spec.ts index e21e3a186..7691f6543 100644 --- a/packages/app/e2e/agent-timeline-resume.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-resume.spec.ts @@ -1,5 +1,5 @@ -import { test } from "./fixtures"; -import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { test } from "../support/fixtures"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { expectTimelinePresentationUnchanged, expectTimelinePromptNotMounted, @@ -9,14 +9,14 @@ import { scrollTimelinePromptIntoView, scrollTimelineUntilOlderHistoryIsReachable, seedLongMockAgentTimeline, -} from "./helpers/timeline-pagination"; +} from "../support/helpers/timeline-pagination"; import { commitTimelineTurnsWhileDisconnected, disconnectViewedTimeline, expectOneTailCheckWithoutCatchUpPages, rememberTimelineRequestCounts, restoreViewedTimelineWithHeldTail, -} from "./helpers/timeline-resume"; +} from "../support/helpers/timeline-resume"; test.describe("Agent timeline resume", () => { test("a long disconnected run lands as one latest-tail update", async ({ page }) => { diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/browser/archive-tab.spec.ts similarity index 94% rename from packages/app/e2e/archive-tab.spec.ts rename to packages/app/e2e/browser/archive-tab.spec.ts index 3daaaef53..e250489a9 100644 --- a/packages/app/e2e/archive-tab.spec.ts +++ b/packages/app/e2e/browser/archive-tab.spec.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import { expect } from "@playwright/test"; -import { test } from "./fixtures"; -import { getServerId } from "./helpers/server-id"; -import { connectSeedClient } from "./helpers/seed-client"; -import { createTempGitRepo } from "./helpers/workspace"; +import { test } from "../support/fixtures"; +import { getServerId } from "../support/helpers/server-id"; +import { connectSeedClient } from "../support/helpers/seed-client"; +import { createTempGitRepo } from "../support/helpers/workspace"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { archiveAgentFromDaemon, @@ -20,7 +20,7 @@ import { primeAdditionalPage, resetSeededPageState, reloadWorkspace, -} from "./helpers/archive-tab"; +} from "../support/helpers/archive-tab"; test.describe("Archive tab reconciliation", () => { let client: Awaited>; diff --git a/packages/app/e2e/archived-codex-agent.real.spec.ts b/packages/app/e2e/browser/archived-codex-agent.real.spec.ts similarity index 91% rename from packages/app/e2e/archived-codex-agent.real.spec.ts rename to packages/app/e2e/browser/archived-codex-agent.real.spec.ts index 76b2b7ed6..6d079fe01 100644 --- a/packages/app/e2e/archived-codex-agent.real.spec.ts +++ b/packages/app/e2e/browser/archived-codex-agent.real.spec.ts @@ -1,18 +1,18 @@ import { mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { test, expect } from "./fixtures"; -import { openSessions } from "./helpers/archive-tab"; +import { test, expect } from "../support/fixtures"; +import { openSessions } from "../support/helpers/archive-tab"; import { assertChatTranscript, cleanupRewindFlow, launchAgent, sendMessage, type AgentHandle, -} from "./helpers/rewind-flow"; -import type { SeedDaemonClient } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +} from "../support/helpers/rewind-flow"; +import type { SeedDaemonClient } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; interface TimelineClient extends SeedDaemonClient { fetchAgentTimeline( diff --git a/packages/app/e2e/assistant-fork-menu.spec.ts b/packages/app/e2e/browser/assistant-fork-menu.spec.ts similarity index 92% rename from packages/app/e2e/assistant-fork-menu.spec.ts rename to packages/app/e2e/browser/assistant-fork-menu.spec.ts index 9d26c00a2..2819347ab 100644 --- a/packages/app/e2e/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/browser/assistant-fork-menu.spec.ts @@ -1,5 +1,5 @@ -import { expect, test as base } from "./fixtures"; -import { awaitAssistantMessage } from "./helpers/agent-stream"; +import { expect, test as base } from "../support/fixtures"; +import { awaitAssistantMessage } from "../support/helpers/agent-stream"; import { expectChatHistoryAttachment, expectInFlightForkAvailable, @@ -8,18 +8,18 @@ import { forkMostRecentAssistantTurnToNewTab, forkMostRecentAssistantTurnToNewWorkspace, observeForkAttachment, -} from "./helpers/assistant-fork"; -import { expectComposerVisible, submitMessage } from "./helpers/composer"; -import { getE2EDaemonPort } from "./helpers/daemon-port"; +} from "../support/helpers/assistant-fork"; +import { expectComposerVisible, submitMessage } from "../support/helpers/composer"; +import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentOptions, type MockAgentWorkspace, -} from "./helpers/mock-agent"; -import { getServerId } from "./helpers/server-id"; -import { seedSavedSettingsHosts } from "./helpers/settings"; -import { submitNewWorkspaceEmpty } from "./helpers/new-workspace"; +} from "../support/helpers/mock-agent"; +import { getServerId } from "../support/helpers/server-id"; +import { seedSavedSettingsHosts } from "../support/helpers/settings"; +import { submitNewWorkspaceEmpty } from "../support/helpers/new-workspace"; const test = base.extend<{ seedForkWorkspace: (options: MockAgentOptions) => Promise; diff --git a/packages/app/e2e/auto-archive-after-merge.real.spec.ts b/packages/app/e2e/browser/auto-archive-after-merge.real.spec.ts similarity index 90% rename from packages/app/e2e/auto-archive-after-merge.real.spec.ts rename to packages/app/e2e/browser/auto-archive-after-merge.real.spec.ts index bd68c77b5..84f065702 100644 --- a/packages/app/e2e/auto-archive-after-merge.real.spec.ts +++ b/packages/app/e2e/browser/auto-archive-after-merge.real.spec.ts @@ -1,15 +1,25 @@ import { existsSync } from "node:fs"; -import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { clickSessionRow, expectSessionRowArchived, openSessions } from "./helpers/archive-tab"; -import { cloneGithubRepoDefaultBranchOnly, createTempGithubRepo } from "./helpers/github-fixtures"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + clickSessionRow, + expectSessionRowArchived, + openSessions, +} from "../support/helpers/archive-tab"; +import { + cloneGithubRepoDefaultBranchOnly, + createTempGithubRepo, +} from "../support/helpers/github-fixtures"; import { archiveWorkspaceFromDaemon, connectNewWorkspaceDaemonClient, -} from "./helpers/new-workspace"; -import { connectSeedClient } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; +} from "../support/helpers/new-workspace"; +import { connectSeedClient } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { + waitForSidebarHydration, + waitForWorkspaceInSidebar, +} from "../support/helpers/workspace-ui"; test.describe("Auto-archive after pull request merge", () => { test.describe.configure({ retries: 0, timeout: 180_000 }); diff --git a/packages/app/e2e/bottom-sheet-reopen.spec.ts b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts similarity index 95% rename from packages/app/e2e/bottom-sheet-reopen.spec.ts rename to packages/app/e2e/browser/bottom-sheet-reopen.spec.ts index 10ad84ff2..5e6abb0ad 100644 --- a/packages/app/e2e/bottom-sheet-reopen.spec.ts +++ b/packages/app/e2e/browser/bottom-sheet-reopen.spec.ts @@ -1,6 +1,6 @@ -import { expect, test, type Page } from "./fixtures"; -import { expectComposerVisible } from "./helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { expect, test, type Page } from "../support/fixtures"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; const MOBILE_VIEWPORT = { width: 390, height: 844 }; diff --git a/packages/app/e2e/branch-switcher.spec.ts b/packages/app/e2e/browser/branch-switcher.spec.ts similarity index 90% rename from packages/app/e2e/branch-switcher.spec.ts rename to packages/app/e2e/browser/branch-switcher.spec.ts index e80f38b27..6e81e9302 100644 --- a/packages/app/e2e/branch-switcher.spec.ts +++ b/packages/app/e2e/browser/branch-switcher.spec.ts @@ -1,15 +1,18 @@ -import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { expectNoBranchSwitcherInWorkspaceHeader, expectWorkspaceBranch, openChangesPanel, switchBranchFromChangesPanel, -} from "./helpers/branch-switcher"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { readWorktreeBranchInfo } from "./helpers/workspace"; -import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui"; +} from "../support/helpers/branch-switcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { readWorktreeBranchInfo } from "../support/helpers/workspace"; +import { + switchWorkspaceViaSidebar, + waitForSidebarHydration, +} from "../support/helpers/workspace-ui"; async function renameWorkspaceViaSidebar( page: Page, diff --git a/packages/app/e2e/client-slash-commands.spec.ts b/packages/app/e2e/browser/client-slash-commands.spec.ts similarity index 95% rename from packages/app/e2e/client-slash-commands.spec.ts rename to packages/app/e2e/browser/client-slash-commands.spec.ts index 2937a9f34..a3b5a658b 100644 --- a/packages/app/e2e/client-slash-commands.spec.ts +++ b/packages/app/e2e/browser/client-slash-commands.spec.ts @@ -1,12 +1,12 @@ -import { expect, test, type Page } from "./fixtures"; -import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { expect, test, type Page } from "../support/fixtures"; +import { composerLocator, expectComposerVisible, submitMessage } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { expectSessionRowArchived, expectWorkspaceTabHidden, expectWorkspaceTabVisible, openSessions, -} from "./helpers/archive-tab"; +} from "../support/helpers/archive-tab"; interface SlashCommandScenario { agentId: string; diff --git a/packages/app/e2e/codex-plan-approval.spec.ts b/packages/app/e2e/browser/codex-plan-approval.spec.ts similarity index 80% rename from packages/app/e2e/codex-plan-approval.spec.ts rename to packages/app/e2e/browser/codex-plan-approval.spec.ts index ff2c9aedc..d9ef0c17b 100644 --- a/packages/app/e2e/codex-plan-approval.spec.ts +++ b/packages/app/e2e/browser/codex-plan-approval.spec.ts @@ -1,6 +1,6 @@ -import { expect, test } from "./fixtures"; -import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { expect, test } from "../support/fixtures"; +import { allowPermission, waitForPermissionPrompt } from "../support/helpers/permissions"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; test.describe("Codex plan approval", () => { test("shows a single actionable plan panel and removes it after implementation starts", async ({ diff --git a/packages/app/e2e/command-center-agent-controls.spec.ts b/packages/app/e2e/browser/command-center-agent-controls.spec.ts similarity index 87% rename from packages/app/e2e/command-center-agent-controls.spec.ts rename to packages/app/e2e/browser/command-center-agent-controls.spec.ts index 77479aeff..3021a242e 100644 --- a/packages/app/e2e/command-center-agent-controls.spec.ts +++ b/packages/app/e2e/browser/command-center-agent-controls.spec.ts @@ -1,5 +1,5 @@ -import { test } from "./fixtures"; -import { closeCommandCenter, openCommandCenter } from "./helpers/command-center"; +import { test } from "../support/fixtures"; +import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center"; import { applyCommandCenterAgentControls, chooseCommandCenterAgentControl, @@ -8,11 +8,11 @@ import { expectWorkspaceAgentConfiguration, submitDraftAgent, waitForDraftComposer, -} from "./helpers/command-center-agent-controls"; -import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +} from "../support/helpers/command-center-agent-controls"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; diff --git a/packages/app/e2e/command-center-host.spec.ts b/packages/app/e2e/browser/command-center-host.spec.ts similarity index 81% rename from packages/app/e2e/command-center-host.spec.ts rename to packages/app/e2e/browser/command-center-host.spec.ts index 652bca55f..f430969a8 100644 --- a/packages/app/e2e/command-center-host.spec.ts +++ b/packages/app/e2e/browser/command-center-host.spec.ts @@ -1,13 +1,13 @@ import { randomUUID } from "node:crypto"; import { expect } from "@playwright/test"; -import { test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { createIdleAgent } from "./helpers/archive-tab"; -import { openCommandCenter } from "./helpers/command-center"; -import { addOfflineHostAndReload } from "./helpers/hosts"; -import { expectAgentTabActive } from "./helpers/launcher"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +import { test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { createIdleAgent } from "../support/helpers/archive-tab"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { addOfflineHostAndReload } from "../support/helpers/hosts"; +import { expectAgentTabActive } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; const PRIMARY_HOST_LABEL = "Primary Host"; const SECONDARY_HOST_ID = "host-command-center-secondary"; diff --git a/packages/app/e2e/command-center-scroll.spec.ts b/packages/app/e2e/browser/command-center-scroll.spec.ts similarity index 88% rename from packages/app/e2e/command-center-scroll.spec.ts rename to packages/app/e2e/browser/command-center-scroll.spec.ts index dbfd57b79..598e35e59 100644 --- a/packages/app/e2e/command-center-scroll.spec.ts +++ b/packages/app/e2e/browser/command-center-scroll.spec.ts @@ -1,5 +1,5 @@ -import { test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { expectCommandCenterToRemainAtTheTop, expectPrimaryCommandCenterActions, @@ -8,8 +8,8 @@ import { expectWorkspaceResultsToBeLimitedUntilSearch, observeCommandCenterScroll, openCommandCenterWithKeyboard, -} from "./helpers/command-center-scroll"; -import { seedWorkspace } from "./helpers/seed-client"; +} from "../support/helpers/command-center-scroll"; +import { seedWorkspace } from "../support/helpers/seed-client"; test.use({ userAgent: diff --git a/packages/app/e2e/command-center-workspace-actions.spec.ts b/packages/app/e2e/browser/command-center-workspace-actions.spec.ts similarity index 75% rename from packages/app/e2e/command-center-workspace-actions.spec.ts rename to packages/app/e2e/browser/command-center-workspace-actions.spec.ts index 17d6cb03c..b24fa1cd4 100644 --- a/packages/app/e2e/command-center-workspace-actions.spec.ts +++ b/packages/app/e2e/browser/command-center-workspace-actions.spec.ts @@ -1,14 +1,14 @@ -import { test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { openCommandCenter } from "./helpers/command-center"; +import { test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { openCommandCenter } from "../support/helpers/command-center"; import { createTerminalFromCommandCenter, expectDefaultWorkspaceActions, expectWorkspaceActionsAvailableThroughSearch, makeWorkspacePrimaryGitActionCommit, openWorkspaceFromCommandCenter, -} from "./helpers/command-center-workspace-actions"; -import { seedWorkspace } from "./helpers/seed-client"; +} from "../support/helpers/command-center-workspace-actions"; +import { seedWorkspace } from "../support/helpers/seed-client"; test("workspace actions use the Git policy and dispatch through the active workspace", async ({ page, diff --git a/packages/app/e2e/command-center-workspaces.spec.ts b/packages/app/e2e/browser/command-center-workspaces.spec.ts similarity index 91% rename from packages/app/e2e/command-center-workspaces.spec.ts rename to packages/app/e2e/browser/command-center-workspaces.spec.ts index d515d3669..44892a499 100644 --- a/packages/app/e2e/command-center-workspaces.spec.ts +++ b/packages/app/e2e/browser/command-center-workspaces.spec.ts @@ -1,13 +1,13 @@ import { execFileSync } from "node:child_process"; import { expect } from "@playwright/test"; -import { test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { createIdleAgent } from "./helpers/archive-tab"; -import { openCommandCenter } from "./helpers/command-center"; -import { addOfflineHostAndReload } from "./helpers/hosts"; -import { expectAppRoute } from "./helpers/route-assertions"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +import { test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { createIdleAgent } from "../support/helpers/archive-tab"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { addOfflineHostAndReload } from "../support/helpers/hosts"; +import { expectAppRoute } from "../support/helpers/route-assertions"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; const PRIMARY_HOST_LABEL = "Primary Host"; diff --git a/packages/app/e2e/commit-diff-panel.spec.ts b/packages/app/e2e/browser/commit-diff-panel.spec.ts similarity index 98% rename from packages/app/e2e/commit-diff-panel.spec.ts rename to packages/app/e2e/browser/commit-diff-panel.spec.ts index f290b008b..1f658fac0 100644 --- a/packages/app/e2e/commit-diff-panel.spec.ts +++ b/packages/app/e2e/browser/commit-diff-panel.spec.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import { writeFile } from "node:fs/promises"; import path from "node:path"; -import { test, expect } from "./fixtures"; +import { test, expect } from "../support/fixtures"; const COMMIT_SUBJECT = "Show commit timestamps"; diff --git a/packages/app/e2e/composer-attachments.spec.ts b/packages/app/e2e/browser/composer-attachments.spec.ts similarity index 91% rename from packages/app/e2e/composer-attachments.spec.ts rename to packages/app/e2e/browser/composer-attachments.spec.ts index c270cf71b..8b72243df 100644 --- a/packages/app/e2e/composer-attachments.spec.ts +++ b/packages/app/e2e/browser/composer-attachments.spec.ts @@ -1,7 +1,7 @@ -import { expect, test } from "./fixtures"; -import { clickNewChat } from "./helpers/launcher"; -import { expectComposerVisible } from "./helpers/composer"; -import { expectAgentIdle } from "./helpers/agent-stream"; +import { expect, test } from "../support/fixtures"; +import { clickNewChat } from "../support/helpers/launcher"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { expectAgentIdle } from "../support/helpers/agent-stream"; import { openAttachmentMenu, openGithubPickerFromMenu, @@ -23,13 +23,19 @@ import { selectGithubOption, expectGithubAttachmentPill, openGithubWorkspace, -} from "./helpers/composer"; -import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace"; -import { gotoAppShell } from "./helpers/app"; -import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; -import { seedWorkspace } from "./helpers/seed-client"; -import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures"; -import { getServerId } from "./helpers/server-id"; +} from "../support/helpers/composer"; +import { + delayBrowserAgentCreatedStatus, + openNewWorkspaceComposer, +} from "../support/helpers/new-workspace"; +import { gotoAppShell } from "../support/helpers/app"; +import { + waitForSidebarHydration, + switchWorkspaceViaSidebar, +} from "../support/helpers/workspace-ui"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures"; +import { getServerId } from "../support/helpers/server-id"; const MINIMAL_PNG = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", @@ -144,11 +150,6 @@ test.describe("Composer attachments", () => { // needed before this can be exercised end-to-end. }); - test.fixme("browser-element attachment pill is created from Electron webview selection", async () => { - // The browser-element attachment is only created in browser-pane.electron.tsx via DOM - // element selection in the Electron webview. It is not exercisable in headless Chromium E2E. - }); - test("image lightbox opens on pill click and closes on Escape", async ({ page, withWorkspace, diff --git a/packages/app/e2e/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts similarity index 97% rename from packages/app/e2e/composer-autocomplete.spec.ts rename to packages/app/e2e/browser/composer-autocomplete.spec.ts index c8b19b31c..37d9ed436 100644 --- a/packages/app/e2e/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -1,14 +1,14 @@ -import { expect, test, type Page } from "./fixtures"; -import { composerLocator, expectComposerVisible } from "./helpers/composer"; +import { expect, test, type Page } from "../support/fixtures"; +import { composerLocator, expectComposerVisible } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace, -} from "./helpers/mock-agent"; -import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab"; -import { daemonWsRoutePattern } from "./helpers/daemon-port"; -import { getServerId } from "./helpers/server-id"; -import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; +} from "../support/helpers/mock-agent"; +import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/archive-tab"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { getServerId } from "../support/helpers/server-id"; +import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; const TEST_COMMANDS = [ { diff --git a/packages/app/e2e/diff-row-alignment.spec.ts b/packages/app/e2e/browser/diff-row-alignment.spec.ts similarity index 98% rename from packages/app/e2e/diff-row-alignment.spec.ts rename to packages/app/e2e/browser/diff-row-alignment.spec.ts index aff4789a0..b775371a3 100644 --- a/packages/app/e2e/diff-row-alignment.spec.ts +++ b/packages/app/e2e/browser/diff-row-alignment.spec.ts @@ -1,12 +1,12 @@ import { unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { type Page } from "@playwright/test"; -import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes"; -import { test, expect } from "./fixtures"; -import { getServerId } from "./helpers/server-id"; -import { connectSeedClient } from "./helpers/seed-client"; -import { createTempGitRepo } from "./helpers/workspace"; -import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes"; +import { test, expect } from "../support/fixtures"; +import { getServerId } from "../support/helpers/server-id"; +import { connectSeedClient } from "../support/helpers/seed-client"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; interface DirtyWorkspace { id: string; diff --git a/packages/app/e2e/directory-bootstrap.spec.ts b/packages/app/e2e/browser/directory-bootstrap.spec.ts similarity index 81% rename from packages/app/e2e/directory-bootstrap.spec.ts rename to packages/app/e2e/browser/directory-bootstrap.spec.ts index ad42b991f..6aa3a1a44 100644 --- a/packages/app/e2e/directory-bootstrap.spec.ts +++ b/packages/app/e2e/browser/directory-bootstrap.spec.ts @@ -1,5 +1,5 @@ -import { test } from "./fixtures"; -import { DirectoryBootstrapScenario } from "./helpers/directory-bootstrap-scenario"; +import { test } from "../support/fixtures"; +import { DirectoryBootstrapScenario } from "../support/helpers/directory-bootstrap-scenario"; test.describe("Directory bootstrap correctness", () => { test("connect, pushed deltas, and reconnect keep directories current without duplicate bootstraps", async ({ diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/browser/empty-project-persists.spec.ts similarity index 94% rename from packages/app/e2e/empty-project-persists.spec.ts rename to packages/app/e2e/browser/empty-project-persists.spec.ts index 9ce761ab3..fb2f98841 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/browser/empty-project-persists.spec.ts @@ -1,18 +1,18 @@ import path from "node:path"; import { existsSync } from "node:fs"; -import { test, expect, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { test, expect, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { addProjectFlowInput, chooseAddProjectMethod, openAddProjectFlow, -} from "./helpers/add-project-flow"; -import { expectOpenedProject } from "./helpers/project-picker-ui"; -import { connectSeedClient, seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { projectEquivalenceViewKey } from "./helpers/project-view-key"; -import { createTempGitRepo } from "./helpers/workspace"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +} from "../support/helpers/add-project-flow"; +import { expectOpenedProject } from "../support/helpers/project-picker-ui"; +import { connectSeedClient, seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; function workspaceRowTestId(workspaceId: string): string { return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; diff --git a/packages/app/e2e/file-change-conflicts.spec.ts b/packages/app/e2e/browser/file-change-conflicts.spec.ts similarity index 97% rename from packages/app/e2e/file-change-conflicts.spec.ts rename to packages/app/e2e/browser/file-change-conflicts.spec.ts index af031974b..976139f43 100644 --- a/packages/app/e2e/file-change-conflicts.spec.ts +++ b/packages/app/e2e/browser/file-change-conflicts.spec.ts @@ -1,14 +1,18 @@ import { mkdir, readFile, rename, rm, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; -import { expect, test, type Page } from "./fixtures"; -import { expectFileTabOpen, openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer"; -import type { WithWorkspace } from "./helpers/with-workspace"; -import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { expect, test, type Page } from "../support/fixtures"; +import { + expectFileTabOpen, + openFileExplorer, + openFileFromExplorer, +} from "../support/helpers/file-explorer"; +import type { WithWorkspace } from "../support/helpers/with-workspace"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { expectFileCalloutWasRendered, expectNoFileCalloutWasRendered, recordFileCallouts, -} from "./helpers/file-callouts"; +} from "../support/helpers/file-callouts"; function visibleEditor(page: Page) { return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"); diff --git a/packages/app/e2e/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts similarity index 98% rename from packages/app/e2e/file-editing.spec.ts rename to packages/app/e2e/browser/file-editing.spec.ts index 0e5db9d16..6e46b4da6 100644 --- a/packages/app/e2e/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -1,9 +1,13 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { expect, test, type Page } from "./fixtures"; -import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer"; -import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { expect, test, type Page } from "../support/fixtures"; +import { + openFileExplorer, + openFileFromExplorer, + expectFileTabOpen, +} from "../support/helpers/file-explorer"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; const RED_PIXEL = Buffer.from( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=", diff --git a/packages/app/e2e/file-explorer-collapse.spec.ts b/packages/app/e2e/browser/file-explorer-collapse.spec.ts similarity index 83% rename from packages/app/e2e/file-explorer-collapse.spec.ts rename to packages/app/e2e/browser/file-explorer-collapse.spec.ts index 830b63a6b..705f66726 100644 --- a/packages/app/e2e/file-explorer-collapse.spec.ts +++ b/packages/app/e2e/browser/file-explorer-collapse.spec.ts @@ -1,4 +1,4 @@ -import { test } from "./fixtures"; +import { test } from "../support/fixtures"; import { collapseFolder, expandFolder, @@ -7,9 +7,9 @@ import { expectFileTabOpen, openFileExplorer, openFileFromExplorer, -} from "./helpers/file-explorer"; -import { gotoWorkspace } from "./helpers/launcher"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +} from "../support/helpers/file-explorer"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; let workspace: SeededWorkspace; diff --git a/packages/app/e2e/import-session.opencode.real.spec.ts b/packages/app/e2e/browser/import-session.opencode.real.spec.ts similarity index 95% rename from packages/app/e2e/import-session.opencode.real.spec.ts rename to packages/app/e2e/browser/import-session.opencode.real.spec.ts index 2f5660f20..c72fccb95 100644 --- a/packages/app/e2e/import-session.opencode.real.spec.ts +++ b/packages/app/e2e/browser/import-session.opencode.real.spec.ts @@ -2,14 +2,14 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; import { spawn } from "node:child_process"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { expect, test, type Page } from "./fixtures"; -import { connectSeedClient, type SeededWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { expect, test, type Page } from "../support/fixtures"; +import { connectSeedClient, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; const OPENCODE_REAL_MODEL = "openrouter/google/gemini-2.5-flash-lite"; const OPENCODE_SEED_TIMEOUT_MS = 45_000; -const PASEO_REPO_PATH = path.resolve(__dirname, "../../.."); +const PASEO_REPO_PATH = path.resolve(__dirname, "../../../.."); interface OpenCodeSeedResult { stdout: string; diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/browser/launcher-tab.spec.ts similarity index 96% rename from packages/app/e2e/launcher-tab.spec.ts rename to packages/app/e2e/browser/launcher-tab.spec.ts index 15d916709..d9cff153c 100644 --- a/packages/app/e2e/launcher-tab.spec.ts +++ b/packages/app/e2e/browser/launcher-tab.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "./fixtures"; +import { test, expect } from "../support/fixtures"; import { gotoWorkspace, assertNewChatTileVisible, @@ -13,10 +13,13 @@ import { measureTileTransition, sampleTabsDuringTransition, terminalSurfaceLocator, -} from "./helpers/launcher"; -import { expectComposerVisible, composerLocator } from "./helpers/composer"; -import { expectTerminalSurfaceVisible, setupDeterministicPrompt } from "./helpers/terminal-perf"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +} from "../support/helpers/launcher"; +import { expectComposerVisible, composerLocator } from "../support/helpers/composer"; +import { + expectTerminalSurfaceVisible, + setupDeterministicPrompt, +} from "../support/helpers/terminal-perf"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; // ─── Shared state ────────────────────────────────────────────────────────── diff --git a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts similarity index 92% rename from packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts rename to packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts index 9b4fbf6e1..33247ab60 100644 --- a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts @@ -1,17 +1,17 @@ -import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { daemonWsRoutePattern } from "./helpers/daemon-port"; -import { openAgentRoute } from "./helpers/mock-agent"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { openAgentRoute } from "../support/helpers/mock-agent"; import { openGlobalNewWorkspaceComposer, selectNewWorkspaceProject, submitNewWorkspacePrompt, -} from "./helpers/new-workspace"; -import { expectNoTruncation } from "./helpers/no-truncation"; -import { escapeRegex } from "./helpers/regex"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +} from "../support/helpers/new-workspace"; +import { expectNoTruncation } from "../support/helpers/no-truncation"; +import { escapeRegex } from "../support/helpers/regex"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; diff --git a/packages/app/e2e/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts similarity index 83% rename from packages/app/e2e/new-workspace-composer-draft.spec.ts rename to packages/app/e2e/browser/new-workspace-composer-draft.spec.ts index d561f846b..1a3795136 100644 --- a/packages/app/e2e/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts @@ -1,6 +1,6 @@ -import { test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { expectNewWorkspaceDraft, expectNewWorkspaceProjectSelected, @@ -9,11 +9,11 @@ import { openNewWorkspaceComposer, selectNewWorkspaceHost, selectNewWorkspaceProject, -} from "./helpers/new-workspace"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { seedSavedSettingsHosts } from "./helpers/settings"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +} from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { seedSavedSettingsHosts } from "../support/helpers/settings"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const DRAFT = `Please investigate the workspace startup failure. diff --git a/packages/app/e2e/new-workspace-dictation-submit.spec.ts b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts similarity index 93% rename from packages/app/e2e/new-workspace-dictation-submit.spec.ts rename to packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts index b96f97dd0..d4444d6f2 100644 --- a/packages/app/e2e/new-workspace-dictation-submit.spec.ts +++ b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts @@ -1,9 +1,12 @@ -import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { daemonWsRoutePattern } from "./helpers/daemon-port"; -import { openNewWorkspaceComposer, selectWorkspaceIsolation } from "./helpers/new-workspace"; -import { seedWorkspace } from "./helpers/seed-client"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { + openNewWorkspaceComposer, + selectWorkspaceIsolation, +} from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; type WebSocketMessage = string | Buffer; diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/browser/new-workspace-entry.spec.ts similarity index 93% rename from packages/app/e2e/new-workspace-entry.spec.ts rename to packages/app/e2e/browser/new-workspace-entry.spec.ts index fae10ce82..1a1fcb5c2 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/browser/new-workspace-entry.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { connectNewWorkspaceDaemonClient, expectNewWorkspaceControlsEnabled, @@ -8,14 +8,17 @@ import { openMissingProjectNewWorkspaceComposer, openNewWorkspaceComposer, openNewWorkspaceProjectPickerWithShortcut, -} from "./helpers/new-workspace"; -import { getE2EDaemonPort } from "./helpers/daemon-port"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -import { seedSavedSettingsHosts } from "./helpers/settings"; -import { getServerId } from "./helpers/server-id"; -import { projectEquivalenceViewKey } from "./helpers/project-view-key"; -import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +} from "../support/helpers/new-workspace"; +import { getE2EDaemonPort } from "../support/helpers/daemon-port"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { seedSavedSettingsHosts } from "../support/helpers/settings"; +import { getServerId } from "../support/helpers/server-id"; +import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; +import { + clickArchiveWorkspaceMenuItem, + expectWorkspaceAbsentFromSidebar, +} from "../support/helpers/sidebar"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; // Model B entry points into the New Workspace screen. The surviving entries are // the global button (universal) and each project's per-row New workspace icon diff --git a/packages/app/e2e/new-workspace-host-project-preservation.spec.ts b/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts similarity index 91% rename from packages/app/e2e/new-workspace-host-project-preservation.spec.ts rename to packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts index 6ee95a900..13e752fd8 100644 --- a/packages/app/e2e/new-workspace-host-project-preservation.spec.ts +++ b/packages/app/e2e/browser/new-workspace-host-project-preservation.spec.ts @@ -1,17 +1,20 @@ -import { expect, test as base } from "./fixtures"; -import { openProjectDirectoryWithHosts } from "./helpers/project-grouping"; -import { projectPlacementViewKey } from "./helpers/project-view-key"; -import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; +import { expect, test as base } from "../support/fixtures"; +import { openProjectDirectoryWithHosts } from "../support/helpers/project-grouping"; +import { projectPlacementViewKey } from "../support/helpers/project-view-key"; +import { + type IsolatedHostDaemon, + startIsolatedHostDaemon, +} from "../support/helpers/isolated-host-daemon"; import { expectNewWorkspaceProjectSelected, openGlobalNewWorkspaceComposer, selectNewWorkspaceHost, selectNewWorkspaceProject, -} from "./helpers/new-workspace"; -import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; -import { createTempGitRepo } from "./helpers/workspace"; +} from "../support/helpers/new-workspace"; +import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui"; +import { createTempGitRepo } from "../support/helpers/workspace"; const PRIMARY_HOST_LABEL = "Primary host"; const SECONDARY_HOST_LABEL = "Secondary host"; diff --git a/packages/app/e2e/new-workspace-isolation-memory.spec.ts b/packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts similarity index 89% rename from packages/app/e2e/new-workspace-isolation-memory.spec.ts rename to packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts index 4f9cc0669..7ec3fd455 100644 --- a/packages/app/e2e/new-workspace-isolation-memory.spec.ts +++ b/packages/app/e2e/browser/new-workspace-isolation-memory.spec.ts @@ -1,5 +1,5 @@ -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { archiveLocalWorkspaceFromDaemon, archiveWorkspaceFromDaemon, @@ -10,11 +10,11 @@ import { openProjectViaDaemon, openStartingRefPicker, selectBranchInPicker, -} from "./helpers/new-workspace"; -import { expectNoTruncation } from "./helpers/no-truncation"; -import { createTempGitRepo } from "./helpers/workspace"; -import { getServerId } from "./helpers/server-id"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +} from "../support/helpers/new-workspace"; +import { expectNoTruncation } from "../support/helpers/no-truncation"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; // Regression for "the local / worktree selection in the new workspace is not // remembered." The isolation choice persists in the create-form preferences diff --git a/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts similarity index 92% rename from packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts rename to packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts index 9f125f19e..4ba076efa 100644 --- a/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts +++ b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts @@ -1,9 +1,12 @@ -import { expect, test, type Page } from "./fixtures"; -import { daemonWsRoutePattern } from "./helpers/daemon-port"; -import { openAgentRoute } from "./helpers/mock-agent"; -import { openGlobalNewWorkspaceComposer, selectNewWorkspaceProject } from "./helpers/new-workspace"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +import { expect, test, type Page } from "../support/fixtures"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { openAgentRoute } from "../support/helpers/mock-agent"; +import { + openGlobalNewWorkspaceComposer, + selectNewWorkspaceProject, +} from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; diff --git a/packages/app/e2e/new-workspace-preselect.spec.ts b/packages/app/e2e/browser/new-workspace-preselect.spec.ts similarity index 93% rename from packages/app/e2e/new-workspace-preselect.spec.ts rename to packages/app/e2e/browser/new-workspace-preselect.spec.ts index db627576b..88d6cce77 100644 --- a/packages/app/e2e/new-workspace-preselect.spec.ts +++ b/packages/app/e2e/browser/new-workspace-preselect.spec.ts @@ -1,16 +1,19 @@ -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { expectNewWorkspaceProjectSelected, openGlobalNewWorkspaceComposer, -} from "./helpers/new-workspace"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { seedSavedSettingsHosts } from "./helpers/settings"; +} from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { seedSavedSettingsHosts } from "../support/helpers/settings"; import { LAST_WORKSPACE_SELECTION_STORAGE_KEY } from "@/stores/last-workspace-selection"; import { buildHostWorkspaceRoute, buildNewWorkspaceRoute } from "@/utils/host-routes"; -import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./helpers/workspace-ui"; +import { + switchWorkspaceViaSidebar, + waitForSidebarHydration, +} from "../support/helpers/workspace-ui"; const OFFLINE_SERVER_IDS = [ "srv_e2e_preselect_offline_1", diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/browser/new-workspace.spec.ts similarity index 98% rename from packages/app/e2e/new-workspace.spec.ts rename to packages/app/e2e/browser/new-workspace.spec.ts index 2fd03012c..950916935 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/browser/new-workspace.spec.ts @@ -1,8 +1,8 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { archiveWorkspaceFromDaemon, archiveLocalWorkspaceFromDaemon, @@ -30,26 +30,26 @@ import { selectPickerOptionByKeyboard, selectWorkspaceIsolation, submitNewWorkspacePrompt, -} from "./helpers/new-workspace"; -import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; +} from "../support/helpers/new-workspace"; +import { createTempGitRepo, readWorktreeBranchInfo } from "../support/helpers/workspace"; import { createLocalGithubPrFixture, cloneGithubRepoDefaultBranchOnly, createTempGithubRepo, hasGithubAuth, type LocalGhPrFixture, -} from "./helpers/github-fixtures"; -import { getServerId } from "./helpers/server-id"; -import { getE2EDaemonPort } from "./helpers/daemon-port"; -import { chooseAddProjectMethod, expectAddProjectPage } from "./helpers/add-project-flow"; -import { seedSavedSettingsHosts } from "./helpers/settings"; +} from "../support/helpers/github-fixtures"; +import { getServerId } from "../support/helpers/server-id"; +import { getE2EDaemonPort } from "../support/helpers/daemon-port"; +import { chooseAddProjectMethod, expectAddProjectPage } from "../support/helpers/add-project-flow"; +import { seedSavedSettingsHosts } from "../support/helpers/settings"; import { expectSidebarWorkspaceSelected, expectWorkspaceHeader, switchWorkspaceViaSidebar, waitForSidebarHydration, waitForWorkspaceInSidebar, -} from "./helpers/workspace-ui"; +} from "../support/helpers/workspace-ui"; interface WorkspaceStatusGroupEvent { rowTestId: string; diff --git a/packages/app/e2e/out-of-band-command.codex.real.spec.ts b/packages/app/e2e/browser/out-of-band-command.codex.real.spec.ts similarity index 87% rename from packages/app/e2e/out-of-band-command.codex.real.spec.ts rename to packages/app/e2e/browser/out-of-band-command.codex.real.spec.ts index cbec6733c..072699b45 100644 --- a/packages/app/e2e/out-of-band-command.codex.real.spec.ts +++ b/packages/app/e2e/browser/out-of-band-command.codex.real.spec.ts @@ -1,9 +1,9 @@ import { mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "./fixtures"; -import { submitMessage } from "./helpers/composer"; -import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow"; +import { expect, test } from "../support/fixtures"; +import { submitMessage } from "../support/helpers/composer"; +import { cleanupRewindFlow, launchAgent, type AgentHandle } from "../support/helpers/rewind-flow"; test.describe("Codex out-of-band commands", () => { test.setTimeout(300_000); diff --git a/packages/app/e2e/browser/pair-device-relay.spec.ts b/packages/app/e2e/browser/pair-device-relay.spec.ts new file mode 100644 index 000000000..8c4e306a1 --- /dev/null +++ b/packages/app/e2e/browser/pair-device-relay.spec.ts @@ -0,0 +1,20 @@ +import { test } from "../support/fixtures"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { + expectRelayConsent, + openPairDeviceModal, + preparePairingHost, +} from "../support/helpers/pair-device"; + +test("opens relay consent in browser web", async ({ page }) => { + const daemon = await startIsolatedHostDaemon("pair-device-browser-relay-off", { + mutableRelay: { enabled: false }, + }); + try { + await preparePairingHost(page, daemon); + await openPairDeviceModal(page); + await expectRelayConsent(page); + } finally { + await daemon.close(); + } +}); diff --git a/packages/app/e2e/pr-pane.spec.ts b/packages/app/e2e/browser/pr-pane.spec.ts similarity index 92% rename from packages/app/e2e/pr-pane.spec.ts rename to packages/app/e2e/browser/pr-pane.spec.ts index faea7bfce..a3b623ea1 100644 --- a/packages/app/e2e/pr-pane.spec.ts +++ b/packages/app/e2e/browser/pr-pane.spec.ts @@ -1,18 +1,22 @@ -import { test } from "./fixtures"; +import { test } from "../support/fixtures"; import { openPrPane, expectPrPaneTitle, expectPrPaneState, expectPrPaneCheckSummary, expectPrPaneActivityCount, -} from "./helpers/pr-pane"; -import { gotoWorkspace } from "./helpers/launcher"; -import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures"; +} from "../support/helpers/pr-pane"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { + hasGithubAuth, + createTempGithubRepo, + type GhRepoFixture, +} from "../support/helpers/github-fixtures"; import { connectWorkspaceSetupClient, openProjectViaDaemon, type WorkspaceSetupDaemonClient, -} from "./helpers/workspace-setup"; +} from "../support/helpers/workspace-setup"; const GITHUB_AUTH = hasGithubAuth(); diff --git a/packages/app/e2e/projects-settings.spec.ts b/packages/app/e2e/browser/projects-settings.spec.ts similarity index 97% rename from packages/app/e2e/projects-settings.spec.ts rename to packages/app/e2e/browser/projects-settings.spec.ts index 0387653cf..a6a68a3ba 100644 --- a/packages/app/e2e/projects-settings.spec.ts +++ b/packages/app/e2e/browser/projects-settings.spec.ts @@ -1,7 +1,7 @@ import { chmod, readFile } from "node:fs/promises"; import path from "node:path"; -import { expect, test as base, type Page } from "./fixtures"; -import { connectSeedClient, seedWorkspace } from "./helpers/seed-client"; +import { expect, test as base, type Page } from "../support/fixtures"; +import { connectSeedClient, seedWorkspace } from "../support/helpers/seed-client"; import { blockPaseoConfigWrites, bumpPaseoConfigOnDisk, @@ -40,14 +40,14 @@ import { returnToProjectsList, saveProjectEdits, unblockPaseoConfigWrites, -} from "./helpers/project-settings"; -import { gotoAppShell } from "./helpers/app"; +} from "../support/helpers/project-settings"; +import { gotoAppShell } from "../support/helpers/app"; import { addProjectFlowInput, chooseAddProjectMethod, openAddProjectFlow, -} from "./helpers/add-project-flow"; -import { createTempGitRepo } from "./helpers/workspace"; +} from "../support/helpers/add-project-flow"; +import { createTempGitRepo } from "../support/helpers/workspace"; const updatedSetup = ["npm install", "npm run build"]; diff --git a/packages/app/e2e/provider-removal.spec.ts b/packages/app/e2e/browser/provider-removal.spec.ts similarity index 90% rename from packages/app/e2e/provider-removal.spec.ts rename to packages/app/e2e/browser/provider-removal.spec.ts index dcddc3ad2..7e17a1fbe 100644 --- a/packages/app/e2e/provider-removal.spec.ts +++ b/packages/app/e2e/browser/provider-removal.spec.ts @@ -1,15 +1,15 @@ import type { Dialog } from "@playwright/test"; -import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell, openSettings } from "./helpers/app"; -import { connectDaemonClient } from "./helpers/daemon-client-loader"; -import { getServerId } from "./helpers/server-id"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; +import { getServerId } from "../support/helpers/server-id"; import { expectProviderInstalledInSettings, installAcpCatalogProvider, openAddProviderArea, openSettingsHost, openSettingsHostSection, -} from "./helpers/settings"; +} from "../support/helpers/settings"; const CUSTOM_PROVIDER = { id: "junie", diff --git a/packages/app/e2e/provider-settings-refresh.spec.ts b/packages/app/e2e/browser/provider-settings-refresh.spec.ts similarity index 97% rename from packages/app/e2e/provider-settings-refresh.spec.ts rename to packages/app/e2e/browser/provider-settings-refresh.spec.ts index 03161f242..85a7dabc3 100644 --- a/packages/app/e2e/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/browser/provider-settings-refresh.spec.ts @@ -1,7 +1,7 @@ import type { Locator } from "@playwright/test"; -import { expect, test, type Page } from "./fixtures"; -import { expectComposerVisible } from "./helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { expect, test, type Page } from "../support/fixtures"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; const MOBILE_VIEWPORT = { width: 390, height: 844 }; diff --git a/packages/app/e2e/provider-subagents.real.spec.ts b/packages/app/e2e/browser/provider-subagents.real.spec.ts similarity index 95% rename from packages/app/e2e/provider-subagents.real.spec.ts rename to packages/app/e2e/browser/provider-subagents.real.spec.ts index a4bf02fad..6e9bc64f6 100644 --- a/packages/app/e2e/provider-subagents.real.spec.ts +++ b/packages/app/e2e/browser/provider-subagents.real.spec.ts @@ -1,15 +1,15 @@ import { mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { test, expect } from "./fixtures"; +import { test, expect } from "../support/fixtures"; import { cleanupRewindFlow, launchAgent, sendMessage, type AgentHandle, type RewindFlowProvider, -} from "./helpers/rewind-flow"; -import { openSubagentsTrack } from "./helpers/subagents"; +} from "../support/helpers/rewind-flow"; +import { openSubagentsTrack } from "../support/helpers/subagents"; interface ProviderSubagentCase { provider: RewindFlowProvider; diff --git a/packages/app/e2e/provider-usage-settings.spec.ts b/packages/app/e2e/browser/provider-usage-settings.spec.ts similarity index 94% rename from packages/app/e2e/provider-usage-settings.spec.ts rename to packages/app/e2e/browser/provider-usage-settings.spec.ts index f9d21f6a8..0763233e1 100644 --- a/packages/app/e2e/provider-usage-settings.spec.ts +++ b/packages/app/e2e/browser/provider-usage-settings.spec.ts @@ -1,8 +1,8 @@ -import { expect, test } from "./fixtures"; -import { gotoAppShell, openSettings } from "./helpers/app"; -import { installProviderUsageFixture } from "./helpers/provider-usage"; -import { getServerId } from "./helpers/server-id"; -import { openSettingsHostSection } from "./helpers/settings"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { installProviderUsageFixture } from "../support/helpers/provider-usage"; +import { getServerId } from "../support/helpers/server-id"; +import { openSettingsHostSection } from "../support/helpers/settings"; test.describe("provider usage settings", () => { test("renders every provider returned by the daemon usage RPC", async ({ page }) => { diff --git a/packages/app/e2e/provider-usage-tooltip.spec.ts b/packages/app/e2e/browser/provider-usage-tooltip.spec.ts similarity index 91% rename from packages/app/e2e/provider-usage-tooltip.spec.ts rename to packages/app/e2e/browser/provider-usage-tooltip.spec.ts index 55666a7a1..680b7b4b8 100644 --- a/packages/app/e2e/provider-usage-tooltip.spec.ts +++ b/packages/app/e2e/browser/provider-usage-tooltip.spec.ts @@ -1,7 +1,7 @@ -import { expect, test, type Page } from "./fixtures"; -import { expectComposerVisible } from "./helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; -import { installProviderUsageFixture } from "./helpers/provider-usage"; +import { expect, test, type Page } from "../support/fixtures"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { installProviderUsageFixture } from "../support/helpers/provider-usage"; const MOBILE_VIEWPORT = { width: 390, height: 844 }; diff --git a/packages/app/e2e/question-prompt-pagination.spec.ts b/packages/app/e2e/browser/question-prompt-pagination.spec.ts similarity index 95% rename from packages/app/e2e/question-prompt-pagination.spec.ts rename to packages/app/e2e/browser/question-prompt-pagination.spec.ts index a2d5747fd..b014b2d1c 100644 --- a/packages/app/e2e/question-prompt-pagination.spec.ts +++ b/packages/app/e2e/browser/question-prompt-pagination.spec.ts @@ -1,5 +1,5 @@ -import { test } from "./fixtures"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { test } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { chooseQuestionOption, continueToNextQuestion, @@ -14,7 +14,7 @@ import { openQuestion, submitQuestionAnswers, waitForQuestionPrompt, -} from "./helpers/questions"; +} from "../support/helpers/questions"; const TOTAL_QUESTIONS = 3; const SURFACE_QUESTION = "Which surface should this apply to?"; diff --git a/packages/app/e2e/rewind-flow.claude.real.spec.ts b/packages/app/e2e/browser/rewind-flow.claude.real.spec.ts similarity index 100% rename from packages/app/e2e/rewind-flow.claude.real.spec.ts rename to packages/app/e2e/browser/rewind-flow.claude.real.spec.ts diff --git a/packages/app/e2e/rewind-flow.codex.real.spec.ts b/packages/app/e2e/browser/rewind-flow.codex.real.spec.ts similarity index 100% rename from packages/app/e2e/rewind-flow.codex.real.spec.ts rename to packages/app/e2e/browser/rewind-flow.codex.real.spec.ts diff --git a/packages/app/e2e/rewind-flow.opencode.real.spec.ts b/packages/app/e2e/browser/rewind-flow.opencode.real.spec.ts similarity index 100% rename from packages/app/e2e/rewind-flow.opencode.real.spec.ts rename to packages/app/e2e/browser/rewind-flow.opencode.real.spec.ts diff --git a/packages/app/e2e/rewind-flow.pi.real.spec.ts b/packages/app/e2e/browser/rewind-flow.pi.real.spec.ts similarity index 100% rename from packages/app/e2e/rewind-flow.pi.real.spec.ts rename to packages/app/e2e/browser/rewind-flow.pi.real.spec.ts diff --git a/packages/app/e2e/rewind-flow.shared.ts b/packages/app/e2e/browser/rewind-flow.shared.ts similarity index 96% rename from packages/app/e2e/rewind-flow.shared.ts rename to packages/app/e2e/browser/rewind-flow.shared.ts index c34e9d782..6e89b85f2 100644 --- a/packages/app/e2e/rewind-flow.shared.ts +++ b/packages/app/e2e/browser/rewind-flow.shared.ts @@ -1,7 +1,7 @@ import { mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { test } from "./fixtures"; +import { test } from "../support/fixtures"; import { assertChatTranscript, assertComposerIdle, @@ -15,7 +15,7 @@ import { type AgentHandle, type RewindFlowMode, type RewindFlowProvider, -} from "./helpers/rewind-flow"; +} from "../support/helpers/rewind-flow"; const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN"; diff --git a/packages/app/e2e/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts similarity index 96% rename from packages/app/e2e/rewind-menu.ui-contract.spec.ts rename to packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts index af5d75a0f..e620b4d0f 100644 --- a/packages/app/e2e/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts @@ -1,15 +1,15 @@ import type { Locator } from "@playwright/test"; -import { expect, test, type Page } from "./fixtures"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; -import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; -import { scrollChatAwayFromBottom } from "./helpers/agent-bottom-anchor"; +import { expect, test, type Page } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; +import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor"; import { composerLocator, expectComposerDraft, expectComposerVisible, fillComposerDraft, submitMessage, -} from "./helpers/composer"; +} from "../support/helpers/composer"; // UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`. diff --git a/packages/app/e2e/same-directory-workspaces.spec.ts b/packages/app/e2e/browser/same-directory-workspaces.spec.ts similarity index 92% rename from packages/app/e2e/same-directory-workspaces.spec.ts rename to packages/app/e2e/browser/same-directory-workspaces.spec.ts index 967d274ef..8546ffb4e 100644 --- a/packages/app/e2e/same-directory-workspaces.spec.ts +++ b/packages/app/e2e/browser/same-directory-workspaces.spec.ts @@ -1,10 +1,10 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; -import { test, expect, type Page } from "./fixtures"; -import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -import { expectExplorerEntryVisible } from "./helpers/file-explorer"; -import { expectNoTerminalTabs, clickFirstTerminalTab } from "./helpers/workspace-tabs"; +import { test, expect, type Page } from "../support/fixtures"; +import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { expectExplorerEntryVisible } from "../support/helpers/file-explorer"; +import { expectNoTerminalTabs, clickFirstTerminalTab } from "../support/helpers/workspace-tabs"; // Model B: two workspaces can back the SAME directory. What follows from that // split is the contract these specs pin: diff --git a/packages/app/e2e/schedules-edit-model-hydration.spec.ts b/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts similarity index 95% rename from packages/app/e2e/schedules-edit-model-hydration.spec.ts rename to packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts index 4b7851ccb..9690aed03 100644 --- a/packages/app/e2e/schedules-edit-model-hydration.spec.ts +++ b/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts @@ -1,16 +1,16 @@ -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { addFakeScheduleHostAndReload, buildFakeScheduleHostWorkspace, FAKE_HOST_MODEL_ID, FAKE_HOST_MODEL_LABEL, installFakeScheduleHost, -} from "./helpers/schedule-fake-host"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -import { expectSettled, expectStableHeight } from "./helpers/settled"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; -import { buildSchedulesRoute } from "../src/utils/host-routes"; +} from "../support/helpers/schedule-fake-host"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { expectSettled, expectStableHeight } from "../support/helpers/settled"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { buildSchedulesRoute } from "../../src/utils/host-routes"; interface ScheduleSeedClient { scheduleCreate(input: { diff --git a/packages/app/e2e/schedules-project-target.spec.ts b/packages/app/e2e/browser/schedules-project-target.spec.ts similarity index 95% rename from packages/app/e2e/schedules-project-target.spec.ts rename to packages/app/e2e/browser/schedules-project-target.spec.ts index 29ea4c2c1..4479213e0 100644 --- a/packages/app/e2e/schedules-project-target.spec.ts +++ b/packages/app/e2e/browser/schedules-project-target.spec.ts @@ -1,18 +1,18 @@ -import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { addFakeScheduleHostAndReload, buildFakeScheduleHostWorkspace, installFakeScheduleHost, -} from "./helpers/schedule-fake-host"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { projectEquivalenceViewKey } from "./helpers/project-view-key"; -import { escapeRegex } from "./helpers/regex"; -import { expectNoTruncation } from "./helpers/no-truncation"; -import { expectSettled, expectStableHeight } from "./helpers/settled"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; -import { buildSchedulesRoute } from "../src/utils/host-routes"; +} from "../support/helpers/schedule-fake-host"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; +import { escapeRegex } from "../support/helpers/regex"; +import { expectNoTruncation } from "../support/helpers/no-truncation"; +import { expectSettled, expectStableHeight } from "../support/helpers/settled"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { buildSchedulesRoute } from "../../src/utils/host-routes"; const MOBILE_SHEET_VIEWPORT = { width: 390, height: 844 }; diff --git a/packages/app/e2e/browser/sessions-empty.spec.ts b/packages/app/e2e/browser/sessions-empty.spec.ts new file mode 100644 index 000000000..05884b45a --- /dev/null +++ b/packages/app/e2e/browser/sessions-empty.spec.ts @@ -0,0 +1,44 @@ +import { randomUUID } from "node:crypto"; +import { metroTest as test } from "../support/fixtures"; +import { expectSessionsEmptyState, openSessions } from "../support/helpers/archive-tab"; +import { gotoAppShell } from "../support/helpers/app"; +import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { seedWorkspace } from "../support/helpers/seed-client"; + +test("Sessions shows an empty placeholder when the host has no history", async ({ page }) => { + const serverId = `srv_sessions_empty_${randomUUID().replaceAll("-", "").slice(0, 12)}`; + const daemon = await startIsolatedHostDaemon(serverId); + let workspace: Awaited> | null = null; + + try { + workspace = await seedWorkspace({ + repoPrefix: "sessions-empty-", + port: daemon.port, + }); + const host = buildSeededHost({ + serverId, + endpoint: `127.0.0.1:${daemon.port}`, + nowIso: new Date().toISOString(), + }); + await page.route(/:6767\b/, (route) => route.abort()); + await page.routeWebSocket(/:6767\b/, async (webSocket) => { + await webSocket.close({ code: 1008, reason: "Blocked developer daemon during e2e." }); + }); + await page.addInitScript( + ({ seededHost, preferences }) => { + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + }, + { seededHost: host, preferences: buildCreateAgentPreferences(serverId) }, + ); + + await gotoAppShell(page); + await openSessions(page); + await expectSessionsEmptyState(page); + } finally { + await workspace?.cleanup(); + await daemon.close(); + } +}); diff --git a/packages/app/e2e/settings-host-page.spec.ts b/packages/app/e2e/browser/settings-host-page.spec.ts similarity index 63% rename from packages/app/e2e/settings-host-page.spec.ts rename to packages/app/e2e/browser/settings-host-page.spec.ts index 3e48e1d2b..8a702f8c0 100644 --- a/packages/app/e2e/settings-host-page.spec.ts +++ b/packages/app/e2e/browser/settings-host-page.spec.ts @@ -1,8 +1,8 @@ -import { expect, test } from "./fixtures"; -import { gotoAppShell, openSettings } from "./helpers/app"; -import { getE2EDaemonPort } from "./helpers/daemon-port"; -import { TEST_HOST_LABEL } from "./helpers/daemon-registry"; -import { getServerId } from "./helpers/server-id"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getE2EDaemonPort } from "../support/helpers/daemon-port"; +import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry"; +import { getServerId } from "../support/helpers/server-id"; import { expectSettingsHeader, openSettingsHost, @@ -17,9 +17,8 @@ import { expectHostNoDaemonLifecycleRow, expectRetiredSidebarSectionsAbsent, expectHostPageVisible, - expectLocalHostEntryFirst, seedSavedSettingsHosts, -} from "./helpers/settings"; +} from "../support/helpers/settings"; test.describe("Settings host page", () => { test("connections section shows the seeded connection endpoint", async ({ page }) => { @@ -95,24 +94,6 @@ test.describe("Settings host page", () => { await expect(updateButton).toBeEnabled(); }); - test("a Desktop-managed daemon explains why its update action is disabled", async ({ - page, - desktopManagedOutdatedDaemon, - }) => { - await seedSavedSettingsHosts(page, [desktopManagedOutdatedDaemon]); - await page.reload(); - await openSettings(page); - await openSettingsHost(page, desktopManagedOutdatedDaemon.serverId); - await openHostSection(page, desktopManagedOutdatedDaemon.serverId, "host"); - - const updateCard = page.getByTestId("host-page-update-card"); - await expect(updateCard).toBeVisible(); - await expect(updateCard).toContainText( - "This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.", - ); - await expect(page.getByTestId("host-page-update-button")).toBeDisabled(); - }); - test("clicking the label pencil reveals the inline editor", async ({ page }) => { const serverId = getServerId(); @@ -161,46 +142,4 @@ test.describe("Settings host page", () => { await expectHostLabelDisplayed(page); await expectHostActionCards(page, serverId); }); - - test("sidebar pins the local daemon host first", async ({ page }) => { - const serverId = getServerId(); - - // Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the - // seeded host to the local daemon. `manageBuiltInDaemon: false` (returned - // from get_desktop_settings) bypasses the desktop bootstrap flow so only - // the sidebar's status query runs against the seeded test daemon. - await page.addInitScript((localServerId) => { - (window as unknown as { paseoDesktop: unknown }).paseoDesktop = { - platform: "darwin", - invoke: async (command: string) => { - if (command === "desktop_daemon_status") { - return { - serverId: localServerId, - status: "running", - listen: null, - hostname: null, - pid: null, - home: "", - version: null, - desktopManaged: true, - error: null, - }; - } - if (command === "get_desktop_settings") { - return { - releaseChannel: "stable", - daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true }, - }; - } - return null; - }, - getPendingOpenProject: async () => null, - events: { on: async () => () => undefined }, - }; - }, serverId); - - await gotoAppShell(page); - await openSettings(page); - await expectLocalHostEntryFirst(page, serverId); - }); }); diff --git a/packages/app/e2e/settings-i18n.spec.ts b/packages/app/e2e/browser/settings-i18n.spec.ts similarity index 81% rename from packages/app/e2e/settings-i18n.spec.ts rename to packages/app/e2e/browser/settings-i18n.spec.ts index 556c82f56..808e0741d 100644 --- a/packages/app/e2e/settings-i18n.spec.ts +++ b/packages/app/e2e/browser/settings-i18n.spec.ts @@ -1,6 +1,6 @@ -import { test, expect } from "./fixtures"; -import { gotoAppShell, openSettings } from "./helpers/app"; -import { openSettingsSection } from "./helpers/settings"; +import { test, expect } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { openSettingsSection } from "../support/helpers/settings"; test("Settings language selector switches General labels", async ({ page }) => { test.setTimeout(120_000); diff --git a/packages/app/e2e/settings-navigation.spec.ts b/packages/app/e2e/browser/settings-navigation.spec.ts similarity index 95% rename from packages/app/e2e/settings-navigation.spec.ts rename to packages/app/e2e/browser/settings-navigation.spec.ts index 70a8de6f4..5a3624f06 100644 --- a/packages/app/e2e/settings-navigation.spec.ts +++ b/packages/app/e2e/browser/settings-navigation.spec.ts @@ -1,12 +1,12 @@ -import { test, expect } from "./fixtures"; +import { test, expect } from "../support/fixtures"; import { buildHostWorkspaceRoute, buildOpenProjectRoute, buildSettingsRoute, buildSettingsSectionRoute, } from "@/utils/host-routes"; -import { gotoAppShell, openSettings } from "./helpers/app"; -import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { closeCompactSettings, openSettingsSection, @@ -39,9 +39,9 @@ import { expectSettingsHostPickerLabel, openSettingsHostSection, removeCurrentHostFromSettings, -} from "./helpers/settings"; -import { getServerId } from "./helpers/server-id"; -import { expectAppRoute } from "./helpers/route-assertions"; +} from "../support/helpers/settings"; +import { getServerId } from "../support/helpers/server-id"; +import { expectAppRoute } from "../support/helpers/route-assertions"; async function openWorkspace( page: import("@playwright/test").Page, diff --git a/packages/app/e2e/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts similarity index 92% rename from packages/app/e2e/settings-toggle-tab-regression.spec.ts rename to packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts index 319289860..ff3d1f7cc 100644 --- a/packages/app/e2e/settings-toggle-tab-regression.spec.ts +++ b/packages/app/e2e/browser/settings-toggle-tab-regression.spec.ts @@ -1,9 +1,9 @@ import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { expect, test } from "./fixtures"; -import { createIdleAgent, openWorkspaceWithAgents } from "./helpers/archive-tab"; -import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +import { expect, test } from "../support/fixtures"; +import { createIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { waitForTabBar, expectAgentTabActive } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) { const modifier = process.platform === "darwin" ? "Meta" : "Control"; diff --git a/packages/app/e2e/sidebar-help.spec.ts b/packages/app/e2e/browser/sidebar-help.spec.ts similarity index 96% rename from packages/app/e2e/sidebar-help.spec.ts rename to packages/app/e2e/browser/sidebar-help.spec.ts index 129251516..5e3ccf74c 100644 --- a/packages/app/e2e/sidebar-help.spec.ts +++ b/packages/app/e2e/browser/sidebar-help.spec.ts @@ -1,6 +1,6 @@ -import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell, openSettings } from "./helpers/app"; -import { openSettingsSection } from "./helpers/settings"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell, openSettings } from "../support/helpers/app"; +import { openSettingsSection } from "../support/helpers/settings"; const DISCORD_DESTINATION = /^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/; diff --git a/packages/app/e2e/sidebar-host-filter-multi.spec.ts b/packages/app/e2e/browser/sidebar-host-filter-multi.spec.ts similarity index 88% rename from packages/app/e2e/sidebar-host-filter-multi.spec.ts rename to packages/app/e2e/browser/sidebar-host-filter-multi.spec.ts index ee065cb0d..30d4a4422 100644 --- a/packages/app/e2e/sidebar-host-filter-multi.spec.ts +++ b/packages/app/e2e/browser/sidebar-host-filter-multi.spec.ts @@ -1,15 +1,15 @@ import { expect } from "@playwright/test"; -import { test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { addOfflineHostAndReload, expectHostFilterRow, openSidebarDisplayPreferences, selectAllHostsFilter, toggleHostFilter, -} from "./helpers/hosts"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +} from "../support/helpers/hosts"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; const SECONDARY_HOST_ID = "host-filter-secondary"; diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts similarity index 92% rename from packages/app/e2e/sidebar-model-b.spec.ts rename to packages/app/e2e/browser/sidebar-model-b.spec.ts index 7d5e0fdc1..5cf6313ca 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -1,12 +1,12 @@ -import { test, expect, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -import { seedMockAgentWorkspace } from "./helpers/mock-agent"; -import { getServerId } from "./helpers/server-id"; -import { projectEquivalenceViewKey } from "./helpers/project-view-key"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; -import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; +import { test, expect, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { getServerId } from "../support/helpers/server-id"; +import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs"; // Model B sidebar shape: every project — git or non-git, single- or // multi-workspace — renders as the same expandable parent, the deepest sidebar diff --git a/packages/app/e2e/sidebar-project-grouping.spec.ts b/packages/app/e2e/browser/sidebar-project-grouping.spec.ts similarity index 95% rename from packages/app/e2e/sidebar-project-grouping.spec.ts rename to packages/app/e2e/browser/sidebar-project-grouping.spec.ts index d2e72bfa9..bd9b934aa 100644 --- a/packages/app/e2e/sidebar-project-grouping.spec.ts +++ b/packages/app/e2e/browser/sidebar-project-grouping.spec.ts @@ -1,6 +1,6 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { test as base } from "./fixtures"; +import { test as base } from "../support/fixtures"; import { beginWorkspaceFromProject, createWorkspaceWithoutAgent, @@ -14,11 +14,14 @@ import { openProjectDirectoryWithHosts, renameProject, selectWorkspaceHost, -} from "./helpers/project-grouping"; -import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; -import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { createTempGitRepo } from "./helpers/workspace"; +} from "../support/helpers/project-grouping"; +import { + type IsolatedHostDaemon, + startIsolatedHostDaemon, +} from "../support/helpers/isolated-host-daemon"; +import { connectSeedClient, type SeedDaemonClient } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { createTempGitRepo } from "../support/helpers/workspace"; const PRIMARY_HOST_LABEL = "Primary Host"; const SECONDARY_HOST_LABEL = "Secondary Host"; @@ -139,8 +142,14 @@ const test = base.extend<{ const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", { originUrl: SHARED_REMOTE_URL, }); - const primaryClient = await connectSeedClient({ port: primaryHost.port }); - const secondaryClient = await connectSeedClient({ port: secondaryHost.port }); + const primaryClient = await connectSeedClient({ + port: primaryHost.port, + projectOwnership: "host", + }); + const secondaryClient = await connectSeedClient({ + port: secondaryHost.port, + projectOwnership: "host", + }); try { await createProject(primaryClient, { diff --git a/packages/app/e2e/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts similarity index 87% rename from packages/app/e2e/sidebar-reorder.spec.ts rename to packages/app/e2e/browser/sidebar-reorder.spec.ts index b8019537c..c0d199148 100644 --- a/packages/app/e2e/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -1,10 +1,10 @@ import type { Locator } from "@playwright/test"; -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { projectEquivalenceViewKey } from "./helpers/project-view-key"; -import { getServerId } from "./helpers/server-id"; -import { seedWorkspace } from "./helpers/seed-client"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; +import { getServerId } from "../support/helpers/server-id"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; async function rowTestIds(rows: Locator) { return rows.evaluateAll((elements) => diff --git a/packages/app/e2e/sidebar-resize-handle.spec.ts b/packages/app/e2e/browser/sidebar-resize-handle.spec.ts similarity index 94% rename from packages/app/e2e/sidebar-resize-handle.spec.ts rename to packages/app/e2e/browser/sidebar-resize-handle.spec.ts index eda7f2c81..7df4c64f6 100644 --- a/packages/app/e2e/sidebar-resize-handle.spec.ts +++ b/packages/app/e2e/browser/sidebar-resize-handle.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "./fixtures"; +import { expect, test, type Page } from "../support/fixtures"; test.use({ viewport: { width: 1600, height: 900 } }); diff --git a/packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts b/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts similarity index 96% rename from packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts rename to packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts index b9a0bb89e..5f5e710a3 100644 --- a/packages/app/e2e/sidebar-workspace-pin-shortcut.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-pin-shortcut.spec.ts @@ -1,8 +1,8 @@ -import { test, expect, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { daemonWsRoutePattern } from "./helpers/daemon-port"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +import { test, expect, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; // The pin shortcut used to be registered by the sidebar row itself, so it silently did nothing // whenever the row was unmounted — a collapsed project section being the common case. It now diff --git a/packages/app/e2e/sidebar-workspace-rename.spec.ts b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts similarity index 92% rename from packages/app/e2e/sidebar-workspace-rename.spec.ts rename to packages/app/e2e/browser/sidebar-workspace-rename.spec.ts index 8e3dadce0..21ad51a6b 100644 --- a/packages/app/e2e/sidebar-workspace-rename.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts @@ -1,7 +1,7 @@ -import { test, expect, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; +import { test, expect, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; function workspaceRowTestId(workspaceId: string): string { return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts similarity index 96% rename from packages/app/e2e/sidebar-workspace.spec.ts rename to packages/app/e2e/browser/sidebar-workspace.spec.ts index 793473f16..64d808d9d 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -1,18 +1,18 @@ import path from "node:path"; -import { test, expect } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { test, expect } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, openMobileAgentSidebar, pinWorkspaceFromSidebar, -} from "./helpers/sidebar"; -import { seedWorkspace } from "./helpers/seed-client"; -import { expectWorkspaceHeader } from "./helpers/workspace-ui"; -import { getServerId } from "./helpers/server-id"; -import { projectEquivalenceViewKey } from "./helpers/project-view-key"; -import { escapeRegex } from "./helpers/regex"; +} from "../support/helpers/sidebar"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { expectWorkspaceHeader } from "../support/helpers/workspace-ui"; +import { getServerId } from "../support/helpers/server-id"; +import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; +import { escapeRegex } from "../support/helpers/regex"; const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git"; diff --git a/packages/app/e2e/browser/startup-loading.spec.ts b/packages/app/e2e/browser/startup-loading.spec.ts new file mode 100644 index 000000000..e33a733d4 --- /dev/null +++ b/packages/app/e2e/browser/startup-loading.spec.ts @@ -0,0 +1,19 @@ +import { test } from "../support/fixtures"; +import { startupScenario } from "../support/helpers/startup-dsl"; + +test.describe("Startup loading presentation", () => { + test("mobile reconnect preserves the saved host shell", async ({ page }) => { + const startup = await startupScenario(page) + .withMobileViewport() + .withSavedHost({ + serverId: "srv_unreachable_mobile", + label: "Dev", + endpoint: "127.0.0.1:45678", + }) + .openRoot(); + + await startup.expectsSavedHostShell({ serverId: "srv_unreachable_mobile", label: "Dev" }); + await startup.expectsNoSavedHostErrorStatus(); + await startup.expectsNoLocalServerStartupCopy(); + }); +}); diff --git a/packages/app/e2e/startup-wire-metrics.spec.ts b/packages/app/e2e/browser/startup-wire-metrics.spec.ts similarity index 98% rename from packages/app/e2e/startup-wire-metrics.spec.ts rename to packages/app/e2e/browser/startup-wire-metrics.spec.ts index 184d0a458..d624d5e31 100644 --- a/packages/app/e2e/startup-wire-metrics.spec.ts +++ b/packages/app/e2e/browser/startup-wire-metrics.spec.ts @@ -1,8 +1,8 @@ import { Buffer } from "node:buffer"; import type { CDPSession, Page, TestInfo } from "@playwright/test"; -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; type WireDirection = "sent" | "received"; type WirePhase = "startup" | "workspace_clicks"; diff --git a/packages/app/e2e/subagent-detach.spec.ts b/packages/app/e2e/browser/subagent-detach.spec.ts similarity index 73% rename from packages/app/e2e/subagent-detach.spec.ts rename to packages/app/e2e/browser/subagent-detach.spec.ts index b2da2217c..a8a4e0cf4 100644 --- a/packages/app/e2e/subagent-detach.spec.ts +++ b/packages/app/e2e/browser/subagent-detach.spec.ts @@ -1,15 +1,15 @@ -import { test } from "./fixtures"; -import { expectWorkspaceTabVisible } from "./helpers/archive-tab"; -import { expectAgentTabActive } from "./helpers/launcher"; -import { openAgentRoute } from "./helpers/mock-agent"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { test } from "../support/fixtures"; +import { expectWorkspaceTabVisible } from "../support/helpers/archive-tab"; +import { expectAgentTabActive } from "../support/helpers/launcher"; +import { openAgentRoute } from "../support/helpers/mock-agent"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { detachSubagentFromTrack, expectSubagentRowGone, expectSubagentRowVisible, openSubagentsTrack, seedParentWithSubagent, -} from "./helpers/subagents"; +} from "../support/helpers/subagents"; test.describe("Subagent detach", () => { let workspace: SeededWorkspace; diff --git a/packages/app/e2e/terminal-activity-indicators.spec.ts b/packages/app/e2e/browser/terminal-activity-indicators.spec.ts similarity index 97% rename from packages/app/e2e/terminal-activity-indicators.spec.ts rename to packages/app/e2e/browser/terminal-activity-indicators.spec.ts index 953611de1..f6f23ac93 100644 --- a/packages/app/e2e/terminal-activity-indicators.spec.ts +++ b/packages/app/e2e/browser/terminal-activity-indicators.spec.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { test, expect, type Page } from "./fixtures"; -import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl"; +import { test, expect, type Page } from "../support/fixtures"; +import { TerminalE2EHarness, type TerminalInstance } from "../support/helpers/terminal-dsl"; type HookActivityState = "running" | "idle" | "needs-input"; type TabStatusBucket = "running" | "needs_input" | "attention" | "none"; diff --git a/packages/app/e2e/terminal-alternate-screen.spec.ts b/packages/app/e2e/browser/terminal-alternate-screen.spec.ts similarity index 96% rename from packages/app/e2e/terminal-alternate-screen.spec.ts rename to packages/app/e2e/browser/terminal-alternate-screen.spec.ts index 53a657810..db6f0e7e8 100644 --- a/packages/app/e2e/terminal-alternate-screen.spec.ts +++ b/packages/app/e2e/browser/terminal-alternate-screen.spec.ts @@ -1,15 +1,15 @@ import type { Page } from "@playwright/test"; -import { test, expect } from "./fixtures"; -import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl"; -import { getTabTestIds } from "./helpers/launcher"; +import { test, expect } from "../support/fixtures"; +import { TerminalE2EHarness, withTerminalInApp } from "../support/helpers/terminal-dsl"; +import { getTabTestIds } from "../support/helpers/launcher"; import { installTerminalRenderProbe, readTerminalRenderProbe, resetTerminalRenderProbe, startTerminalFrameSampling, summarizeTerminalRenderProbe, -} from "./helpers/terminal-probes"; -import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf"; +} from "../support/helpers/terminal-probes"; +import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf"; interface TerminalLayoutMetrics { visibleSurfaceCount: number; diff --git a/packages/app/e2e/terminal-keystroke-stress.spec.ts b/packages/app/e2e/browser/terminal-keystroke-stress.spec.ts similarity index 98% rename from packages/app/e2e/terminal-keystroke-stress.spec.ts rename to packages/app/e2e/browser/terminal-keystroke-stress.spec.ts index f12adcbfc..fb5bbbd00 100644 --- a/packages/app/e2e/terminal-keystroke-stress.spec.ts +++ b/packages/app/e2e/browser/terminal-keystroke-stress.spec.ts @@ -1,14 +1,14 @@ import { performance } from "node:perf_hooks"; import type { Page, TestInfo } from "@playwright/test"; -import { test, expect } from "./fixtures"; -import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl"; +import { test, expect } from "../support/fixtures"; +import { TerminalE2EHarness, type TerminalInstance } from "../support/helpers/terminal-dsl"; import { installTerminalKeystrokeStressProbe, readTerminalKeystrokeStressReport, resetTerminalKeystrokeStressProbe, type LatencyStats, -} from "./helpers/terminal-probes"; -import { waitForTerminalContent } from "./helpers/terminal-perf"; +} from "../support/helpers/terminal-probes"; +import { waitForTerminalContent } from "../support/helpers/terminal-perf"; const INPUT_TEXT = buildStressText(600); const BIG_DIFF_BYTES = 256_000; diff --git a/packages/app/e2e/terminal-performance.spec.ts b/packages/app/e2e/browser/terminal-performance.spec.ts similarity index 96% rename from packages/app/e2e/terminal-performance.spec.ts rename to packages/app/e2e/browser/terminal-performance.spec.ts index 3ed3f4fae..cf02b4647 100644 --- a/packages/app/e2e/terminal-performance.spec.ts +++ b/packages/app/e2e/browser/terminal-performance.spec.ts @@ -1,12 +1,12 @@ -import { test, expect } from "./fixtures"; -import { TerminalE2EHarness } from "./helpers/terminal-dsl"; +import { test, expect } from "../support/fixtures"; +import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; import { waitForTerminalContent, measureKeystrokeLatency, computePercentile, round2, type LatencySample, -} from "./helpers/terminal-perf"; +} from "../support/helpers/terminal-perf"; const LINE_COUNT = 50_000; const THROUGHPUT_BUDGET_MS = 30_000; diff --git a/packages/app/e2e/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts similarity index 92% rename from packages/app/e2e/terminal-protocol-query.spec.ts rename to packages/app/e2e/browser/terminal-protocol-query.spec.ts index 6122a5c34..023bf12b9 100644 --- a/packages/app/e2e/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -1,8 +1,8 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; -import { test, expect } from "./fixtures"; -import { TerminalE2EHarness } from "./helpers/terminal-dsl"; -import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf"; +import { test, expect } from "../support/fixtures"; +import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; +import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf"; const OSC11_CAPTURE_SCRIPT = ` let captured = Buffer.alloc(0); diff --git a/packages/app/e2e/terminal-split-resize.spec.ts b/packages/app/e2e/browser/terminal-split-resize.spec.ts similarity index 96% rename from packages/app/e2e/terminal-split-resize.spec.ts rename to packages/app/e2e/browser/terminal-split-resize.spec.ts index 819a3ed5f..7b443907a 100644 --- a/packages/app/e2e/terminal-split-resize.spec.ts +++ b/packages/app/e2e/browser/terminal-split-resize.spec.ts @@ -1,7 +1,7 @@ import type { Page } from "@playwright/test"; -import { test, expect } from "./fixtures"; -import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl"; -import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf"; +import { test, expect } from "../support/fixtures"; +import { TerminalE2EHarness, withTerminalInApp } from "../support/helpers/terminal-dsl"; +import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf"; interface TerminalSize { rows: number | null; diff --git a/packages/app/e2e/terminal-stuck-size.spec.ts b/packages/app/e2e/browser/terminal-stuck-size.spec.ts similarity index 95% rename from packages/app/e2e/terminal-stuck-size.spec.ts rename to packages/app/e2e/browser/terminal-stuck-size.spec.ts index 9e80f16b5..4c41cb598 100644 --- a/packages/app/e2e/terminal-stuck-size.spec.ts +++ b/packages/app/e2e/browser/terminal-stuck-size.spec.ts @@ -1,8 +1,8 @@ -import { test, expect, type Page } from "./fixtures"; -import { TerminalE2EHarness } from "./helpers/terminal-dsl"; -import { getTerminalBufferText } from "./helpers/terminal-perf"; -import { buildHostWorkspaceRoute } from "../src/utils/host-routes"; -import { getServerId } from "./helpers/server-id"; +import { test, expect, type Page } from "../support/fixtures"; +import { TerminalE2EHarness } from "../support/helpers/terminal-dsl"; +import { getTerminalBufferText } from "../support/helpers/terminal-perf"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { getServerId } from "../support/helpers/server-id"; /** * Regression: a terminal created while the window is unfocused must still claim its PTY size diff --git a/packages/app/e2e/tool-call-shimmer.spec.ts b/packages/app/e2e/browser/tool-call-shimmer.spec.ts similarity index 97% rename from packages/app/e2e/tool-call-shimmer.spec.ts rename to packages/app/e2e/browser/tool-call-shimmer.spec.ts index c0d1ad514..a988626b9 100644 --- a/packages/app/e2e/tool-call-shimmer.spec.ts +++ b/packages/app/e2e/browser/tool-call-shimmer.spec.ts @@ -1,7 +1,7 @@ import type { Locator, Page } from "@playwright/test"; -import { test, expect } from "./fixtures"; -import { daemonWsRoutePattern } from "./helpers/daemon-port"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { test, expect } from "../support/fixtures"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; type WebSocketMessage = string | Buffer; diff --git a/packages/app/e2e/user-message-contract.ui-contract.spec.ts b/packages/app/e2e/browser/user-message-contract.ui-contract.spec.ts similarity index 91% rename from packages/app/e2e/user-message-contract.ui-contract.spec.ts rename to packages/app/e2e/browser/user-message-contract.ui-contract.spec.ts index 4a536368c..55a4df4ba 100644 --- a/packages/app/e2e/user-message-contract.ui-contract.spec.ts +++ b/packages/app/e2e/browser/user-message-contract.ui-contract.spec.ts @@ -1,12 +1,12 @@ -import { expect, test, type Page } from "./fixtures"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { expect, test, type Page } from "../support/fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { composerLocator, expectComposerEditable, expectComposerVisible, fillComposerDraft, submitMessage, -} from "./helpers/composer"; +} from "../support/helpers/composer"; async function expectUserMessageCount(page: Page, expected: number): Promise { await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 }); diff --git a/packages/app/e2e/viewed-agent-timelines.spec.ts b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts similarity index 94% rename from packages/app/e2e/viewed-agent-timelines.spec.ts rename to packages/app/e2e/browser/viewed-agent-timelines.spec.ts index 35a4e24e1..4a6885766 100644 --- a/packages/app/e2e/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts @@ -1,20 +1,20 @@ import { expect, type Page } from "@playwright/test"; import { buildHostAgentDetailRoute } from "@/utils/host-routes"; -import { test } from "./fixtures"; -import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { observeTimelineSubscriptions } from "./helpers/timeline-delivery"; -import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; -import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { test } from "../support/fixtures"; +import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { expectAgentIdle, expectInlineWorkingIndicator, expectTurnCopyButton, -} from "./helpers/agent-stream"; +} from "../support/helpers/agent-stream"; import { expectReconnectingToastGone, expectReconnectingToastVisible, -} from "./helpers/workspace-ui"; +} from "../support/helpers/workspace-ui"; interface ViewedTimelineScenario { client: SeedDaemonClient; diff --git a/packages/app/e2e/workspace-agent-tab-rename.spec.ts b/packages/app/e2e/browser/workspace-agent-tab-rename.spec.ts similarity index 84% rename from packages/app/e2e/workspace-agent-tab-rename.spec.ts rename to packages/app/e2e/browser/workspace-agent-tab-rename.spec.ts index 68a69de63..2fd2c66e8 100644 --- a/packages/app/e2e/workspace-agent-tab-rename.spec.ts +++ b/packages/app/e2e/browser/workspace-agent-tab-rename.spec.ts @@ -1,11 +1,11 @@ import { randomUUID } from "node:crypto"; -import { test, expect, type Page } from "./fixtures"; -import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; -import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab"; -import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { test, expect, type Page } from "../support/fixtures"; +import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client"; +import { createIdleAgent, expectWorkspaceTabVisible } from "../support/helpers/archive-tab"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; import { buildHostAgentDetailRoute } from "@/utils/host-routes"; -import { renameModalInput, renameModalSubmit } from "./helpers/rename"; -import { getServerId } from "./helpers/server-id"; +import { renameModalInput, renameModalSubmit } from "../support/helpers/rename"; +import { getServerId } from "../support/helpers/server-id"; async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) { await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.workspaceId)); diff --git a/packages/app/e2e/workspace-agent-title-handoff.spec.ts b/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts similarity index 91% rename from packages/app/e2e/workspace-agent-title-handoff.spec.ts rename to packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts index fa9e922ba..b7375104c 100644 --- a/packages/app/e2e/workspace-agent-title-handoff.spec.ts +++ b/packages/app/e2e/browser/workspace-agent-title-handoff.spec.ts @@ -1,10 +1,10 @@ -import { test, expect } from "./fixtures"; -import { expectComposerVisible, submitMessage } from "./helpers/composer"; -import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate"; -import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; -import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client"; -import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; -import { getServerId } from "./helpers/server-id"; +import { test, expect } from "../support/fixtures"; +import { expectComposerVisible, submitMessage } from "../support/helpers/composer"; +import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate"; +import { delayBrowserAgentCreatedStatus } from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { getServerId } from "../support/helpers/server-id"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; async function waitForCreatedAgentId( diff --git a/packages/app/e2e/workspace-archive-shortcut.spec.ts b/packages/app/e2e/browser/workspace-archive-shortcut.spec.ts similarity index 68% rename from packages/app/e2e/workspace-archive-shortcut.spec.ts rename to packages/app/e2e/browser/workspace-archive-shortcut.spec.ts index 8e6881bed..8ad2b1f1b 100644 --- a/packages/app/e2e/workspace-archive-shortcut.spec.ts +++ b/packages/app/e2e/browser/workspace-archive-shortcut.spec.ts @@ -1,9 +1,12 @@ import { existsSync } from "node:fs"; -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { seedWorkspace } from "./helpers/seed-client"; -import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { + expectWorkspaceAbsentFromSidebar, + selectWorkspaceInSidebar, +} from "../support/helpers/sidebar"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; test.describe("Workspace archive shortcut", () => { test("archives the selected workspace without removing its local checkout", async ({ page }) => { diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/browser/workspace-cwd.spec.ts similarity index 93% rename from packages/app/e2e/workspace-cwd.spec.ts rename to packages/app/e2e/browser/workspace-cwd.spec.ts index 01544b735..e702cc0f7 100644 --- a/packages/app/e2e/workspace-cwd.spec.ts +++ b/packages/app/e2e/browser/workspace-cwd.spec.ts @@ -1,13 +1,13 @@ -import { expect, test } from "./fixtures"; -import { clickNewChat, clickNewTerminal, gotoWorkspace } from "./helpers/launcher"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +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 "./helpers/terminal-perf"; +} from "../support/helpers/terminal-perf"; interface CreatedAgentCwdAssertion { workspaceId: string; diff --git a/packages/app/e2e/workspace-focus-mode.spec.ts b/packages/app/e2e/browser/workspace-focus-mode.spec.ts similarity index 98% rename from packages/app/e2e/workspace-focus-mode.spec.ts rename to packages/app/e2e/browser/workspace-focus-mode.spec.ts index fc1dc7701..819a14fad 100644 --- a/packages/app/e2e/workspace-focus-mode.spec.ts +++ b/packages/app/e2e/browser/workspace-focus-mode.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "./fixtures"; +import { expect, test, type Page } from "../support/fixtures"; const modifier = process.platform === "darwin" ? "Meta" : "Control"; diff --git a/packages/app/e2e/workspace-lifecycle.spec.ts b/packages/app/e2e/browser/workspace-lifecycle.spec.ts similarity index 95% rename from packages/app/e2e/workspace-lifecycle.spec.ts rename to packages/app/e2e/browser/workspace-lifecycle.spec.ts index 8e04244ea..ca1accab8 100644 --- a/packages/app/e2e/workspace-lifecycle.spec.ts +++ b/packages/app/e2e/browser/workspace-lifecycle.spec.ts @@ -1,9 +1,9 @@ -import { test } from "./fixtures"; +import { test } from "../support/fixtures"; import { createAgentChatFromLauncher, createStandaloneTerminalFromLauncher, expectTerminalCwd, -} from "./helpers/workspace-lifecycle"; +} from "../support/helpers/workspace-lifecycle"; test.describe("Workspace lifecycle", () => { // The first test after a spec-file switch can intermittently fail because diff --git a/packages/app/e2e/workspace-model-regressions.spec.ts b/packages/app/e2e/browser/workspace-model-regressions.spec.ts similarity index 96% rename from packages/app/e2e/workspace-model-regressions.spec.ts rename to packages/app/e2e/browser/workspace-model-regressions.spec.ts index 5a8554d5a..2866e83ab 100644 --- a/packages/app/e2e/workspace-model-regressions.spec.ts +++ b/packages/app/e2e/browser/workspace-model-regressions.spec.ts @@ -1,7 +1,11 @@ -import { test, expect } from "./fixtures"; -import { expectWorkspaceTabVisible } from "./helpers/archive-tab"; -import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer"; -import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; +import { test, expect } from "../support/fixtures"; +import { expectWorkspaceTabVisible } from "../support/helpers/archive-tab"; +import { + expectComposerEditable, + expectComposerVisible, + submitMessage, +} from "../support/helpers/composer"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { assertNewWorkspaceSidebarAndHeader, connectNewWorkspaceDaemonClient, @@ -10,16 +14,16 @@ import { selectWorkspaceIsolation, submitNewWorkspaceEmpty, submitNewWorkspacePrompt, -} from "./helpers/new-workspace"; -import { getServerId } from "./helpers/server-id"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +} from "../support/helpers/new-workspace"; +import { getServerId } from "../support/helpers/server-id"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; import { expectSubagentRowVisible, openSubagentsTrack, seedParentWithCrossWorkspaceSubagent, -} from "./helpers/subagents"; -import { expectWorkspaceHeader, waitForSidebarHydration } from "./helpers/workspace-ui"; -import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; +} from "../support/helpers/subagents"; +import { expectWorkspaceHeader, waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs"; type NewWorkspaceDaemonClient = Awaited>; type WorkspaceIndicator = "attention" | "done" | "failed" | "loading" | "needs_input" | "running"; diff --git a/packages/app/e2e/workspace-model-restart.spec.ts b/packages/app/e2e/browser/workspace-model-restart.spec.ts similarity index 95% rename from packages/app/e2e/workspace-model-restart.spec.ts rename to packages/app/e2e/browser/workspace-model-restart.spec.ts index 5c799ae00..cecf4f991 100644 --- a/packages/app/e2e/workspace-model-restart.spec.ts +++ b/packages/app/e2e/browser/workspace-model-restart.spec.ts @@ -5,20 +5,24 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod import { tmpdir } from "node:os"; import path from "node:path"; import net from "node:net"; -import { test, expect, type Page } from "@playwright/test"; +import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; -import { buildSeededHost } from "./helpers/daemon-registry"; -import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader"; -import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory"; -import { withDisabledE2ESpeechEnv } from "./helpers/speech-env"; +import { metroTest as test } from "../support/fixtures"; +import { buildSeededHost } from "../support/helpers/daemon-registry"; +import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader"; +import { + createNodeWebSocketFactory, + type NodeWebSocketFactory, +} from "../support/helpers/node-ws-factory"; +import { withDisabledE2ESpeechEnv } from "../support/helpers/speech-env"; import { expectNewWorkspaceProjectSelected, openGlobalNewWorkspaceComposer, selectNewWorkspaceProject, submitNewWorkspaceEmpty, -} from "./helpers/new-workspace"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; -import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; +} from "../support/helpers/new-workspace"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs"; const LEGACY_AGENT_ID = "10000000-0000-4000-8000-000000000001"; const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`; @@ -244,7 +248,7 @@ async function startRestartDaemon(input: { return startRestartDaemon(input); } - const serverDir = path.resolve(__dirname, "../../server"); + const serverDir = path.resolve(__dirname, "../../../server"); const tsxBin = execSync("which tsx").toString().trim(); const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], { cwd: serverDir, @@ -301,7 +305,7 @@ async function connectRestartDaemonClient(port: number): Promise { - await page.addInitScript(() => { - const trace: StartupPresentation[] = []; - window.__paseoStartupPresentationTrace = trace; - - document.addEventListener("DOMContentLoaded", () => { - const recordPresentation = () => { - let presentation: StartupPresentation | null = null; - if (document.querySelector('[data-testid="startup-splash"]')) { - presentation = "splash"; - } else if ( - document.querySelector( - '[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]', - ) - ) { - presentation = "app"; - } - if (presentation && trace.at(-1) !== presentation) { - trace.push(presentation); - } - }; - new MutationObserver(recordPresentation).observe(document.documentElement, { - childList: true, - subtree: true, - }); - recordPresentation(); - }); - }); -} - -async function getStartupPresentation(page: Page): Promise { - return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []); -} - async function expectNoLoadingWorkspacePane( page: Page, input: { label: string; durationMs?: number }, @@ -127,39 +84,6 @@ async function closeFirstVisibleDraftTab(page: Page): Promise { await closeButton.first().click(); } -async function openWorkspaceThroughApp( - page: Page, - input: { - serverId: string; - workspace: Awaited>; - }, -): Promise { - await gotoAppShell(page); - await waitForSidebarHydration(page); - await switchWorkspaceViaSidebar({ - page, - serverId: input.serverId, - workspaceId: input.workspace.workspaceId, - }); - await waitForWorkspaceTabsVisible(page); - await expectWorkspaceLocation(page, input); -} - -async function expectWorkspaceLocation( - page: Page, - input: { - serverId: string; - workspace: Awaited>; - }, -): Promise { - const workspaceRoute = buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId); - await expectAppRoute(page, workspaceRoute, { timeout: 30_000 }); - await expectWorkspaceHeader(page, { - title: input.workspace.workspaceName, - subtitle: input.workspace.projectDisplayName, - }); -} - test.describe("Workspace navigation regression", () => { test.describe.configure({ timeout: 240_000 }); @@ -322,47 +246,6 @@ test.describe("Workspace navigation regression", () => { } }); - test("refresh keeps one continuous splash before restoring the workspace route", async ({ - page, - }) => { - const serverId = getServerId(); - const daemonGate = await installDaemonWebSocketGate(page); - const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" }); - - try { - const agent = await createIdleAgent(workspace.client, { - cwd: workspace.repoPath, - workspaceId: workspace.workspaceId, - title: `workspace-refresh-route-${Date.now()}`, - }); - await injectDesktopBridge(page, { - serverId, - manageBuiltInDaemon: true, - hangDaemonStart: true, - desktopSettingsDelayMs: 250, - daemonListen: `127.0.0.1:${getE2EDaemonPort()}`, - }); - await openWorkspaceThroughApp(page, { serverId, workspace }); - await waitForWorkspaceTabsVisible(page); - await expectWorkspaceTabVisible(page, agent.id); - await expectWorkspaceLocation(page, { serverId, workspace }); - - await observeStartupPresentation(page); - await daemonGate.drop(); - await page.reload(); - await waitForDesktopDaemonStartRequest(page); - daemonGate.restore(); - await waitForSidebarHydration(page); - - await expectWorkspaceLocation(page, { serverId, workspace }); - await waitForWorkspaceTabsVisible(page); - expect(await getStartupPresentation(page)).toEqual(["splash", "app"]); - } finally { - daemonGate.restore(); - await workspace.cleanup(); - } - }); - test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({ page, }) => { diff --git a/packages/app/e2e/workspace-pane-remount.spec.ts b/packages/app/e2e/browser/workspace-pane-remount.spec.ts similarity index 79% rename from packages/app/e2e/workspace-pane-remount.spec.ts rename to packages/app/e2e/browser/workspace-pane-remount.spec.ts index de56c8c4e..e440c7df2 100644 --- a/packages/app/e2e/workspace-pane-remount.spec.ts +++ b/packages/app/e2e/browser/workspace-pane-remount.spec.ts @@ -1,10 +1,10 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes"; -import { expect, test } from "./fixtures"; -import { createIdleAgent } from "./helpers/archive-tab"; -import { expectComposerVisible } from "./helpers/composer"; -import { seedWorkspace } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { expect, test } from "../support/fixtures"; +import { createIdleAgent } from "../support/helpers/archive-tab"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; test.describe("Workspace pane mounting", () => { test("opening the first split pane keeps the existing agent composer mounted", async ({ diff --git a/packages/app/e2e/workspace-pins.spec.ts b/packages/app/e2e/browser/workspace-pins.spec.ts similarity index 75% rename from packages/app/e2e/workspace-pins.spec.ts rename to packages/app/e2e/browser/workspace-pins.spec.ts index aafac4e0b..8c8e6ec51 100644 --- a/packages/app/e2e/workspace-pins.spec.ts +++ b/packages/app/e2e/browser/workspace-pins.spec.ts @@ -1,9 +1,9 @@ -import { test, expect } from "./fixtures"; -import { gotoWorkspace } from "./helpers/launcher"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; -import { togglePinFromMenu, tabRowPin } from "./helpers/pins"; -import { expectTerminalTabOpen } from "./helpers/workspace-tabs"; -import type { PinnedTabTarget } from "../src/workspace-pins/target"; +import { test, expect } from "../support/fixtures"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { togglePinFromMenu, tabRowPin } from "../support/helpers/pins"; +import { expectTerminalTabOpen } from "../support/helpers/workspace-tabs"; +import type { PinnedTabTarget } from "../../src/workspace-pins/target"; const DRAFT_TARGET: PinnedTabTarget = { kind: "draft" }; const TERMINAL_TARGET: PinnedTabTarget = { kind: "terminal" }; diff --git a/packages/app/e2e/workspace-scripts-menu-resize.spec.ts b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts similarity index 87% rename from packages/app/e2e/workspace-scripts-menu-resize.spec.ts rename to packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts index dec22a14c..17c4d3dd4 100644 --- a/packages/app/e2e/workspace-scripts-menu-resize.spec.ts +++ b/packages/app/e2e/browser/workspace-scripts-menu-resize.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures"; -import { createTempGitRepo } from "./helpers/workspace"; +import { test, expect } from "../support/fixtures"; +import { createTempGitRepo } from "../support/helpers/workspace"; import { connectWorkspaceSetupClient, createWorkspaceThroughDaemon, @@ -7,10 +7,10 @@ import { seedProjectForWorkspaceSetup, startWorkspaceScriptFromMenu, waitForWorkspaceSetupProgress, -} from "./helpers/workspace-setup"; -import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; -import { getServerId } from "./helpers/server-id"; -import { buildHostWorkspaceRoute } from "../src/utils/host-routes"; +} from "../support/helpers/workspace-setup"; +import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; +import { getServerId } from "../support/helpers/server-id"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; test("scripts menu resizes when a service row grows after launch", async ({ page }) => { const client = await connectWorkspaceSetupClient(); diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/browser/workspace-setup-runtime.spec.ts similarity index 90% rename from packages/app/e2e/workspace-setup-runtime.spec.ts rename to packages/app/e2e/browser/workspace-setup-runtime.spec.ts index bd312c139..cc7d9f36a 100644 --- a/packages/app/e2e/workspace-setup-runtime.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-runtime.spec.ts @@ -1,8 +1,8 @@ import { existsSync } from "node:fs"; -import { expect, test } from "./fixtures"; -import { createTempGitRepo } from "./helpers/workspace"; -import { clickNewTerminal } from "./helpers/launcher"; -import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf"; +import { expect, test } from "../support/fixtures"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { clickNewTerminal } from "../support/helpers/launcher"; +import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf"; import { connectWorkspaceSetupClient, createWorkspaceThroughDaemon, @@ -10,7 +10,7 @@ import { navigateToWorkspaceViaSidebar, openHomeWithProject, seedProjectForWorkspaceSetup, -} from "./helpers/workspace-setup"; +} from "../support/helpers/workspace-setup"; test.describe("Workspace setup runtime authority", () => { test.describe.configure({ retries: 1 }); diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts similarity index 95% rename from packages/app/e2e/workspace-setup-streaming.spec.ts rename to packages/app/e2e/browser/workspace-setup-streaming.spec.ts index b9a1dd24a..92351ae19 100644 --- a/packages/app/e2e/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -1,15 +1,18 @@ -import { test, expect } from "./fixtures"; -import { createTempGitRepo } from "./helpers/workspace"; +import { test, expect } from "../support/fixtures"; +import { createTempGitRepo } from "../support/helpers/workspace"; import { waitForWorkspaceTabsVisible, expectNoTerminalTabs, clickFirstTerminalTab, expectFirstTerminalTabContains, -} from "./helpers/workspace-tabs"; -import { clickNewChat } from "./helpers/launcher"; -import { expectComposerVisible } from "./helpers/composer"; -import { openFileExplorer, expectExplorerEntryVisible } from "./helpers/file-explorer"; -import { expectTerminalSurfaceVisible, waitForTerminalAttached } from "./helpers/terminal-perf"; +} from "../support/helpers/workspace-tabs"; +import { clickNewChat } from "../support/helpers/launcher"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { openFileExplorer, expectExplorerEntryVisible } from "../support/helpers/file-explorer"; +import { + expectTerminalSurfaceVisible, + waitForTerminalAttached, +} from "../support/helpers/terminal-perf"; import { connectWorkspaceSetupClient, createWorkspaceThroughDaemon, @@ -21,7 +24,7 @@ import { closeWorkspaceScriptsMenu, seedProjectForWorkspaceSetup, waitForWorkspaceSetupProgress, -} from "./helpers/workspace-setup"; +} from "../support/helpers/workspace-setup"; interface WorkspaceScriptStarter { startWorkspaceScript( diff --git a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts similarity index 93% rename from packages/app/e2e/workspace-terminal-tab-rename.spec.ts rename to packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts index 6ee477f7c..33eba8676 100644 --- a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts +++ b/packages/app/e2e/browser/workspace-terminal-tab-rename.spec.ts @@ -1,7 +1,7 @@ -import { test, expect, type Page } from "./fixtures"; -import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher"; -import { renameModalInput, renameModalSubmit } from "./helpers/rename"; -import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { test, expect, type Page } from "../support/fixtures"; +import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher"; +import { renameModalInput, renameModalSubmit } from "../support/helpers/rename"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; async function fetchTerminalTitle( workspace: SeededWorkspace, diff --git a/packages/app/e2e/worktree-archive-risk-warning.spec.ts b/packages/app/e2e/browser/worktree-archive-risk-warning.spec.ts similarity index 91% rename from packages/app/e2e/worktree-archive-risk-warning.spec.ts rename to packages/app/e2e/browser/worktree-archive-risk-warning.spec.ts index 3fe198430..7162a52b2 100644 --- a/packages/app/e2e/worktree-archive-risk-warning.spec.ts +++ b/packages/app/e2e/browser/worktree-archive-risk-warning.spec.ts @@ -3,18 +3,24 @@ import { existsSync } from "node:fs"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import type { Dialog, Page } from "@playwright/test"; -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { archiveWorkspaceFromDaemon, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, openProjectViaDaemon, -} from "./helpers/new-workspace"; -import { getServerId } from "./helpers/server-id"; -import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; -import { createTempGitRepo } from "./helpers/workspace"; -import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; +} from "../support/helpers/new-workspace"; +import { getServerId } from "../support/helpers/server-id"; +import { + clickArchiveWorkspaceMenuItem, + expectWorkspaceAbsentFromSidebar, +} from "../support/helpers/sidebar"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { + waitForSidebarHydration, + waitForWorkspaceInSidebar, +} from "../support/helpers/workspace-ui"; async function seedRiskyWorktree( client: Awaited>, diff --git a/packages/app/e2e/worktree-archive.spec.ts b/packages/app/e2e/browser/worktree-archive.spec.ts similarity index 87% rename from packages/app/e2e/worktree-archive.spec.ts rename to packages/app/e2e/browser/worktree-archive.spec.ts index ed874f18d..e14b357e8 100644 --- a/packages/app/e2e/worktree-archive.spec.ts +++ b/packages/app/e2e/browser/worktree-archive.spec.ts @@ -1,16 +1,22 @@ import { existsSync } from "node:fs"; -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { archiveWorkspaceFromDaemon, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, openProjectViaDaemon, -} from "./helpers/new-workspace"; -import { getServerId } from "./helpers/server-id"; -import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; -import { createTempGitRepo } from "./helpers/workspace"; -import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; +} from "../support/helpers/new-workspace"; +import { getServerId } from "../support/helpers/server-id"; +import { + archiveWorkspaceFromSidebar, + expectWorkspaceAbsentFromSidebar, +} from "../support/helpers/sidebar"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { + waitForSidebarHydration, + waitForWorkspaceInSidebar, +} from "../support/helpers/workspace-ui"; test.describe("Workspace archive with worktree backing", () => { let client: Awaited>; diff --git a/packages/app/e2e/worktree-restore-after-restart.spec.ts b/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts similarity index 83% rename from packages/app/e2e/worktree-restore-after-restart.spec.ts rename to packages/app/e2e/browser/worktree-restore-after-restart.spec.ts index a11df5daf..4cf294d69 100644 --- a/packages/app/e2e/worktree-restore-after-restart.spec.ts +++ b/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts @@ -1,19 +1,27 @@ import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; -import { expect, test, type Page } from "@playwright/test"; -import { gotoAppShell } from "./helpers/app"; -import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab"; -import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry"; -import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon"; +import { expect, type Page } from "@playwright/test"; +import { metroTest as test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + createIdleAgent, + expectSessionRowArchived, + openSessions, +} from "../support/helpers/archive-tab"; +import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry"; +import { + startIsolatedHostDaemon, + type IsolatedHostDaemon, +} from "../support/helpers/isolated-host-daemon"; import { archiveWorkspaceFromDaemon, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, openProjectViaDaemon, -} from "./helpers/new-workspace"; -import { connectSeedClient } from "./helpers/seed-client"; -import { createTempGitRepo } from "./helpers/workspace"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +} from "../support/helpers/new-workspace"; +import { connectSeedClient } from "../support/helpers/seed-client"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; test.describe("Worktree restore after daemon restart", () => { const serverId = `srv_worktree_restart_${randomUUID().replaceAll("-", "").slice(0, 12)}`; @@ -29,7 +37,10 @@ test.describe("Worktree restore after daemon restart", () => { test.beforeEach(async () => { daemon = await startIsolatedHostDaemon(serverId); client = await connectSeedClient({ port: daemon.port }); - worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port }); + worktreeClient = await connectNewWorkspaceDaemonClient({ + port: daemon.port, + ownProjects: false, + }); tempRepo = await createTempGitRepo("wt-restart-"); }); @@ -106,7 +117,10 @@ test.describe("Worktree restore after daemon restart", () => { await worktreeClient.close().catch(() => undefined); await daemon.restart(); client = await connectSeedClient({ port: daemon.port }); - worktreeClient = await connectNewWorkspaceDaemonClient({ port: daemon.port }); + worktreeClient = await connectNewWorkspaceDaemonClient({ + port: daemon.port, + ownProjects: false, + }); await seedBrowser(page); await gotoAppShell(page); diff --git a/packages/app/e2e/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts similarity index 96% rename from packages/app/e2e/worktree-restore.spec.ts rename to packages/app/e2e/browser/worktree-restore.spec.ts index a3abdf9bf..d8bac26fe 100644 --- a/packages/app/e2e/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -4,30 +4,33 @@ import { existsSync } from "node:fs"; import { rename } from "node:fs/promises"; import type { Page } from "@playwright/test"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { expect, test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; import { expectWorkspaceBranch, openChangesPanel, switchBranchFromChangesPanel, -} from "./helpers/branch-switcher"; +} from "../support/helpers/branch-switcher"; import { createIdleAgent, expectSessionRowNotArchived, fetchAgentArchivedAt, openSessions, -} from "./helpers/archive-tab"; +} from "../support/helpers/archive-tab"; import { archiveWorkspaceFromDaemon, archiveLocalWorkspaceFromDaemon, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, openProjectViaDaemon, -} from "./helpers/new-workspace"; -import { connectSeedClient } from "./helpers/seed-client"; -import { getServerId } from "./helpers/server-id"; -import { createTempGitRepo } from "./helpers/workspace"; -import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; +} from "../support/helpers/new-workspace"; +import { connectSeedClient } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { createTempGitRepo } from "../support/helpers/workspace"; +import { + waitForSidebarHydration, + waitForWorkspaceInSidebar, +} from "../support/helpers/workspace-ui"; test.describe("Worktree restore", () => { let client: Awaited>; diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts deleted file mode 100644 index 7232374e4..000000000 --- a/packages/app/e2e/global-setup.ts +++ /dev/null @@ -1,929 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { spawn, type ChildProcess, execSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import net from "node:net"; -import { Buffer } from "node:buffer"; -import dotenv from "dotenv"; -import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader"; -import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory"; -import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork"; -import { withDisabledE2ESpeechEnv } from "./helpers/speech-env"; - -const wranglerCliPath = path.resolve(__dirname, "../node_modules/wrangler/bin/wrangler.js"); - -export interface WaitForServerOptions { - host?: string; - timeoutMs?: number; - label: string; - childProcess?: ChildProcess | null; - getRecentOutput?: () => string; -} - -type ServerProbe = (host: string, port: number) => Promise; - -async function getAvailablePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to acquire port"))); - return; - } - server.close(() => resolve(address.port)); - }); - }); -} - -const RESERVED_LOCAL_PORTS = new Set([ - // Default developer daemon. - 6767, - // OpenCode's default local server port. Some provider probes can spawn it - // during daemon startup, so the E2E daemon must not choose the same port. - 61680, -]); - -function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } { - const lines: string[] = []; - return { - add(line: string) { - lines.push(line); - if (lines.length > maxLines) { - lines.shift(); - } - }, - dump() { - return lines.join("\n"); - }, - }; -} - -function formatRecentOutput(getRecentOutput?: () => string): string { - if (!getRecentOutput) { - return ""; - } - const output = getRecentOutput().trim(); - if (!output) { - return ""; - } - return `\nRecent output:\n${output}`; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function connectToServer(host: string, port: number): Promise { - await new Promise((resolve, reject) => { - const socket = net.connect(port, host, () => { - socket.end(); - resolve(); - }); - socket.setTimeout(1000, () => { - socket.destroy(); - reject(new Error(`Connection timed out to ${host}:${port}`)); - }); - socket.on("error", reject); - }); -} - -async function waitForServer( - port: number, - options: WaitForServerOptions, - probe: ServerProbe = connectToServer, -): Promise { - const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options; - const start = Date.now(); - let lastConnectionError: unknown = null; - - while (Date.now() - start < timeoutMs) { - if (childProcess && childProcess.exitCode !== null) { - const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : ""; - throw new Error( - `${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`, - ); - } - - try { - await probe(host, port); - return; - } catch (error) { - lastConnectionError = error; - await new Promise((r) => setTimeout(r, 100)); - } - } - - const reason = - lastConnectionError instanceof Error - ? ` Last connection error: ${lastConnectionError.message}` - : ""; - throw new Error( - `${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`, - ); -} - -async function probeMetro(host: string, port: number): Promise { - const response = await fetch(`http://${host}:${port}/status`, { - signal: AbortSignal.timeout(1000), - }); - const body = (await response.text()).trim(); - if (response.status !== 200 || body !== "packager-status:running") { - throw new Error( - `Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`, - ); - } -} - -export async function waitForMetro(port: number, options: WaitForServerOptions): Promise { - await waitForServer(port, options, probeMetro); -} - -function parseRelayStartupFailure(line: string): string | null { - const clean = stripAnsi(line); - if (/Address already in use/i.test(clean)) { - return clean; - } - if (/failed: ::bind\(/i.test(clean)) { - return clean; - } - if (/Fatal uncaught/i.test(clean)) { - return clean; - } - return null; -} - -async function stopProcess(child: ChildProcess | null): Promise { - if (!child) { - return; - } - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - child.kill("SIGTERM"); - await new Promise((resolve) => { - let pendingResolve: (() => void) | null = resolve; - const settle = () => { - if (!pendingResolve) return; - const fn = pendingResolve; - pendingResolve = null; - clearTimeout(timeout); - fn(); - }; - const timeout = setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - } - settle(); - }, 5000); - child.once("exit", settle); - }); -} - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function readSupervisorPidLock(home: string): Promise { - try { - const content = await readFile(path.join(home, "paseo.pid"), "utf8"); - const parsed = JSON.parse(content) as { pid?: unknown }; - return typeof parsed.pid === "number" ? parsed.pid : null; - } catch { - return null; - } -} - -async function stopProcessByPid(pid: number): Promise { - if (!isProcessRunning(pid)) { - return; - } - - try { - process.kill(pid, "SIGTERM"); - } catch { - return; - } - const deadline = Date.now() + 5000; - while (Date.now() < deadline) { - if (!isProcessRunning(pid)) { - return; - } - await sleep(100); - } - - if (isProcessRunning(pid)) { - try { - process.kill(pid, "SIGKILL"); - } catch { - return; - } - } -} - -async function stopCurrentDaemonFromPidLock(): Promise { - if (!paseoHome) { - return; - } - if (process.env.E2E_DAEMON_PORT === "6767") { - throw new Error("Refusing to clean up daemon PID lock for developer daemon port 6767."); - } - - const pid = await readSupervisorPidLock(paseoHome); - if (pid === null) { - return; - } - await stopProcessByPid(pid); -} - -function summarizeOpenAiErrorBody(body: string): string { - const trimmed = body.trim(); - if (!trimmed) { - return "empty response body"; - } - if (trimmed.length <= 240) { - return trimmed; - } - return `${trimmed.slice(0, 240)}…`; -} - -async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise { - const key = apiKey?.trim(); - if (!key) { - return false; - } - - try { - const response = await fetch("https://api.openai.com/v1/models?limit=1", { - method: "GET", - headers: { - Authorization: `Bearer ${key}`, - }, - }); - if (response.ok) { - return true; - } - const body = await response.text(); - console.warn( - `[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`, - ); - return false; - } catch (error) { - console.warn( - `[e2e] OPENAI_API_KEY probe request failed: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - return false; - } -} - -let daemonProcess: ChildProcess | null = null; -let metroProcess: ChildProcess | null = null; -let paseoHome: string | null = null; -let fakeEditorBinDir: string | null = null; -let relayProcess: ChildProcess | null = null; - -function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - if (trimmed === "current") { - return resolvePaseoHomePath("~/.paseo"); - } - return resolvePaseoHomePath(trimmed); -} - -interface OfferPayload { - v: 2; - serverId: string; - daemonPublicKeyB64: string; - relay: { endpoint: string }; -} - -interface DaemonClientConfig { - url: string; - clientId: string; - clientType: "cli"; - webSocketFactory: NodeWebSocketFactory; -} - -interface PairingDaemonClient { - connect(): Promise; - close(): Promise; - getDaemonPairingOffer(): Promise<{ - relayEnabled: boolean; - url: string; - }>; -} - -async function createFakeEditorBin(): Promise { - const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-")); - let realGhPath = ""; - try { - realGhPath = execSync("which gh").toString().trim(); - } catch { - // The local PR fixture below remains usable without a system gh binary. - } - - const fakeEditorSource = `#!/usr/bin/env node -const fs = require("fs"); -const path = require("path"); -const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH; - -if (recordPath) { - fs.appendFileSync(recordPath, JSON.stringify({ - command: path.basename(process.argv[1]), - args: process.argv.slice(2), - cwd: process.cwd(), - at: Date.now() - }) + "\\n"); -} -`; - for (const editorCommand of ["cursor", "code"]) { - const editorPath = path.join(binDir, editorCommand); - await writeFile(editorPath, fakeEditorSource); - await chmod(editorPath, 0o755); - } - - const fakeGhPath = path.join(binDir, "gh"); - const fakeGhSource = `#!/usr/bin/env node -const { spawnSync } = require("child_process"); -const args = process.argv.slice(2); -const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git"; -const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"] -}).stdout?.trim(); - -if (origin === fixtureRemote) { - const command = args.slice(0, 2).join(" "); - if (command === "auth status") process.exit(0); - if (command === "repo view") { - process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null })); - process.exit(0); - } - if (command === "issue list") { - process.stdout.write("[]"); - process.exit(0); - } - if (command === "pr list" || command === "pr view") { - const pr = { - number: 1, - title: "Use pasted PR as start ref", - url: "https://github.com/paseo-e2e/local-fixture/pull/1", - state: "OPEN", - body: null, - labels: [], - baseRefName: "main", - headRefName: "pr-branch-1", - updatedAt: "2026-01-01T00:00:00Z" - }; - process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr)); - process.exit(0); - } - if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) { - process.stdout.write(JSON.stringify({ - data: { repository: { pullRequest: { - number: 1, - baseRefName: "main", - headRefName: "pr-branch-1", - isCrossRepository: false, - headRepositoryOwner: { login: "paseo-e2e" }, - headRepository: { - sshUrl: "git@github.com:paseo-e2e/local-fixture.git", - url: fixtureRemote - } - } } } - })); - process.exit(0); - } - process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n"); - process.exit(1); -} - -const realGhPath = ${JSON.stringify(realGhPath)}; -if (!realGhPath) process.exit(127); -const result = spawnSync(realGhPath, args, { stdio: "inherit" }); -process.exit(result.status ?? 1); -`; - await writeFile(fakeGhPath, fakeGhSource); - await chmod(fakeGhPath, 0o755); - - return binDir; -} - -const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g"); - -function stripAnsi(input: string): string { - return input.replace(ANSI_PATTERN, ""); -} - -function ensureRelayBuildArtifact(repoRoot: string): void { - const relayDistEntry = path.join(repoRoot, "packages/relay/dist/e2ee.js"); - if (existsSync(relayDistEntry)) { - return; - } - - console.log("[e2e] Building @getpaseo/relay for daemon startup"); - execSync("npm run build:relay", { - cwd: repoRoot, - stdio: "inherit", - }); -} - -function decodeOfferFromFragmentUrl(url: string): OfferPayload { - const marker = "#offer="; - const idx = url.indexOf(marker); - if (idx === -1) { - throw new Error(`missing ${marker} fragment: ${url}`); - } - const encoded = url.slice(idx + marker.length); - const json = Buffer.from(encoded, "base64url").toString("utf8"); - const offer = JSON.parse(json) as Partial; - if (offer.v !== 2) throw new Error("offer.v missing/invalid"); - if (!offer.serverId) throw new Error("offer.serverId missing"); - if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing"); - if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing"); - return offer as OfferPayload; -} - -async function loadPairingOfferFromDaemon(port: number): Promise { - const DaemonClient = await loadDaemonClientConstructor(); - const client = new DaemonClient({ - url: `ws://127.0.0.1:${port}/ws`, - clientId: `playwright-global-setup-${randomUUID()}`, - clientType: "cli", - webSocketFactory: createNodeWebSocketFactory(), - }); - - await client.connect(); - try { - const pairing = await client.getDaemonPairingOffer(); - if (!pairing.relayEnabled || !pairing.url) { - throw new Error("Daemon returned a disabled pairing offer"); - } - return decodeOfferFromFragmentUrl(pairing.url); - } finally { - await client.close().catch(() => {}); - } -} - -async function waitForPairingOfferFromDaemon(args: { - port: number; - timeoutMs?: number; -}): Promise { - const timeoutMs = args.timeoutMs ?? 15000; - const start = Date.now(); - let lastError: unknown = null; - - while (Date.now() - start < timeoutMs) { - try { - return await loadPairingOfferFromDaemon(args.port); - } catch (error) { - lastError = error; - await sleep(100); - } - } - - throw new Error( - `Timed out waiting for daemon pairing offer: ${ - lastError instanceof Error ? lastError.message : String(lastError) - }`, - ); -} - -async function loadEnvTestFile(repoRoot: string): Promise { - const envTestPath = path.join(repoRoot, ".env.test"); - if (existsSync(envTestPath)) { - dotenv.config({ path: envTestPath }); - } -} - -async function applyPaseoHomeFork(targetHome: string): Promise { - const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM); - if (!forkSourceHome) { - return; - } - const forkResult = await forkPaseoHomeMetadata({ - sourceHome: forkSourceHome, - targetHome, - }); - process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome; - process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome; - process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles); - process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes); - console.log( - `[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` + - `(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` + - `${forkResult.copiedBytes} bytes)`, - ); - if (forkResult.skippedMissing.length > 0) { - console.warn( - `[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`, - ); - } -} - -async function logSpeechHarnessConfig(): Promise { - const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY); - const defaultLocalModelsDir = path.join( - process.env.HOME ?? "", - ".paseo", - "models", - "local-speech", - ); - const hasDefaultLocalModelsDir = - defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir); - - // Default app E2E does not cover speech flows. Keep speech disabled here so - // unrelated tests never start background local-model downloads. - if (!openAiUsable && !hasDefaultLocalModelsDir) { - console.warn( - "[e2e] Neither OPENAI_API_KEY nor local speech models found — app E2E keeps dictation/voice disabled. " + - "Tests that require dictation should gate on PASEO_DICTATION_ENABLED.", - ); - return; - } - - const speechAssets = openAiUsable ? "OpenAI" : `local models at ${defaultLocalModelsDir}`; - console.log( - `[e2e] Speech assets available from ${speechAssets}; app E2E keeps dictation/voice disabled.`, - ); -} - -interface RelayStreamState { - failureLine: string | null; - readyForSelectedPort: boolean; -} - -function attachRelayStreamHandlers( - child: ChildProcess, - relayPort: number, - buffer: ReturnType, - state: RelayStreamState, -): void { - function handleChunk(data: Buffer, streamTag: "stdout" | "stderr") { - const lines = data - .toString() - .split("\n") - .filter((line) => line.trim()); - for (const line of lines) { - buffer.add(`[${streamTag}] ${line}`); - const failure = parseRelayStartupFailure(line); - if (failure) { - state.failureLine = failure; - } - const clean = stripAnsi(line); - const readyMatch = clean.match(/Ready on .*:(\d+)\b/i); - if (readyMatch && Number(readyMatch[1]) === relayPort) { - state.readyForSelectedPort = true; - } - if (streamTag === "stdout") { - console.log(`[relay] ${line}`); - } else { - console.error(`[relay] ${line}`); - } - } - } - - child.stdout?.on("data", (data: Buffer) => handleChunk(data, "stdout")); - child.stderr?.on("data", (data: Buffer) => handleChunk(data, "stderr")); -} - -async function awaitRelayReady( - child: ChildProcess, - relayPort: number, - state: RelayStreamState, - buffer: ReturnType, -): Promise { - await waitForServer(relayPort, { - label: "Relay dev server", - timeoutMs: 30000, - childProcess: child, - getRecentOutput: buffer.dump, - }); - - const readyDeadline = Date.now() + 5000; - function isRelayReadyCheckPending(): boolean { - if (state.readyForSelectedPort) return false; - if (state.failureLine !== null) return false; - if (child.exitCode !== null) return false; - if (child.signalCode !== null) return false; - if (Date.now() >= readyDeadline) return false; - return true; - } - while (isRelayReadyCheckPending()) await sleep(100); - - if (state.failureLine) { - throw new Error(`Relay startup failed: ${state.failureLine}`); - } - if (!state.readyForSelectedPort) { - throw new Error( - `Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput( - buffer.dump, - )}`, - ); - } - if (child.exitCode !== null || child.signalCode !== null) { - throw new Error( - `Relay process exited before startup completed (exit code ${child.exitCode}, signal ${child.signalCode}).${formatRecentOutput( - buffer.dump, - )}`, - ); - } -} - -async function getAvailablePortExcluding(excludedPorts: Set): Promise { - for (;;) { - const port = await getAvailablePort(); - if (!excludedPorts.has(port) && !RESERVED_LOCAL_PORTS.has(port)) { - return port; - } - } -} - -interface RelayPorts { - relayPort: number; - inspectorPort: number; -} - -async function startRelay(excludedPorts: Set): Promise { - const relayDir = path.resolve(__dirname, "..", "..", "relay"); - const maxRelayStartupAttempts = 5; - let lastRelayStartupError: unknown = null; - - for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) { - const relayPort = await getAvailablePortExcluding(excludedPorts); - const inspectorPort = await getAvailablePortExcluding(new Set([...excludedPorts, relayPort])); - const buffer = createLineBuffer(); - const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false }; - - relayProcess = spawn( - process.execPath, - [ - wranglerCliPath, - "dev", - "--local", - "--ip", - "127.0.0.1", - "--port", - String(relayPort), - "--inspector-ip", - "127.0.0.1", - "--inspector-port", - String(inspectorPort), - "--live-reload=false", - "--show-interactive-dev-session=false", - ], - { - cwd: relayDir, - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }, - ); - attachRelayStreamHandlers(relayProcess, relayPort, buffer, state); - - try { - await awaitRelayReady(relayProcess, relayPort, state, buffer); - return { relayPort, inspectorPort }; - } catch (error) { - lastRelayStartupError = error; - await stopProcess(relayProcess); - relayProcess = null; - } - } - - const message = - lastRelayStartupError instanceof Error - ? lastRelayStartupError.message - : String(lastRelayStartupError); - throw new Error( - `Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`, - ); -} - -function startMetro(input: { - metroPort: number; - daemonPort: number; - buffer: ReturnType; -}): ChildProcess { - const appDir = path.resolve(__dirname, ".."); - const child = spawn("npx", ["expo", "start", "--web", "--port", String(input.metroPort)], { - cwd: appDir, - env: { - ...process.env, - BROWSER: "none", - ...(process.env.E2E_DESKTOP_RUNTIME === "1" - ? { - PASEO_WEB_PLATFORM: "electron", - EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${input.daemonPort}`, - } - : {}), - }, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); - - child.stdout?.on("data", (data: Buffer) => { - const lines = data - .toString() - .split("\n") - .filter((line) => line.trim()); - for (const line of lines) { - input.buffer.add(`[stdout] ${line}`); - console.log(`[metro] ${line}`); - } - }); - - child.stderr?.on("data", (data: Buffer) => { - const lines = data - .toString() - .split("\n") - .filter((line) => line.trim()); - for (const line of lines) { - input.buffer.add(`[stderr] ${line}`); - console.error(`[metro] ${line}`); - } - }); - - return child; -} - -interface DaemonSpawnArgs { - port: number; - relayPort: number; - metroPort: number; - paseoHome: string; - fakeEditorBinDir: string; - editorRecordPath: string; - buffer: ReturnType; -} - -function startDaemon(args: DaemonSpawnArgs): ChildProcess { - const serverDir = path.resolve(__dirname, "../../..", "packages/server"); - const tsxBin = execSync("which tsx").toString().trim(); - const env = withDisabledE2ESpeechEnv({ - ...process.env, - PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`, - PASEO_HOME: args.paseoHome, - PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath, - PASEO_SERVER_ID: "srv_e2e_test_daemon", - PASEO_LISTEN: `0.0.0.0:${args.port}`, - PASEO_RELAY_ENABLED: "true", - PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`, - PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`, - PASEO_NODE_ENV: "development", - NODE_ENV: "development", - }); - - const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], { - cwd: serverDir, - env, - stdio: ["ignore", "pipe", "pipe"], - detached: false, - }); - - let stdoutBuffer = ""; - child.stdout?.on("data", (data: Buffer) => { - stdoutBuffer += data.toString("utf8"); - const lines = stdoutBuffer.split("\n"); - stdoutBuffer = lines.pop() ?? ""; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - args.buffer.add(`[stdout] ${trimmed}`); - console.log(`[daemon] ${trimmed}`); - } - }); - - child.stderr?.on("data", (data: Buffer) => { - const lines = data - .toString() - .split("\n") - .filter((line) => line.trim()); - for (const line of lines) { - args.buffer.add(`[stderr] ${line}`); - console.error(`[daemon] ${line}`); - } - }); - - return child; -} - -async function removeTempTree(targetPath: string): Promise { - await rm(targetPath, { - recursive: true, - force: true, - maxRetries: 40, - retryDelay: 250, - }); -} - -async function performCleanup(shouldRemovePaseoHome: boolean): Promise { - await Promise.all([ - stopProcess(daemonProcess), - stopProcess(metroProcess), - stopProcess(relayProcess), - ]); - await stopCurrentDaemonFromPidLock(); - daemonProcess = null; - metroProcess = null; - relayProcess = null; - if (paseoHome && shouldRemovePaseoHome) { - await removeTempTree(paseoHome); - paseoHome = null; - } else if (paseoHome) { - console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`); - } - if (fakeEditorBinDir) { - await removeTempTree(fakeEditorBinDir); - fakeEditorBinDir = null; - } -} - -export default async function globalSetup() { - const repoRoot = path.resolve(__dirname, "../../.."); - ensureRelayBuildArtifact(repoRoot); - await loadEnvTestFile(repoRoot); - - const port = await getAvailablePortExcluding(new Set()); - const metroPort = await getAvailablePortExcluding(new Set([port])); - const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME); - const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1"; - paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"))); - const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl"); - fakeEditorBinDir = await createFakeEditorBin(); - const metroLineBuffer = createLineBuffer(); - const daemonLineBuffer = createLineBuffer(); - - await applyPaseoHomeFork(paseoHome); - - const cleanup = () => performCleanup(shouldRemovePaseoHome); - - await logSpeechHarnessConfig(); - - try { - metroProcess = startMetro({ - metroPort, - daemonPort: port, - buffer: metroLineBuffer, - }); - await waitForMetro(metroPort, { - label: "Metro web server", - timeoutMs: 120000, - childProcess: metroProcess, - getRecentOutput: metroLineBuffer.dump, - }); - - const { relayPort, inspectorPort } = await startRelay(new Set([port, metroPort])); - daemonProcess = startDaemon({ - port, - relayPort, - metroPort, - paseoHome, - fakeEditorBinDir, - editorRecordPath, - buffer: daemonLineBuffer, - }); - - await waitForServer(port, { - label: "Paseo daemon", - childProcess: daemonProcess, - getRecentOutput: daemonLineBuffer.dump, - }); - - const offer = await waitForPairingOfferFromDaemon({ - port, - }); - - process.env.E2E_DAEMON_PORT = String(port); - process.env.E2E_RELAY_PORT = String(relayPort); - process.env.E2E_SERVER_ID = offer.serverId; - process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64; - process.env.E2E_METRO_PORT = String(metroPort); - process.env.E2E_PASEO_HOME = paseoHome; - process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath; - console.log( - `[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, relay on port ${relayPort}, relay inspector on port ${inspectorPort}, home: ${paseoHome}`, - ); - - return async () => { - await cleanup(); - console.log("[e2e] Test daemon stopped"); - }; - } catch (error) { - await cleanup(); - throw error; - } -} diff --git a/packages/app/e2e/startup-loading.spec.ts b/packages/app/e2e/startup-loading.spec.ts deleted file mode 100644 index 896b7fb7e..000000000 --- a/packages/app/e2e/startup-loading.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { test } from "./fixtures"; -import { getE2EDaemonPort } from "./helpers/daemon-port"; -import { getServerId } from "./helpers/server-id"; -import { startupScenario } from "./helpers/startup-dsl"; - -test.describe("Startup loading presentation", () => { - test("mobile reconnect preserves the saved host shell", async ({ page }) => { - const startup = await startupScenario(page) - .withMobileViewport() - .withSavedHost({ - serverId: "srv_unreachable_mobile", - label: "Dev", - endpoint: "127.0.0.1:45678", - }) - .openRoot(); - - await startup.expectsSavedHostShell({ serverId: "srv_unreachable_mobile", label: "Dev" }); - await startup.expectsNoSavedHostErrorStatus(); - await startup.expectsNoLocalServerStartupCopy(); - }); - - test("desktop daemon bootstrap keeps the local server startup copy desktop-only", async ({ - page, - }) => { - const startup = await startupScenario(page) - .withPendingDesktopDaemon() - .withBlockedPort(getE2EDaemonPort()) - .openRoot(); - - await startup.expectsDesktopDaemonStartup(); - await startup.expectsSidebarHidden(); - await startup.expectsNoUndefinedRoute(); - }); - - test("host-route refresh does not render route chrome around the bootstrap splash", async ({ - page, - }) => { - const serverId = getServerId(); - const startup = await startupScenario(page) - .withPendingDesktopDaemon() - .withBlockedPort(getE2EDaemonPort()) - .openHostWorkspace({ - serverId, - workspaceId: "workspace-1", - }); - - await startup.expectsDesktopDaemonStartup(); - await startup.expectsSidebarHidden(); - }); -}); diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/support/fixtures.ts similarity index 73% rename from packages/app/e2e/fixtures.ts rename to packages/app/e2e/support/fixtures.ts index 8af68446a..a4ee90049 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -1,5 +1,6 @@ import { test as base, expect, type Page } from "@playwright/test"; import { startOutdatedDaemon, type OutdatedDaemon } from "./helpers/daemon-update"; +import { startE2EWorker } from "./helpers/e2e-worker"; import { getE2EDaemonPort } from "./helpers/daemon-port"; import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry"; import { @@ -7,7 +8,7 @@ import { removeProjectPickerFixture, type ProjectPickerFixture, } from "./helpers/project-picker-fixture"; -import { connectSeedClient } from "./helpers/seed-client"; +import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client"; import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace"; const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts"; @@ -21,14 +22,7 @@ interface TrackedProjectPickerFixture extends ProjectPickerFixture { // across spec-file boundaries — Playwright sometimes skips it for the first test of a // subsequent spec when multiple specs run in the same worker. Auto fixtures run // reliably for every test that uses this `test` object. -const test = base.extend<{ - paseoE2ESetup: void; - outdatedDaemon: OutdatedDaemon; - desktopManagedOutdatedDaemon: OutdatedDaemon; - relayConfigOutdatedDaemon: OutdatedDaemon; - projectPickerFixture: TrackedProjectPickerFixture; - withWorkspace: WithWorkspace; -}>({ +const metroTest = base.extend({ baseURL: async ({}, provide) => { const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) { @@ -36,6 +30,67 @@ const test = base.extend<{ } await provide(`http://localhost:${metroPort}`); }, +}); + +const test = metroTest.extend< + { + paseoE2ESetup: void; + projectOwnership: void; + outdatedDaemon: OutdatedDaemon; + desktopManagedOutdatedDaemon: OutdatedDaemon; + relayConfigOutdatedDaemon: OutdatedDaemon; + projectPickerFixture: TrackedProjectPickerFixture; + withWorkspace: WithWorkspace; + }, + { e2eWorker: void; e2eWorkerClient: SeedDaemonClient } +>({ + e2eWorker: [ + async ({}, provide, workerInfo) => { + const worker = await startE2EWorker(workerInfo.workerIndex); + try { + await provide(); + } finally { + await worker.close(); + } + }, + { scope: "worker", auto: true }, + ], + e2eWorkerClient: [ + async ({ e2eWorker }, provide) => { + void e2eWorker; + const client = await connectSeedClient(); + try { + await provide(client); + } finally { + await client.close().catch(() => undefined); + } + }, + { scope: "worker" }, + ], + projectOwnership: [ + async ({ e2eWorkerClient }, provide, testInfo) => { + const before = new Set( + (await e2eWorkerClient.fetchWorkspaces()).entries.map((workspace) => workspace.projectId), + ); + + await provide(); + + const leaked = [ + ...new Set( + (await e2eWorkerClient.fetchWorkspaces()).entries.map((workspace) => workspace.projectId), + ), + ].filter((projectId) => !before.has(projectId)); + for (const projectId of leaked) { + await e2eWorkerClient.removeProject(projectId).catch(() => undefined); + } + if (leaked.length > 0) { + const message = `Test leaked daemon project records: ${leaked.join(", ")}`; + if (testInfo.status === testInfo.expectedStatus) throw new Error(message); + await testInfo.attach("project-leaks", { body: message, contentType: "text/plain" }); + } + }, + { auto: true }, + ], paseoE2ESetup: [ async ({ page }, provide, testInfo) => { const daemonPort = getE2EDaemonPort(); @@ -67,7 +122,7 @@ const test = base.extend<{ const seedNonce = Math.random().toString(36).slice(2); const serverId = process.env.E2E_SERVER_ID; if (!serverId) { - throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup."); + throw new Error("E2E_SERVER_ID is not set - expected from the Playwright worker fixture."); } const testDaemon = buildSeededHost({ serverId, @@ -169,4 +224,4 @@ const test = base.extend<{ }, }); -export { test, expect, type Page }; +export { test, metroTest, expect, type Page }; diff --git a/packages/app/e2e/fixtures/recording.baseline.txt b/packages/app/e2e/support/fixtures/recording.baseline.txt similarity index 100% rename from packages/app/e2e/fixtures/recording.baseline.txt rename to packages/app/e2e/support/fixtures/recording.baseline.txt diff --git a/packages/app/e2e/fixtures/recording.wav b/packages/app/e2e/support/fixtures/recording.wav similarity index 100% rename from packages/app/e2e/fixtures/recording.wav rename to packages/app/e2e/support/fixtures/recording.wav diff --git a/packages/app/e2e/fixtures/recording.webm b/packages/app/e2e/support/fixtures/recording.webm similarity index 100% rename from packages/app/e2e/fixtures/recording.webm rename to packages/app/e2e/support/fixtures/recording.webm diff --git a/packages/app/e2e/support/global-setup.ts b/packages/app/e2e/support/global-setup.ts new file mode 100644 index 000000000..11f16ccc9 --- /dev/null +++ b/packages/app/e2e/support/global-setup.ts @@ -0,0 +1,226 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import net from "node:net"; +import dotenv from "dotenv"; + +export interface WaitForServerOptions { + host?: string; + timeoutMs?: number; + label: string; + childProcess?: ChildProcess | null; + getRecentOutput?: () => string; +} + +type ServerProbe = (host: string, port: number) => Promise; + +const RESERVED_LOCAL_PORTS = new Set([ + 6767, // Developer daemon. + 61680, // OpenCode's default local server. +]); + +function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } { + const lines: string[] = []; + return { + add(line) { + lines.push(line); + if (lines.length > maxLines) lines.shift(); + }, + dump: () => lines.join("\n"), + }; +} + +function formatRecentOutput(getRecentOutput?: () => string): string { + const output = getRecentOutput?.().trim(); + return output ? `\nRecent output:\n${output}` : ""; +} + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire port"))); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +async function getAvailableE2EPort(): Promise { + for (;;) { + const port = await getAvailablePort(); + if (!RESERVED_LOCAL_PORTS.has(port)) return port; + } +} + +async function connectToServer(host: string, port: number): Promise { + await new Promise((resolve, reject) => { + const socket = net.connect(port, host, () => { + socket.end(); + resolve(); + }); + socket.setTimeout(1_000, () => { + socket.destroy(); + reject(new Error(`Connection timed out to ${host}:${port}`)); + }); + socket.on("error", reject); + }); +} + +async function waitForServer( + port: number, + options: WaitForServerOptions, + probe: ServerProbe = connectToServer, +): Promise { + const { host = "127.0.0.1", timeoutMs = 15_000, label, childProcess, getRecentOutput } = options; + const deadline = Date.now() + timeoutMs; + let lastConnectionError: unknown = null; + + while (Date.now() < deadline) { + if (childProcess?.exitCode !== null && childProcess?.exitCode !== undefined) { + const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : ""; + throw new Error( + `${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`, + ); + } + try { + await probe(host, port); + return; + } catch (error) { + lastConnectionError = error; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + + const reason = + lastConnectionError instanceof Error + ? ` Last connection error: ${lastConnectionError.message}` + : ""; + throw new Error( + `${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`, + ); +} + +async function probeMetro(host: string, port: number): Promise { + const response = await fetch(`http://${host}:${port}/status`, { + signal: AbortSignal.timeout(1_000), + }); + const body = (await response.text()).trim(); + if (response.status !== 200 || body !== "packager-status:running") { + throw new Error( + `Expected Metro status on ${host}:${port}, received HTTP ${response.status}: ${JSON.stringify(body.slice(0, 200))}`, + ); + } +} + +export async function waitForMetro(port: number, options: WaitForServerOptions): Promise { + await waitForServer(port, options, probeMetro); +} + +export async function warmMetro(port: number): Promise { + const origin = `http://127.0.0.1:${port}`; + const documentResponse = await fetch(origin, { signal: AbortSignal.timeout(120_000) }); + if (!documentResponse.ok) { + throw new Error(`Metro document warmup failed with HTTP ${documentResponse.status}`); + } + const document = await documentResponse.text(); + const scriptSources = [...document.matchAll(/]+src=["']([^"']+)["']/g)].map( + (match) => match[1], + ); + if (scriptSources.length === 0) { + throw new Error("Metro document warmup found no scripts to compile"); + } + for (const source of scriptSources) { + const scriptUrl = new URL(source, origin); + if (scriptUrl.origin !== origin) continue; + const response = await fetch(scriptUrl, { signal: AbortSignal.timeout(120_000) }); + if (!response.ok) { + throw new Error( + `Metro bundle warmup failed for ${scriptUrl.pathname}: HTTP ${response.status}`, + ); + } + await response.arrayBuffer(); + } +} + +async function stopProcess(child: ChildProcess | null): Promise { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + child.kill("SIGTERM"); + let timeout: ReturnType | undefined; + try { + await Promise.race([ + once(child, "exit"), + new Promise((resolve) => { + timeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + resolve(); + }, 5_000); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function startMetro(port: number, buffer: ReturnType): ChildProcess { + const appDir = path.resolve(__dirname, "../.."); + const child = spawn("npx", ["expo", "start", "--web", "--port", String(port)], { + cwd: appDir, + env: { + ...process.env, + BROWSER: "none", + ...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_WEB_PLATFORM: "electron" } : {}), + }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + const log = (chunk: Buffer, stream: "stdout" | "stderr") => { + for (const line of chunk.toString().split("\n").filter(Boolean)) { + buffer.add(`[${stream}] ${line}`); + console[stream === "stdout" ? "log" : "error"](`[metro] ${line}`); + } + }; + child.stdout?.on("data", (chunk: Buffer) => log(chunk, "stdout")); + child.stderr?.on("data", (chunk: Buffer) => log(chunk, "stderr")); + return child; +} + +async function loadHarnessEnvironment(repoRoot: string): Promise { + const envTestPath = path.join(repoRoot, ".env.test"); + if (existsSync(envTestPath)) dotenv.config({ path: envTestPath }); +} + +export default async function globalSetup() { + const repoRoot = path.resolve(__dirname, "../../../.."); + await loadHarnessEnvironment(repoRoot); + + const metroPort = await getAvailableE2EPort(); + const metroOutput = createLineBuffer(); + let metroProcess: ChildProcess | null = null; + + try { + metroProcess = startMetro(metroPort, metroOutput); + await waitForMetro(metroPort, { + label: "Metro web server", + timeoutMs: 120_000, + childProcess: metroProcess, + getRecentOutput: metroOutput.dump, + }); + await warmMetro(metroPort); + process.env.E2E_METRO_PORT = String(metroPort); + console.log(`[e2e] Metro warmed on port ${metroPort}`); + + return async () => { + await stopProcess(metroProcess); + console.log("[e2e] Metro stopped"); + }; + } catch (error) { + await stopProcess(metroProcess); + throw error; + } +} diff --git a/packages/app/e2e/helpers/add-project-flow.ts b/packages/app/e2e/support/helpers/add-project-flow.ts similarity index 100% rename from packages/app/e2e/helpers/add-project-flow.ts rename to packages/app/e2e/support/helpers/add-project-flow.ts diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/support/helpers/agent-bottom-anchor.ts similarity index 91% rename from packages/app/e2e/helpers/agent-bottom-anchor.ts rename to packages/app/e2e/support/helpers/agent-bottom-anchor.ts index c13244ef2..ed8951b94 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/support/helpers/agent-bottom-anchor.ts @@ -111,8 +111,34 @@ export async function scrollChatAwayFromBottom( if (!box) { throw new Error("Agent chat scroll container is not visible"); } + const wheelSettled = scroll.evaluate( + (root: Element) => + new Promise((resolve) => { + root.addEventListener( + "wheel", + () => { + const scrollElement = root as HTMLElement; + let previousOffset = scrollElement.scrollTop; + let stableFrames = 0; + const sample = () => { + const currentOffset = scrollElement.scrollTop; + stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0; + previousOffset = currentOffset; + if (stableFrames >= 3) { + resolve(); + return; + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, + { once: true }, + ); + }), + ); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.wheel(0, input.deltaY); + await wheelSettled; await expect .poll(async () => { diff --git a/packages/app/e2e/helpers/agent-message-gate.ts b/packages/app/e2e/support/helpers/agent-message-gate.ts similarity index 100% rename from packages/app/e2e/helpers/agent-message-gate.ts rename to packages/app/e2e/support/helpers/agent-message-gate.ts diff --git a/packages/app/e2e/helpers/agent-stream.ts b/packages/app/e2e/support/helpers/agent-stream.ts similarity index 100% rename from packages/app/e2e/helpers/agent-stream.ts rename to packages/app/e2e/support/helpers/agent-stream.ts diff --git a/packages/app/e2e/helpers/agent-timeline-gate.ts b/packages/app/e2e/support/helpers/agent-timeline-gate.ts similarity index 100% rename from packages/app/e2e/helpers/agent-timeline-gate.ts rename to packages/app/e2e/support/helpers/agent-timeline-gate.ts diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/support/helpers/app.ts similarity index 100% rename from packages/app/e2e/helpers/app.ts rename to packages/app/e2e/support/helpers/app.ts diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/support/helpers/archive-tab.ts similarity index 98% rename from packages/app/e2e/helpers/archive-tab.ts rename to packages/app/e2e/support/helpers/archive-tab.ts index 979768c68..201bb189a 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/support/helpers/archive-tab.ts @@ -254,7 +254,8 @@ export async function clickSessionRow(page: Page, title: string): Promise } export async function expectSessionsEmptyState(page: Page): Promise { - // Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts. + // Guard: the sessions-empty spec owns a pristine daemon, so this helper only + // needs to distinguish an empty result from the expected seeded rows. await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 }); await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 }); } diff --git a/packages/app/e2e/helpers/assistant-fork.ts b/packages/app/e2e/support/helpers/assistant-fork.ts similarity index 100% rename from packages/app/e2e/helpers/assistant-fork.ts rename to packages/app/e2e/support/helpers/assistant-fork.ts diff --git a/packages/app/e2e/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts similarity index 100% rename from packages/app/e2e/helpers/assistant-images.ts rename to packages/app/e2e/support/helpers/assistant-images.ts diff --git a/packages/app/e2e/helpers/branch-switcher.ts b/packages/app/e2e/support/helpers/branch-switcher.ts similarity index 100% rename from packages/app/e2e/helpers/branch-switcher.ts rename to packages/app/e2e/support/helpers/branch-switcher.ts diff --git a/packages/app/e2e/helpers/command-center-agent-controls.ts b/packages/app/e2e/support/helpers/command-center-agent-controls.ts similarity index 100% rename from packages/app/e2e/helpers/command-center-agent-controls.ts rename to packages/app/e2e/support/helpers/command-center-agent-controls.ts diff --git a/packages/app/e2e/helpers/command-center-scroll.ts b/packages/app/e2e/support/helpers/command-center-scroll.ts similarity index 100% rename from packages/app/e2e/helpers/command-center-scroll.ts rename to packages/app/e2e/support/helpers/command-center-scroll.ts diff --git a/packages/app/e2e/helpers/command-center-workspace-actions.ts b/packages/app/e2e/support/helpers/command-center-workspace-actions.ts similarity index 100% rename from packages/app/e2e/helpers/command-center-workspace-actions.ts rename to packages/app/e2e/support/helpers/command-center-workspace-actions.ts diff --git a/packages/app/e2e/helpers/command-center.ts b/packages/app/e2e/support/helpers/command-center.ts similarity index 100% rename from packages/app/e2e/helpers/command-center.ts rename to packages/app/e2e/support/helpers/command-center.ts diff --git a/packages/app/e2e/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts similarity index 100% rename from packages/app/e2e/helpers/composer.ts rename to packages/app/e2e/support/helpers/composer.ts diff --git a/packages/app/e2e/helpers/daemon-client-loader.ts b/packages/app/e2e/support/helpers/daemon-client-loader.ts similarity index 94% rename from packages/app/e2e/helpers/daemon-client-loader.ts rename to packages/app/e2e/support/helpers/daemon-client-loader.ts index b9a6e7b22..b36bbae32 100644 --- a/packages/app/e2e/helpers/daemon-client-loader.ts +++ b/packages/app/e2e/support/helpers/daemon-client-loader.ts @@ -8,7 +8,7 @@ import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws export async function loadDaemonClientConstructor(): Promise< new (config: ClientConfig) => ClientInstance > { - const repoRoot = path.resolve(__dirname, "../../../../"); + const repoRoot = path.resolve(__dirname, "../../../../../"); const moduleUrl = pathToFileURL( path.join(repoRoot, "packages/client/dist/daemon-client.js"), ).href; @@ -57,7 +57,7 @@ export async function connectDaemonClient; +} + +function resolveOptionalHome(value: string | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + return resolvePaseoHomePath(trimmed === "current" ? "~/.paseo" : trimmed); +} + +async function createFakeEditorBin(): Promise { + const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-")); + let realGhPath = ""; + try { + realGhPath = execSync("which gh").toString().trim(); + } catch { + // The local GitHub fixture remains usable without a system gh binary. + } + + const fakeEditorSource = `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH; +if (recordPath) { + fs.appendFileSync(recordPath, JSON.stringify({ + command: path.basename(process.argv[1]), + args: process.argv.slice(2), + cwd: process.cwd(), + at: Date.now() + }) + "\\n"); +} +`; + for (const editorCommand of ["cursor", "code"]) { + const editorPath = path.join(binDir, editorCommand); + await writeFile(editorPath, fakeEditorSource); + await chmod(editorPath, 0o755); + } + + const fakeGhPath = path.join(binDir, "gh"); + const fakeGhSource = `#!/usr/bin/env node +const { spawnSync } = require("child_process"); +const args = process.argv.slice(2); +const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git"; +const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] +}).stdout?.trim(); + +if (origin === fixtureRemote) { + const command = args.slice(0, 2).join(" "); + if (command === "auth status") process.exit(0); + if (command === "repo view") { + process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null })); + process.exit(0); + } + if (command === "issue list") { + process.stdout.write("[]"); + process.exit(0); + } + if (command === "pr list" || command === "pr view") { + const pr = { + number: 1, + title: "Use pasted PR as start ref", + url: "https://github.com/paseo-e2e/local-fixture/pull/1", + state: "OPEN", + body: null, + labels: [], + baseRefName: "main", + headRefName: "pr-branch-1", + updatedAt: "2026-01-01T00:00:00Z" + }; + process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr)); + process.exit(0); + } + if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) { + process.stdout.write(JSON.stringify({ + data: { repository: { pullRequest: { + number: 1, + baseRefName: "main", + headRefName: "pr-branch-1", + isCrossRepository: false, + headRepositoryOwner: { login: "paseo-e2e" }, + headRepository: { + sshUrl: "git@github.com:paseo-e2e/local-fixture.git", + url: fixtureRemote + } + } } } + })); + process.exit(0); + } + process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n"); + process.exit(1); +} + +const realGhPath = ${JSON.stringify(realGhPath)}; +if (!realGhPath) process.exit(127); +const result = spawnSync(realGhPath, args, { stdio: "inherit" }); +process.exit(result.status ?? 1); +`; + await writeFile(fakeGhPath, fakeGhSource); + await chmod(fakeGhPath, 0o755); + return binDir; +} + +async function applyMetadataFork(targetHome: string): Promise { + const sourceHome = resolveOptionalHome(process.env.E2E_FORK_PASEO_HOME_FROM); + if (!sourceHome) return; + const result = await forkPaseoHomeMetadata({ sourceHome, targetHome }); + process.env.E2E_FORK_SOURCE_PASEO_HOME = result.sourceHome; + process.env.E2E_FORK_TARGET_PASEO_HOME = result.targetHome; + process.env.E2E_FORK_COPIED_FILES = String(result.copiedFiles); + process.env.E2E_FORK_COPIED_BYTES = String(result.copiedBytes); +} + +export async function startE2EWorker(workerIndex: number): Promise { + const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME); + const paseoHome = requestedRoot + ? path.join(requestedRoot, `worker-${workerIndex}`) + : await mkdtemp(path.join(tmpdir(), `paseo-e2e-worker-${workerIndex}-`)); + const preserveHome = Boolean(requestedRoot) || process.env.E2E_KEEP_PASEO_HOME === "1"; + const fakeEditorBin = await createFakeEditorBin(); + const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl"); + const serverId = `srv_e2e_worker_${workerIndex}`; + + try { + await applyMetadataFork(paseoHome); + const daemon = await startIsolatedHostDaemon(serverId, { + paseoHome, + preserveHome, + environment: { + NODE_ENV: "development", + PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`, + PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath, + }, + }); + + process.env.E2E_DAEMON_PORT = String(daemon.port); + process.env.E2E_SERVER_ID = daemon.serverId; + process.env.E2E_PASEO_HOME = daemon.paseoHome; + process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath; + delete process.env.E2E_RELAY_PORT; + delete process.env.E2E_RELAY_DAEMON_PUBLIC_KEY; + + console.log( + `[e2e] Worker ${workerIndex} daemon started on port ${daemon.port}, home: ${daemon.paseoHome}`, + ); + return { + close: async () => { + await daemon.close(); + await rm(fakeEditorBin, { recursive: true, force: true }); + console.log(`[e2e] Worker ${workerIndex} daemon stopped`); + }, + }; + } catch (error) { + await rm(fakeEditorBin, { recursive: true, force: true }); + if (!preserveHome) await rm(paseoHome, { recursive: true, force: true }); + throw error; + } +} diff --git a/packages/app/e2e/helpers/file-callouts.ts b/packages/app/e2e/support/helpers/file-callouts.ts similarity index 100% rename from packages/app/e2e/helpers/file-callouts.ts rename to packages/app/e2e/support/helpers/file-callouts.ts diff --git a/packages/app/e2e/helpers/file-explorer.ts b/packages/app/e2e/support/helpers/file-explorer.ts similarity index 100% rename from packages/app/e2e/helpers/file-explorer.ts rename to packages/app/e2e/support/helpers/file-explorer.ts diff --git a/packages/app/e2e/helpers/github-fixtures.ts b/packages/app/e2e/support/helpers/github-fixtures.ts similarity index 100% rename from packages/app/e2e/helpers/github-fixtures.ts rename to packages/app/e2e/support/helpers/github-fixtures.ts diff --git a/packages/app/e2e/helpers/hosts.ts b/packages/app/e2e/support/helpers/hosts.ts similarity index 100% rename from packages/app/e2e/helpers/hosts.ts rename to packages/app/e2e/support/helpers/hosts.ts diff --git a/packages/app/e2e/helpers/isolated-host-daemon.ts b/packages/app/e2e/support/helpers/isolated-host-daemon.ts similarity index 91% rename from packages/app/e2e/helpers/isolated-host-daemon.ts rename to packages/app/e2e/support/helpers/isolated-host-daemon.ts index c24ac5de9..c6dcd2d01 100644 --- a/packages/app/e2e/helpers/isolated-host-daemon.ts +++ b/packages/app/e2e/support/helpers/isolated-host-daemon.ts @@ -15,11 +15,14 @@ export interface IsolatedHostDaemon { close(): Promise; } -interface IsolatedHostDaemonOptions { +export interface IsolatedHostDaemonOptions { + environment?: NodeJS.ProcessEnv; mutableRelay?: { enabled: boolean; endpoint?: string; }; + paseoHome?: string; + preserveHome?: boolean; } async function getAvailablePort(): Promise { @@ -95,7 +98,8 @@ export async function startIsolatedHostDaemon( const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon"); - const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-")); + const paseoHome = + options.paseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-secondary-host-"))); if (options.mutableRelay) { const endpoint = options.mutableRelay.endpoint ?? @@ -116,13 +120,14 @@ export async function startIsolatedHostDaemon( })}\n`, ); } - const serverDir = path.resolve(__dirname, "../../../server"); + const serverDir = path.resolve(__dirname, "../../../../server"); const tsxBin = execSync("which tsx").toString().trim(); const spawnDaemon = async (): Promise => { const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], { cwd: serverDir, env: withDisabledE2ESpeechEnv({ ...process.env, + ...options.environment, PASEO_HOME: paseoHome, PASEO_SERVER_ID: serverId, PASEO_LISTEN: `127.0.0.1:${port}`, @@ -157,7 +162,9 @@ export async function startIsolatedHostDaemon( try { child = await spawnDaemon(); } catch (error) { - await rm(paseoHome, { recursive: true, force: true }); + if (!options.preserveHome) { + await rm(paseoHome, { recursive: true, force: true }); + } throw error; } let closed = false; @@ -176,7 +183,9 @@ export async function startIsolatedHostDaemon( if (closed) return; closed = true; await stopProcess(child); - await rm(paseoHome, { recursive: true, force: true }); + if (!options.preserveHome) { + await rm(paseoHome, { recursive: true, force: true }); + } }, }; } diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/support/helpers/launcher.ts similarity index 99% rename from packages/app/e2e/helpers/launcher.ts rename to packages/app/e2e/support/helpers/launcher.ts index 87a9bcab1..0c7debd19 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/support/helpers/launcher.ts @@ -1,5 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes"; import { createTempGitRepo } from "./workspace"; import { getServerId } from "./server-id"; diff --git a/packages/app/e2e/helpers/mock-agent.ts b/packages/app/e2e/support/helpers/mock-agent.ts similarity index 97% rename from packages/app/e2e/helpers/mock-agent.ts rename to packages/app/e2e/support/helpers/mock-agent.ts index 82dad5169..8999cab85 100644 --- a/packages/app/e2e/helpers/mock-agent.ts +++ b/packages/app/e2e/support/helpers/mock-agent.ts @@ -1,7 +1,7 @@ import type { Page } from "@playwright/test"; import { seedWorkspace, type SeedDaemonClient } from "./seed-client"; import { getServerId } from "./server-id"; -import { buildHostAgentDetailRoute } from "../../src/utils/host-routes"; +import { buildHostAgentDetailRoute } from "../../../src/utils/host-routes"; export interface MockAgentWorkspace { agentId: string; diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts similarity index 98% rename from packages/app/e2e/helpers/new-workspace.ts rename to packages/app/e2e/support/helpers/new-workspace.ts index 55caebd6b..4d451c147 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -5,6 +5,7 @@ import { connectDaemonClient } from "./daemon-client-loader"; import { daemonWsRoutePattern } from "./daemon-port"; import { projectEquivalenceViewKey } from "./project-view-key"; import { expectWorkspaceHeader } from "./workspace-ui"; +import { withProjectOwnership } from "./project-ownership"; type NewWorkspaceDaemonClient = Pick< InternalDaemonClient, @@ -103,11 +104,15 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul export async function connectNewWorkspaceDaemonClient(options?: { port?: number; + ownProjects?: boolean; }): Promise { - return connectDaemonClient({ + const client = await connectDaemonClient({ clientIdPrefix: "app-e2e-new-workspace", port: options?.port, }); + if (options?.ownProjects === false) return client; + + return withProjectOwnership(client); } export async function openProjectViaDaemon( diff --git a/packages/app/e2e/helpers/no-truncation.ts b/packages/app/e2e/support/helpers/no-truncation.ts similarity index 100% rename from packages/app/e2e/helpers/no-truncation.ts rename to packages/app/e2e/support/helpers/no-truncation.ts diff --git a/packages/app/e2e/helpers/node-ws-factory.ts b/packages/app/e2e/support/helpers/node-ws-factory.ts similarity index 100% rename from packages/app/e2e/helpers/node-ws-factory.ts rename to packages/app/e2e/support/helpers/node-ws-factory.ts diff --git a/packages/app/e2e/helpers/pair-device.ts b/packages/app/e2e/support/helpers/pair-device.ts similarity index 100% rename from packages/app/e2e/helpers/pair-device.ts rename to packages/app/e2e/support/helpers/pair-device.ts diff --git a/packages/app/e2e/helpers/paseo-home-fork.ts b/packages/app/e2e/support/helpers/paseo-home-fork.ts similarity index 100% rename from packages/app/e2e/helpers/paseo-home-fork.ts rename to packages/app/e2e/support/helpers/paseo-home-fork.ts diff --git a/packages/app/e2e/helpers/permissions.ts b/packages/app/e2e/support/helpers/permissions.ts similarity index 100% rename from packages/app/e2e/helpers/permissions.ts rename to packages/app/e2e/support/helpers/permissions.ts diff --git a/packages/app/e2e/helpers/pins.ts b/packages/app/e2e/support/helpers/pins.ts similarity index 92% rename from packages/app/e2e/helpers/pins.ts rename to packages/app/e2e/support/helpers/pins.ts index f7a08d3e1..e68c2c24c 100644 --- a/packages/app/e2e/helpers/pins.ts +++ b/packages/app/e2e/support/helpers/pins.ts @@ -1,6 +1,6 @@ import { expect, type Page } from "@playwright/test"; -import type { PinnedTabTarget } from "../../src/workspace-pins/target"; -import { pinnedTargetKey } from "../../src/workspace-pins/target"; +import type { PinnedTabTarget } from "../../../src/workspace-pins/target"; +import { pinnedTargetKey } from "../../../src/workspace-pins/target"; // The new-tab dropdown menu items carry no stable web ARIA role for the inline // pin toggle (it lives inside a reveal-on-hover slot), so the pins flow is diff --git a/packages/app/e2e/helpers/pr-pane.ts b/packages/app/e2e/support/helpers/pr-pane.ts similarity index 100% rename from packages/app/e2e/helpers/pr-pane.ts rename to packages/app/e2e/support/helpers/pr-pane.ts diff --git a/packages/app/e2e/helpers/project-grouping.ts b/packages/app/e2e/support/helpers/project-grouping.ts similarity index 100% rename from packages/app/e2e/helpers/project-grouping.ts rename to packages/app/e2e/support/helpers/project-grouping.ts diff --git a/packages/app/e2e/support/helpers/project-ownership.ts b/packages/app/e2e/support/helpers/project-ownership.ts new file mode 100644 index 000000000..a8e4cc7e8 --- /dev/null +++ b/packages/app/e2e/support/helpers/project-ownership.ts @@ -0,0 +1,64 @@ +interface OwnedResourceResult { + project?: { projectId: string } | null; + workspace?: { projectId: string } | null; +} + +interface ProjectClient { + addProject?: (...args: unknown[]) => Promise; + close(): Promise; + createPaseoWorktree?: (...args: unknown[]) => Promise; + createWorkspace?: (...args: unknown[]) => Promise; + removeProject(projectId: string): Promise; +} + +/** + * Makes a test client own every project it creates. Closing the client removes + * those projects first, which also terminates their workspaces and terminals. + */ +export function withProjectOwnership(client: Client): Client { + const target = client as Client & ProjectClient; + const projectIds = new Set(); + let closed = false; + + async function trackProject(result: OwnedResourceResult) { + const projectId = result.project?.projectId ?? result.workspace?.projectId; + if (projectId) projectIds.add(projectId); + return result; + } + + return new Proxy(client, { + get(_target, property) { + if (property === "addProject" && target.addProject) { + const addProject = target.addProject.bind(target); + return async (...args: unknown[]) => trackProject(await addProject(...args)); + } + if (property === "createWorkspace" && target.createWorkspace) { + const createWorkspace = target.createWorkspace.bind(target); + return async (...args: unknown[]) => trackProject(await createWorkspace(...args)); + } + if (property === "createPaseoWorktree" && target.createPaseoWorktree) { + const createPaseoWorktree = target.createPaseoWorktree.bind(target); + return async (...args: unknown[]) => trackProject(await createPaseoWorktree(...args)); + } + if (property === "removeProject") { + return async (projectId: string) => { + const result = await target.removeProject(projectId); + projectIds.delete(projectId); + return result; + }; + } + if (property === "close") { + return async () => { + if (closed) return; + closed = true; + for (const projectId of projectIds) { + await target.removeProject(projectId).catch(() => undefined); + } + await target.close(); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} diff --git a/packages/app/e2e/helpers/project-picker-fixture.ts b/packages/app/e2e/support/helpers/project-picker-fixture.ts similarity index 100% rename from packages/app/e2e/helpers/project-picker-fixture.ts rename to packages/app/e2e/support/helpers/project-picker-fixture.ts diff --git a/packages/app/e2e/helpers/project-picker-ui.ts b/packages/app/e2e/support/helpers/project-picker-ui.ts similarity index 100% rename from packages/app/e2e/helpers/project-picker-ui.ts rename to packages/app/e2e/support/helpers/project-picker-ui.ts diff --git a/packages/app/e2e/helpers/project-settings.ts b/packages/app/e2e/support/helpers/project-settings.ts similarity index 100% rename from packages/app/e2e/helpers/project-settings.ts rename to packages/app/e2e/support/helpers/project-settings.ts diff --git a/packages/app/e2e/helpers/project-view-key.ts b/packages/app/e2e/support/helpers/project-view-key.ts similarity index 100% rename from packages/app/e2e/helpers/project-view-key.ts rename to packages/app/e2e/support/helpers/project-view-key.ts diff --git a/packages/app/e2e/helpers/provider-usage.ts b/packages/app/e2e/support/helpers/provider-usage.ts similarity index 100% rename from packages/app/e2e/helpers/provider-usage.ts rename to packages/app/e2e/support/helpers/provider-usage.ts diff --git a/packages/app/e2e/helpers/questions.ts b/packages/app/e2e/support/helpers/questions.ts similarity index 100% rename from packages/app/e2e/helpers/questions.ts rename to packages/app/e2e/support/helpers/questions.ts diff --git a/packages/app/e2e/helpers/regex.ts b/packages/app/e2e/support/helpers/regex.ts similarity index 100% rename from packages/app/e2e/helpers/regex.ts rename to packages/app/e2e/support/helpers/regex.ts diff --git a/packages/app/e2e/helpers/rename.ts b/packages/app/e2e/support/helpers/rename.ts similarity index 100% rename from packages/app/e2e/helpers/rename.ts rename to packages/app/e2e/support/helpers/rename.ts diff --git a/packages/app/e2e/helpers/rewind-flow.ts b/packages/app/e2e/support/helpers/rewind-flow.ts similarity index 100% rename from packages/app/e2e/helpers/rewind-flow.ts rename to packages/app/e2e/support/helpers/rewind-flow.ts diff --git a/packages/app/e2e/helpers/route-assertions.ts b/packages/app/e2e/support/helpers/route-assertions.ts similarity index 100% rename from packages/app/e2e/helpers/route-assertions.ts rename to packages/app/e2e/support/helpers/route-assertions.ts diff --git a/packages/app/e2e/helpers/schedule-fake-host.ts b/packages/app/e2e/support/helpers/schedule-fake-host.ts similarity index 100% rename from packages/app/e2e/helpers/schedule-fake-host.ts rename to packages/app/e2e/support/helpers/schedule-fake-host.ts diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts similarity index 93% rename from packages/app/e2e/helpers/seed-client.ts rename to packages/app/e2e/support/helpers/seed-client.ts index 03ed14e2b..f498fb382 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { readFileSync } from "node:fs"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { connectDaemonClient } from "./daemon-client-loader"; +import { withProjectOwnership } from "./project-ownership"; import { createTempDirectory, createTempGitRepo } from "./workspace"; export interface SeedWorkspaceDescriptor { @@ -167,12 +168,17 @@ export interface SeedDaemonClient { killTerminal(terminalId: string): Promise<{ error: string | null }>; } -export async function connectSeedClient(options?: { port?: number }): Promise { - return connectDaemonClient({ +export async function connectSeedClient(options?: { + port?: number; + /** Use only with a private host whose teardown removes its entire PASEO_HOME. */ + projectOwnership?: "client" | "host"; +}): Promise { + const client = await connectDaemonClient({ clientIdPrefix: "seed", appVersion: loadAppVersion(), port: options?.port, }); + return options?.projectOwnership === "host" ? client : withProjectOwnership(client); } /** @@ -200,6 +206,7 @@ export interface SeededWorkspace { export async function seedWorkspace(options: { repoPrefix: string; title?: string; + port?: number; /** Repo fixture options; only applies to git projects (the default). */ repo?: Parameters[1]; /** Set to false to seed a plain non-git directory instead of a git repo. */ @@ -209,7 +216,7 @@ export async function seedWorkspace(options: { options.git === false ? await createTempDirectory(options.repoPrefix) : await createTempGitRepo(options.repoPrefix, options.repo); - const client = await connectSeedClient(); + const client = await connectSeedClient({ port: options.port }); try { const created = await client.createWorkspace({ source: { kind: "directory", path: project.path }, @@ -247,7 +254,7 @@ export async function seedWorkspace(options: { } function loadAppVersion(): string { - const packageJsonPath = path.resolve(__dirname, "../../package.json"); + const packageJsonPath = path.resolve(__dirname, "../../../package.json"); const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { throw new Error(`Missing app version in ${packageJsonPath}`); diff --git a/packages/app/e2e/helpers/server-id.ts b/packages/app/e2e/support/helpers/server-id.ts similarity index 81% rename from packages/app/e2e/helpers/server-id.ts rename to packages/app/e2e/support/helpers/server-id.ts index ae0fac983..fbf355cb0 100644 --- a/packages/app/e2e/helpers/server-id.ts +++ b/packages/app/e2e/support/helpers/server-id.ts @@ -7,7 +7,7 @@ export function getServerId(): string { const serverId = process.env.E2E_SERVER_ID; if (!serverId) { - throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + throw new Error("E2E_SERVER_ID is not set (expected from the Playwright worker fixture)."); } return serverId; } diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts similarity index 100% rename from packages/app/e2e/helpers/settings.ts rename to packages/app/e2e/support/helpers/settings.ts diff --git a/packages/app/e2e/helpers/settled.ts b/packages/app/e2e/support/helpers/settled.ts similarity index 100% rename from packages/app/e2e/helpers/settled.ts rename to packages/app/e2e/support/helpers/settled.ts diff --git a/packages/app/e2e/helpers/sheet-layout.ts b/packages/app/e2e/support/helpers/sheet-layout.ts similarity index 100% rename from packages/app/e2e/helpers/sheet-layout.ts rename to packages/app/e2e/support/helpers/sheet-layout.ts diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts similarity index 100% rename from packages/app/e2e/helpers/sidebar.ts rename to packages/app/e2e/support/helpers/sidebar.ts diff --git a/packages/app/e2e/helpers/speech-env.ts b/packages/app/e2e/support/helpers/speech-env.ts similarity index 100% rename from packages/app/e2e/helpers/speech-env.ts rename to packages/app/e2e/support/helpers/speech-env.ts diff --git a/packages/app/e2e/helpers/startup-dsl.ts b/packages/app/e2e/support/helpers/startup-dsl.ts similarity index 100% rename from packages/app/e2e/helpers/startup-dsl.ts rename to packages/app/e2e/support/helpers/startup-dsl.ts diff --git a/packages/app/e2e/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts similarity index 100% rename from packages/app/e2e/helpers/subagents.ts rename to packages/app/e2e/support/helpers/subagents.ts diff --git a/packages/app/e2e/helpers/terminal-dsl.ts b/packages/app/e2e/support/helpers/terminal-dsl.ts similarity index 100% rename from packages/app/e2e/helpers/terminal-dsl.ts rename to packages/app/e2e/support/helpers/terminal-dsl.ts diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/support/helpers/terminal-perf.ts similarity index 98% rename from packages/app/e2e/helpers/terminal-perf.ts rename to packages/app/e2e/support/helpers/terminal-perf.ts index 3ab56dd5c..ac41b7343 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/support/helpers/terminal-perf.ts @@ -1,5 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; +import { buildHostWorkspaceRoute } from "../../../src/utils/host-routes"; import { getServerId } from "./server-id"; export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string { diff --git a/packages/app/e2e/helpers/terminal-probes.ts b/packages/app/e2e/support/helpers/terminal-probes.ts similarity index 100% rename from packages/app/e2e/helpers/terminal-probes.ts rename to packages/app/e2e/support/helpers/terminal-probes.ts diff --git a/packages/app/e2e/helpers/timeline-delivery.ts b/packages/app/e2e/support/helpers/timeline-delivery.ts similarity index 100% rename from packages/app/e2e/helpers/timeline-delivery.ts rename to packages/app/e2e/support/helpers/timeline-delivery.ts diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts similarity index 100% rename from packages/app/e2e/helpers/timeline-pagination.ts rename to packages/app/e2e/support/helpers/timeline-pagination.ts diff --git a/packages/app/e2e/helpers/timeline-resume.ts b/packages/app/e2e/support/helpers/timeline-resume.ts similarity index 100% rename from packages/app/e2e/helpers/timeline-resume.ts rename to packages/app/e2e/support/helpers/timeline-resume.ts diff --git a/packages/app/e2e/helpers/with-workspace.ts b/packages/app/e2e/support/helpers/with-workspace.ts similarity index 100% rename from packages/app/e2e/helpers/with-workspace.ts rename to packages/app/e2e/support/helpers/with-workspace.ts diff --git a/packages/app/e2e/helpers/workspace-lifecycle.ts b/packages/app/e2e/support/helpers/workspace-lifecycle.ts similarity index 100% rename from packages/app/e2e/helpers/workspace-lifecycle.ts rename to packages/app/e2e/support/helpers/workspace-lifecycle.ts diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/support/helpers/workspace-setup.ts similarity index 97% rename from packages/app/e2e/helpers/workspace-setup.ts rename to packages/app/e2e/support/helpers/workspace-setup.ts index cb5e38600..a6786ae5c 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/support/helpers/workspace-setup.ts @@ -1,10 +1,11 @@ import { realpathSync } from "node:fs"; import { expect, type Page } from "@playwright/test"; import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes"; +import { parseHostWorkspaceRouteFromPathname } from "../../../src/utils/host-routes"; import { gotoAppShell } from "./app"; import { connectDaemonClient } from "./daemon-client-loader"; import { getServerId } from "./server-id"; +import { withProjectOwnership } from "./project-ownership"; import { switchWorkspaceViaSidebar } from "./workspace-ui"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -31,7 +32,10 @@ export type WorkspaceSetupProgressPayload = Extract< export type { WorkspaceSetupDaemonClient }; export async function connectWorkspaceSetupClient(): Promise { - return connectDaemonClient({ clientIdPrefix: "workspace-setup" }); + const client = await connectDaemonClient({ + clientIdPrefix: "workspace-setup", + }); + return withProjectOwnership(client); } export async function openProjectViaDaemon( diff --git a/packages/app/e2e/helpers/workspace-tabs.ts b/packages/app/e2e/support/helpers/workspace-tabs.ts similarity index 100% rename from packages/app/e2e/helpers/workspace-tabs.ts rename to packages/app/e2e/support/helpers/workspace-tabs.ts diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/support/helpers/workspace-ui.ts similarity index 100% rename from packages/app/e2e/helpers/workspace-ui.ts rename to packages/app/e2e/support/helpers/workspace-ui.ts diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/support/helpers/workspace.ts similarity index 100% rename from packages/app/e2e/helpers/workspace.ts rename to packages/app/e2e/support/helpers/workspace.ts diff --git a/packages/app/package.json b/packages/app/package.json index 8d98c1560..280a0217f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -21,8 +21,7 @@ "typecheck": "tsgo --noEmit", "test": "vitest run", "test:browser": "vitest run --project browser", - "test:e2e": "playwright test --project='Desktop Chrome'", - "test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts", + "test:e2e": "playwright test --project=browser", "test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider", "test:e2e:ui": "playwright test --ui", "build": "npm run build:web", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index cc0d117e7..3f7798353 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -6,16 +6,16 @@ const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; export default defineConfig({ - testDir: "./e2e", - globalSetup: "./e2e/global-setup.ts", + testDir: "./e2e/browser", + globalSetup: "./e2e/support/global-setup.ts", timeout: 60_000, expect: { timeout: 10_000, }, - // E2E tests share a single daemon/relay/metro stack from global setup. - // Running tests concurrently causes cross-test contention and non-deterministic failures. + // Files run concurrently, while each worker owns its daemon state. Keeping a + // spec on one worker avoids repeating its file-level setup across daemons. fullyParallel: false, - workers: 1, + workers: Number(process.env.E2E_WORKERS ?? (process.env.CI ? "2" : "1")), retries: process.env.CI ? 1 : 0, reporter: [["list"]], use: { @@ -26,7 +26,7 @@ export default defineConfig({ }, projects: [ { - name: "Desktop Chrome", + name: "browser", testIgnore: ["**/*.real.spec.ts"], use: { ...devices["Desktop Chrome"] }, }, diff --git a/packages/app/src/browser-automation/handler.test.ts b/packages/app/src/desktop/browser/automation/handler.test.ts similarity index 99% rename from packages/app/src/browser-automation/handler.test.ts rename to packages/app/src/desktop/browser/automation/handler.test.ts index 4af65a17f..56c3c28df 100644 --- a/packages/app/src/browser-automation/handler.test.ts +++ b/packages/app/src/desktop/browser/automation/handler.test.ts @@ -3,7 +3,7 @@ import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/pr import { createJSONStorage, type StateStorage } from "zustand/middleware"; import { mountBrowserAutomationHandler } from "./handler"; import type { DesktopHostBridge } from "@/desktop/host"; -import { useBrowserStore } from "@/stores/browser-store"; +import { useBrowserStore } from "@/desktop/browser/store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; diff --git a/packages/app/src/browser-automation/handler.ts b/packages/app/src/desktop/browser/automation/handler.ts similarity index 99% rename from packages/app/src/browser-automation/handler.ts rename to packages/app/src/desktop/browser/automation/handler.ts index b7cf3d7cd..a75e666fa 100644 --- a/packages/app/src/browser-automation/handler.ts +++ b/packages/app/src/desktop/browser/automation/handler.ts @@ -4,8 +4,8 @@ import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault, removeResidentBrowserWebview, resizeResidentBrowserWebview, -} from "@/components/browser-webview-resident"; -import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store"; +} from "@/desktop/browser/resident-webviews"; +import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/desktop/browser/store"; import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; diff --git a/packages/app/src/browser/new-tab-requests/index.test.ts b/packages/app/src/desktop/browser/new-tab-requests/index.test.ts similarity index 100% rename from packages/app/src/browser/new-tab-requests/index.test.ts rename to packages/app/src/desktop/browser/new-tab-requests/index.test.ts diff --git a/packages/app/src/browser/new-tab-requests/index.ts b/packages/app/src/desktop/browser/new-tab-requests/index.ts similarity index 100% rename from packages/app/src/browser/new-tab-requests/index.ts rename to packages/app/src/desktop/browser/new-tab-requests/index.ts diff --git a/packages/app/src/components/browser-pane.electron.tsx b/packages/app/src/desktop/browser/pane/index.electron.tsx similarity index 99% rename from packages/app/src/components/browser-pane.electron.tsx rename to packages/app/src/desktop/browser/pane/index.electron.tsx index f3b9141aa..5130d9ce0 100644 --- a/packages/app/src/components/browser-pane.electron.tsx +++ b/packages/app/src/desktop/browser/pane/index.electron.tsx @@ -49,12 +49,12 @@ import { isElectronRuntime, type DesktopBrowserShortcutEvent, } from "@/desktop/host"; -import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store"; +import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/desktop/browser/store"; import { prepareBrowserWebview, releaseResidentBrowserWebview, takeResidentBrowserWebview, -} from "./browser-webview-resident"; +} from "../resident-webviews"; type ElectronWebview = HTMLElement & { canGoBack?: () => boolean; diff --git a/packages/app/src/components/browser-pane.tsx b/packages/app/src/desktop/browser/pane/index.tsx similarity index 100% rename from packages/app/src/components/browser-pane.tsx rename to packages/app/src/desktop/browser/pane/index.tsx diff --git a/packages/app/src/components/browser-pane.web.tsx b/packages/app/src/desktop/browser/pane/index.web.tsx similarity index 100% rename from packages/app/src/components/browser-pane.web.tsx rename to packages/app/src/desktop/browser/pane/index.web.tsx diff --git a/packages/app/src/panels/browser-panel.tsx b/packages/app/src/desktop/browser/panel.tsx similarity index 95% rename from packages/app/src/panels/browser-panel.tsx rename to packages/app/src/desktop/browser/panel.tsx index 0f6b4a437..16539a3fd 100644 --- a/packages/app/src/panels/browser-panel.tsx +++ b/packages/app/src/desktop/browser/panel.tsx @@ -2,10 +2,10 @@ import { useMemo } from "react"; import { Image } from "react-native"; import { Globe } from "lucide-react-native"; import invariant from "tiny-invariant"; -import { BrowserPane } from "@/components/browser-pane"; +import { BrowserPane } from "@/desktop/browser/pane"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelIconProps, PanelRegistration } from "@/panels/panel-registry"; -import { useBrowserStore } from "@/stores/browser-store"; +import { useBrowserStore } from "@/desktop/browser/store"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; function getBrowserLabel(input: { title: string; url: string }): string { diff --git a/packages/app/src/components/browser-webview-resident.browser.test.ts b/packages/app/src/desktop/browser/resident-webviews.browser.test.ts similarity index 99% rename from packages/app/src/components/browser-webview-resident.browser.test.ts rename to packages/app/src/desktop/browser/resident-webviews.browser.test.ts index c4f4de663..b3208205b 100644 --- a/packages/app/src/components/browser-webview-resident.browser.test.ts +++ b/packages/app/src/desktop/browser/resident-webviews.browser.test.ts @@ -8,11 +8,11 @@ import { releaseResidentBrowserWebview, removeResidentBrowserWebview, takeResidentBrowserWebview, -} from "./browser-webview-resident"; +} from "./resident-webviews"; import { setCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, -} from "../utils/command-center-focus-restore"; +} from "../../utils/command-center-focus-restore"; const RESIDENT_HOST_ID = "paseo-browser-resident-webviews"; const attachedBrowsers: Array<{ diff --git a/packages/app/src/components/browser-webview-resident.ts b/packages/app/src/desktop/browser/resident-webviews.ts similarity index 100% rename from packages/app/src/components/browser-webview-resident.ts rename to packages/app/src/desktop/browser/resident-webviews.ts diff --git a/packages/app/src/desktop/components/browser-data-section.tsx b/packages/app/src/desktop/browser/settings/browser-data-section.tsx similarity index 97% rename from packages/app/src/desktop/components/browser-data-section.tsx rename to packages/app/src/desktop/browser/settings/browser-data-section.tsx index 3d61f922e..0efc3ebf8 100644 --- a/packages/app/src/desktop/components/browser-data-section.tsx +++ b/packages/app/src/desktop/browser/settings/browser-data-section.tsx @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"; import { useToast } from "@/contexts/toast-context"; import { getDesktopHost } from "@/desktop/host"; import { SettingsSection } from "@/screens/settings/settings-section"; -import { useBrowserStore } from "@/stores/browser-store"; +import { useBrowserStore } from "@/desktop/browser/store"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; diff --git a/packages/app/src/keyboard/browser-shortcuts.test.ts b/packages/app/src/desktop/browser/shortcuts.test.ts similarity index 99% rename from packages/app/src/keyboard/browser-shortcuts.test.ts rename to packages/app/src/desktop/browser/shortcuts.test.ts index 9dd323946..804ed7336 100644 --- a/packages/app/src/keyboard/browser-shortcuts.test.ts +++ b/packages/app/src/desktop/browser/shortcuts.test.ts @@ -3,8 +3,8 @@ import { buildBrowserKeyboardPolicy, parseBrowserShortcutInput, shouldPublishBrowserShortcutPolicy, -} from "./browser-shortcuts"; -import { buildEffectiveBindings, resolveKeyboardShortcut } from "./keyboard-shortcuts"; +} from "./shortcuts"; +import { buildEffectiveBindings, resolveKeyboardShortcut } from "../../keyboard/keyboard-shortcuts"; describe("buildBrowserKeyboardPolicy", () => { it("publishes only chord starts while no browser chord is pending", () => { diff --git a/packages/app/src/keyboard/browser-shortcuts.ts b/packages/app/src/desktop/browser/shortcuts.ts similarity index 98% rename from packages/app/src/keyboard/browser-shortcuts.ts rename to packages/app/src/desktop/browser/shortcuts.ts index 003790802..2bd7def5e 100644 --- a/packages/app/src/keyboard/browser-shortcuts.ts +++ b/packages/app/src/desktop/browser/shortcuts.ts @@ -3,8 +3,8 @@ import { matchesKeyboardShortcutContext, type KeyboardShortcutInput, type ParsedShortcutBinding, -} from "./keyboard-shortcuts"; -import type { KeyCombo } from "./shortcut-string"; +} from "../../keyboard/keyboard-shortcuts"; +import type { KeyCombo } from "../../keyboard/shortcut-string"; export interface BrowserShortcutPrefix { alt: boolean; diff --git a/packages/app/src/stores/browser-store/index.ts b/packages/app/src/desktop/browser/store/index.ts similarity index 100% rename from packages/app/src/stores/browser-store/index.ts rename to packages/app/src/desktop/browser/store/index.ts diff --git a/packages/app/src/stores/browser-store/state.test.ts b/packages/app/src/desktop/browser/store/state.test.ts similarity index 100% rename from packages/app/src/stores/browser-store/state.test.ts rename to packages/app/src/desktop/browser/store/state.test.ts diff --git a/packages/app/src/stores/browser-store/state.ts b/packages/app/src/desktop/browser/store/state.ts similarity index 100% rename from packages/app/src/stores/browser-store/state.ts rename to packages/app/src/desktop/browser/store/state.ts diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 152f32abc..2c8c17680 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -1,6 +1,6 @@ import { Platform } from "react-native"; import { getElectronHost } from "@/desktop/electron/host"; -import type { BrowserKeyboardPolicy } from "@/keyboard/browser-shortcuts"; +import type { BrowserKeyboardPolicy } from "@/desktop/browser/shortcuts"; import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; type BrowserAutomationExecuteRequest = Extract< diff --git a/packages/app/src/e2e-metro-readiness.test.ts b/packages/app/src/e2e-metro-readiness.test.ts index 0ba5df9e3..68b136157 100644 --- a/packages/app/src/e2e-metro-readiness.test.ts +++ b/packages/app/src/e2e-metro-readiness.test.ts @@ -1,10 +1,11 @@ import { createServer, type Server } from "node:http"; import { afterEach, expect, test } from "vitest"; -import { waitForMetro } from "../e2e/global-setup"; +import { waitForMetro, warmMetro } from "../e2e/support/global-setup"; class MetroPort { - private response = { status: 500, body: "fallback" }; + private readonly responses = new Map(); + readonly requests: string[] = []; private constructor( readonly port: number, @@ -13,9 +14,12 @@ class MetroPort { static async listen(): Promise { let endpoint!: MetroPort; - const server = createServer((_request, response) => { - response.writeHead(endpoint.response.status, { "content-type": "text/plain" }); - response.end(endpoint.response.body); + const server = createServer((request, response) => { + const pathname = new URL(request.url ?? "/", "http://localhost").pathname; + endpoint.requests.push(pathname); + const served = endpoint.responses.get(pathname) ?? { status: 500, body: "fallback" }; + response.writeHead(served.status, { "content-type": "text/plain" }); + response.end(served.body); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); @@ -28,7 +32,15 @@ class MetroPort { } serveMetro(): void { - this.response = { status: 200, body: "packager-status:running" }; + this.responses.set("/status", { status: 200, body: "packager-status:running" }); + } + + serveWarmableDocument(): void { + this.responses.set("/", { + status: 200, + body: '', + }); + this.responses.set("/index.bundle", { status: 200, body: "compiled bundle" }); } async close(): Promise { @@ -63,3 +75,12 @@ test("Metro readiness rejects another HTTP listener on the selected port", async undefined, ); }); + +test("Metro warmup compiles the document's same-origin scripts before tests start", async () => { + endpoint = await MetroPort.listen(); + endpoint.serveWarmableDocument(); + + await warmMetro(endpoint.port); + + expect(endpoint.requests).toEqual(["/", "/index.bundle"]); +}); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index b6f7f03c9..607b90119 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -3,7 +3,7 @@ import { usePathname, useRouter } from "expo-router"; import { getIsElectronRuntime } from "@/constants/layout"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; -import { getResidentBrowserWebview } from "@/components/browser-webview-resident"; +import { getResidentBrowserWebview } from "@/desktop/browser/resident-webviews"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { @@ -18,7 +18,7 @@ import { buildBrowserKeyboardPolicy, parseBrowserShortcutInput, shouldPublishBrowserShortcutPolicy, -} from "@/keyboard/browser-shortcuts"; +} from "@/desktop/browser/shortcuts"; import type { KeyboardFocusScope, KeyboardShortcutPayload } from "@/keyboard/actions"; import { routeKeyboardShortcut, diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index 9b8d37069..0e9ed7ad8 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -1,5 +1,5 @@ import { agentPanelRegistration } from "@/panels/agent-panel"; -import { browserPanelRegistration } from "@/panels/browser-panel"; +import { browserPanelRegistration } from "@/desktop/browser/panel"; import { commitDiffPanelRegistration, workingDiffPanelRegistration } from "@/panels/diff-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 6d05aa0e7..96f51fecd 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -47,7 +47,7 @@ import { invalidateServerDataQueriesAfterReconnect, mountServerDataPushRouter, } from "@/data/push-router"; -import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler"; +import { mountBrowserAutomationDaemonClientHandler } from "@/desktop/browser/automation/handler"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions"; import { createMessageSubmissionWriter } from "@/composer/submission/writer"; diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 942f2f2a6..0e0e85191 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -77,7 +77,7 @@ import { CommunityLinks } from "@/components/community-links"; import { SegmentedControl } from "@/components/ui/segmented-control"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; -import { BrowserDataSection } from "@/desktop/components/browser-data-section"; +import { BrowserDataSection } from "@/desktop/browser/settings/browser-data-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 025ae8003..9126a4a3b 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -110,8 +110,8 @@ import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { confirmDialog } from "@/utils/confirm-dialog"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { removeResidentBrowserWebview } from "@/components/browser-webview-resident"; -import { createWorkspaceBrowser, useBrowserStore } from "@/stores/browser-store"; +import { removeResidentBrowserWebview } from "@/desktop/browser/resident-webviews"; +import { createWorkspaceBrowser, useBrowserStore } from "@/desktop/browser/store"; import { getDesktopHost } from "@/desktop/host"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; @@ -135,7 +135,7 @@ import { type WorkspaceTabMenuEntry, type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; -import { useDesktopBrowserNewTabRequests } from "@/browser/new-tab-requests"; +import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { resolveWorkspaceHeaderRenderState, diff --git a/packages/cli/src/commands/hooks.test.ts b/packages/cli/src/commands/hooks.test.ts index 93f2219be..3f8c31d85 100644 --- a/packages/cli/src/commands/hooks.test.ts +++ b/packages/cli/src/commands/hooks.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { AGENT_HOOK_PROVIDERS } from "@getpaseo/server/agent-hooks"; import { describe, expect, it } from "vitest"; import { runHooksCommand } from "./hooks.js"; @@ -75,6 +76,14 @@ function expectPostedState(fetch: RecordingFetch, state: string) { } describe("runHooksCommand", () => { + it("keeps provider names out of the generic command", () => { + const source = readFileSync(new URL("./hooks.ts", import.meta.url), "utf8").toLowerCase(); + + for (const providerId of Object.keys(AGENT_HOOK_PROVIDERS)) { + expect(source).not.toContain(providerId); + } + }); + it.each([ [claudeProvider.events[0].event, "running"], [claudeProvider.events[1].event, "idle"], diff --git a/packages/cli/tests/26-daemon-launch-supervision.test.ts b/packages/cli/tests/26-daemon-launch-supervision.test.ts deleted file mode 100644 index bf9f2fa16..000000000 --- a/packages/cli/tests/26-daemon-launch-supervision.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env npx tsx - -/** - * Regression: executable daemon launch commands must enter the supervisor. - * The worker entry remains an implementation detail of supervisor-entrypoint. - */ - -import assert from "node:assert"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - -const repoRoot = join(import.meta.dirname, "../../.."); -const serverPackagePath = join(repoRoot, "packages/server/package.json"); -const appGlobalSetupPath = join(repoRoot, "packages/app/e2e/global-setup.ts"); -const serverConnectionOfferE2ePath = join( - repoRoot, - "packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts", -); -const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts"); -const nixPackagePath = join(repoRoot, "nix/package.nix"); -const nixModulePath = join(repoRoot, "nix/module.nix"); - -function assertNoDirectWorkerLaunch(label: string, command: string): void { - assert( - !command.includes("src/server/index.ts"), - `${label} must not launch src/server/index.ts directly: ${command}`, - ); - assert( - !command.includes("dist/server/server/index.js"), - `${label} must not launch dist/server/server/index.js directly: ${command}`, - ); - assert( - !command.includes("src/server/daemon-worker.ts"), - `${label} must not launch src/server/daemon-worker.ts directly: ${command}`, - ); - assert( - !command.includes("dist/server/server/daemon-worker.js"), - `${label} must not launch dist/server/server/daemon-worker.js directly: ${command}`, - ); -} - -function assertNoSpawnedWorkerEntrypoint(label: string, source: string): void { - assertNoDirectWorkerLaunch(label, source); - assert( - !/spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s.test(source), - `${label} must not spawn ../index.ts directly`, - ); -} - -console.log("=== Daemon Launch Supervision Regression ===\n"); - -console.log("Test 1: server package scripts launch supervisor-entrypoint"); -const serverPackage = JSON.parse(await readFile(serverPackagePath, "utf-8")) as { - scripts?: Record; -}; -const startScript = serverPackage.scripts?.start ?? ""; -const devScript = serverPackage.scripts?.dev ?? ""; -const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? ""; - -assert(startScript.includes("dist/scripts/supervisor-entrypoint.js"), startScript); -assertNoDirectWorkerLaunch("server start script", startScript); -assert(devScript.includes("scripts/dev-runner.ts"), devScript); -assertNoDirectWorkerLaunch("server dev script", devScript); -assert(devTsxScript.includes("scripts/dev-runner.ts"), devTsxScript); -assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript); -console.log("✓ server package scripts enter supervisor\n"); - -console.log("Test 2: app e2e global setup launches supervisor-entrypoint in dev mode"); -const appGlobalSetup = await readFile(appGlobalSetupPath, "utf-8"); -assert( - appGlobalSetup.includes('spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"]'), - "app e2e setup should spawn supervisor-entrypoint.ts with --dev", -); -assertNoSpawnedWorkerEntrypoint("app e2e global setup", appGlobalSetup); -console.log("✓ app e2e setup enters supervisor\n"); - -console.log("Test 3: server daemon e2e process launch enters supervisor"); -const serverConnectionOfferE2e = await readFile(serverConnectionOfferE2ePath, "utf-8"); -assert( - serverConnectionOfferE2e.includes("scripts/supervisor-entrypoint.ts"), - "server daemon e2e process launch should use supervisor-entrypoint.ts", -); -assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e); -console.log("✓ server daemon e2e process launch enters supervisor\n"); - -console.log("Test 4: desktop runtime and Nix wrapper point at supervisor-entrypoint"); -const desktopRuntimePaths = await readFile(desktopRuntimePathsPath, "utf-8"); -assert( - desktopRuntimePaths.includes('"dist", "scripts", "supervisor-entrypoint.js"'), - "desktop packaged daemon runner should resolve dist/scripts/supervisor-entrypoint.js", -); -assert( - desktopRuntimePaths.includes('"scripts", "supervisor-entrypoint.ts"'), - "desktop dev daemon runner should resolve scripts/supervisor-entrypoint.ts", -); -assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths); - -const nixPackage = await readFile(nixPackagePath, "utf-8"); -assert( - nixPackage.includes("dist/scripts/supervisor-entrypoint.js"), - "Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js", -); -assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage); -assert( - nixPackage.includes("--set PASEO_NODE_ENV production"), - "Nix paseo-server wrapper should keep the daemon runtime mode Paseo-scoped", -); -assert( - !/--set(-default)?\s+NODE_ENV\b/.test(nixPackage), - "Nix paseo-server wrapper must not leak NODE_ENV to external agent processes", -); - -const nixModule = await readFile(nixModulePath, "utf-8"); -assert( - !/\bNODE_ENV\b\s*=/.test(nixModule), - "NixOS module must not leak NODE_ENV to external agent processes", -); -assert( - !/\bPASEO_NODE_ENV\b/.test(nixModule), - "NixOS module must not duplicate the packaged daemon runtime mode", -); -console.log("✓ desktop runtime and Nix wrapper enter supervisor without leaking runtime env\n"); - -console.log("=== Daemon launch supervision regression test passed ==="); diff --git a/packages/cli/tests/34-daemon-status-auth.test.ts b/packages/cli/tests/34-daemon-status-auth.test.ts index 336c79601..bb683ed02 100644 --- a/packages/cli/tests/34-daemon-status-auth.test.ts +++ b/packages/cli/tests/34-daemon-status-auth.test.ts @@ -1,35 +1,16 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { createTestPaseoDaemon } from "../../server/src/server/test-utils/paseo-daemon.ts"; import { runLocalPaseo } from "./helpers/local-cli.ts"; +import { startTestDaemon } from "./helpers/test-daemon.ts"; console.log("=== Daemon Status Auth ===\n"); -const CORRECT_PASSWORD_HASH = "$2b$12$GMhF7pN4QnMlHOQXOqjd1OitKWPSmAO3FwB0PHzKtcZR/sAMryz76"; - -const daemon = await createTestPaseoDaemon({ - auth: { password: CORRECT_PASSWORD_HASH }, +const daemon = await startTestDaemon({ + env: { PASEO_PASSWORD: "shared-secret" }, }); try { - await writeFile( - join(daemon.paseoHome, "paseo.pid"), - `${JSON.stringify( - { - pid: process.pid, - startedAt: new Date().toISOString(), - hostname: "status-auth-test", - uid: process.getuid?.(), - listen: `0.0.0.0:${daemon.port}`, - }, - null, - 2, - )}\n`, - ); - { console.log("Test 1: status reports password requirement without marking daemon unreachable"); const result = await runLocalPaseo(["daemon", "status", "--json"], { @@ -86,7 +67,7 @@ try { console.log("✓ password-authenticated status remains reachable\n"); } } finally { - await daemon.close(); + await daemon.stop(); } console.log("=== Daemon Status Auth Tests Passed ==="); diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 5052ec935..8607b1cfb 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -169,7 +169,7 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st * Wait for daemon to be ready by running `paseo agent ls` * This connects via WebSocket and ensures the daemon is responsive */ -async function probeDaemonReady(port: number): Promise { +async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise { try { const { exitCode } = await runPaseoCli( { @@ -182,6 +182,7 @@ async function probeDaemonReady(port: number): Promise { stop: async () => {}, }, ["agent", "ls"], + { env }, ); return exitCode === 0; } catch { @@ -189,11 +190,15 @@ async function probeDaemonReady(port: number): Promise { } } -async function waitForDaemonReady(port: number, timeout = 30000): Promise { +async function waitForDaemonReady( + port: number, + timeout = 30000, + env?: NodeJS.ProcessEnv, +): Promise { const deadline = Date.now() + timeout; async function poll(): Promise { - if (await probeDaemonReady(port)) return; + if (await probeDaemonReady(port, env)) return; if (Date.now() >= deadline) { throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`); } @@ -314,7 +319,7 @@ export async function startTestDaemon(options?: { // Wait for daemon to be ready try { - await waitForDaemonReady(port, timeout); + await waitForDaemonReady(port, timeout, options?.env); ctx.isReady = true; } catch (err) { // Daemon failed to start - clean up and rethrow diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index e8eeff2bf..b51735275 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -2434,7 +2434,7 @@ async function runAutomationGroup() { // 1px resident host, and Electron does not propagate CSS-box resizes to a // parked guest's capture surface (see docs/browser-capture-harness.md). // The production resize path is app-owned webview sizing, covered by - // packages/app/src/browser-automation/handler.test.ts. + // packages/app/src/desktop/browser/automation/handler.test.ts. await fsp.writeFile( path.join(OUT_DIR, "automation-results.json"), diff --git a/packages/app/e2e/adaptive-sheet-content-inset.spec.ts b/packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts similarity index 92% rename from packages/app/e2e/adaptive-sheet-content-inset.spec.ts rename to packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts index 1be86f2df..0b1ed4545 100644 --- a/packages/app/e2e/adaptive-sheet-content-inset.spec.ts +++ b/packages/desktop/e2e/adaptive-sheet-content-inset.spec.ts @@ -1,18 +1,18 @@ -import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; +import { expect, test, type Page } from "../../app/e2e/support/fixtures"; +import { gotoAppShell } from "../../app/e2e/support/helpers/app"; import { readRails, readSheetHeaderRails, readVerticalGap, waitForSettledPosition, -} from "./helpers/sheet-layout"; +} from "../../app/e2e/support/helpers/sheet-layout"; import { openCompactSkillsIntegrations, openSkillSelection, openSkillsIntegrations, startSkillsSandbox, type SkillsSandbox, -} from "./helpers/skills-selection"; +} from "./support/skills-selection"; const COMPACT_VIEWPORT = { width: 390, height: 844 }; const DESKTOP_VIEWPORT = { width: 1280, height: 900 }; diff --git a/packages/desktop/scripts/browser-tab-bridge.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs similarity index 94% rename from packages/desktop/scripts/browser-tab-bridge.e2e.mjs rename to packages/desktop/e2e/browser-tabs.e2e.mjs index 139a186e6..3b2a833a9 100644 --- a/packages/desktop/scripts/browser-tab-bridge.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -18,10 +18,10 @@ const desktopDir = path.resolve(scriptDir, ".."); const rootDir = path.resolve(desktopDir, "../.."); const devRunner = path.join(desktopDir, "scripts", "dev-runner.mjs"); const workspaceIds = [ - "tab-bridge-original", - "tab-bridge-evict-one", - "tab-bridge-evict-two", - "tab-bridge-evict-three", + "desktop-browser-original", + "desktop-browser-evict-one", + "desktop-browser-evict-two", + "desktop-browser-evict-three", ]; const timeoutMs = 90_000; @@ -91,7 +91,7 @@ function seedPaseoHome(paseoHome, listen, workspaceRoot) { projectId: `project-${workspaceId}`, rootPath: cwd, kind: "non_git", - displayName: `Tab bridge project ${index + 1}`, + displayName: `Desktop browser project ${index + 1}`, customName: null, createdAt: timestamp, updatedAt: timestamp, @@ -103,8 +103,8 @@ function seedPaseoHome(paseoHome, listen, workspaceRoot) { projectId: projects[index].projectId, cwd: projects[index].rootPath, kind: "directory", - displayName: `Tab bridge workspace ${index + 1}`, - title: `Tab bridge workspace ${index + 1}`, + displayName: `Desktop browser workspace ${index + 1}`, + title: `Desktop browser workspace ${index + 1}`, branch: null, baseBranch: null, createdAt: timestamp, @@ -196,7 +196,7 @@ async function startTargetPage() { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end(` - Tab bridge target + Desktop browser target @@ -240,7 +240,7 @@ async function createCallerAgent(daemonPort) { args: { relationship: { kind: "detached" }, workspace: { kind: "existing", workspaceId: workspaceIds[0] }, - title: "Browser tab bridge E2E caller", + title: "Browser desktop browser E2E caller", provider: "mock/ten-second-stream", settings: { modeId: "load-test" }, initialPrompt: "Remain available while the browser bridge regression runs.", @@ -356,9 +356,9 @@ async function runRegression({ page, client, serverId, targetUrl }) { async function main() { const artifactDir = - process.env.PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR ?? - fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-artifacts-")); - const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-")); + process.env.PASEO_DESKTOP_BROWSER_E2E_ARTIFACT_DIR ?? + fs.mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-browser-e2e-artifacts-")); + const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-browser-e2e-")); fs.mkdirSync(artifactDir, { recursive: true }); const paseoHome = path.join(runtimeDir, "paseo-home"); const userData = path.join(runtimeDir, "electron-user-data"); @@ -449,10 +449,10 @@ async function main() { }); writeJson(path.join(artifactDir, "result.json"), report); console.log( - `Browser tab bridge E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`, + `Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`, ); } catch (error) { - console.error(`Browser tab bridge E2E failed. Artifacts: ${artifactDir}`); + console.error(`Browser desktop browser E2E failed. Artifacts: ${artifactDir}`); console.error(error); throw error; } finally { diff --git a/packages/desktop/e2e/local-daemon-host.spec.ts b/packages/desktop/e2e/local-daemon-host.spec.ts new file mode 100644 index 000000000..a15818338 --- /dev/null +++ b/packages/desktop/e2e/local-daemon-host.spec.ts @@ -0,0 +1,42 @@ +import { test } from "../../app/e2e/support/fixtures"; +import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; +import { getServerId } from "../../app/e2e/support/helpers/server-id"; +import { expectLocalHostEntryFirst } from "../../app/e2e/support/helpers/settings"; + +test("sidebar pins the local desktop daemon host first", async ({ page }) => { + const serverId = getServerId(); + + await page.addInitScript((localServerId) => { + (window as unknown as { paseoDesktop: unknown }).paseoDesktop = { + platform: "darwin", + invoke: async (command: string) => { + if (command === "desktop_daemon_status") { + return { + serverId: localServerId, + status: "running", + listen: null, + hostname: null, + pid: null, + home: "", + version: null, + desktopManaged: true, + error: null, + }; + } + if (command === "get_desktop_settings") { + return { + releaseChannel: "stable", + daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true }, + }; + } + return null; + }, + getPendingOpenProject: async () => null, + events: { on: async () => () => undefined }, + }; + }, serverId); + + await gotoAppShell(page); + await openSettings(page); + await expectLocalHostEntryFirst(page, serverId); +}); diff --git a/packages/app/e2e/workspace-open-in-editor.spec.ts b/packages/desktop/e2e/open-in-editor.spec.ts similarity index 91% rename from packages/app/e2e/workspace-open-in-editor.spec.ts rename to packages/desktop/e2e/open-in-editor.spec.ts index 62b35d270..655155d28 100644 --- a/packages/app/e2e/workspace-open-in-editor.spec.ts +++ b/packages/desktop/e2e/open-in-editor.spec.ts @@ -1,8 +1,8 @@ import { readFile, rm } from "node:fs/promises"; -import { expect, test, type Page } from "./fixtures"; -import { gotoAppShell, openSettings } from "./helpers/app"; -import { injectDesktopBridge } from "./helpers/desktop-updates"; -import { clickSettingsBackToWorkspace } from "./helpers/settings"; +import { expect, test, type Page } from "../../app/e2e/support/fixtures"; +import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; +import { installDesktopRuntime } from "./support/runtime"; +import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings"; interface EditorOpenRecord { editorId: string; @@ -75,7 +75,7 @@ test.describe("Workspace open in editor", () => { const serverId = requireE2EEnv("E2E_SERVER_ID"); const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH"); await rm(recordPath, { force: true }); - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId, editorTargets: [ { diff --git a/packages/desktop/scripts/smoke-packaged-desktop-app.js b/packages/desktop/e2e/packaged-app-smoke.js similarity index 100% rename from packages/desktop/scripts/smoke-packaged-desktop-app.js rename to packages/desktop/e2e/packaged-app-smoke.js diff --git a/packages/app/e2e/pair-device-relay.spec.ts b/packages/desktop/e2e/pair-device-relay.spec.ts similarity index 93% rename from packages/app/e2e/pair-device-relay.spec.ts rename to packages/desktop/e2e/pair-device-relay.spec.ts index 981fc7013..2f23852c2 100644 --- a/packages/app/e2e/pair-device-relay.spec.ts +++ b/packages/desktop/e2e/pair-device-relay.spec.ts @@ -1,6 +1,9 @@ -import { expect, test } from "./fixtures"; -import { startIsolatedHostDaemon, type IsolatedHostDaemon } from "./helpers/isolated-host-daemon"; -import { connectDaemonClient } from "./helpers/daemon-client-loader"; +import { expect, test } from "../../app/e2e/support/fixtures"; +import { + startIsolatedHostDaemon, + type IsolatedHostDaemon, +} from "../../app/e2e/support/helpers/isolated-host-daemon"; +import { connectDaemonClient } from "../../app/e2e/support/helpers/daemon-client-loader"; import { closePairDeviceModal, declineRelay, @@ -16,11 +19,10 @@ import { openRelaySecurityDocs, observePairingOfferRequests, prepareLocalPairingHost, - preparePairingHost, reloadAndOpenPairDevice, retryRelayAndExpectFailure, switchPairDeviceToHost, -} from "./helpers/pair-device"; +} from "../../app/e2e/support/helpers/pair-device"; interface RelayConfigDaemonClient { close(): Promise; @@ -92,12 +94,6 @@ test.describe("local device relay pairing", () => { await expect(modal.getByRole("button", { name: "Enable relay", exact: true })).toBeVisible(); }); - test("opens relay consent in browser web", async ({ page }) => { - await preparePairingHost(page, relayOffDaemon); - await openPairDeviceModal(page); - await expectRelayConsent(page); - }); - test("shows an actionable error when the daemon disconnects", async ({ page }) => { const daemon = await startIsolatedHostDaemon("pair-device-disconnect", { mutableRelay: { enabled: false }, diff --git a/packages/app/e2e/project-picker-desktop.spec.ts b/packages/desktop/e2e/project-picker.spec.ts similarity index 71% rename from packages/app/e2e/project-picker-desktop.spec.ts rename to packages/desktop/e2e/project-picker.spec.ts index 878c42ee3..fb11c24b6 100644 --- a/packages/app/e2e/project-picker-desktop.spec.ts +++ b/packages/desktop/e2e/project-picker.spec.ts @@ -1,20 +1,21 @@ -import { test, expect } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates"; -import { getE2EDaemonPort } from "./helpers/daemon-port"; -import { waitForConnectedHost } from "./helpers/hosts"; -import { expectOpenedProject } from "./helpers/project-picker-ui"; -import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow"; -import { getServerId } from "./helpers/server-id"; -import { connectSeedClient } from "./helpers/seed-client"; - -test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires Metro's Electron platform overlay"); +import { test, expect } from "../../app/e2e/support/fixtures"; +import { gotoAppShell } from "../../app/e2e/support/helpers/app"; +import { + expectNewWorkspaceForAddedProject, + openAddProjectFlow, +} from "../../app/e2e/support/helpers/add-project-flow"; +import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port"; +import { waitForConnectedHost } from "../../app/e2e/support/helpers/hosts"; +import { expectOpenedProject } from "../../app/e2e/support/helpers/project-picker-ui"; +import { getServerId } from "../../app/e2e/support/helpers/server-id"; +import { connectSeedClient } from "../../app/e2e/support/helpers/seed-client"; +import { installDesktopRuntime, waitForDirectoryDialog } from "./support/runtime"; test("Browse opens the folder selected by the desktop dialog", async ({ page, projectPickerFixture, }) => { - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false, dialogOpenResult: projectPickerFixture.projectPath, @@ -50,7 +51,7 @@ test("canceling Browse returns to the Add Project methods", async ({ page, projectPickerFixture, }) => { - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false, dialogOpenResult: null, diff --git a/packages/app/e2e/settings-sidebar-scroll.spec.ts b/packages/desktop/e2e/settings-sidebar-scroll.spec.ts similarity index 92% rename from packages/app/e2e/settings-sidebar-scroll.spec.ts rename to packages/desktop/e2e/settings-sidebar-scroll.spec.ts index 06c6b6b25..942fcdb42 100644 --- a/packages/app/e2e/settings-sidebar-scroll.spec.ts +++ b/packages/desktop/e2e/settings-sidebar-scroll.spec.ts @@ -1,5 +1,5 @@ -import { test, expect } from "./fixtures"; -import { gotoAppShell, openSettings } from "./helpers/app"; +import { test, expect } from "../../app/e2e/support/fixtures"; +import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; test.describe("Settings sidebar scrolling", () => { test.use({ viewport: { width: 900, height: 260 } }); diff --git a/packages/app/e2e/skills-selection.spec.ts b/packages/desktop/e2e/skills-selection.spec.ts similarity index 97% rename from packages/app/e2e/skills-selection.spec.ts rename to packages/desktop/e2e/skills-selection.spec.ts index 4eb1185d3..c10fc78b1 100644 --- a/packages/app/e2e/skills-selection.spec.ts +++ b/packages/desktop/e2e/skills-selection.spec.ts @@ -1,8 +1,8 @@ -import { test as base } from "./fixtures"; +import { test as base } from "../../app/e2e/support/fixtures"; import { expect } from "@playwright/test"; -import { gotoAppShell } from "./helpers/app"; -import { injectDesktopBridge } from "./helpers/desktop-updates"; -import { getServerId } from "./helpers/server-id"; +import { gotoAppShell } from "../../app/e2e/support/helpers/app"; +import { installDesktopRuntime } from "./support/runtime"; +import { getServerId } from "../../app/e2e/support/helpers/server-id"; import { cancelSkillSelection, chooseAllSkills, @@ -26,7 +26,7 @@ import { serveRealSkillsCommands, type SkillsSandbox, type SkillsSandboxOptions, -} from "./helpers/skills-selection"; +} from "./support/skills-selection"; interface SkillsSandboxSetup extends SkillsSandboxOptions { /** What the desktop confirm dialog answers when the removal warning fires. */ @@ -44,7 +44,7 @@ const test = base.extend<{ startSkills: StartSkills }>({ await provide(async (setup = {}) => { const { confirmRemoval, ...options } = setup; const sandbox = await createSkillsSandbox(options); - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId: getServerId(), confirmShouldAccept: confirmRemoval ?? false, }); diff --git a/packages/desktop/e2e/startup.spec.ts b/packages/desktop/e2e/startup.spec.ts new file mode 100644 index 000000000..502f7e01a --- /dev/null +++ b/packages/desktop/e2e/startup.spec.ts @@ -0,0 +1,32 @@ +import { test } from "../../app/e2e/support/fixtures"; +import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port"; +import { getServerId } from "../../app/e2e/support/helpers/server-id"; +import { startupScenario } from "../../app/e2e/support/helpers/startup-dsl"; + +test.describe("Desktop startup", () => { + test("daemon bootstrap keeps the local server startup copy desktop-only", async ({ page }) => { + const startup = await startupScenario(page) + .withPendingDesktopDaemon() + .withBlockedPort(getE2EDaemonPort()) + .openRoot(); + + await startup.expectsDesktopDaemonStartup(); + await startup.expectsSidebarHidden(); + await startup.expectsNoUndefinedRoute(); + }); + + test("host-route refresh does not render route chrome around the bootstrap splash", async ({ + page, + }) => { + const startup = await startupScenario(page) + .withPendingDesktopDaemon() + .withBlockedPort(getE2EDaemonPort()) + .openHostWorkspace({ + serverId: getServerId(), + workspaceId: "workspace-1", + }); + + await startup.expectsDesktopDaemonStartup(); + await startup.expectsSidebarHidden(); + }); +}); diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/desktop/e2e/support/runtime.ts similarity index 96% rename from packages/app/e2e/helpers/desktop-updates.ts rename to packages/desktop/e2e/support/runtime.ts index c52d042c0..fdffe1f31 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/desktop/e2e/support/runtime.ts @@ -1,10 +1,14 @@ import { readFileSync } from "node:fs"; import { appendFile } from "node:fs/promises"; import { expect, type Page } from "@playwright/test"; -import { openSettings } from "./app"; -import { getE2EDaemonPort } from "./daemon-port"; -import { escapeRegex } from "./regex"; -import { openSettingsHost, openSettingsHostSection, openSettingsSection } from "./settings"; +import { openSettings } from "../../../app/e2e/support/helpers/app"; +import { getE2EDaemonPort } from "../../../app/e2e/support/helpers/daemon-port"; +import { escapeRegex } from "../../../app/e2e/support/helpers/regex"; +import { + openSettingsHost, + openSettingsHostSection, + openSettingsSection, +} from "../../../app/e2e/support/helpers/settings"; interface DaemonApiStatus { version: string; @@ -31,7 +35,7 @@ export interface RealDaemonState { export async function loadRealDaemonState(): Promise { const port = getE2EDaemonPort(); const paseoHome = process.env.E2E_PASEO_HOME; - if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first"); + if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — the worker fixture must run first"); const resp = await fetch(`http://127.0.0.1:${port}/api/status`); const data: DaemonApiStatus = await resp.json(); @@ -49,7 +53,7 @@ export async function loadRealDaemonState(): Promise { return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` }; } -export interface DesktopBridgeConfig { +export interface DesktopRuntimeConfig { serverId: string; updateAvailable?: boolean; latestVersion?: string; @@ -116,7 +120,10 @@ declare global { * dialog.ask captures call arguments on window.__capturedDialogCall so tests * can assert dialog copy without depending on window.confirm concatenation. */ -export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise { +export async function installDesktopRuntime( + page: Page, + config: DesktopRuntimeConfig, +): Promise { if (config.editorRecordPath) { await page.exposeFunction( "__recordDesktopEditorOpen", diff --git a/packages/app/e2e/helpers/skills-selection.ts b/packages/desktop/e2e/support/skills-selection.ts similarity index 94% rename from packages/app/e2e/helpers/skills-selection.ts rename to packages/desktop/e2e/support/skills-selection.ts index 873fca479..f1c8869d9 100644 --- a/packages/app/e2e/helpers/skills-selection.ts +++ b/packages/desktop/e2e/support/skills-selection.ts @@ -2,21 +2,21 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promis import os from "node:os"; import path from "node:path"; import { expect, type Page } from "@playwright/test"; -import { buildOpenProjectRoute } from "@/utils/host-routes"; -import { openSettings } from "./app"; -import { injectDesktopBridge } from "./desktop-updates"; -import { getServerId } from "./server-id"; -import { openCompactSettings, openSettingsSection } from "./settings"; +import { buildOpenProjectRoute } from "../../../app/src/utils/host-routes"; +import { openSettings } from "../../../app/e2e/support/helpers/app"; +import { installDesktopRuntime } from "./runtime"; +import { getServerId } from "../../../app/e2e/support/helpers/server-id"; +import { + openCompactSettings, + openSettingsSection, +} from "../../../app/e2e/support/helpers/settings"; // The desktop skills module is the real command surface: the same handlers the // Electron main process registers, running against a temp bundle and temp user // data. Nothing about persistence or convergence is simulated in the browser. -import type { - SkillSelection, - SkillTargets, -} from "../../../desktop/src/integrations/skills/operations.js"; -import { createSkillSelectionStore } from "../../../desktop/src/integrations/skills/selection-store.js"; -import { createSkillsCommandHandlers } from "../../../desktop/src/integrations/skills/skills-commands.js"; -import { createSkillsController } from "../../../desktop/src/integrations/skills/skills-controller.js"; +import type { SkillSelection, SkillTargets } from "../../src/integrations/skills/operations.js"; +import { createSkillSelectionStore } from "../../src/integrations/skills/selection-store.js"; +import { createSkillsCommandHandlers } from "../../src/integrations/skills/skills-commands.js"; +import { createSkillsController } from "../../src/integrations/skills/skills-controller.js"; const SKILL_COMMANDS = [ "get_skills_status", @@ -187,7 +187,7 @@ export async function expectSavedSkillSelection( /** * Routes the skills desktop commands to the real handlers in Node, leaving every - * other command on the injected desktop bridge. Call after `injectDesktopBridge` + * other command on the injected desktop bridge. Call after `installDesktopRuntime` * and before navigation. */ export async function serveRealSkillsCommands(page: Page, sandbox: SkillsSandbox): Promise { @@ -232,7 +232,7 @@ export async function startSkillsSandbox( options: SkillsSandboxOptions = {}, ): Promise { const sandbox = await createSkillsSandbox(options); - await injectDesktopBridge(page, { serverId: getServerId(), confirmShouldAccept: false }); + await installDesktopRuntime(page, { serverId: getServerId(), confirmShouldAccept: false }); await serveRealSkillsCommands(page, sandbox); return sandbox; } diff --git a/packages/app/e2e/desktop-updates.spec.ts b/packages/desktop/e2e/updates.spec.ts similarity index 76% rename from packages/app/e2e/desktop-updates.spec.ts rename to packages/desktop/e2e/updates.spec.ts index ee9277f86..6cef40e50 100644 --- a/packages/app/e2e/desktop-updates.spec.ts +++ b/packages/desktop/e2e/updates.spec.ts @@ -1,9 +1,14 @@ -import { test } from "./fixtures"; -import { gotoAppShell } from "./helpers/app"; -import { getServerId } from "./helpers/server-id"; +import { expect, test } from "../../app/e2e/support/fixtures"; +import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app"; +import { getServerId } from "../../app/e2e/support/helpers/server-id"; +import { + openHostSection, + openSettingsHost, + seedSavedSettingsHosts, +} from "../../app/e2e/support/helpers/settings"; import { loadRealDaemonState, - injectDesktopBridge, + installDesktopRuntime, openDesktopAboutSettings, openDesktopSettings, expectUpdateBanner, @@ -19,13 +24,31 @@ import { expectDaemonStatusPid, expectDaemonStatusLogPath, expectDaemonStatusVersion, -} from "./helpers/desktop-updates"; +} from "./support/runtime"; // No Playwright Electron runner exists; we simulate the desktop bridge via // addInitScript so Electron-gated UI activates without a real Electron process. test.describe("Desktop updates", () => { + test("a desktop-managed daemon explains why its update action is disabled", async ({ + page, + desktopManagedOutdatedDaemon, + }) => { + await seedSavedSettingsHosts(page, [desktopManagedOutdatedDaemon]); + await page.reload(); + await openSettings(page); + await openSettingsHost(page, desktopManagedOutdatedDaemon.serverId); + await openHostSection(page, desktopManagedOutdatedDaemon.serverId, "host"); + + const updateCard = page.getByTestId("host-page-update-card"); + await expect(updateCard).toBeVisible(); + await expect(updateCard).toContainText( + "This daemon is managed by Paseo Desktop. Update Paseo Desktop on the host.", + ); + await expect(page.getByTestId("host-page-update-button")).toBeDisabled(); + }); + test("update banner appears in the sidebar when an app update is available", async ({ page }) => { - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId: getServerId(), updateAvailable: true, latestVersion: "1.2.3", @@ -36,7 +59,7 @@ test.describe("Desktop updates", () => { }); test("clicking install shows the installing state on the callout", async ({ page }) => { - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId: getServerId(), updateAvailable: true, latestVersion: "1.2.3", @@ -50,7 +73,7 @@ test.describe("Desktop updates", () => { }); test("manual check reports a found update while it downloads", async ({ page }) => { - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId: getServerId(), updateAvailable: true, latestVersion: "1.2.3", @@ -70,7 +93,7 @@ test.describe("Desktop daemon management", () => { page, }) => { const serverId = getServerId(); - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, confirmShouldAccept: false, @@ -86,7 +109,7 @@ test.describe("Desktop daemon management", () => { test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => { const serverId = getServerId(); - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, confirmShouldAccept: false, @@ -101,7 +124,7 @@ test.describe("Desktop daemon management", () => { test("confirming the dialog disables built-in daemon management", async ({ page }) => { const serverId = getServerId(); - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, confirmShouldAccept: true, @@ -119,7 +142,7 @@ test.describe("Desktop daemon management", () => { }) => { const serverId = getServerId(); const realState = await loadRealDaemonState(); - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: false, daemonPid: realState.pid, @@ -137,7 +160,7 @@ test.describe("Desktop daemon management", () => { test("stopping and restarting the daemon updates the PID", async ({ page }) => { const serverId = getServerId(); const realState = await loadRealDaemonState(); - await injectDesktopBridge(page, { + await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, daemonPid: realState.pid, diff --git a/packages/desktop/e2e/workspace-restart.spec.ts b/packages/desktop/e2e/workspace-restart.spec.ts new file mode 100644 index 000000000..36259bee4 --- /dev/null +++ b/packages/desktop/e2e/workspace-restart.spec.ts @@ -0,0 +1,120 @@ +import { buildHostWorkspaceRoute } from "../../app/src/utils/host-routes"; +import { expect, test, type Page } from "../../app/e2e/support/fixtures"; +import { gotoAppShell } from "../../app/e2e/support/helpers/app"; +import { expectWorkspaceTabVisible } from "../../app/e2e/support/helpers/archive-tab"; +import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port"; +import { installDaemonWebSocketGate } from "../../app/e2e/support/helpers/daemon-websocket-gate"; +import { expectAppRoute } from "../../app/e2e/support/helpers/route-assertions"; +import { seedWorkspace } from "../../app/e2e/support/helpers/seed-client"; +import { getServerId } from "../../app/e2e/support/helpers/server-id"; +import { waitForWorkspaceTabsVisible } from "../../app/e2e/support/helpers/workspace-tabs"; +import { + expectWorkspaceHeader, + switchWorkspaceViaSidebar, + waitForSidebarHydration, +} from "../../app/e2e/support/helpers/workspace-ui"; +import { installDesktopRuntime, waitForDesktopDaemonStartRequest } from "./support/runtime"; + +type StartupPresentation = "splash" | "app"; + +declare global { + interface Window { + __paseoStartupPresentationTrace?: StartupPresentation[]; + } +} + +async function observeStartupPresentation(page: Page): Promise { + await page.addInitScript(() => { + const trace: StartupPresentation[] = []; + window.__paseoStartupPresentationTrace = trace; + + document.addEventListener("DOMContentLoaded", () => { + const recordPresentation = () => { + let presentation: StartupPresentation | null = null; + if (document.querySelector('[data-testid="startup-splash"]')) { + presentation = "splash"; + } else if ( + document.querySelector( + '[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]', + ) + ) { + presentation = "app"; + } + if (presentation && trace.at(-1) !== presentation) { + trace.push(presentation); + } + }; + new MutationObserver(recordPresentation).observe(document.documentElement, { + childList: true, + subtree: true, + }); + recordPresentation(); + }); + }); +} + +async function getStartupPresentation(page: Page): Promise { + return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []); +} + +async function expectWorkspaceLocation( + page: Page, + input: { + serverId: string; + workspace: Awaited>; + }, +): Promise { + await expectAppRoute(page, buildHostWorkspaceRoute(input.serverId, input.workspace.workspaceId), { + timeout: 30_000, + }); + await expectWorkspaceHeader(page, { + title: input.workspace.workspaceName, + subtitle: input.workspace.projectDisplayName, + }); +} + +test("refresh keeps one continuous splash before restoring the desktop workspace", async ({ + page, +}) => { + const serverId = getServerId(); + const daemonGate = await installDaemonWebSocketGate(page); + const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" }); + + try { + const agent = await workspace.client.createAgent({ + provider: "mock", + model: "ten-second-stream", + modeId: "load-test", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: `workspace-refresh-route-${Date.now()}`, + }); + await installDesktopRuntime(page, { + serverId, + manageBuiltInDaemon: true, + hangDaemonStart: true, + desktopSettingsDelayMs: 250, + daemonListen: `127.0.0.1:${getE2EDaemonPort()}`, + }); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId }); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceTabVisible(page, agent.id); + await expectWorkspaceLocation(page, { serverId, workspace }); + + await observeStartupPresentation(page); + await daemonGate.drop(); + await page.reload(); + await waitForDesktopDaemonStartRequest(page); + daemonGate.restore(); + await waitForSidebarHydration(page); + + await expectWorkspaceLocation(page, { serverId, workspace }); + await waitForWorkspaceTabsVisible(page); + expect(await getStartupPresentation(page)).toEqual(["splash", "app"]); + } finally { + daemonGate.restore(); + await workspace.cleanup(); + } +}); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 083370fe2..4275bc89a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -20,9 +20,10 @@ "capture-harness": "./capture-harness/run.sh", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", - "test:e2e:browser-tab-bridge": "npm run build:main && node ./scripts/browser-tab-bridge.e2e.mjs", + "test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop", + "test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs", "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", - "test": "vitest run", + "test": "vitest run --exclude \"e2e/**\"", "typecheck": "tsgo --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/packages/desktop/playwright.config.ts b/packages/desktop/playwright.config.ts new file mode 100644 index 000000000..3e74d1a5d --- /dev/null +++ b/packages/desktop/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = + process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; + +export default defineConfig({ + testDir: "./e2e", + testMatch: ["**/*.spec.ts"], + globalSetup: "../app/e2e/support/global-setup.ts", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: [["list"]], + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: process.env.E2E_RECORD_VIDEO === "1" ? "on" : "retain-on-failure", + }, + projects: [ + { + name: "desktop", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/packages/desktop/scripts/after-pack.js b/packages/desktop/scripts/after-pack.js index ecbc69fd9..e5222d68f 100644 --- a/packages/desktop/scripts/after-pack.js +++ b/packages/desktop/scripts/after-pack.js @@ -1,7 +1,7 @@ const fs = require("fs"); const path = require("path"); -const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js"); +const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js"); const EXECUTABLE_NAME = "Paseo"; diff --git a/packages/desktop/scripts/after-sign.js b/packages/desktop/scripts/after-sign.js index 7d3aa4bbd..25a0a141e 100644 --- a/packages/desktop/scripts/after-sign.js +++ b/packages/desktop/scripts/after-sign.js @@ -1,6 +1,6 @@ const path = require("node:path"); -const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js"); +const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js"); const EXECUTABLE_NAME = "Paseo"; diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts new file mode 100644 index 000000000..ebf47fe19 --- /dev/null +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod"; +import { + AgentSnapshotPayloadSchema, + AgentTimelineItemPayloadSchema, + ServerInfoStatusPayloadSchema, + WSHelloMessageSchema, +} from "./messages.js"; + +const LegacySubAgentToolCallSchema = z.object({ + type: z.literal("tool_call"), + callId: z.string(), + name: z.string(), + status: z.enum(["running", "completed", "failed", "canceled"]), + error: z.unknown().nullable(), + detail: z.object({ + type: z.literal("sub_agent"), + subAgentType: z.string().optional(), + description: z.string().optional(), + log: z.string(), + // Copied from v0.1.65-beta.3: actions was required even though the UI ignored it. + actions: z.array( + z.object({ + index: z.number().int().positive(), + toolName: z.string(), + summary: z.string().optional(), + }), + ), + }), +}); + +const LegacyAgentCapabilityFlagsSchema = z.object({ + supportsStreaming: z.boolean(), + supportsSessionPersistence: z.boolean(), + supportsDynamicModes: z.boolean(), + supportsMcpServers: z.boolean(), + supportsReasoningStream: z.boolean(), + supportsToolInvocations: z.boolean(), +}); + +const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({ + capabilities: LegacyAgentCapabilityFlagsSchema, +}); + +describe("wire schema compatibility", () => { + test("hello parses with and without the project update capability", () => { + const legacy = WSHelloMessageSchema.parse({ + type: "hello", + clientId: "legacy-client", + clientType: "mobile", + protocolVersion: 1, + }); + const capable = WSHelloMessageSchema.parse({ + type: "hello", + clientId: "capable-client", + clientType: "mobile", + protocolVersion: 1, + capabilities: { project_updates: true }, + }); + + expect([legacy, capable]).toEqual([ + { + type: "hello", + clientId: "legacy-client", + clientType: "mobile", + protocolVersion: 1, + }, + { + type: "hello", + clientId: "capable-client", + clientType: "mobile", + protocolVersion: 1, + capabilities: { project_updates: true }, + }, + ]); + }); + + test("server info strips unknown legacy features while accepting former turn identity", () => { + const parsed = ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "legacy-server", + features: { + workspaceGithubClone: true, + agentTurnIdentity: true, + }, + }); + + expect(parsed).toEqual({ + status: "server_info", + serverId: "legacy-server", + hostname: null, + version: null, + features: { agentTurnIdentity: true }, + }); + }); + + test("assistant timeline message ids are optional on the wire", () => { + expect( + AgentTimelineItemPayloadSchema.parse({ + type: "assistant_message", + text: "old daemon shape", + }), + ).toEqual({ + type: "assistant_message", + text: "old daemon shape", + }); + expect( + AgentTimelineItemPayloadSchema.parse({ + type: "assistant_message", + text: "new daemon shape", + messageId: "msg-1", + }), + ).toEqual({ + type: "assistant_message", + text: "new daemon shape", + messageId: "msg-1", + }); + }); + + test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => { + const parsed = LegacySubAgentToolCallSchema.parse({ + type: "tool_call", + callId: "call-sub-agent-1", + name: "Task", + status: "completed", + error: null, + detail: { + type: "sub_agent", + subAgentType: "Explore", + description: "Inspect repository structure", + childSessionId: "child-session-1", + log: "[Read] README.md", + actions: [], + }, + }); + + expect(parsed.detail.actions).toEqual([]); + }); + + test("old clients parse agent snapshots with rewind capabilities", () => { + const parsed = LegacyAgentSnapshotPayloadSchema.parse({ + id: "agent-1", + provider: "claude", + cwd: "/tmp/project", + model: null, + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: "2026-05-23T00:00:00.000Z", + updatedAt: "2026-05-23T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindConversation: true, + supportsRewindFiles: true, + supportsRewindBoth: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }); + + expect(parsed.capabilities).toEqual({ + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }); + }); + + test("new clients parse agent snapshots without rewind capabilities", () => { + const parsed = AgentSnapshotPayloadSchema.parse({ + id: "agent-1", + provider: "claude", + cwd: "/tmp/project", + model: null, + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: "2026-05-23T00:00:00.000Z", + updatedAt: "2026-05-23T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }); + + expect(parsed.capabilities.supportsRewindConversation).toBe(false); + expect(parsed.capabilities.supportsRewindFiles).toBe(false); + expect(parsed.capabilities.supportsRewindBoth).toBe(false); + }); +}); diff --git a/packages/server/package.json b/packages/server/package.json index 382d8c589..3267e60d7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -51,6 +51,7 @@ "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts", + "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", "test:integration:local": "vitest run local.e2e.test.ts", diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index bdc1b269d..f05c49f4d 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -29,7 +29,7 @@ const openaiApiKey = process.env.OPENAI_API_KEY ?? null; const localModelsDir = process.env.PASEO_LOCAL_MODELS_DIR ?? path.join(homedir(), ".paseo", "models", "local-speech"); const testFileDir = path.dirname(fileURLToPath(import.meta.url)); -const appE2eFixturesDir = path.resolve(testFileDir, "../../../app/e2e/fixtures"); +const appE2eFixturesDir = path.resolve(testFileDir, "../../../app/e2e/support/fixtures"); function fixturePath(fileName: string): string { return path.join(appE2eFixturesDir, fileName); diff --git a/packages/server/src/server/hub/hub-cli-contract.test.ts b/packages/server/src/server/hub/hub-cli-contract.test.ts new file mode 100644 index 000000000..32aa866ae --- /dev/null +++ b/packages/server/src/server/hub/hub-cli-contract.test.ts @@ -0,0 +1,28 @@ +import { afterEach, expect, test } from "vitest"; +import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; + +let relationship: HubRelationshipHarness | null = null; + +afterEach(async () => { + await relationship?.close(); + relationship = null; +}); + +test("the Hub CLI connects, reports status, and disconnects through the daemon", async () => { + relationship = await HubRelationshipHarness.start(); + const connected = await relationship.beginConnect().result; + relationship.connectLatestSocket(); + + const status = await relationship.status(); + const enrollment = relationship.enrollmentAttempts()[0]; + const secret = relationship.relationshipFile()?.credential?.secret; + const disconnected = await relationship.disconnect(); + + expect(connected.state).toBe("connecting"); + expect(status.state).toBe("connected"); + expect(relationship.loggableValues(status)).not.toContain(secret); + expect(relationship.loggableValues(status)).not.toContain(enrollment.credentialVerifier); + expect(relationship.loggableValues(status)).not.toContain(enrollment.token); + expect(relationship.loggableValues(status)).not.toContain(enrollment.idempotencyKey); + expect(disconnected.state).toBe("not_connected"); +}, 30_000); diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index d7bbe1f68..763d33692 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -24,25 +24,6 @@ describe("Hub relationship", () => { relationship = null; }); - test("the CLI connects, reports status, and disconnects through the daemon", async () => { - relationship = await HubRelationshipHarness.start(); - const connected = await relationship.beginConnect().result; - relationship.connectLatestSocket(); - - const status = await relationship.status(); - const enrollment = relationship.enrollmentAttempts()[0]; - const secret = relationship.relationshipFile()?.credential?.secret; - const disconnected = await relationship.disconnect(); - - expect(connected.state).toBe("connecting"); - expect(status.state).toBe("connected"); - expect(relationship.loggableValues(status)).not.toContain(secret); - expect(relationship.loggableValues(status)).not.toContain(enrollment.credentialVerifier); - expect(relationship.loggableValues(status)).not.toContain(enrollment.token); - expect(relationship.loggableValues(status)).not.toContain(enrollment.idempotencyKey); - expect(disconnected.state).toBe("not_connected"); - }, 30_000); - test("Hub URLs cannot persist embedded credentials", async () => { relationship = await HubRelationshipHarness.start(); diff --git a/packages/server/src/server/test-utils/hub-cli-entry.ts b/packages/server/src/server/hub/test-utils/hub-cli-entry.ts similarity index 66% rename from packages/server/src/server/test-utils/hub-cli-entry.ts rename to packages/server/src/server/hub/test-utils/hub-cli-entry.ts index 3e64439c3..5bb419d26 100644 --- a/packages/server/src/server/test-utils/hub-cli-entry.ts +++ b/packages/server/src/server/hub/test-utils/hub-cli-entry.ts @@ -1,4 +1,4 @@ -import { createHubCommand } from "../../../../cli/src/commands/hub/index.js"; +import { createHubCommand } from "../../../../../cli/src/commands/hub/index.js"; const argv = [process.argv[0] ?? "node", process.argv[1] ?? "paseo", ...process.argv.slice(3)]; await createHubCommand().parseAsync(argv, { from: "node" }); diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 228b76ad9..28da3eb9c 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -1328,7 +1328,7 @@ export class HubRelationshipHarness { } private async executeCli(args: string[]): Promise> { - const entrypoint = path.join(import.meta.dirname, "../../test-utils/hub-cli-entry.ts"); + const entrypoint = path.join(import.meta.dirname, "hub-cli-entry.ts"); const { stdout } = await execFileAsync( process.execPath, [ diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 78af3adec..85e402e61 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -4,14 +4,11 @@ import { describe, expect, test } from "vitest"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { - AgentSnapshotPayloadSchema, AgentTimelineItemPayloadSchema, FetchAgentTimelineResponseMessageSchema, - ServerInfoStatusPayloadSchema, SessionInboundMessageSchema, SessionOutboundMessageSchema, type SessionOutboundMessage, - WSHelloMessageSchema, } from "@getpaseo/protocol/messages"; import { Session, type SessionOptions } from "./session.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; @@ -44,41 +41,6 @@ const LegacyFetchAgentTimelineResponseMessageSchema = z.object({ }), }); -const LegacySubAgentToolCallSchema = z.object({ - type: z.literal("tool_call"), - callId: z.string(), - name: z.string(), - status: z.enum(["running", "completed", "failed", "canceled"]), - error: z.unknown().nullable(), - detail: z.object({ - type: z.literal("sub_agent"), - subAgentType: z.string().optional(), - description: z.string().optional(), - log: z.string(), - // Copied from v0.1.65-beta.3: actions was required even though the UI ignored it. - actions: z.array( - z.object({ - index: z.number().int().positive(), - toolName: z.string(), - summary: z.string().optional(), - }), - ), - }), -}); - -const LegacyAgentCapabilityFlagsSchema = z.object({ - supportsStreaming: z.boolean(), - supportsSessionPersistence: z.boolean(), - supportsDynamicModes: z.boolean(), - supportsMcpServers: z.boolean(), - supportsReasoningStream: z.boolean(), - supportsToolInvocations: z.boolean(), -}); - -const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({ - capabilities: LegacyAgentCapabilityFlagsSchema, -}); - interface SessionInternals { handleFetchAgentTimelineRequest: ( message: Extract< @@ -390,84 +352,6 @@ describe("wire compatibility", () => { ]); }); - test("hello parses with and without current client capabilities", () => { - const legacy = WSHelloMessageSchema.parse({ - type: "hello", - clientId: "legacy-client", - clientType: "mobile", - protocolVersion: 1, - }); - const capable = WSHelloMessageSchema.parse({ - type: "hello", - clientId: "capable-client", - clientType: "mobile", - protocolVersion: 1, - capabilities: { - [CLIENT_CAPS.projectUpdates]: true, - }, - }); - - expect([legacy, capable]).toEqual([ - { - type: "hello", - clientId: "legacy-client", - clientType: "mobile", - protocolVersion: 1, - }, - { - type: "hello", - clientId: "capable-client", - clientType: "mobile", - protocolVersion: 1, - capabilities: { - project_updates: true, - }, - }, - ]); - }); - - test("server info strips unknown legacy features while accepting former turn identity", () => { - const parsed = ServerInfoStatusPayloadSchema.parse({ - status: "server_info", - serverId: "legacy-server", - features: { - workspaceGithubClone: true, - agentTurnIdentity: true, - }, - }); - - expect(parsed).toEqual({ - status: "server_info", - serverId: "legacy-server", - hostname: null, - version: null, - features: { agentTurnIdentity: true }, - }); - }); - - test("assistant timeline message ids are optional on the wire", () => { - expect( - AgentTimelineItemPayloadSchema.parse({ - type: "assistant_message", - text: "old daemon shape", - }), - ).toEqual({ - type: "assistant_message", - text: "old daemon shape", - }); - expect( - AgentTimelineItemPayloadSchema.parse({ - type: "assistant_message", - text: "new daemon shape", - messageId: "msg-1", - }), - ).toEqual({ - type: "assistant_message", - text: "new daemon shape", - messageId: "msg-1", - }); - }); - test("downgrades reasoning_merge for clients that do not declare the capability", async () => { const response = await emitTimelineResponse(); @@ -487,100 +371,6 @@ describe("wire compatibility", () => { expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge"); }); - test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => { - const parsed = LegacySubAgentToolCallSchema.parse({ - type: "tool_call", - callId: "call-sub-agent-1", - name: "Task", - status: "completed", - error: null, - detail: { - type: "sub_agent", - subAgentType: "Explore", - description: "Inspect repository structure", - childSessionId: "child-session-1", - log: "[Read] README.md", - actions: [], - }, - }); - - expect(parsed.detail.actions).toEqual([]); - }); - - test("old clients parse agent snapshots with rewind capabilities", () => { - const parsed = LegacyAgentSnapshotPayloadSchema.parse({ - id: "agent-1", - provider: "claude", - cwd: "/tmp/project", - model: null, - thinkingOptionId: null, - effectiveThinkingOptionId: null, - createdAt: "2026-05-23T00:00:00.000Z", - updatedAt: "2026-05-23T00:00:00.000Z", - lastUserMessageAt: null, - status: "idle", - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - supportsRewindConversation: true, - supportsRewindFiles: true, - supportsRewindBoth: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: null, - labels: {}, - }); - - expect(parsed.capabilities).toEqual({ - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }); - }); - - test("new clients parse agent snapshots without rewind capabilities", () => { - const parsed = AgentSnapshotPayloadSchema.parse({ - id: "agent-1", - provider: "claude", - cwd: "/tmp/project", - model: null, - thinkingOptionId: null, - effectiveThinkingOptionId: null, - createdAt: "2026-05-23T00:00:00.000Z", - updatedAt: "2026-05-23T00:00:00.000Z", - lastUserMessageAt: null, - status: "idle", - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - title: null, - labels: {}, - }); - - expect(parsed.capabilities.supportsRewindConversation).toBe(false); - expect(parsed.capabilities.supportsRewindFiles).toBe(false); - expect(parsed.capabilities.supportsRewindBoth).toBe(false); - }); - test("legacy worktree request shape normalizes to the same internal input as the new shape", async () => { const workflow = new InMemoryWorktreeWorkflow(); diff --git a/packages/server/src/terminal/agent-hooks/claude/claude.test.ts b/packages/server/src/terminal/agent-hooks/claude/claude.test.ts index 488b236cc..b92ad24b2 100644 --- a/packages/server/src/terminal/agent-hooks/claude/claude.test.ts +++ b/packages/server/src/terminal/agent-hooks/claude/claude.test.ts @@ -1,8 +1,7 @@ import { spawnSync } from "node:child_process"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { delimiter, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { isPlatform } from "../../../test-utils/platform.js"; import { buildTerminalEnvironment } from "../../terminal.js"; @@ -15,8 +14,6 @@ import { } from "../provider-registry.js"; const temporaryDirs: string[] = []; -const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../.."); - afterEach(() => { while (temporaryDirs.length > 0) { const dir = temporaryDirs.pop(); @@ -167,17 +164,14 @@ describe("Claude terminal agent hooks", () => { }, ); - it("keeps provider names out of generic CLI and bootstrap integration points", () => { - const genericFiles = [ - join(repositoryRoot, "packages", "cli", "src", "commands", "hooks.ts"), - join(repositoryRoot, "packages", "server", "src", "server", "bootstrap.ts"), - ]; + it("keeps provider names out of the generic server bootstrap", () => { + const source = readFileSync( + new URL("../../../server/bootstrap.ts", import.meta.url), + "utf8", + ).toLowerCase(); - for (const filePath of genericFiles) { - const contents = readFileSync(filePath, "utf8").toLowerCase(); - for (const providerId of Object.keys(AGENT_HOOK_PROVIDERS)) { - expect(contents).not.toContain(providerId); - } + for (const providerId of Object.keys(AGENT_HOOK_PROVIDERS)) { + expect(source).not.toContain(providerId); } }); diff --git a/packages/server/tsconfig.server.json b/packages/server/tsconfig.server.json index aee1b6367..9c868b42d 100644 --- a/packages/server/tsconfig.server.json +++ b/packages/server/tsconfig.server.json @@ -26,7 +26,7 @@ "exclude": [ "node_modules", "dist", - "src/server/test-utils/**", + "src/server/**/test-utils/**", "src/server/daemon-e2e/**", "src/server/**/*.e2e.ts", "src/server/**/*.e2e.tsx", diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 7954357dd..8ba01afbe 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -1,8 +1,35 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; +import { relative as relativePath } from "node:path"; import test from "node:test"; -const workflowPath = new URL("../.github/workflows/ci.yml", import.meta.url); +const repoRoot = new URL("../", import.meta.url); +const ciWorkflowPath = new URL(".github/workflows/ci.yml", repoRoot); +const dockerWorkflowPath = new URL(".github/workflows/docker.yml", repoRoot); +const nixWorkflowPath = new URL(".github/workflows/nix.yml", repoRoot); +const filtersPath = new URL(".github/ci-paths.yml", repoRoot); +const serverTsconfigPath = new URL("packages/server/tsconfig.server.json", repoRoot); +const desktopPackagePath = new URL("packages/desktop/package.json", repoRoot); + +const gatedCiJobs = new Map([ + ["format", { name: "format", contract: "format" }], + ["lint", { name: "lint", contract: "quality" }], + ["typecheck", { name: "typecheck", contract: "quality" }], + ["server-tests-ubuntu", { name: "server-tests (ubuntu-latest)", contracts: ["server", "hub"] }], + ["server-tests-windows", { name: "server-tests (windows-latest)", contracts: ["server", "hub"] }], + ["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }], + ["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }], + ["app-tests", { name: "app-tests", contract: "app" }], + ["sdk-tests", { name: "sdk-tests", contract: "sdk" }], + ["playwright-1", { name: "playwright (shard 1/4)", contract: "browser" }], + ["playwright-2", { name: "playwright (shard 2/4)", contract: "browser" }], + ["playwright-3", { name: "playwright (shard 3/4)", contract: "browser" }], + ["playwright-4", { name: "playwright (shard 4/4)", contract: "browser" }], + ["relay-tests", { name: "relay-tests", contract: "relay" }], + ["cli-tests-1", { name: "cli-tests (shard 1/3)", contract: "cli" }], + ["cli-tests-2", { name: "cli-tests (shard 2/3)", contract: "cli" }], + ["cli-tests-3", { name: "cli-tests (shard 3/3)", contract: "cli" }], +]); function jobBlocks(source) { const jobs = new Map(); @@ -15,43 +42,219 @@ function jobBlocks(source) { jobs.set(currentJob, []); continue; } - - if (currentJob && (/^ \S/.test(line) || /^ \S/.test(line))) { - jobs.get(currentJob).push(line); - } + if (currentJob) jobs.get(currentJob).push(line); } - return jobs; } -test("matrix jobs expand before change gating", () => { - const workflow = readFileSync(workflowPath, "utf8"); - const gatedMatrixJobs = [...jobBlocks(workflow)] - .filter(([, lines]) => { - const hasMatrix = lines.some((line) => line.startsWith(" matrix:")); - const unsafeJobCondition = lines.some( - (line) => line.startsWith(" if:") && line.trim() !== "if: ${{ !cancelled() }}", - ); - return hasMatrix && unsafeJobCondition; - }) - .map(([jobId]) => jobId); +function loadFilters(path) { + const filters = {}; + let currentFilter; - assert.deepEqual( - gatedMatrixJobs, - [], - "change-based job conditions skip a matrix before GitHub can emit its interpolated check names", - ); + for (const line of readFileSync(path, "utf8").split("\n")) { + const filterMatch = /^([a-z_]+):\s*$/.exec(line); + if (filterMatch) { + currentFilter = filterMatch[1]; + filters[currentFilter] = []; + continue; + } + const patternMatch = /^ - "([^"]+)"\s*$/.exec(line); + if (currentFilter && patternMatch) filters[currentFilter].push(patternMatch[1]); + } + return filters; +} + +function filesUnder(relativeDirectory, predicate) { + const directory = new URL(`${relativeDirectory}/`, repoRoot); + return readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => + [relativeDirectory, relativePath(directory.pathname, entry.parentPath), entry.name] + .filter(Boolean) + .join("/") + .replaceAll("\\", "/"), + ) + .filter(predicate) + .sort(); +} + +test("gated checks are statically named jobs with real job-level gating", () => { + const workflowSource = readFileSync(ciWorkflowPath, "utf8"); + const jobs = jobBlocks(workflowSource); + const trigger = workflowSource.split("jobs:", 1)[0]; + + assert.match(trigger, /^\s+merge_group:\s*$/m); + assert.doesNotMatch(workflowSource, /strategy:\s*\n\s+matrix:/); + assert.doesNotMatch(workflowSource, /RUN_TESTS|Skip unaffected|No .* changes detected/); + + for (const [jobId, expected] of gatedCiJobs) { + const job = jobs.get(jobId)?.join("\n"); + assert.ok(job, `missing static job ${jobId}`); + assert.match(job, new RegExp(`^ name: ${expected.name.replace(/[()]/g, "\\$&")}$`, "m")); + assert.match(job, /needs\.changes\.outputs\.full != 'false'/); + for (const contract of expected.contracts ?? [expected.contract]) { + assert.match(job, new RegExp(`needs\\.changes\\.outputs\\.${contract} != 'false'`)); + } + } }); test("change gating allows superseded workflow runs to cancel", () => { - const workflow = readFileSync(workflowPath, "utf8"); - const cancellationBlockingJobs = [...jobBlocks(workflow)] - .filter(([, lines]) => lines.some((line) => line.trim().startsWith("${{ always()"))) - .map(([jobId]) => jobId); - - assert.deepEqual( - cancellationBlockingJobs, - [], - "always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating", + for (const workflowPath of [ciWorkflowPath, dockerWorkflowPath, nixWorkflowPath]) { + const source = readFileSync(workflowPath, "utf8"); + assert.doesNotMatch( + source, + /\$\{\{\s*always\(\)/, + "always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating", + ); + } +}); + +test("focused contracts stay inside existing required checks", () => { + const jobs = jobBlocks(readFileSync(ciWorkflowPath, "utf8")); + const changes = jobs.get("changes")?.join("\n") ?? ""; + const server = jobs.get("server-tests-ubuntu")?.join("\n") ?? ""; + const desktop = jobs.get("desktop-tests-ubuntu")?.join("\n") ?? ""; + + assert.match(changes, /scripts\/daemon-launch-contract\.test\.mjs/); + assert.doesNotMatch(changes, /Install dependencies|npm run build/); + + assert.match(server, /test:hub-cli-contract/); + assert.match(server, /npm run test --workspace=@getpaseo\/server/); + assert.ok(!jobs.has("hub-cli-contract")); + + assert.match(desktop, /test:e2e:renderer/); + assert.match(desktop, /test:e2e:browser-tabs/); + assert.match(desktop, /npm run test --workspace=@getpaseo\/desktop/); + assert.ok(!jobs.has("desktop-browser-bridge")); + assert.ok(!jobs.has("playwright-desktop")); +}); + +test("server builds exclude test utilities at every domain depth", () => { + const tsconfig = JSON.parse(readFileSync(serverTsconfigPath, "utf8")); + assert.ok(tsconfig.exclude.includes("src/server/**/test-utils/**")); + assert.ok(!tsconfig.exclude.includes("src/server/test-utils/**")); +}); + +test("PR routing declares stable behavior ownership", () => { + const filters = loadFilters(filtersPath); + assert.deepEqual(filters, { + routing: [".github/ci-paths.yml"], + workspace: [ + ".mise.toml", + ".tool-versions", + "package.json", + "package-lock.json", + "patches/**", + "scripts/**", + "tsconfig.json", + "tsconfig.base.json", + "vitest.config.ts", + ], + ci: [".github/actions/**", ".github/workflows/ci.yml"], + format: [ + ".agents/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}", + ".github/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}", + "**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}", + "packages/expo-two-way-audio/**", + ], + quality: ["**/*.{cjs,js,json,jsx,mjs,ts,tsx}", "packages/expo-two-way-audio/**"], + hub: ["packages/cli/src/commands/hub/**", "packages/server/src/server/hub/**"], + server: ["packages/server/**", "packages/app/e2e/support/fixtures/recording.*"], + desktop: [ + "packages/desktop/**", + "packages/app/src/desktop/**", + "packages/server/src/server/browser-tools/**", + "packages/app/e2e/support/**", + "packages/app/*config.{cjs,js,ts}", + "packages/app/package.json", + ], + app: ["packages/app/**", "packages/expo-two-way-audio/**"], + sdk: ["packages/client/**", "packages/highlight/**", "packages/protocol/**"], + browser: [ + "packages/app/src/!(desktop)/**", + "packages/app/e2e/browser/**", + "packages/app/e2e/support/**", + "packages/app/assets/**", + "packages/app/public/**", + "packages/app/index.ts", + "packages/app/*config.{cjs,js,ts}", + "packages/app/package.json", + ], + relay: ["packages/relay/**"], + cli: ["packages/cli/**"], + }); +}); + +test("cross-package invariants live in the suite that owns them", () => { + const cliTests = filesUnder("packages/cli", (path) => path.endsWith(".test.ts")); + assert.ok(cliTests.length > 0); + for (const path of cliTests) { + assert.doesNotMatch( + readFileSync(new URL(path, repoRoot), "utf8"), + /server\/src\/server\/test-utils/, + path, + ); + } + + const protocolWireCompatibility = new URL( + "packages/protocol/src/messages.wire-compat.test.ts", + repoRoot, ); + assert.match(readFileSync(protocolWireCompatibility, "utf8"), /wire schema compatibility/); +}); + +test("browser and desktop tests have exclusive, directory-owned suites", () => { + const filters = loadFilters(filtersPath); + const browserSpecs = filesUnder("packages/app/e2e", (path) => path.endsWith(".spec.ts")); + const desktopSpecs = filesUnder("packages/desktop/e2e", (path) => path.endsWith(".spec.ts")); + const electronModules = filesUnder("packages/app/src", (path) => /\.electron\.tsx?$/.test(path)); + + assert.ok(browserSpecs.length > 0); + assert.ok(desktopSpecs.length > 0); + assert.ok(browserSpecs.every((path) => path.startsWith("packages/app/e2e/browser/"))); + assert.ok(desktopSpecs.every((path) => path.startsWith("packages/desktop/e2e/"))); + assert.ok(electronModules.every((path) => path.startsWith("packages/app/src/desktop/"))); + + const desktopPackage = JSON.parse(readFileSync(desktopPackagePath, "utf8")); + assert.match(desktopPackage.scripts.test, /--exclude ["']e2e\/\*\*["']/); + + for (const path of browserSpecs) { + assert.doesNotMatch( + readFileSync(new URL(path, repoRoot), "utf8"), + /paseoDesktop|injectDesktopBridge/, + ); + } + for (const path of desktopSpecs) { + assert.ok(path.startsWith("packages/desktop/e2e/")); + } + + const routingSource = readFileSync(filtersPath, "utf8"); + assert.doesNotMatch(routingSource, /desktop_bridge|playwright_desktop|browser-\*|browser-\*\//); + assert.deepEqual(filters.desktop, [ + "packages/desktop/**", + "packages/app/src/desktop/**", + "packages/server/src/server/browser-tools/**", + "packages/app/e2e/support/**", + "packages/app/*config.{cjs,js,ts}", + "packages/app/package.json", + ]); + assert.deepEqual(filters.browser, [ + "packages/app/src/!(desktop)/**", + "packages/app/e2e/browser/**", + "packages/app/e2e/support/**", + "packages/app/assets/**", + "packages/app/public/**", + "packages/app/index.ts", + "packages/app/*config.{cjs,js,ts}", + "packages/app/package.json", + ]); +}); + +test("non-required Docker and Nix workflows avoid runners with workflow path filters", () => { + for (const workflowPath of [dockerWorkflowPath, nixWorkflowPath]) { + const source = readFileSync(workflowPath, "utf8"); + const trigger = source.split("jobs:", 1)[0]; + assert.match(trigger, /^\s+paths:\s*$/m); + assert.doesNotMatch(source, /dorny\/paths-filter/); + } }); diff --git a/scripts/daemon-launch-contract.test.mjs b/scripts/daemon-launch-contract.test.mjs new file mode 100644 index 000000000..ff92cda12 --- /dev/null +++ b/scripts/daemon-launch-contract.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; + +const repoRoot = join(import.meta.dirname, ".."); + +function assertNoDirectWorkerLaunch(label, command) { + for (const workerEntrypoint of [ + "src/server/index.ts", + "dist/server/server/index.js", + "src/server/daemon-worker.ts", + "dist/server/server/daemon-worker.js", + ]) { + assert.ok( + !command.includes(workerEntrypoint), + `${label} must not launch ${workerEntrypoint} directly: ${command}`, + ); + } +} + +function assertNoSpawnedWorkerEntrypoint(label, source) { + assertNoDirectWorkerLaunch(label, source); + assert.doesNotMatch( + source, + /spawn\([^)]*["'`][^"'`]*\.\.\/index\.ts["'`]/s, + `${label} must not spawn ../index.ts directly`, + ); +} + +test("every executable daemon entrypoint enters the supervisor", async () => { + const [ + serverPackageSource, + appIsolatedHostDaemon, + serverConnectionOfferE2e, + desktopRuntimePaths, + nixPackage, + nixModule, + ] = await Promise.all([ + readFile(join(repoRoot, "packages/server/package.json"), "utf8"), + readFile(join(repoRoot, "packages/app/e2e/support/helpers/isolated-host-daemon.ts"), "utf8"), + readFile( + join(repoRoot, "packages/server/src/server/daemon-e2e/connection-offer.e2e.test.ts"), + "utf8", + ), + readFile(join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts"), "utf8"), + readFile(join(repoRoot, "nix/package.nix"), "utf8"), + readFile(join(repoRoot, "nix/module.nix"), "utf8"), + ]); + + const serverPackage = JSON.parse(serverPackageSource); + const startScript = serverPackage.scripts?.start ?? ""; + const devScript = serverPackage.scripts?.dev ?? ""; + const devTsxScript = serverPackage.scripts?.["dev:tsx"] ?? ""; + + assert.match(startScript, /dist\/scripts\/supervisor-entrypoint\.js/); + assertNoDirectWorkerLaunch("server start script", startScript); + assert.match(devScript, /scripts\/dev-runner\.ts/); + assertNoDirectWorkerLaunch("server dev script", devScript); + assert.match(devTsxScript, /scripts\/dev-runner\.ts/); + assertNoDirectWorkerLaunch("server dev:tsx script", devTsxScript); + + assert.match( + appIsolatedHostDaemon, + /spawn\(tsxBin, \["scripts\/supervisor-entrypoint\.ts", "--dev"\]/, + ); + assertNoSpawnedWorkerEntrypoint("app e2e isolated host daemon", appIsolatedHostDaemon); + + assert.match(serverConnectionOfferE2e, /scripts\/supervisor-entrypoint\.ts/); + assertNoSpawnedWorkerEntrypoint("server daemon e2e process launch", serverConnectionOfferE2e); + + assert.match(desktopRuntimePaths, /"dist", "scripts", "supervisor-entrypoint\.js"/); + assert.match(desktopRuntimePaths, /"scripts", "supervisor-entrypoint\.ts"/); + assertNoDirectWorkerLaunch("desktop runtime paths", desktopRuntimePaths); + + assert.match(nixPackage, /dist\/scripts\/supervisor-entrypoint\.js/); + assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage); + assert.match(nixPackage, /--set PASEO_NODE_ENV production/); + assert.doesNotMatch(nixPackage, /--set(-default)?\s+NODE_ENV\b/); + assert.doesNotMatch(nixModule, /\bNODE_ENV\b\s*=/); + assert.doesNotMatch(nixModule, /\bPASEO_NODE_ENV\b/); +});