diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml new file mode 100644 index 0000000000..c156ec0a16 --- /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 3f0214819f..e7fa5c814c 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 6948690cad..5fe29499a5 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 4ae9ebe89f..44e1a7ae88 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 1a5f4f9bd8..6a6eba020b 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 8769719794..782442dbf7 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 48cbb9e87f..0000000000 --- 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 7671d325c6..0000000000 --- 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 0000000000..52f4337992 --- /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 6b5f55001f..9307aef33d 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 51ef13f787..3d5bea938d 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 acb7eaab49..4285f05e7c 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 7e291f06fd..26ee605ea7 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 b0797786fb..78752a3533 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 fccf211fbd..6a8d60e9c4 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 45f9cb9df2..229536893b 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 6ad5d0550f..08014cfe43 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 8cb1fdca6e..86ca233f59 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 e21e3a1867..7691f65434 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 3daaaef533..e250489a9b 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 76b2b7ed63..6d079fe016 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 9d26c00a29..2819347ab7 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 bd68c77b5f..84f0657020 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 10ad84ff23..5e6abb0ada 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 e80f38b276..6e81e93029 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 2937a9f345..a3b5a658bc 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 ff2c9aedc3..d9ef0c17b9 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 77479aeffd..3021a242ed 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 652bca55fc..f430969a87 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 dbfd57b79b..598e35e592 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 17d6cb03cc..b24fa1cd44 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 d515d36698..44892a4994 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 f290b008b7..1f658fac0d 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 c270cf71b4..8b72243df4 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 c8b19b31c1..37d9ed436c 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 aff4789a09..b775371a36 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 ad42b991f7..6aa3a1a44e 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 9ce761ab3f..fb2f98841e 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 af031974b2..976139f432 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 0e5db9d160..6e46b4da67 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 830b63a6bc..705f667260 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 2f5660f202..c72fccb959 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 15d916709f..d9cff153cb 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 9b4fbf6e15..33247ab60c 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 d561f846b7..1a3795136a 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 b96f97dd0d..d4444d6f22 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 fae10ce821..1a1fcb5c2b 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 6ee95a900f..13e752fd88 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 4f9cc06691..7ec3fd4554 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 9f125f19e2..4ba076efa7 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 db627576b5..88d6cce775 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 2fd03012cb..9509169357 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 cbec6733c1..072699b450 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 0000000000..8c4e306a1e --- /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 faea7bfce3..a3b623ea1f 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 0387653cf9..a6a68a3ba7 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 dcddc3ad2c..7e17a1fbe0 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 03161f2422..85a7dabc35 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 a4bf02fade..6e9bc64f65 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 f9d21f6a8f..0763233e10 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 55666a7a13..680b7b4b80 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 a2d5747fdc..b014b2d1cd 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 c34e9d7825..6e89b85f23 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 af5d75a0f0..e620b4d0fb 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 967d274ef4..8546ffb4e3 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 4b7851ccb0..9690aed03a 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 29ea4c2c14..4479213e0f 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 0000000000..05884b45a6 --- /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 3e48e1d2b4..8a702f8c08 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 556c82f561..808e0741d8 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 70a8de6f42..5a3624f063 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 319289860b..ff3d1f7cce 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 129251516d..5e3ccf74c9 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 ee065cb0d1..30d4a44223 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 7d5e0fdc1d..5cf6313cad 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 d2e72bfa94..bd9b934aad 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 b8019537c5..c0d199148e 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 eda7f2c818..7df4c64f65 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 b9a0bb89e8..5f5e710a37 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 8e3dadce0e..21ad51a6bb 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 793473f162..64d808d9d8 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 0000000000..e33a733d4b --- /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 184d0a4588..d624d5e316 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 b2da2217cd..a8a4e0cf4b 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 953611de17..f6f23ac93d 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 53a6578106..db6f0e7e8a 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 f12adcbfc9..fb5bbbd008 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 3ed3f4faeb..cf02b46475 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 6122a5c344..023bf12b9d 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 819a3ed5ff..7b443907a5 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 9e80f16b58..4c41cb5988 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 c0d1ad5144..a988626b9e 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 4a536368c6..55a4df4ba2 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 35a4e24e1d..4a6885766f 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 68a69de63f..2fd2c66e8d 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 fa9e922bae..b7375104c7 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 8e6881bed3..8ad2b1f1be 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 01544b735b..e702cc0f7d 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 fc1dc77014..819a14fad8 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 8e04244ea2..ca1accab8e 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 5a8554d5a1..2866e83aba 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 5c799ae005..cecf4f9916 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 de56c8c4eb..e440c7df21 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 aafac4e0bb..8c8e6ec517 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 dec22a14c5..17c4d3dd45 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 bd312c1396..cc7d9f36a9 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 b9a1dd24a7..92351ae195 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 6ee477f7c1..33eba86764 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 3fe1984305..7162a52b2d 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 ed874f18de..e14b357e89 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 a11df5dafb..4cf294d69d 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 a3abdf9bf4..d8bac26fe4 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 7232374e4e..0000000000 --- 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 896b7fb7e5..0000000000 --- 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 8af68446a1..a4ee900497 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 0000000000..11f16ccc93 --- /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 c13244ef21..ed8951b948 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 979768c683..201bb189ab 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 b9a6e7b22e..b36bbae32a 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 c24ac5de9a..c6dcd2d01f 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 87a9bcab1d..0c7debd19f 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 82dad5169a..8999cab859 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 55caebd6b1..4d451c1470 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 f7a08d3e12..e68c2c24c1 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 0000000000..a8e4cc7e82 --- /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 03ed14e2b1..f498fb382c 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 ae0fac9832..fbf355cb01 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 3ab56dd5ca..ac41b7343b 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 cb5e38600f..a6786ae5cf 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 8d98c1560c..280a0217fc 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 cc0d117e75..3f77983533 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 4af65a17ff..56c3c28dfc 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 b7cf3d7cdd..a75e666fa3 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 f3b9141aac..5130d9ce00 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 0f6b4a437b..16539a3fd2 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 c4f4de663d..b3208205b5 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 3d61f922e5..0efc3ebf8e 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 9dd3239469..804ed73369 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 003790802f..2bd7def5ed 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 152f32abc9..2c8c176800 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 0ba5df9e3c..68b1361576 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 b6f7f03c93..607b901192 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 9b8d370694..0e9ed7ad84 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 6d05aa0e7f..96f51fecd2 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 942f2f2a66..0e0e85191f 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 025ae80034..9126a4a3b4 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 93f2219be9..3f8c31d855 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 bf9f2fa164..0000000000 --- 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 336c796018..bb683ed027 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 5052ec935a..8607b1cfb5 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 e8eeff2bfc..b51735275b 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 1be86f2dfc..0b1ed45458 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 139a186e68..3b2a833a96 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 0000000000..a15818338c --- /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 62b35d270a..655155d280 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 981fc7013c..2f23852c2e 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 878c42ee3b..fb11c24b63 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 06c6b6b250..942fcdb420 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 4eb1185d32..c10fc78b19 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 0000000000..502f7e01ad --- /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 c52d042c0b..fdffe1f31c 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 873fca4791..f1c8869d9f 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 ee9277f86d..6cef40e508 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 0000000000..36259bee4f --- /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 083370fe24..4275bc89aa 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 0000000000..3e74d1a5d0 --- /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 ecbc69fd93..e5222d68fe 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 7d3aa4bbd3..25a0a141e7 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 0000000000..ebf47fe19e --- /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 382d8c589e..3267e60d76 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 bdc1b269dc..f05c49f4d4 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 0000000000..32aa866aed --- /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 d7bbe1f685..763d336925 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 3e64439c36..5bb419d265 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 228b76ad9b..28da3eb9c1 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 78af3adecc..85e402e61c 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 488b236cce..b92ad24b2b 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 aee1b6367c..9c868b42df 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 7954357ddf..8ba01afbe6 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 0000000000..ff92cda12e --- /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/); +});