diff --git a/.github/scripts/tests/pr-dependency-cache.test.mjs b/.github/scripts/tests/pr-dependency-cache.test.mjs index c4803e0108..e37593fd2e 100644 --- a/.github/scripts/tests/pr-dependency-cache.test.mjs +++ b/.github/scripts/tests/pr-dependency-cache.test.mjs @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; const workflow = readFileSync(new URL("../../workflows/pr-trusted.yml", import.meta.url), "utf8"); const jobs = [...workflow.matchAll(/^ ([a-z_][a-z_0-9]*):\n([\s\S]*?)(?=^ [a-z_][a-z_0-9]*:\n|$(?![\s\S]))/gm)]; -const installers = jobs.filter(([, , body]) => body.includes("run: pnpm install --frozen-lockfile")); +const installers = jobs.filter(([, , body]) => body.includes("pnpm install --frozen-lockfile")); test("PR workflows restore dependency stores without creating branch copies", () => { assert.equal(installers.length, 7); @@ -20,20 +20,22 @@ test("PR workflows restore dependency stores without creating branch copies", () }); for (const [, job, body] of installers) { - test(`${job}: reuse master keys before restoring the resolved PR lockfile`, () => { + test(`${job}: reuse master keys before installing with an inline stale-lockfile fallback`, () => { const locate = body.indexOf(" - name: Locate pnpm store"); const restore = body.indexOf(" - name: Restore pnpm store (read only)"); - const artifact = body.indexOf(" - name: Restore regenerated PR lockfile"); - const install = body.indexOf("run: pnpm install --frozen-lockfile"); - assert.ok(locate >= 0 && locate < restore && restore < artifact && artifact < install); - const cache = body.slice(restore, artifact); + const install = body.indexOf(" - name: Install dependencies"); + assert.ok(locate >= 0 && locate < restore && restore < install); + const cache = body.slice(restore, install); assert.match(body.slice(locate, restore), /pnpm store path --silent/); assert.match(body.slice(locate, restore), /node -p 'process.arch'/); assert.match(cache, /uses: actions\/cache\/restore@[a-f0-9]{40}/); assert.ok(cache.includes("key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}")); assert.ok(cache.includes("restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-")); - assert.match(body.slice(artifact, install), /if: needs.policy.outputs.lockfile_regenerated == '1'/); - assert.match(body.slice(artifact, install), /name: pr-lockfile/); - assert.doesNotMatch(body.slice(artifact, install), /continue-on-error/); + // Lanes must not wait on the policy job for a regenerated lockfile; each + // install resolves a stale one inline and then re-validates frozen. + const installStep = body.slice(install).split(" - name:")[1] ?? body.slice(install); + assert.match(installStep, /if ! pnpm install --frozen-lockfile; then/); + assert.match(installStep, /pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile/); + assert.doesNotMatch(installStep, /needs\.policy/); }); } diff --git a/.github/workflows/pr-trusted.yml b/.github/workflows/pr-trusted.yml index 81c5d597f6..4e2b36c91d 100644 --- a/.github/workflows/pr-trusted.yml +++ b/.github/workflows/pr-trusted.yml @@ -257,8 +257,6 @@ jobs: needs: [gate] runs-on: ${{ needs.gate.outputs.runner }} timeout-minutes: 10 - outputs: - lockfile_regenerated: ${{ steps.regen_lockfile.outputs.regenerated }} steps: - name: Checkout repository @@ -344,32 +342,14 @@ jobs: PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \ node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}" - - name: Validate dependency resolution and regenerate stale lockfile - id: regen_lockfile - run: | - cp pnpm-lock.yaml "$RUNNER_TEMP/pnpm-lock.before.yaml" - pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile - if cmp -s "$RUNNER_TEMP/pnpm-lock.before.yaml" pnpm-lock.yaml; then - echo "regenerated=0" >> "$GITHUB_OUTPUT" - else - echo "regenerated=1" >> "$GITHUB_OUTPUT" - fi - - # Manifest-only and stacked PRs keep pnpm-lock.yaml at the default branch. - # Upload a regenerated copy whenever the checked-out merge tree needs one. - # Every downstream job then consumes the same hash without recomputing. - - name: Upload regenerated lockfile for downstream jobs - if: steps.regen_lockfile.outputs.regenerated == '1' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: pr-lockfile - path: pnpm-lock.yaml - retention-days: 1 - if-no-files-found: error + # Each lane resolves a stale lockfile inline; this early check still + # fails fast when the merge tree cannot resolve at all. + - name: Validate dependency resolution + run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile typecheck_release_registry: name: Typecheck + Release Registry - needs: [gate, policy] + needs: [gate] if: ${{ needs.gate.outputs.full_ci == 'true' }} runs-on: ${{ needs.gate.outputs.runner }} timeout-minutes: 20 @@ -410,15 +390,68 @@ jobs: key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm- - - name: Restore regenerated PR lockfile (if policy uploaded one) - if: needs.policy.outputs.lockfile_regenerated == '1' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: pr-lockfile - path: . - - name: Install dependencies - run: pnpm install --frozen-lockfile + # Manifest-changing and stacked PRs can hold a pnpm-lock.yaml that is + # stale for the merge tree. Resolve it inline instead of waiting on a + # policy-job artifact: that dependency put the policy job's queue and + # runtime (~60s) on every lane's critical path, and policy still + # validates resolution as a required check in parallel. + run: | + if ! pnpm install --frozen-lockfile; then + echo '::notice title=Lockfile::checked-in lockfile is stale for this merge tree; resolving it inline' + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + pnpm install --frozen-lockfile + fi + + # typecheck:build-gaps builds @paperclipai/server, whose build script + # rebuilds the Runner release binary; without the shared Rust cache that + # is a ~3m40s cold compile of all third-party crates (run 35036001734, + # 2026-09-15). Same restore-only contract as Verify Paperclip Runner. + - name: Select the pinned Runner Rust toolchain + working-directory: packages/paperclip-runner + run: | + set -uo pipefail + + # release-verify.yml runs on a single post-merge fleet image; the + # gate here can route to ubuntu-latest or the public PR fleet, so + # this tolerates an image without rustup instead of failing every + # pull request. Without the pin the cache key simply will not match. + if ! command -v rustup >/dev/null 2>&1; then + echo '::notice title=Runner Rust cache::rustup is unavailable; building with the image default toolchain' + exit 0 + fi + + rustup show + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + + # rust-cache hashes every installed toolchain into the cache key, not + # only the active one. Each runner image also ships its own stable + # Rust, and those disagree across images: 1.98.0 on the RunsOn fleets + # against 1.98.1 on ubuntu-latest. Two runners that agreed on the pin + # therefore still computed different keys, and every GitHub-hosted + # pull request missed this cache while the fleet hit it. Remove every + # toolchain except the pin, so the key depends on the pinned compiler + # and the lockfile alone rather than on what the image happens to + # carry. Keep this block identical in both workflows: the reader and + # the writer must agree or the key matches nothing. + extra_toolchains="$(rustup toolchain list | awk '{print $1}' | grep -vx "$toolchain" || true)" + if [ -n "$extra_toolchains" ]; then + echo "$extra_toolchains" | xargs -n1 rustup toolchain uninstall \ + || echo '::notice title=Runner Rust cache::could not remove an extra toolchain; the cache key may not match' + fi + rustup toolchain list + + - name: Restore Runner Rust dependencies (read only) + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: packages/paperclip-runner/runner -> target + shared-key: release-runner-v1 + # Mirror the master writer: these also feed the cache key. + cache-workspace-crates: false + cache-bin: false + # Restore only. Never let a pull request evict master's entry. + save-if: false - name: Typecheck workspaces whose build scripts skip TypeScript run: pnpm run typecheck:build-gaps @@ -428,7 +461,7 @@ jobs: general_tests: name: General tests (${{ matrix.group_label }}) - needs: [gate, policy] + needs: [gate] if: ${{ needs.gate.outputs.full_ci == 'true' }} runs-on: ${{ needs.gate.outputs.runner }} timeout-minutes: 20 @@ -438,31 +471,75 @@ jobs: include: # The server suite is pinned to maxWorkers=1 (server/vitest.config.ts), # so it can only be parallelized across runners. Shard it to keep this - # lane off the PR critical path. Five shards because the suite has - # grown to ~946s of serial vitest wall time (run 30930345729, - # 2026-08-04): at four shards the worst shard ran 311s and was the - # slowest check in the whole PR run; five brings each shard to ~196s - # of suite time (~240s job), level with the other ~250-300s lanes. - - group: general-server - group_label: server (1/5) + # lane off the PR critical path. The suite has grown to ~3184s of + # serial vitest wall time (mean of runs 35036001734 and 35024948947, + # 2026-09-15): at five plain general-server shards with stale + # recorded durations the worst shard ran 806s of tests while the + # best ran 417s. This now uses the same shape as release-verify.yml: + # the ~429s chat integration suite splits by collected test location + # across three dedicated runners (~143s each), and the remaining + # ~2755s levels across twelve duration-balanced shards at ~230s + # each, in line with the other ~200-290s lanes. + - group: general-server-without-chat + group_label: server (1/12) shard_index: 0 - shard_count: 5 - - group: general-server - group_label: server (2/5) + shard_count: 12 + - group: general-server-without-chat + group_label: server (2/12) shard_index: 1 - shard_count: 5 - - group: general-server - group_label: server (3/5) + shard_count: 12 + - group: general-server-without-chat + group_label: server (3/12) shard_index: 2 - shard_count: 5 - - group: general-server - group_label: server (4/5) + shard_count: 12 + - group: general-server-without-chat + group_label: server (4/12) shard_index: 3 - shard_count: 5 - - group: general-server - group_label: server (5/5) + shard_count: 12 + - group: general-server-without-chat + group_label: server (5/12) shard_index: 4 - shard_count: 5 + shard_count: 12 + - group: general-server-without-chat + group_label: server (6/12) + shard_index: 5 + shard_count: 12 + - group: general-server-without-chat + group_label: server (7/12) + shard_index: 6 + shard_count: 12 + - group: general-server-without-chat + group_label: server (8/12) + shard_index: 7 + shard_count: 12 + - group: general-server-without-chat + group_label: server (9/12) + shard_index: 8 + shard_count: 12 + - group: general-server-without-chat + group_label: server (10/12) + shard_index: 9 + shard_count: 12 + - group: general-server-without-chat + group_label: server (11/12) + shard_index: 10 + shard_count: 12 + - group: general-server-without-chat + group_label: server (12/12) + shard_index: 11 + shard_count: 12 + - group: general-chat + group_label: chat (1/3) + shard_index: 0 + shard_count: 3 + - group: general-chat + group_label: chat (2/3) + shard_index: 1 + shard_count: 3 + - group: general-chat + group_label: chat (3/3) + shard_index: 2 + shard_count: 3 # workspaces-a was the slowest check in the fully-green PR run # 31371439296 (2026-08-10) at 319s, with the ui project's single # vitest invocation accounting for ~224s and the paperclipai CLI @@ -516,15 +593,18 @@ jobs: key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm- - - name: Restore regenerated PR lockfile (if policy uploaded one) - if: needs.policy.outputs.lockfile_regenerated == '1' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: pr-lockfile - path: . - - name: Install dependencies - run: pnpm install --frozen-lockfile + # Manifest-changing and stacked PRs can hold a pnpm-lock.yaml that is + # stale for the merge tree. Resolve it inline instead of waiting on a + # policy-job artifact: that dependency put the policy job's queue and + # runtime (~60s) on every lane's critical path, and policy still + # validates resolution as a required check in parallel. + run: | + if ! pnpm install --frozen-lockfile; then + echo '::notice title=Lockfile::checked-in lockfile is stale for this merge tree; resolving it inline' + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + pnpm install --frozen-lockfile + fi - name: Run grouped general test suites run: | @@ -606,11 +686,34 @@ jobs: esac verify_paperclip_runner: - name: Verify Paperclip Runner - needs: [gate, policy] + name: Verify Paperclip Runner (${{ matrix.lane_label }}) + needs: [gate] if: ${{ needs.gate.outputs.full_ci == 'true' }} runs-on: ${{ needs.gate.outputs.runner }} timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + # check:all spent ~380s of its ~600s cache-warm step inside the + # runner package's vitest suite (run 35036001734, 2026-09-15); the + # Rust tests took ~160s and every remaining check ~60s combined, + # which made this single job the PR critical path once the test + # lanes were rebalanced. Split the vitest suite across two runners + # with Vitest's native --shard, the ~160s Rust tests into their own + # lane, and the remaining static checks into a fourth. The four + # lanes union to exactly check:all: check:static covers + # check:eval-kernel, check:protocol-without-vitest, and + # check:api-authority; check:runner covers the Rust half; and the + # two vitest shards cover the package vitest file list. + - lane_label: static checks + command: check:static + - lane_label: rust + command: check:runner + - lane_label: vitest 1/2 + command: test:typescript:vitest --shard=1/2 + - lane_label: vitest 2/2 + command: test:typescript:vitest --shard=2/2 steps: - name: Checkout repository @@ -648,15 +751,18 @@ jobs: key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm- - - name: Restore regenerated PR lockfile (if policy uploaded one) - if: needs.policy.outputs.lockfile_regenerated == '1' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: pr-lockfile - path: . - - name: Install dependencies - run: pnpm install --frozen-lockfile + # Manifest-changing and stacked PRs can hold a pnpm-lock.yaml that is + # stale for the merge tree. Resolve it inline instead of waiting on a + # policy-job artifact: that dependency put the policy job's queue and + # runtime (~60s) on every lane's critical path, and policy still + # validates resolution as a required check in parallel. + run: | + if ! pnpm install --frozen-lockfile; then + echo '::notice title=Lockfile::checked-in lockfile is stale for this merge tree; resolving it inline' + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + pnpm install --frozen-lockfile + fi # Same restore-only contract as the pnpm store above, for the Rust # dependency tree: master's post-merge verification is the sole writer @@ -712,11 +818,15 @@ jobs: save-if: false - name: Verify Paperclip Runner - run: pnpm --filter @paperclipai/paperclip-runner check:all + # pnpm appends trailing args to the end of the script's shell chain, + # so the vitest lanes' --shard lands on `vitest run`. Do not add a + # `--` separator: pnpm forwards it literally and vitest would then + # read the shard flag as a test filter. + run: pnpm --filter @paperclipai/paperclip-runner ${{ matrix.command }} build: name: Build - needs: [gate, policy] + needs: [gate] if: ${{ needs.gate.outputs.full_ci == 'true' }} runs-on: ${{ needs.gate.outputs.runner }} timeout-minutes: 20 @@ -757,15 +867,68 @@ jobs: key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm- - - name: Restore regenerated PR lockfile (if policy uploaded one) - if: needs.policy.outputs.lockfile_regenerated == '1' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: pr-lockfile - path: . - - name: Install dependencies - run: pnpm install --frozen-lockfile + # Manifest-changing and stacked PRs can hold a pnpm-lock.yaml that is + # stale for the merge tree. Resolve it inline instead of waiting on a + # policy-job artifact: that dependency put the policy job's queue and + # runtime (~60s) on every lane's critical path, and policy still + # validates resolution as a required check in parallel. + run: | + if ! pnpm install --frozen-lockfile; then + echo '::notice title=Lockfile::checked-in lockfile is stale for this merge tree; resolving it inline' + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + pnpm install --frozen-lockfile + fi + + # pnpm build reaches the Runner package's build:binary step; without the + # shared Rust cache that is a ~3m40s cold compile of all third-party + # crates (run 35036001734, 2026-09-15). Same restore-only contract as + # Verify Paperclip Runner. + - name: Select the pinned Runner Rust toolchain + working-directory: packages/paperclip-runner + run: | + set -uo pipefail + + # release-verify.yml runs on a single post-merge fleet image; the + # gate here can route to ubuntu-latest or the public PR fleet, so + # this tolerates an image without rustup instead of failing every + # pull request. Without the pin the cache key simply will not match. + if ! command -v rustup >/dev/null 2>&1; then + echo '::notice title=Runner Rust cache::rustup is unavailable; building with the image default toolchain' + exit 0 + fi + + rustup show + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + + # rust-cache hashes every installed toolchain into the cache key, not + # only the active one. Each runner image also ships its own stable + # Rust, and those disagree across images: 1.98.0 on the RunsOn fleets + # against 1.98.1 on ubuntu-latest. Two runners that agreed on the pin + # therefore still computed different keys, and every GitHub-hosted + # pull request missed this cache while the fleet hit it. Remove every + # toolchain except the pin, so the key depends on the pinned compiler + # and the lockfile alone rather than on what the image happens to + # carry. Keep this block identical in both workflows: the reader and + # the writer must agree or the key matches nothing. + extra_toolchains="$(rustup toolchain list | awk '{print $1}' | grep -vx "$toolchain" || true)" + if [ -n "$extra_toolchains" ]; then + echo "$extra_toolchains" | xargs -n1 rustup toolchain uninstall \ + || echo '::notice title=Runner Rust cache::could not remove an extra toolchain; the cache key may not match' + fi + rustup toolchain list + + - name: Restore Runner Rust dependencies (read only) + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: packages/paperclip-runner/runner -> target + shared-key: release-runner-v1 + # Mirror the master writer: these also feed the cache key. + cache-workspace-crates: false + cache-bin: false + # Restore only. Never let a pull request evict master's entry. + save-if: false - name: Build Runner Evalbook viewer run: pnpm --filter @paperclipai/paperclip-runner build:issue-thread @@ -775,7 +938,7 @@ jobs: verify_serialized_server: name: Verify serialized server suites (${{ matrix.shard_label }}) - needs: [gate, policy] + needs: [gate] if: ${{ needs.gate.outputs.full_ci == 'true' }} runs-on: ${{ needs.gate.outputs.runner }} timeout-minutes: 20 @@ -783,27 +946,41 @@ jobs: fail-fast: false matrix: include: - # A successful PR run on 2026-08-17 (32012408876) spent 291s in - # serialized shard 1/5 while its siblings ran 170-201s: round-robin - # clustered the heavy suites on one runner. Shards are now balanced - # by recorded duration (scripts/serialized-shard-durations.json), - # which levels the measured 968s suite total to about 194s per - # runner before setup overhead. + # Shards are balanced by recorded duration + # (scripts/serialized-shard-durations.json, refreshed 2026-09-12); + # round-robin used to cluster the heavy suites on one runner (291s + # vs 170-201s in run 32012408876, 2026-08-17). The suite total has + # since grown to ~1926s recorded: five shards ran ~390s of tests + # each while the rebalanced general/e2e lanes run ~210-260s, so + # nine shards level this lane to ~214s and keep it off the + # critical path. - shard_index: 0 - shard_count: 5 - shard_label: 1/5 + shard_count: 9 + shard_label: 1/9 - shard_index: 1 - shard_count: 5 - shard_label: 2/5 + shard_count: 9 + shard_label: 2/9 - shard_index: 2 - shard_count: 5 - shard_label: 3/5 + shard_count: 9 + shard_label: 3/9 - shard_index: 3 - shard_count: 5 - shard_label: 4/5 + shard_count: 9 + shard_label: 4/9 - shard_index: 4 - shard_count: 5 - shard_label: 5/5 + shard_count: 9 + shard_label: 5/9 + - shard_index: 5 + shard_count: 9 + shard_label: 6/9 + - shard_index: 6 + shard_count: 9 + shard_label: 7/9 + - shard_index: 7 + shard_count: 9 + shard_label: 8/9 + - shard_index: 8 + shard_count: 9 + shard_label: 9/9 steps: - name: Checkout repository @@ -841,22 +1018,25 @@ jobs: key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm- - - name: Restore regenerated PR lockfile (if policy uploaded one) - if: needs.policy.outputs.lockfile_regenerated == '1' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: pr-lockfile - path: . - - name: Install dependencies - run: pnpm install --frozen-lockfile + # Manifest-changing and stacked PRs can hold a pnpm-lock.yaml that is + # stale for the merge tree. Resolve it inline instead of waiting on a + # policy-job artifact: that dependency put the policy job's queue and + # runtime (~60s) on every lane's critical path, and policy still + # validates resolution as a required check in parallel. + run: | + if ! pnpm install --frozen-lockfile; then + echo '::notice title=Lockfile::checked-in lockfile is stale for this merge tree; resolving it inline' + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + pnpm install --frozen-lockfile + fi - name: Run serialized server test shard run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }} canary_dry_run: name: Canary Dry Run - needs: [gate, policy] + needs: [gate] if: ${{ needs.gate.outputs.full_ci == 'true' }} runs-on: ${{ needs.gate.outputs.runner }} timeout-minutes: 20 @@ -897,43 +1077,91 @@ jobs: key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm- - - name: Restore regenerated PR lockfile (if policy uploaded one) - if: needs.policy.outputs.lockfile_regenerated == '1' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: pr-lockfile - path: . - - name: Install dependencies - run: pnpm install --frozen-lockfile + # Manifest-changing and stacked PRs can hold a pnpm-lock.yaml that is + # stale for the merge tree. Resolve it inline instead of waiting on a + # policy-job artifact: that dependency put the policy job's queue and + # runtime (~60s) on every lane's critical path, and policy still + # validates resolution as a required check in parallel. + run: | + if ! pnpm install --frozen-lockfile; then + echo '::notice title=Lockfile::checked-in lockfile is stale for this merge tree; resolving it inline' + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + pnpm install --frozen-lockfile + fi + + # release.sh's Step 2/7 workspace build reaches the Runner package's + # build:binary step; without the shared Rust cache that is a ~3m40s cold + # compile of all third-party crates (run 35036001734, 2026-09-15). Same + # restore-only contract as Verify Paperclip Runner. + - name: Select the pinned Runner Rust toolchain + working-directory: packages/paperclip-runner + run: | + set -uo pipefail + + # release-verify.yml runs on a single post-merge fleet image; the + # gate here can route to ubuntu-latest or the public PR fleet, so + # this tolerates an image without rustup instead of failing every + # pull request. Without the pin the cache key simply will not match. + if ! command -v rustup >/dev/null 2>&1; then + echo '::notice title=Runner Rust cache::rustup is unavailable; building with the image default toolchain' + exit 0 + fi + + rustup show + toolchain="$(rustup show active-toolchain | awk '{print $1}')" + echo "RUSTUP_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + + # rust-cache hashes every installed toolchain into the cache key, not + # only the active one. Each runner image also ships its own stable + # Rust, and those disagree across images: 1.98.0 on the RunsOn fleets + # against 1.98.1 on ubuntu-latest. Two runners that agreed on the pin + # therefore still computed different keys, and every GitHub-hosted + # pull request missed this cache while the fleet hit it. Remove every + # toolchain except the pin, so the key depends on the pinned compiler + # and the lockfile alone rather than on what the image happens to + # carry. Keep this block identical in both workflows: the reader and + # the writer must agree or the key matches nothing. + extra_toolchains="$(rustup toolchain list | awk '{print $1}' | grep -vx "$toolchain" || true)" + if [ -n "$extra_toolchains" ]; then + echo "$extra_toolchains" | xargs -n1 rustup toolchain uninstall \ + || echo '::notice title=Runner Rust cache::could not remove an extra toolchain; the cache key may not match' + fi + rustup toolchain list + + - name: Restore Runner Rust dependencies (read only) + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: packages/paperclip-runner/runner -> target + shared-key: release-runner-v1 + # Mirror the master writer: these also feed the cache key. + cache-workspace-crates: false + cache-bin: false + # Restore only. Never let a pull request evict master's entry. + save-if: false # `release.sh` always executes its Step 2/7 workspace build, even when # `--skip-verify` bypasses the initial verification gate. release.sh - # also requires a clean working tree, so any in-place lockfile churn - # from `pnpm install --frozen-lockfile` must be reverted first — unless - # the policy job uploaded a regenerated lockfile (manifest-changing - # PRs), in which case we stage the artifact-restored copy into an - # ephemeral local commit so release.sh sees a clean tree and its - # workspace build sees a lockfile that matches the manifest. + # also requires a clean working tree, and the install step may have + # resolved a stale lockfile in place (manifest-changing or stacked + # PRs), so stage any changed lockfile into an ephemeral local commit: + # release.sh then sees a clean tree and its workspace build sees a + # lockfile that matches the manifests. - name: Release canary dry run via release.sh internal build - env: - USED_ARTIFACT_LOCKFILE: ${{ needs.policy.outputs.lockfile_regenerated || '0' }} run: | git checkout -B master HEAD - if [ "$USED_ARTIFACT_LOCKFILE" = "1" ]; then - git add pnpm-lock.yaml - if ! git diff --cached --quiet; then - git -c user.email=ci@paperclip.local -c user.name=CI \ - commit --no-verify -m "ci(canary): stage regenerated lockfile" - fi - else + if git diff --quiet pnpm-lock.yaml; then git checkout -- pnpm-lock.yaml + else + git add pnpm-lock.yaml + git -c user.email=ci@paperclip.local -c user.name=CI \ + commit --no-verify -m "ci(canary): stage regenerated lockfile" fi ./scripts/release.sh canary --skip-verify --dry-run e2e_shards: name: e2e shard (${{ matrix.shard_label }}) - needs: [gate, policy] + needs: [gate] if: ${{ needs.gate.outputs.full_ci == 'true' }} runs-on: ${{ needs.gate.outputs.runner }} timeout-minutes: 30 @@ -945,18 +1173,37 @@ jobs: # because every spec shares one throwaway server and some toggle # instance-level flags, so it can only be parallelized across runners. # Each shard boots its own server, which keeps that isolation intact. - # Three shards let the ~3min smoke-lab spec ride alone while the rest - # of the catalog splits evenly, pulling this lane off the PR critical - # path (it was the slowest check at ~8min20s with two shards). + # The catalog has grown to ~1694s of serial spec time (mean of runs + # 35036001734 and 35024948947, 2026-09-15): with three shards and + # stale durations the worst shard ran 745s of specs while the best + # ran 277s. The former floors — chat-adapters-ui at ~442s and + # agent-chat at ~310s — are each split into two specs, so eight + # shards with refreshed durations level to ~212s each; the heaviest + # remaining single spec is chat-adapters-ui-messaging at ~242s. - shard_index: 0 - shard_count: 3 - shard_label: 1/3 + shard_count: 8 + shard_label: 1/8 - shard_index: 1 - shard_count: 3 - shard_label: 2/3 + shard_count: 8 + shard_label: 2/8 - shard_index: 2 - shard_count: 3 - shard_label: 3/3 + shard_count: 8 + shard_label: 3/8 + - shard_index: 3 + shard_count: 8 + shard_label: 4/8 + - shard_index: 4 + shard_count: 8 + shard_label: 5/8 + - shard_index: 5 + shard_count: 8 + shard_label: 6/8 + - shard_index: 6 + shard_count: 8 + shard_label: 7/8 + - shard_index: 7 + shard_count: 8 + shard_label: 8/8 steps: - name: Checkout repository @@ -994,15 +1241,18 @@ jobs: key: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: node-cache-${{ runner.os }}-${{ steps.pnpm_store.outputs.arch }}-pnpm- - - name: Restore regenerated PR lockfile (if policy uploaded one) - if: needs.policy.outputs.lockfile_regenerated == '1' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: pr-lockfile - path: . - - name: Install dependencies - run: pnpm install --frozen-lockfile + # Manifest-changing and stacked PRs can hold a pnpm-lock.yaml that is + # stale for the merge tree. Resolve it inline instead of waiting on a + # policy-job artifact: that dependency put the policy job's queue and + # runtime (~60s) on every lane's critical path, and policy still + # validates resolution as a required check in parallel. + run: | + if ! pnpm install --frozen-lockfile; then + echo '::notice title=Lockfile::checked-in lockfile is stale for this merge tree; resolving it inline' + pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile + pnpm install --frozen-lockfile + fi - name: Verify runner Chrome # GitHub's Ubuntu runner image already ships Google Chrome, so use that diff --git a/doc/plans/chat-adapters/2026-09-04-chat-adapters-browser-e2e-runbook.md b/doc/plans/chat-adapters/2026-09-04-chat-adapters-browser-e2e-runbook.md index 3a49dede54..8f8ba81fc5 100644 --- a/doc/plans/chat-adapters/2026-09-04-chat-adapters-browser-e2e-runbook.md +++ b/doc/plans/chat-adapters/2026-09-04-chat-adapters-browser-e2e-runbook.md @@ -153,7 +153,7 @@ Before opening a real provider, I run the deterministic Paperclip-side browser c ```sh pnpm exec playwright test \ --config tests/e2e/playwright.config.ts \ - tests/e2e/chat-adapters-ui.spec.ts \ + tests/e2e/chat-adapters-ui-providers.spec.ts \ --grep '^Slack:' ``` diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index 032ae78653..e7fbc955dc 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -73,7 +73,9 @@ "typecheck:rust": "cargo fmt --manifest-path runner/Cargo.toml --all -- --check && cargo check --manifest-path runner/Cargo.toml --locked --workspace", "typecheck:browser": "tsc -p tsconfig.browser.json --noEmit", "test": "pnpm run test:typescript && pnpm run test:rust", - "test:typescript": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs && vitest run", + "test:typescript": "pnpm run test:typescript:prep && vitest run", + "test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs", + "test:typescript:vitest": "pnpm run ensure:eval-build-deps && pnpm run build:rust && vitest run", "test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace", "test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider", "test:durable": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core durable::", @@ -95,8 +97,11 @@ "generate:semantic-action-catalog": "pnpm run build:typescript && node scripts/generate-semantic-action-catalog.mjs", "check:semantic-action-catalog": "pnpm run build:typescript && node scripts/generate-semantic-action-catalog.mjs --check", "check:protocol": "pnpm run typecheck:typescript && pnpm run check:protocol-manifest && pnpm run test:typescript && pnpm run check:replay-goldens", + "check:protocol-without-vitest": "pnpm run typecheck:typescript && pnpm run check:protocol-manifest && pnpm run test:typescript:prep && pnpm run check:replay-goldens", "check:runner": "pnpm run typecheck:rust && pnpm run test:rust && pnpm run check:conformance-parity && pnpm run check:replay-parity", "check:all": "pnpm run check:eval-kernel && pnpm run check:protocol && pnpm run check:runner && pnpm run check:api-authority", + "check:all-without-vitest": "pnpm run check:eval-kernel && pnpm run check:protocol-without-vitest && pnpm run check:runner && pnpm run check:api-authority", + "check:static": "pnpm run check:eval-kernel && pnpm run check:protocol-without-vitest && pnpm run check:api-authority", "check:api-authority": "node scripts/check-api-authority.mjs", "generate:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs", "check:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs --check", diff --git a/scripts/__tests__/e2e-shard.test.mjs b/scripts/__tests__/e2e-shard.test.mjs index d90021e114..ddeca9c1e3 100644 --- a/scripts/__tests__/e2e-shard.test.mjs +++ b/scripts/__tests__/e2e-shard.test.mjs @@ -17,7 +17,7 @@ const prCallerWorkflow = path.join(repoRoot, ".github", "workflows", "pr.yml"); const trustedPrWorkflowPath = ".github/workflows/pr-trusted.yml"; const trustedPrWorkflow = path.join(repoRoot, trustedPrWorkflowPath); -const SHARD_COUNT = 3; +const SHARD_COUNT = 8; function runShard(args) { const result = spawnSync(process.execPath, [script, ...args], { cwd: repoRoot, encoding: "utf8" }); @@ -317,23 +317,25 @@ test("the trusted PR workflow regenerates stale stacked lockfiles", () => { "resolution-only policy must not restore or save a dependency store", ); assert.match( - workflow, + policy, /pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile/, "the policy job must resolve the complete merge tree without rewriting platform metadata", ); - assert.match( - workflow, - /cmp -s "\$RUNNER_TEMP\/pnpm-lock\.before\.yaml" pnpm-lock\.yaml/, - "the policy job must upload a lockfile only when regeneration changed it", - ); - const restoreSteps = workflow.match( - /- name: Restore regenerated PR lockfile \(if policy uploaded one\)\n if: needs\.policy\.outputs\.lockfile_regenerated == '1'/g, + // Test lanes no longer wait on a policy-job artifact: each install step + // resolves a stale lockfile inline, so a manifest-changing or stacked PR + // still installs while the policy job validates resolution in parallel. + const fallbackInstalls = workflow.match( + /if ! pnpm install --frozen-lockfile; then\n[\s\S]{0,240}?pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile\n\s+pnpm install --frozen-lockfile\n\s+fi/g, ) ?? []; - assert.equal(restoreSteps.length, 7, "every downstream install job must restore a required regenerated artifact"); + assert.equal( + fallbackInstalls.length, + 7, + "every downstream install job must resolve a stale lockfile inline", + ); assert.doesNotMatch( workflow, - /- name: Restore regenerated PR lockfile \(if policy uploaded one\)[\s\S]{0,220}continue-on-error:/, - "a missing artifact must fail after the policy job says it uploaded one", + /Restore regenerated PR lockfile|lockfile_regenerated|name: pr-lockfile/, + "the policy lockfile artifact chain must stay removed; it put the policy job on every lane's critical path", ); }); diff --git a/scripts/__tests__/release-dry-run-notes.test.mjs b/scripts/__tests__/release-dry-run-notes.test.mjs index 6f1d12d003..02648b6184 100644 --- a/scripts/__tests__/release-dry-run-notes.test.mjs +++ b/scripts/__tests__/release-dry-run-notes.test.mjs @@ -36,7 +36,14 @@ git_current_branch() { printf 'master\\n'; } get_last_stable_tag() { printf 'v2026.709.0\\n'; } get_current_stable_version() { printf '2026.709.0\\n'; } utc_date_iso() { printf '2026-07-10\\n'; } -list_public_package_info() { printf 'cli\\tpaperclipai\\t0.0.0\\n'; } +list_public_package_info() { + if [ -n "\${FAKE_PACKAGE_INFO:-}" ]; then + printf '%b' "$FAKE_PACKAGE_INFO" + else + printf 'cli\\tpaperclipai\\t0.0.0\\n' + fi +} +package_publish_tool() { printf 'pnpm\\n'; } next_stable_version() { printf '2026.710.0\\n'; } next_prerelease_version() { printf '2026.710.0-%s.0\\n' "$1"; } release_notes_file() { printf '%s/releases/v%s.md\\n' "$REPO_ROOT" "$1"; } @@ -56,6 +63,7 @@ require_channel_tag_absent_at_head() { fi echo "[fixture] require_channel_tag_absent_at_head $1" } +require_on_master_branch() { :; } require_clean_worktree() { :; } require_npm_publish_auth() { :; } git_local_tag_exists() { return 1; } @@ -112,9 +120,21 @@ exit 0 set -euo pipefail printf 'pnpm %s\\n' "$*" >> "$FAKE_CALL_LOG" if [ "$*" = "build" ]; then + if [ "\${FAKE_BUILD_OK:-}" = "1" ]; then + exit 0 + fi echo "fixture stopped at workspace build" exit 42 fi +if [ "$1" = "publish" ]; then + echo "fixture publish preview in $PWD" + case "$PWD" in + *"\${FAKE_FAIL_PUBLISH_DIR:-__none__}") + echo "fixture preview failure" >&2 + exit 3 + ;; + esac +fi exit 0 `, ); @@ -122,8 +142,11 @@ exit 0 return { binDir, callLog, fixtureDir, script: join(scriptsDir, "release.sh") }; } -function runRelease(args, extraEnv = {}) { +function runRelease(args, extraEnv = {}, prepare = null) { const fixture = createReleaseFixture(); + if (prepare) { + prepare(fixture); + } const result = spawnSync(fixture.script, args, { cwd: fixture.fixtureDir, encoding: "utf8", @@ -247,3 +270,92 @@ test("beta refuses commits that never shipped a nightly", () => { assert.match(result.output, /HEAD has no nightly\/v\* tag/); assert.doesNotMatch(result.calls, /^pnpm /m); }); + +// --- Step 5 concurrent publish-payload previews (dry-run only) --- + +const PREVIEW_PACKAGE_COUNT = 10; // more than one batch of 8 + +function preparePreviewFixture(fixture) { + const { fixtureDir } = fixture; + // Step 2 copies the skills tree into these package dirs. + mkdirSync(join(fixtureDir, "skills"), { recursive: true }); + for (const dir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) { + mkdirSync(join(fixtureDir, dir), { recursive: true }); + } + // Step 2 build helpers that live outside the pnpm stub. + writeExecutable( + join(fixtureDir, "scripts", "build-standalone-public-packages.mjs"), + "#!/usr/bin/env node\nprocess.exit(0);\n", + ); + writeExecutable(join(fixtureDir, "scripts", "prepare-server-ui-dist.sh"), "#!/usr/bin/env bash\nexit 0\n"); + writeExecutable(join(fixtureDir, "scripts", "build-npm.sh"), "#!/usr/bin/env bash\nexit 0\n"); + // Step 4 verifies the CLI package version against the target canary version. + mkdirSync(join(fixtureDir, "cli"), { recursive: true }); + writeFileSync( + join(fixtureDir, "cli", "package.json"), + JSON.stringify({ name: "paperclipai", version: "2026.710.0-canary.0" }), + ); + // Step 5 cds into each previewed package directory. + for (let index = 1; index <= PREVIEW_PACKAGE_COUNT; index += 1) { + mkdirSync(join(fixtureDir, previewPackageDir(index)), { recursive: true }); + } +} + +function previewPackageDir(index) { + return `pkg-${String(index).padStart(2, "0")}`; +} + +function previewPackageInfo() { + return Array.from({ length: PREVIEW_PACKAGE_COUNT }, (_, i) => { + const dir = previewPackageDir(i + 1); + return `${dir}\\t@fixture/${dir}\\t0.0.0\\n`; + }).join(""); +} + +test("canary dry-run previews every publish payload and replays logs in package order", () => { + const result = runRelease( + ["canary", "--skip-verify", "--dry-run"], + { FAKE_BUILD_OK: "1", FAKE_PACKAGE_INFO: previewPackageInfo() }, + preparePreviewFixture, + ); + + assert.equal(result.status, 0, result.output); + const headerOffsets = []; + for (let index = 1; index <= PREVIEW_PACKAGE_COUNT; index += 1) { + const offset = result.output.indexOf(`--- ${previewPackageDir(index)} ---`); + assert.notEqual(offset, -1, `missing preview header for ${previewPackageDir(index)}`); + headerOffsets.push(offset); + } + assert.deepEqual( + headerOffsets, + [...headerOffsets].sort((a, b) => a - b), + "preview logs must replay in package order even though previews run concurrently", + ); + const previews = result.output.match(/fixture publish preview in /g) ?? []; + assert.equal(previews.length, PREVIEW_PACKAGE_COUNT, "every package must be previewed exactly once"); + assert.match(result.output, /Would create git tag/); +}); + +test("canary dry-run fails when any concurrent payload preview fails, after replaying every log", () => { + const result = runRelease( + ["canary", "--skip-verify", "--dry-run"], + { + FAKE_BUILD_OK: "1", + FAKE_PACKAGE_INFO: previewPackageInfo(), + FAKE_FAIL_PUBLISH_DIR: previewPackageDir(7), + }, + preparePreviewFixture, + ); + + assert.notEqual(result.status, 0, "a failed preview must fail the dry run"); + assert.match(result.output, /one or more publish payload previews failed/); + assert.match(result.output, /fixture preview failure/); + for (let index = 1; index <= PREVIEW_PACKAGE_COUNT; index += 1) { + assert.match( + result.output, + new RegExp(`--- ${previewPackageDir(index)} ---`), + "every package's log must still replay so the failure is diagnosable", + ); + } + assert.doesNotMatch(result.output, /Would create git tag/, "a failed preview must not reach tagging"); +}); diff --git a/scripts/__tests__/run-vitest-stable-shard.test.mjs b/scripts/__tests__/run-vitest-stable-shard.test.mjs index 3d5a329eb8..f320fa4859 100644 --- a/scripts/__tests__/run-vitest-stable-shard.test.mjs +++ b/scripts/__tests__/run-vitest-stable-shard.test.mjs @@ -35,8 +35,8 @@ function dryRunJson(args) { return JSON.parse(result.stdout); } -const SHARD_COUNT = 5; -const SERIALIZED_SHARD_COUNT = 5; +const SHARD_COUNT = 12; +const SERIALIZED_SHARD_COUNT = 9; test("the serialized shards form a complete, non-overlapping partition", () => { @@ -174,44 +174,17 @@ test("the checked-in manifest loads and covers most of the current suite set", ( ); }); -test("the measured chat integration cohort does not share a general-server shard", () => { +test("the chat integration suite keeps a measured duration for duration-aware fallbacks", () => { + // The PR and release matrices both run the chat suite in dedicated + // line-sharded lanes, but the plain general-server group (local full runs) + // still weighs it into the LPT partition; a median-fallback weight there + // would silently overload whichever shard receives it. const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts"; const durations = loadShardDurations(durationsManifest); assert.ok( Number.isFinite(durations[chatSuite]), "the full chat cohort must have a measured duration, not the median fallback", ); - const unsharded = dryRunJson([ - "--mode", - "general", - "--group", - "general-server", - "--shard-index", - "0", - "--shard-count", - "1", - ]); - const shards = partitionGeneralServerSuites( - unsharded.selectedGeneralServerSuites, - SHARD_COUNT, - durations, - ); - const chatShards = shards.filter((shard) => shard.files.includes(chatSuite)); - assert.equal( - chatShards.length, - 1, - "the full chat cohort must run exactly once", - ); - assert.deepEqual( - chatShards[0].files, - [chatSuite], - "its measured cost must reserve one existing shard without other suites", - ); - assert.deepEqual( - shards.flatMap((shard) => shard.files).sort(), - [...unsharded.selectedGeneralServerSuites].sort(), - "duration balancing must not omit or duplicate any general-server suite", - ); }); test("the checked-in serialized manifest loads and covers most of the current suite set", () => { @@ -248,10 +221,12 @@ test("the real serialized shard partition is duration-balanced", () => { }); test("the real shard partition is duration-balanced", () => { + // Mirrors the PR matrix: general-server-without-chat across SHARD_COUNT + // runners, with the chat suite carried by the dedicated general-chat lanes. const durations = loadShardDurations(durationsManifest); const fallback = defaultSuiteWeight(durations); const shards = Array.from({ length: SHARD_COUNT }, (_, index) => - dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", String(index), "--shard-count", String(SHARD_COUNT)]), + dryRunJson(["--mode", "general", "--group", "general-server-without-chat", "--shard-index", String(index), "--shard-count", String(SHARD_COUNT)]), ); const totals = shards.map((shard) => @@ -259,8 +234,12 @@ test("the real shard partition is duration-balanced", () => { ); const maxTotal = Math.max(...totals); const minTotal = Math.min(...totals); - // LPT keeps the spread within the heaviest single suite; use that as the bound. - const heaviest = Math.max(...Object.values(durations)); + // LPT keeps the spread within the heaviest single suite; use that as the + // bound. The chat suite runs in its own lanes, so exclude it here. + const chat = "server/src/__tests__/chat-channels.integration.test.ts"; + const heaviest = Math.max( + ...Object.entries(durations).filter(([file]) => file !== chat).map(([, ms]) => ms), + ); assert.ok( maxTotal - minTotal <= heaviest, `shard weight spread ${maxTotal - minTotal}ms exceeds heaviest suite ${heaviest}ms: ${totals.join(", ")}`, @@ -268,20 +247,23 @@ test("the real shard partition is duration-balanced", () => { }); -test("release server shards plus the dedicated chat file cover the original server group exactly", () => { - const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"]); - const shards = Array.from({ length: 10 }, (_, index) => dryRunJson([ - "--mode", "general", "--group", "general-server-without-chat", - "--shard-index", String(index), "--shard-count", "10", - ])); - const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites); - const chat = "server/src/__tests__/chat-channels.integration.test.ts"; - assert.ok(!files.includes(chat)); - assert.deepEqual([...files, chat].sort(), full.selectedGeneralServerSuites.sort()); - assert.equal(new Set(files).size, files.length); - const defaultRun = dryRunJson([]); - assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount); -}); +// 12 mirrors pr-trusted.yml, 10 mirrors release-verify.yml. +for (const withoutChatShardCount of [10, 12]) { + test(`${withoutChatShardCount} without-chat shards plus the dedicated chat file cover the original server group exactly`, () => { + const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"]); + const shards = Array.from({ length: withoutChatShardCount }, (_, index) => dryRunJson([ + "--mode", "general", "--group", "general-server-without-chat", + "--shard-index", String(index), "--shard-count", String(withoutChatShardCount), + ])); + const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites); + const chat = "server/src/__tests__/chat-channels.integration.test.ts"; + assert.ok(!files.includes(chat)); + assert.deepEqual([...files, chat].sort(), full.selectedGeneralServerSuites.sort()); + assert.equal(new Set(files).size, files.length); + const defaultRun = dryRunJson([]); + assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount); + }); +} const lineShardFile = path.join(repoRoot, "server/src/__tests__/chat-channels.integration.test.ts"); const caseAt = (line, name) => ({ name, file: lineShardFile, projectName: "@paperclipai/server", location: { line, column: 3 } }); diff --git a/scripts/e2e-shard-durations.json b/scripts/e2e-shard-durations.json index af5549c94d..8dd90ad997 100644 --- a/scripts/e2e-shard-durations.json +++ b/scripts/e2e-shard-durations.json @@ -1,21 +1,37 @@ { - "$comment": "Per-spec wall-clock durations (ms) for the Playwright e2e lane, used by scripts/e2e-shard.mjs to balance specs across the PR shard matrix. Averaged from two real PR runs of .github/workflows/pr.yml (actions runs 29775184389 and 29769465682, 2026-07-20). The split Smoke Lab weights scale its prior 168s CI duration by the measured local before/after ratio and scenario count; refresh them from the next two real PR runs. Specs missing here get the median weight, so the manifest only needs occasional refreshes: pull the 'Run e2e tests' logs from a recent PR run and sum the Playwright list-reporter durations per spec file.", + "$comment": "Per-spec wall-clock durations (ms) for the Playwright e2e lane, used by scripts/e2e-shard.mjs to balance specs across the PR shard matrix. Averaged from two real PR runs of .github/workflows/pr.yml (actions runs 35036001734 and 35024948947, 2026-09-15) by diffing consecutive test-completion timestamps in the 'Run e2e tests' logs, attributing each gap to the finishing test's spec — that captures fixture and navigation time the list-reporter durations alone miss. Smoke Lab tests report under tests/e2e/smoke-lab.shared.ts; their time is mapped back to the p1-p4/p5-p7 spec by scenario tag. Specs missing here get the median weight, so the manifest only needs occasional refreshes: regenerate the same way when e2e shard steps drift apart. chat-adapters-ui and agent-chat were split 2026-09-15 into providers/messaging specs; their weights are the per-describe sums from the same two runs. agent-chat halves are weighted by their per-test sums scaled to the measured spec total.", "unit": "ms", "durations": { - "tests/e2e/app-not-connected.spec.ts": 26750, - "tests/e2e/application-delete-screenshot.spec.ts": 6750, - "tests/e2e/applications-crud.spec.ts": 16100, - "tests/e2e/apps-dark-mode-shots.spec.ts": 18000, - "tests/e2e/apps-prosumer-mcp-flow.spec.ts": 16800, - "tests/e2e/conference-room-typing-intro.spec.ts": 4900, - "tests/e2e/mcp-user-stories.spec.ts": 39000, - "tests/e2e/nux-phase4-screenshots.spec.ts": 19650, - "tests/e2e/onboarding.spec.ts": 3200, - "tests/e2e/pipelines-tutorial-flow.spec.ts": 30198, - "tests/e2e/planning-mode-visual-verification.spec.ts": 23250, - "tests/e2e/sidebar-takeover.spec.ts": 16650, - "tests/e2e/signoff-policy.spec.ts": 9696, - "tests/e2e/smoke-lab-p1-p4.spec.ts": 71000, - "tests/e2e/smoke-lab-p5-p7.spec.ts": 53000 + "tests/e2e/acp-stop-continuation.spec.ts": 44152, + "tests/e2e/action-test-agent-picker.spec.ts": 10050, + "tests/e2e/agent-chat-projects.spec.ts": 159100, + "tests/e2e/agent-chat-sessions.spec.ts": 151200, + "tests/e2e/agentmail.spec.ts": 8944, + "tests/e2e/app-not-connected.spec.ts": 15684, + "tests/e2e/application-delete-screenshot.spec.ts": 3585, + "tests/e2e/applications-crud.spec.ts": 10924, + "tests/e2e/apps-dark-mode-shots.spec.ts": 27798, + "tests/e2e/apps-prosumer-mcp-flow.spec.ts": 19557, + "tests/e2e/archived-company-url.spec.ts": 11149, + "tests/e2e/board-attachment-receipts.spec.ts": 102785, + "tests/e2e/chat-adapters-ui-messaging.spec.ts": 241600, + "tests/e2e/chat-adapters-ui-providers.spec.ts": 200200, + "tests/e2e/composer-stop.spec.ts": 57673, + "tests/e2e/conference-room-typing-intro.spec.ts": 15196, + "tests/e2e/connection-intents.spec.ts": 14451, + "tests/e2e/connection-reviews.spec.ts": 141760, + "tests/e2e/legacy-failure-continuation.spec.ts": 74050, + "tests/e2e/mcp-user-stories.spec.ts": 38899, + "tests/e2e/nux-phase4-screenshots.spec.ts": 18776, + "tests/e2e/onboarding.spec.ts": 21295, + "tests/e2e/paused-composer.spec.ts": 33211, + "tests/e2e/pipelines-tutorial-flow.spec.ts": 30545, + "tests/e2e/planning-mode-visual-verification.spec.ts": 26193, + "tests/e2e/project-repositories.spec.ts": 21634, + "tests/e2e/runner-e2e-dashboard.spec.ts": 2028, + "tests/e2e/sidebar-takeover.spec.ts": 19034, + "tests/e2e/signoff-policy.spec.ts": 12121, + "tests/e2e/smoke-lab-p1-p4.spec.ts": 95529, + "tests/e2e/smoke-lab-p5-p7.spec.ts": 64328 } } diff --git a/scripts/general-server-shard-durations.json b/scripts/general-server-shard-durations.json index d9c55a30ff..7b91056267 100644 --- a/scripts/general-server-shard-durations.json +++ b/scripts/general-server-shard-durations.json @@ -1,409 +1,652 @@ { - "$comment": "Per-suite wall-clock durations (ms) for the general-server vitest lane, used by scripts/general-server-shard.mjs to balance suites across the PR shard matrix. Sampled from a real PR run of .github/workflows/pr.yml (actions run 32708351172, 2026-08-24) by diffing consecutive per-suite completion timestamps in the 'Run grouped general test suites' logs \u2014 that captures each suite's true serial cost (import + collect + tests), not just the vitest-reported test time. Suites missing here get the median weight, so the manifest only needs occasional refreshes.", - "$chatSample": "chat-channels.integration.test.ts: actions run 34405038082, job 102646337040, 2026-09-09. The first suite completed at 21:19:52.9026416Z after Vitest RUN at 21:09:07.5491992Z: 645354ms rounded up, including startup/import/collection; the 985 tests themselves took 629654ms. All 2972 tests in the shard passed, but the job exceeded its unchanged 20-minute bound during cleanup. Recording this missing heavy-suite weight lets the existing LPT partition reserve one of the existing five shards without changing test coverage, isolation, or deadlines.", - "$nativeRunnerSample": "native-codex-runner.integration.test.ts: actions run 34555686996, job 103127786254, 2026-09-11. Consecutive suite completions at 02:50:34.2830368Z and 02:55:07.9837588Z give 273701ms including import/collection (test body 270773ms). This previously unweighted suite made one four-way server shard take 14m38s; recording its cost lets the existing LPT partition balance it in both PR and release runs.", + "$comment": "Per-suite wall-clock durations (ms) for the general-server vitest lane, used by scripts/general-server-shard.mjs to balance suites across the PR shard matrix. Averaged from two real PR runs of .github/workflows/pr.yml (actions runs 35036001734 and 35024948947, 2026-09-15) by diffing consecutive per-suite completion timestamps in the 'Run grouped general test suites' logs across every shard — that captures each suite's true serial cost (import, setup, and run) under maxWorkers=1. The first suite of each shard is measured from Vitest's RUN banner, so per-shard startup lands on a measured suite instead of vanishing. Suites missing here get the median weight, so the manifest only needs occasional refreshes; regenerate the same way when the lane's shard steps drift apart by more than the heaviest suite.", + "unit": "ms", "durations": { - "server/src/services/native-runtime/native-codex-runner.integration.test.ts": 273701, - "server/src/__tests__/access-service.test.ts": 4757, - "server/src/__tests__/access-validators.test.ts": 645, - "server/src/__tests__/activity-log-responsible-user.test.ts": 4407, - "server/src/__tests__/activity-service.test.ts": 4708, - "server/src/__tests__/adapter-models.test.ts": 1173, - "server/src/__tests__/adapter-registry.test.ts": 1032, - "server/src/__tests__/adapter-session-codecs.test.ts": 791, - "server/src/__tests__/agent-auth-jwt.test.ts": 648, - "server/src/__tests__/agent-auth-middleware.test.ts": 1228, - "server/src/__tests__/agent-instructions-service.test.ts": 248, - "server/src/__tests__/agent-invokability.test.ts": 1113, - "server/src/__tests__/agent-permissions-service.test.ts": 640, - "server/src/__tests__/agent-shortname-collision.test.ts": 1735, - "server/src/__tests__/agent-skill-contract.test.ts": 242, - "server/src/__tests__/agents-adapter-config-user-secret.test.ts": 5407, - "server/src/__tests__/agents-claude-oauth-binding.test.ts": 6610, - "server/src/__tests__/agents-pending-approval-config.test.ts": 4533, - "server/src/__tests__/agents-service-clear-error.test.ts": 5193, - "server/src/__tests__/agents-service-secret-bindings.test.ts": 4658, - "server/src/__tests__/api-compression.test.ts": 341, - "server/src/__tests__/app-hmr-port.test.ts": 3779, - "server/src/__tests__/app-private-hostname-gate.test.ts": 4033, - "server/src/__tests__/app-vite-dev-routing.test.ts": 2993, - "server/src/__tests__/approvals-service.test.ts": 1207, - "server/src/__tests__/artifact-review-documents-service.test.ts": 4200, - "server/src/__tests__/attachment-types.test.ts": 579, - "server/src/__tests__/attention-service.test.ts": 6910, - "server/src/__tests__/authorization-service.test.ts": 6083, - "server/src/__tests__/aws-secrets-manager-provider.test.ts": 285, - "server/src/__tests__/better-auth.test.ts": 1103, - "server/src/__tests__/board-claim.test.ts": 3975, - "server/src/__tests__/board-mutation-guard.test.ts": 293, - "server/src/__tests__/body-limits.test.ts": 203, - "server/src/__tests__/budgets-service.test.ts": 4238, - "server/src/__tests__/build-commit.test.ts": 195, - "server/src/__tests__/build-version.test.ts": 211, - "server/src/__tests__/built-in-agents.test.ts": 10282, - "server/src/__tests__/bundled-plugins.test.ts": 235, - "server/src/__tests__/change-consent-gate.test.ts": 4215, - "server/src/__tests__/chat-channels.integration.test.ts": 645354, - "server/src/__tests__/claude-local-adapter-environment.test.ts": 1105, - "server/src/__tests__/claude-local-adapter.test.ts": 751, - "server/src/__tests__/claude-local-execute.test.ts": 3248, - "server/src/__tests__/claude-local-skill-sync.test.ts": 706, - "server/src/__tests__/cleanup-removal-service.test.ts": 5454, - "server/src/__tests__/cli-invocation-safety.test.ts": 1244, - "server/src/__tests__/cloud-image-bundled-plugins.test.ts": 211, - "server/src/__tests__/cloud-instance.test.ts": 227, - "server/src/__tests__/codex-auth-reconciliation.test.ts": 1256, - "server/src/__tests__/device-login-credential-read.test.ts": 605, - "server/src/__tests__/device-login-reaper.test.ts": 1310, - "server/src/__tests__/device-login-service.test.ts": 4054, - "server/src/__tests__/codex-local-adapter-environment.test.ts": 722, - "server/src/__tests__/codex-local-adapter.test.ts": 739, - "server/src/__tests__/codex-local-execute.test.ts": 2000, - "server/src/__tests__/codex-local-skill-injection.test.ts": 674, - "server/src/__tests__/codex-local-skill-sync.test.ts": 673, - "server/src/__tests__/login-session-runtime.test.ts": 1272, - "server/src/__tests__/companies-service.test.ts": 6166, - "server/src/__tests__/company-artifacts-service.test.ts": 6065, - "server/src/__tests__/company-cloud-floor.test.ts": 1279, - "server/src/__tests__/company-portability-import-batching.test.ts": 5771, - "server/src/__tests__/company-search-extract-service.test.ts": 4422, - "server/src/__tests__/company-search-service.test.ts": 5513, - "server/src/__tests__/company-skill-import-boundary.test.ts": 5493, - "server/src/__tests__/company-skill-policy-service.test.ts": 4498, - "server/src/__tests__/company-skill-test-runs-service.test.ts": 7873, - "server/src/__tests__/company-skills-catalog-service.test.ts": 6859, - "server/src/__tests__/company-skills-detail.test.ts": 5366, - "server/src/__tests__/company-skills-service.test.ts": 17568, - "server/src/__tests__/company-skills.test.ts": 1829, - "server/src/__tests__/company-transfer-runs.test.ts": 3984, - "server/src/__tests__/config-file.test.ts": 653, - "server/src/__tests__/cross-issue-influence-limit-postgres.test.ts": 4526, - "server/src/__tests__/cross-issue-influence-limit.test.ts": 1106, - "server/src/__tests__/cursor-local-adapter-environment.test.ts": 464, - "server/src/__tests__/cursor-local-adapter.test.ts": 320, - "server/src/__tests__/cursor-local-execute.test.ts": 1165, - "server/src/__tests__/cursor-local-skill-injection.test.ts": 278, - "server/src/__tests__/cursor-local-skill-sync.test.ts": 289, - "server/src/__tests__/dashboard-service.test.ts": 3824, - "server/src/__tests__/db-errors.test.ts": 202, - "server/src/__tests__/decision-retention-service.test.ts": 5463, - "server/src/__tests__/decision-training.test.ts": 6158, - "server/src/__tests__/decision-wakeup.test.ts": 204, - "server/src/__tests__/decisions-service.test.ts": 7258, - "server/src/__tests__/dev-runner-output.test.ts": 220, - "server/src/__tests__/dev-runner-paths.test.ts": 197, - "server/src/__tests__/dev-runner-snapshot.test.ts": 199, - "server/src/__tests__/dev-runner-worktree.test.ts": 217, - "server/src/__tests__/dev-server-status.test.ts": 206, - "server/src/__tests__/dev-watch-ignore.test.ts": 206, - "server/src/__tests__/disposition-repair.test.ts": 1780, - "server/src/__tests__/docker-build-stamp.test.ts": 199, - "server/src/__tests__/docker-entrypoint.test.ts": 265, - "server/src/__tests__/document-annotations-service.test.ts": 5743, - "server/src/__tests__/documents-service.test.ts": 4497, - "server/src/__tests__/documents.test.ts": 1104, - "server/src/__tests__/duplex-observability-recorder.test.ts": 207, - "server/src/__tests__/effective-run-config-fingerprints.test.ts": 219, - "server/src/__tests__/embedded-postgres-supervisor.test.ts": 206, - "server/src/__tests__/environment-capability-contract.test.ts": 1735, - "server/src/__tests__/environment-config.test.ts": 1712, - "server/src/__tests__/environment-custom-image-terminal-ws.test.ts": 2846, - "server/src/__tests__/environment-custom-images-service.test.ts": 9820, - "server/src/__tests__/environment-driver-traits.test.ts": 202, - "server/src/__tests__/environment-execution-target-capabilities.test.ts": 751, - "server/src/__tests__/environment-execution-target-duplex-kill-switch.test.ts": 677, - "server/src/__tests__/environment-execution-target-duplex.test.ts": 1346, - "server/src/__tests__/environment-execution-target.test.ts": 811, - "server/src/__tests__/environment-live-ssh.test.ts": 1193, - "server/src/__tests__/environment-probe.test.ts": 1730, - "server/src/__tests__/environment-run-orchestrator.test.ts": 1692, - "server/src/__tests__/environment-runtime-driver-contract.test.ts": 5260, - "server/src/__tests__/environment-runtime-duplex-bridge-input.test.ts": 4482, - "server/src/__tests__/environment-runtime.test.ts": 11452, - "server/src/__tests__/environment-service.test.ts": 4677, - "server/src/__tests__/environment-test-harness.test.ts": 572, - "server/src/__tests__/error-handler.test.ts": 288, - "server/src/__tests__/execution-lock-orphan-cleanup.test.ts": 5533, - "server/src/__tests__/execution-workspace-policy.test.ts": 701, - "server/src/__tests__/execution-workspace-reopen.test.ts": 5598, - "server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts": 2022, - "server/src/__tests__/execution-workspaces-service.test.ts": 18767, - "server/src/__tests__/export-fidelity.test.ts": 4260, - "server/src/__tests__/external-objects-service.test.ts": 5395, - "server/src/__tests__/feedback-flush-controller.test.ts": 4136, - "server/src/__tests__/feedback-service.test.ts": 5006, - "server/src/__tests__/feedback-share-client.test.ts": 208, - "server/src/__tests__/file-resources-git-scan-load.test.ts": 2549, - "server/src/__tests__/file-resources.test.ts": 9337, - "server/src/__tests__/first-admin-claim.test.ts": 3933, - "server/src/__tests__/folders-service.test.ts": 5002, - "server/src/__tests__/forbidden-tokens.test.ts": 209, - "server/src/__tests__/gemini-local-adapter-environment.test.ts": 329, - "server/src/__tests__/gemini-local-adapter.test.ts": 319, - "server/src/__tests__/gemini-local-execute.test.ts": 462, - "server/src/__tests__/gemini-local-skill-sync.test.ts": 288, - "server/src/__tests__/general-capability-classifier.test.ts": 1736, - "server/src/__tests__/git-credentials.test.ts": 1726, - "server/src/__tests__/google-sheets-gallery.test.ts": 1678, - "server/src/__tests__/grok-local-skill-sync.test.ts": 273, - "server/src/__tests__/health-workspace-readiness.test.ts": 1621, - "server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts": 8157, - "server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts": 53682, - "server/src/__tests__/heartbeat-agent-session-message.test.ts": 2803, - "server/src/__tests__/heartbeat-archived-company-guard.test.ts": 4562, - "server/src/__tests__/heartbeat-auto-checkout.test.ts": 1955, - "server/src/__tests__/heartbeat-comment-wake-batching.test.ts": 9076, - "server/src/__tests__/heartbeat-context-summary.test.ts": 1952, - "server/src/__tests__/heartbeat-cost-accounting.test.ts": 1984, - "server/src/__tests__/heartbeat-issue-rewake-throttle.test.ts": 7616, - "server/src/__tests__/heartbeat-ledger-billing-code.test.ts": 2053, - "server/src/__tests__/heartbeat-list.test.ts": 5326, - "server/src/__tests__/heartbeat-local-environment.test.ts": 5312, - "server/src/__tests__/heartbeat-lock-release-on-reassignment.test.ts": 5336, - "server/src/__tests__/heartbeat-managed-clone-credentials.test.ts": 2164, - "server/src/__tests__/heartbeat-model-profile.test.ts": 2000, - "server/src/__tests__/heartbeat-pending-cleanup-sweep.test.ts": 5683, - "server/src/__tests__/heartbeat-plugin-environment.test.ts": 5602, - "server/src/__tests__/heartbeat-project-env.test.ts": 2029, - "server/src/__tests__/heartbeat-referenced-projects.test.ts": 5601, - "server/src/__tests__/heartbeat-remote-referenced-projects.test.ts": 1865, - "server/src/__tests__/heartbeat-responsible-user-invariant.test.ts": 6136, - "server/src/__tests__/heartbeat-retry-scheduling.test.ts": 9278, - "server/src/__tests__/heartbeat-run-lease-release-terminalization.test.ts": 5519, - "server/src/__tests__/heartbeat-run-log.test.ts": 1944, - "server/src/__tests__/heartbeat-run-status-payload.test.ts": 1969, - "server/src/__tests__/heartbeat-run-summary.test.ts": 210, - "server/src/__tests__/heartbeat-run-terminalize-before-release.test.ts": 4525, - "server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts": 5513, - "server/src/__tests__/heartbeat-runtime-skills.test.ts": 6217, - "server/src/__tests__/heartbeat-runtime-state.test.ts": 5495, - "server/src/__tests__/heartbeat-scheduling-suppression.test.ts": 1931, - "server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts": 18600, - "server/src/__tests__/heartbeat-start-lock.test.ts": 231, - "server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts": 1971, - "server/src/__tests__/heartbeat-workspace-branch-containment.test.ts": 7407, - "server/src/__tests__/heartbeat-workspace-busy.test.ts": 10518, - "server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts": 7262, - "server/src/__tests__/heartbeat-workspace-ready-comment.test.ts": 1932, - "server/src/__tests__/heartbeat-workspace-session.test.ts": 2286, - "server/src/__tests__/heartbeat-worktree-suppression.test.ts": 6308, - "server/src/__tests__/heartbeat-zombie-guard.test.ts": 1935, - "server/src/__tests__/hire-hook.test.ts": 807, - "server/src/__tests__/http-log-policy.test.ts": 204, - "server/src/__tests__/http-log-redaction.test.ts": 239, - "server/src/__tests__/inbox-dismissals.test.ts": 5662, - "server/src/__tests__/instance-settings-managed-overlay.test.ts": 1170, - "server/src/__tests__/instance-settings-service.test.ts": 1144, - "server/src/__tests__/instrumentation.test.ts": 239, - "server/src/__tests__/interaction-resolution-cross-issue-cap-postgres.test.ts": 6128, - "server/src/__tests__/invite-join-grants.test.ts": 637, - "server/src/__tests__/invite-rate-limit.test.ts": 207, - "server/src/__tests__/invite-token-entropy.test.ts": 2300, - "server/src/__tests__/issue-blocker-attention.test.ts": 6573, - "server/src/__tests__/issue-comment-redaction.test.ts": 6119, - "server/src/__tests__/issue-continuation-summary.test.ts": 1144, - "server/src/__tests__/issue-execution-policy.test.ts": 705, - "server/src/__tests__/issue-existing-branch-validation-status.test.ts": 1356, - "server/src/__tests__/issue-goal-fallback.test.ts": 198, - "server/src/__tests__/issue-interactions-read-only-contract.test.ts": 203, - "server/src/__tests__/issue-liveness.test.ts": 652, - "server/src/__tests__/issue-monitor-scheduler.test.ts": 11289, - "server/src/__tests__/issue-recovery-actions.test.ts": 8471, - "server/src/__tests__/issue-references-service.test.ts": 4472, - "server/src/__tests__/issue-remove-cascade.test.ts": 5149, - "server/src/__tests__/issue-review-attention.test.ts": 4623, - "server/src/__tests__/issue-review-policy.test.ts": 4125, - "server/src/__tests__/issue-rewake-throttle.test.ts": 213, - "server/src/__tests__/issue-thread-interactions-service.test.ts": 9932, - "server/src/__tests__/issue-thread-interactions-telemetry.test.ts": 5363, + "server/src/__tests__/access-service.test.ts": 6334, + "server/src/__tests__/access-validators.test.ts": 643, + "server/src/__tests__/activity-log-responsible-user.test.ts": 5558, + "server/src/__tests__/activity-service.test.ts": 6025, + "server/src/__tests__/adapter-models.test.ts": 1186, + "server/src/__tests__/adapter-registry.test.ts": 1215, + "server/src/__tests__/adapter-session-codecs.test.ts": 788, + "server/src/__tests__/agent-auth-jwt.test.ts": 709, + "server/src/__tests__/agent-auth-middleware.test.ts": 1106, + "server/src/__tests__/agent-conversations.test.ts": 10707, + "server/src/__tests__/agent-hire-ai-connections.test.ts": 9836, + "server/src/__tests__/agent-instructions-service.test.ts": 206, + "server/src/__tests__/agent-invokability.test.ts": 1093, + "server/src/__tests__/agent-permissions-service.test.ts": 721, + "server/src/__tests__/agent-shortname-collision.test.ts": 2217, + "server/src/__tests__/agent-skill-contract.test.ts": 224, + "server/src/__tests__/agent-task-run-telemetry.test.ts": 6355, + "server/src/__tests__/agentmail-api.test.ts": 1037, + "server/src/__tests__/agents-adapter-config-user-secret.test.ts": 6749, + "server/src/__tests__/agents-claude-oauth-binding.test.ts": 8607, + "server/src/__tests__/agents-paperclip-runner-skills.test.ts": 663, + "server/src/__tests__/agents-pending-approval-config.test.ts": 6398, + "server/src/__tests__/agents-service-clear-error.test.ts": 6976, + "server/src/__tests__/agents-service-secret-bindings.test.ts": 7149, + "server/src/__tests__/ai-connections.test.ts": 9795, + "server/src/__tests__/ai-legacy-compatibility.test.ts": 6594, + "server/src/__tests__/announcement-animation.test.ts": 1109, + "server/src/__tests__/announcement-feed.test.ts": 1274, + "server/src/__tests__/announcement-publisher.test.ts": 1232, + "server/src/__tests__/api-compression.test.ts": 307, + "server/src/__tests__/app-hmr-port.test.ts": 5467, + "server/src/__tests__/app-private-hostname-gate.test.ts": 5273, + "server/src/__tests__/app-vite-dev-routing.test.ts": 3593, + "server/src/__tests__/approvals-service.test.ts": 995, + "server/src/__tests__/artifact-review-documents-service.test.ts": 5832, + "server/src/__tests__/attachment-types.test.ts": 166, + "server/src/__tests__/attention-service.test.ts": 8689, + "server/src/__tests__/authorization-service.test.ts": 7152, + "server/src/__tests__/aws-secrets-manager-provider.test.ts": 291, + "server/src/__tests__/better-auth-credential-signup.integration.test.ts": 6546, + "server/src/__tests__/better-auth.test.ts": 1039, + "server/src/__tests__/board-claim.test.ts": 5641, + "server/src/__tests__/board-mutation-guard.test.ts": 233, + "server/src/__tests__/board-native-attachments.integration.test.ts": 6974, + "server/src/__tests__/body-limits.test.ts": 188, + "server/src/__tests__/budgets-service.test.ts": 5588, + "server/src/__tests__/build-commit.test.ts": 156, + "server/src/__tests__/build-version.test.ts": 186, + "server/src/__tests__/built-in-agents.test.ts": 14258, + "server/src/__tests__/bundled-plugins.test.ts": 221, + "server/src/__tests__/change-consent-gate.test.ts": 5793, + "server/src/__tests__/chat-channels.integration.test.ts": 428744, + "server/src/__tests__/chat-interaction-publications.test.ts": 7076, + "server/src/__tests__/chat-project-tools.test.ts": 10896, + "server/src/__tests__/chat-webhook-diagnostics.test.ts": 7018, + "server/src/__tests__/chat-webhook-public-url.test.ts": 1193, + "server/src/__tests__/claude-local-adapter-environment.test.ts": 1176, + "server/src/__tests__/claude-local-adapter.test.ts": 758, + "server/src/__tests__/claude-local-execute.test.ts": 3581, + "server/src/__tests__/claude-local-skill-sync.test.ts": 639, + "server/src/__tests__/cleanup-removal-service.test.ts": 7531, + "server/src/__tests__/cli-invocation-safety.test.ts": 1940, + "server/src/__tests__/cloud-control-task-drain.test.ts": 833, + "server/src/__tests__/cloud-image-bundled-plugins.test.ts": 162, + "server/src/__tests__/cloud-image-sentry.test.ts": 257, + "server/src/__tests__/cloud-instance.test.ts": 185, + "server/src/__tests__/cloud-runtime-identity.test.ts": 8181, + "server/src/__tests__/cloud-tenant-company-provisioning.test.ts": 5723, + "server/src/__tests__/cloud-tenant-transient-db-retry.test.ts": 1487, + "server/src/__tests__/cloud-ui-snippet.test.ts": 194, + "server/src/__tests__/codex-auth-reconciliation.test.ts": 1248, + "server/src/__tests__/codex-local-adapter-environment.test.ts": 905, + "server/src/__tests__/codex-local-adapter.test.ts": 786, + "server/src/__tests__/codex-local-execute.test.ts": 4536, + "server/src/__tests__/codex-local-skill-injection.test.ts": 786, + "server/src/__tests__/codex-local-skill-sync.test.ts": 632, + "server/src/__tests__/companies-service.test.ts": 7789, + "server/src/__tests__/company-artifacts-service.test.ts": 7582, + "server/src/__tests__/company-cloud-floor.test.ts": 1266, + "server/src/__tests__/company-import-cloud-floor.test.ts": 1421, + "server/src/__tests__/company-portability-import-batching.test.ts": 8021, + "server/src/__tests__/company-search-extract-service.test.ts": 6125, + "server/src/__tests__/company-search-service.test.ts": 6835, + "server/src/__tests__/company-skill-import-boundary.test.ts": 6629, + "server/src/__tests__/company-skill-policy-service.test.ts": 5806, + "server/src/__tests__/company-skill-test-runs-service.test.ts": 10485, + "server/src/__tests__/company-skills-catalog-service.test.ts": 10675, + "server/src/__tests__/company-skills-detail.test.ts": 6905, + "server/src/__tests__/company-skills-service.test.ts": 22837, + "server/src/__tests__/company-skills.test.ts": 2063, + "server/src/__tests__/company-transfer-runs.test.ts": 5541, + "server/src/__tests__/config-file.test.ts": 646, + "server/src/__tests__/connection-grant-resolution.test.ts": 1732, + "server/src/__tests__/connection-intents-service.test.ts": 8169, + "server/src/__tests__/container-init-reaping.test.ts": 189, + "server/src/__tests__/cross-issue-influence-limit-postgres.test.ts": 5522, + "server/src/__tests__/cross-issue-influence-limit.test.ts": 990, + "server/src/__tests__/cursor-local-adapter-environment.test.ts": 3049, + "server/src/__tests__/cursor-local-adapter.test.ts": 797, + "server/src/__tests__/cursor-local-execute.test.ts": 1840, + "server/src/__tests__/cursor-local-skill-injection.test.ts": 712, + "server/src/__tests__/cursor-local-skill-sync.test.ts": 798, + "server/src/__tests__/dashboard-service.test.ts": 5754, + "server/src/__tests__/db-errors.test.ts": 190, + "server/src/__tests__/decision-retention-service.test.ts": 7932, + "server/src/__tests__/decision-training.test.ts": 8829, + "server/src/__tests__/decision-wakeup.test.ts": 161, + "server/src/__tests__/decisions-service.test.ts": 9462, + "server/src/__tests__/dev-runner-native-binary.test.ts": 213, + "server/src/__tests__/dev-runner-options.test.ts": 168, + "server/src/__tests__/dev-runner-output.test.ts": 208, + "server/src/__tests__/dev-runner-paths.test.ts": 184, + "server/src/__tests__/dev-runner-snapshot.test.ts": 164, + "server/src/__tests__/dev-runner-worktree.test.ts": 193, + "server/src/__tests__/dev-server-status.test.ts": 305, + "server/src/__tests__/dev-watch-ignore.test.ts": 192, + "server/src/__tests__/device-login-credential-read.test.ts": 633, + "server/src/__tests__/device-login-reaper.test.ts": 1076, + "server/src/__tests__/device-login-service.test.ts": 4771, + "server/src/__tests__/disposition-repair.test.ts": 2246, + "server/src/__tests__/docker-build-stamp.test.ts": 196, + "server/src/__tests__/docker-entrypoint.test.ts": 273, + "server/src/__tests__/document-annotations-service.test.ts": 8516, + "server/src/__tests__/documents-service.test.ts": 6625, + "server/src/__tests__/documents.test.ts": 961, + "server/src/__tests__/duplex-observability-recorder.test.ts": 205, + "server/src/__tests__/durable-chat-wakeup.test.ts": 12293, + "server/src/__tests__/effective-run-config-fingerprints.test.ts": 172, + "server/src/__tests__/email-channels.integration.test.ts": 14127, + "server/src/__tests__/embedded-postgres-supervisor.test.ts": 158, + "server/src/__tests__/env-file-policy.test.ts": 202, + "server/src/__tests__/environment-capability-contract.test.ts": 1662, + "server/src/__tests__/environment-config.test.ts": 1450, + "server/src/__tests__/environment-custom-image-terminal-ws.test.ts": 2815, + "server/src/__tests__/environment-custom-images-service.test.ts": 10412, + "server/src/__tests__/environment-driver-traits.test.ts": 161, + "server/src/__tests__/environment-execution-target-capabilities.test.ts": 813, + "server/src/__tests__/environment-execution-target-duplex-kill-switch.test.ts": 841, + "server/src/__tests__/environment-execution-target-duplex.test.ts": 1134, + "server/src/__tests__/environment-execution-target.test.ts": 796, + "server/src/__tests__/environment-live-ssh.test.ts": 1595, + "server/src/__tests__/environment-probe.test.ts": 1483, + "server/src/__tests__/environment-run-orchestrator.test.ts": 1629, + "server/src/__tests__/environment-runtime-driver-contract.test.ts": 5548, + "server/src/__tests__/environment-runtime-duplex-bridge-input.test.ts": 4620, + "server/src/__tests__/environment-runtime.test.ts": 9242, + "server/src/__tests__/environment-service.test.ts": 6048, + "server/src/__tests__/environment-test-harness.test.ts": 675, + "server/src/__tests__/error-handler.test.ts": 217, + "server/src/__tests__/execution-lock-orphan-cleanup.test.ts": 7076, + "server/src/__tests__/execution-workspace-policy.test.ts": 608, + "server/src/__tests__/execution-workspace-reopen.test.ts": 6968, + "server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts": 2223, + "server/src/__tests__/execution-workspaces-service.test.ts": 20667, + "server/src/__tests__/export-fidelity.test.ts": 5683, + "server/src/__tests__/external-objects-service.test.ts": 6727, + "server/src/__tests__/feedback-flush-controller.test.ts": 3610, + "server/src/__tests__/feedback-service.test.ts": 5887, + "server/src/__tests__/feedback-share-client.test.ts": 193, + "server/src/__tests__/file-delivery-bridges.test.ts": 14084, + "server/src/__tests__/file-resources-git-scan-load.test.ts": 2835, + "server/src/__tests__/file-resources.test.ts": 13553, + "server/src/__tests__/first-admin-claim.test.ts": 5526, + "server/src/__tests__/folders-service.test.ts": 6914, + "server/src/__tests__/forbidden-tokens.test.ts": 192, + "server/src/__tests__/gemini-local-adapter-environment.test.ts": 680, + "server/src/__tests__/gemini-local-adapter.test.ts": 741, + "server/src/__tests__/gemini-local-execute.test.ts": 862, + "server/src/__tests__/gemini-local-skill-sync.test.ts": 713, + "server/src/__tests__/general-capability-classifier.test.ts": 1457, + "server/src/__tests__/generic-mcp-connection.test.ts": 11804, + "server/src/__tests__/git-credentials.test.ts": 2163, + "server/src/__tests__/github-commit-details.test.ts": 2068, + "server/src/__tests__/github-connection-events.test.ts": 7465, + "server/src/__tests__/github-grant-metadata.test.ts": 1773, + "server/src/__tests__/github-operation-credentials.test.ts": 7527, + "server/src/__tests__/google-sheets-gallery.test.ts": 2025, + "server/src/__tests__/grok-local-skill-sync.test.ts": 802, + "server/src/__tests__/health-workspace-readiness.test.ts": 1392, + "server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts": 10247, + "server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts": 10196, + "server/src/__tests__/heartbeat-agent-session-message.test.ts": 3713, + "server/src/__tests__/heartbeat-archived-company-guard.test.ts": 7346, + "server/src/__tests__/heartbeat-auto-checkout.test.ts": 2631, + "server/src/__tests__/heartbeat-comment-wake-batching.test.ts": 31799, + "server/src/__tests__/heartbeat-context-summary.test.ts": 2323, + "server/src/__tests__/heartbeat-cost-accounting.test.ts": 2650, + "server/src/__tests__/heartbeat-direct-adapter-native-isolation.test.ts": 9556, + "server/src/__tests__/heartbeat-issue-rewake-throttle.test.ts": 10312, + "server/src/__tests__/heartbeat-ledger-billing-code.test.ts": 2640, + "server/src/__tests__/heartbeat-list.test.ts": 7073, + "server/src/__tests__/heartbeat-local-environment.test.ts": 9429, + "server/src/__tests__/heartbeat-lock-release-on-reassignment.test.ts": 9124, + "server/src/__tests__/heartbeat-managed-clone-credentials.test.ts": 3610, + "server/src/__tests__/heartbeat-native-runner-cancellation.test.ts": 2985, + "server/src/__tests__/heartbeat-native-runner-selection.test.ts": 189, + "server/src/__tests__/heartbeat-native-status-context.test.ts": 2617, + "server/src/__tests__/heartbeat-pending-cleanup-sweep.test.ts": 9191, + "server/src/__tests__/heartbeat-plugin-environment.test.ts": 7202, + "server/src/__tests__/heartbeat-project-env.test.ts": 2879, + "server/src/__tests__/heartbeat-project-repositories.test.ts": 12549, + "server/src/__tests__/heartbeat-referenced-projects.test.ts": 7337, + "server/src/__tests__/heartbeat-remote-referenced-projects.test.ts": 2667, + "server/src/__tests__/heartbeat-responsible-user-invariant.test.ts": 12037, + "server/src/__tests__/heartbeat-retry-scheduling.test.ts": 13920, + "server/src/__tests__/heartbeat-reviewed-chat-binding.integration.test.ts": 7952, + "server/src/__tests__/heartbeat-run-event-sequencing.test.ts": 12508, + "server/src/__tests__/heartbeat-run-lease-release-terminalization.test.ts": 7583, + "server/src/__tests__/heartbeat-run-log.test.ts": 2657, + "server/src/__tests__/heartbeat-run-status-payload.test.ts": 2921, + "server/src/__tests__/heartbeat-run-summary.test.ts": 214, + "server/src/__tests__/heartbeat-run-terminalize-before-release.test.ts": 7102, + "server/src/__tests__/heartbeat-running-followup.test.ts": 2638, + "server/src/__tests__/heartbeat-runtime-mcp-servers.test.ts": 9360, + "server/src/__tests__/heartbeat-runtime-skills.test.ts": 10111, + "server/src/__tests__/heartbeat-runtime-state.test.ts": 7809, + "server/src/__tests__/heartbeat-scheduling-suppression.test.ts": 2650, + "server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts": 27050, + "server/src/__tests__/heartbeat-start-lock.test.ts": 234, + "server/src/__tests__/heartbeat-status-preserving-telemetry.test.ts": 7639, + "server/src/__tests__/heartbeat-task-drain-admission-release.test.ts": 8838, + "server/src/__tests__/heartbeat-task-drain.test.ts": 2282, + "server/src/__tests__/heartbeat-task-session-reset.test.ts": 7264, + "server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts": 2642, + "server/src/__tests__/heartbeat-workspace-branch-containment.test.ts": 11586, + "server/src/__tests__/heartbeat-workspace-busy.test.ts": 14605, + "server/src/__tests__/heartbeat-workspace-finalize-branch.test.ts": 10413, + "server/src/__tests__/heartbeat-workspace-ready-comment.test.ts": 2933, + "server/src/__tests__/heartbeat-workspace-session.test.ts": 2611, + "server/src/__tests__/heartbeat-worktree-suppression.test.ts": 9941, + "server/src/__tests__/heartbeat-zombie-guard.test.ts": 2828, + "server/src/__tests__/hire-hook.test.ts": 634, + "server/src/__tests__/hiring-operational-examples.test.ts": 667, + "server/src/__tests__/http-adapter-remote-fetch.test.ts": 230, + "server/src/__tests__/http-log-policy.test.ts": 207, + "server/src/__tests__/http-log-redaction.test.ts": 1360, + "server/src/__tests__/inbox-dismissals.test.ts": 7106, + "server/src/__tests__/instance-settings-cloud-defaults.test.ts": 1010, + "server/src/__tests__/instance-settings-managed-overlay.test.ts": 1165, + "server/src/__tests__/instance-settings-operator-defaults.test.ts": 1130, + "server/src/__tests__/instance-settings-service.test.ts": 1163, + "server/src/__tests__/instrumentation.test.ts": 245, + "server/src/__tests__/interaction-resolution-cross-issue-cap-postgres.test.ts": 11048, + "server/src/__tests__/invite-join-grants.test.ts": 635, + "server/src/__tests__/invite-rate-limit.test.ts": 183, + "server/src/__tests__/invite-token-entropy.test.ts": 2816, + "server/src/__tests__/issue-blocker-attention.test.ts": 7828, + "server/src/__tests__/issue-comment-redaction.test.ts": 8688, + "server/src/__tests__/issue-continuation-summary.test.ts": 1104, + "server/src/__tests__/issue-execution-policy.test.ts": 752, + "server/src/__tests__/issue-existing-branch-validation-status.test.ts": 1328, + "server/src/__tests__/issue-goal-fallback.test.ts": 188, + "server/src/__tests__/issue-interactions-read-only-contract.test.ts": 192, + "server/src/__tests__/issue-liveness.test.ts": 557, + "server/src/__tests__/issue-monitor-scheduler.test.ts": 16200, + "server/src/__tests__/issue-recovery-actions.test.ts": 12381, + "server/src/__tests__/issue-references-service.test.ts": 6056, + "server/src/__tests__/issue-remove-cascade.test.ts": 7455, + "server/src/__tests__/issue-review-attention.test.ts": 7298, + "server/src/__tests__/issue-review-policy.test.ts": 5950, + "server/src/__tests__/issue-rewake-throttle.test.ts": 165, + "server/src/__tests__/issue-runtime-workspace-binding.test.ts": 6869, + "server/src/__tests__/issue-thread-interactions-service.test.ts": 8954, + "server/src/__tests__/issue-thread-interactions-telemetry.test.ts": 7388, "server/src/__tests__/issue-tree-control-service-unit.test.ts": 1131, - "server/src/__tests__/issue-tree-control-service.test.ts": 5585, - "server/src/__tests__/issues-list-query-parsing.test.ts": 5695, - "server/src/__tests__/issues-parent-id-alias.test.ts": 6098, - "server/src/__tests__/issues-user-context.test.ts": 1728, - "server/src/__tests__/join-request-dedupe.test.ts": 212, - "server/src/__tests__/json-schema-secret-refs.test.ts": 208, - "server/src/__tests__/kimi-local-skill-sync.test.ts": 284, - "server/src/__tests__/live-events-ws.test.ts": 813, - "server/src/__tests__/local-service-supervisor.test.ts": 4367, - "server/src/__tests__/log-redaction.test.ts": 201, - "server/src/__tests__/logger-tz.test.ts": 220, - "server/src/__tests__/managed-config.test.ts": 666, - "server/src/__tests__/mcp-http.test.ts": 208, - "server/src/__tests__/merged-pr-confirmation-sweep.test.ts": 4305, - "server/src/__tests__/monthly-spend-service.test.ts": 1987, - "server/src/__tests__/openclaw-gateway-adapter.test.ts": 292, - "server/src/__tests__/opencode-local-adapter.test.ts": 297, - "server/src/__tests__/opencode-local-skill-sync.test.ts": 285, - "server/src/__tests__/paperclip-env.test.ts": 263, - "server/src/__tests__/paperclip-issue-update-helper.test.ts": 4333, - "server/src/__tests__/paperclip-skill-utils.test.ts": 252, - "server/src/__tests__/parse-status-filter.test.ts": 1713, - "server/src/__tests__/pi-local-adapter-environment.test.ts": 396, - "server/src/__tests__/pi-local-execute.test.ts": 491, - "server/src/__tests__/pi-local-skill-sync.test.ts": 288, - "server/src/__tests__/pipelines-service.test.ts": 8966, - "server/src/__tests__/plugin-access-authorization-host-services.test.ts": 5679, - "server/src/__tests__/plugin-agent-sessions.test.ts": 1903, - "server/src/__tests__/plugin-config-startup-delivery.test.ts": 3528, - "server/src/__tests__/plugin-database.test.ts": 4910, - "server/src/__tests__/plugin-dev-watcher.test.ts": 308, - "server/src/__tests__/plugin-environment-driver-ready-recovery.test.ts": 4760, - "server/src/__tests__/plugin-environment-driver-sandbox-capabilities.test.ts": 269, - "server/src/__tests__/plugin-environment-driver-seam.test.ts": 745, - "server/src/__tests__/plugin-execution-workspace-bridge.test.ts": 223, - "server/src/__tests__/plugin-host-services-span.test.ts": 2220, - "server/src/__tests__/plugin-install-autobuild.test.ts": 7492, - "server/src/__tests__/plugin-install-guard.test.ts": 1307, - "server/src/__tests__/plugin-lifecycle-restart.test.ts": 1304, - "server/src/__tests__/plugin-loader-error-retry.test.ts": 1324, - "server/src/__tests__/plugin-local-folders.test.ts": 260, - "server/src/__tests__/plugin-managed-agents.test.ts": 5957, - "server/src/__tests__/plugin-managed-routines.test.ts": 5686, - "server/src/__tests__/plugin-managed-skills.test.ts": 6978, - "server/src/__tests__/plugin-orchestration-apis.test.ts": 8406, - "server/src/__tests__/plugin-sandbox-provider-config-validation.test.ts": 204, - "server/src/__tests__/plugin-sdk-orchestration-contract.test.ts": 717, - "server/src/__tests__/plugin-sdk-testing.test.ts": 584, - "server/src/__tests__/plugin-secrets-handler.test.ts": 5161, - "server/src/__tests__/plugin-telemetry-bridge.test.ts": 2066, - "server/src/__tests__/plugin-tenant-isolation.test.ts": 4622, - "server/src/__tests__/plugin-tool-dispatcher-pluginDbId.test.ts": 795, - "server/src/__tests__/plugin-ui-static.test.ts": 1293, - "server/src/__tests__/plugin-worker-manager-duplex.test.ts": 2233, - "server/src/__tests__/plugin-worker-manager.test.ts": 2669, - "server/src/__tests__/private-hostname-guard.test.ts": 300, - "server/src/__tests__/private-json-etag.test.ts": 271, - "server/src/__tests__/project-icon-persistence.test.ts": 3936, - "server/src/__tests__/project-list-metrics.test.ts": 1198, - "server/src/__tests__/project-shortname-resolution.test.ts": 1196, - "server/src/__tests__/qa-routine-secrets-e2e.test.ts": 5522, - "server/src/__tests__/quota-windows-service.test.ts": 214, - "server/src/__tests__/quota-windows.test.ts": 799, - "server/src/__tests__/recovery-classifiers.test.ts": 1822, - "server/src/__tests__/recovery-observability.test.ts": 4550, - "server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts": 5578, - "server/src/__tests__/redact-sensitive.test.ts": 207, - "server/src/__tests__/remote-http-endpoint-guard.test.ts": 212, - "server/src/__tests__/rerun-survival.integration.test.ts": 623, - "server/src/__tests__/reusable-leases-default.test.ts": 623, - "server/src/__tests__/routable-blocked.test.ts": 205, - "server/src/__tests__/routine-run-telemetry.test.ts": 5399, - "server/src/__tests__/routines-service.test.ts": 9870, - "server/src/__tests__/run-continuations.test.ts": 771, - "server/src/__tests__/run-liveness.test.ts": 210, - "server/src/__tests__/run-secret-redaction.test.ts": 817, - "server/src/__tests__/runtime-api.test.ts": 204, - "server/src/__tests__/sandbox-orphan-cleanup-spool.test.ts": 221, - "server/src/__tests__/sandbox-provider-runtime.test.ts": 196, - "server/src/__tests__/secret-provider-registry.test.ts": 270, - "server/src/__tests__/secrets-service-dangling-user-secret.test.ts": 5159, - "server/src/__tests__/secrets-service-user-secret-owner-scoped.test.ts": 5294, - "server/src/__tests__/secrets-service.test.ts": 6526, - "server/src/__tests__/server-info.test.ts": 648, - "server/src/__tests__/server-package-build-script.test.ts": 194, - "server/src/__tests__/server-startup-feedback-export.test.ts": 1631, - "server/src/__tests__/service-version.test.ts": 200, - "server/src/__tests__/shared-telemetry-events.test.ts": 208, - "server/src/__tests__/skills-catalog-service.test.ts": 248, - "server/src/__tests__/smoke-lab.test.ts": 4920, - "server/src/__tests__/source-trust.test.ts": 3784, - "server/src/__tests__/static-index-html.test.ts": 276, - "server/src/__tests__/status-card-update-engine.test.ts": 639, - "server/src/__tests__/status-cards.test.ts": 8423, - "server/src/__tests__/storage-local-provider.test.ts": 225, - "server/src/__tests__/summary-slots.test.ts": 6669, - "server/src/__tests__/task-watchdogs-classifier.test.ts": 1741, - "server/src/__tests__/task-watchdogs-incident-fixture.test.ts": 203, - "server/src/__tests__/task-watchdogs-scheduler.test.ts": 6504, - "server/src/__tests__/teams-catalog-install-no-overrides.test.ts": 7292, - "server/src/__tests__/teams-catalog-service.test.ts": 697, - "server/src/__tests__/telemetry-client-flush.test.ts": 221, - "server/src/__tests__/tool-access-policy-service.test.ts": 6204, - "server/src/__tests__/tool-access-service.test.ts": 16382, - "server/src/__tests__/tool-content-guards.test.ts": 220, - "server/src/__tests__/tool-gateway-service.test.ts": 6570, - "server/src/__tests__/tool-gateway.test.ts": 18019, - "server/src/__tests__/tool-oauth-legacy-backfill.test.ts": 4994, - "server/src/__tests__/tool-review-queue-unsigned-request.test.ts": 5001, - "server/src/__tests__/trust-preset-resolver.test.ts": 642, - "server/src/__tests__/trust-proxy.test.ts": 249, - "server/src/__tests__/ui-branding.test.ts": 200, - "server/src/__tests__/version.test.ts": 222, - "server/src/__tests__/vite-html-renderer.test.ts": 208, - "server/src/__tests__/work-product-runtime-reconciliation.test.ts": 762, - "server/src/__tests__/work-products.test.ts": 773, - "server/src/__tests__/work-timeline-service.test.ts": 5134, - "server/src/__tests__/workspace-instance-cleanup.test.ts": 247, - "server/src/__tests__/workspace-login-handoff-endpoint.test.ts": 1280, - "server/src/__tests__/workspace-login-handoff-issuer.test.ts": 755, - "server/src/__tests__/workspace-login-handoff.test.ts": 241, - "server/src/__tests__/workspace-operations-reconciliation.test.ts": 4391, - "server/src/__tests__/workspace-readiness.test.ts": 848, - "server/src/__tests__/workspace-reaper-cooldown-config.test.ts": 665, - "server/src/__tests__/workspace-runtime-control-recovery.test.ts": 4196, - "server/src/__tests__/workspace-runtime-exposure-reservation.test.ts": 8194, - "server/src/__tests__/workspace-runtime-https-live-exercise.test.ts": 2333, - "server/src/__tests__/workspace-runtime-leases.test.ts": 4619, - "server/src/__tests__/workspace-runtime-start-terminality.test.ts": 5267, - "server/src/__tests__/workspace-runtime.test.ts": 123272, - "server/src/__tests__/worktree-config.test.ts": 1126, - "server/src/__tests__/worktree-seed-server-spawn.test.ts": 2003, - "server/src/__tests__/write-build-stamp.test.ts": 203, - "server/src/__tests__/write-path-membership.test.ts": 1215, - "server/src/adapters/claude-agent-id-header.test.ts": 203, - "server/src/adapters/http/execute.test.ts": 270, - "server/src/adapters/plugin-loader.test.ts": 236, - "server/src/adapters/registry.test.ts": 1062, - "server/src/instrumentation.test.ts": 222, - "server/src/lib/request-promise-memo.test.ts": 218, - "server/src/middleware/cloud-tenant-actor.test.ts": 1207, - "server/src/routes/adapters.test.ts": 1736, - "server/src/services/adapter-login-lease.test.ts": 199, + "server/src/__tests__/issue-tree-control-service.test.ts": 8166, + "server/src/__tests__/issues-list-query-parsing.test.ts": 7945, + "server/src/__tests__/issues-parent-id-alias.test.ts": 7482, + "server/src/__tests__/issues-user-context.test.ts": 1984, + "server/src/__tests__/join-request-dedupe.test.ts": 183, + "server/src/__tests__/json-schema-secret-refs.test.ts": 159, + "server/src/__tests__/kimi-local-skill-sync.test.ts": 605, + "server/src/__tests__/legacy-finalization-regression.test.ts": 8295, + "server/src/__tests__/live-events-ws.test.ts": 771, + "server/src/__tests__/local-ai-credential-file.test.ts": 164, + "server/src/__tests__/local-ai-credentials.test.ts": 720, + "server/src/__tests__/local-ai-login-policy.test.ts": 192, + "server/src/__tests__/local-service-supervisor.test.ts": 4146, + "server/src/__tests__/log-redaction.test.ts": 188, + "server/src/__tests__/logger-tz.test.ts": 215, + "server/src/__tests__/login-session-runtime.test.ts": 1329, + "server/src/__tests__/managed-config.test.ts": 679, + "server/src/__tests__/managed-loopback-auth.test.ts": 6465, + "server/src/__tests__/managed-runtime-public-url.test.ts": 560, + "server/src/__tests__/mcp-http.test.ts": 221, + "server/src/__tests__/merged-pr-confirmation-sweep.test.ts": 6865, + "server/src/__tests__/monthly-spend-service.test.ts": 2355, + "server/src/__tests__/native-event-boundary-golden.test.ts": 5431, + "server/src/__tests__/native-finalization-migration.test.ts": 5232, + "server/src/__tests__/native-finalization-recovery.test.ts": 11707, + "server/src/__tests__/native-interaction-bridge.test.ts": 8463, + "server/src/__tests__/native-run-finalizer.test.ts": 13674, + "server/src/__tests__/native-runner-input-boundary.test.ts": 211, + "server/src/__tests__/native-runner-phase6.integration.test.ts": 14534, + "server/src/__tests__/native-sandbox-lifecycle.test.ts": 2285, + "server/src/__tests__/native-session-resumption.test.ts": 22979, + "server/src/__tests__/native-status-arbiter-corpus.test.ts": 18510, + "server/src/__tests__/native-workspace-sync.test.ts": 1391, + "server/src/__tests__/oauth-client-metadata.test.ts": 1752, + "server/src/__tests__/openclaw-gateway-adapter.test.ts": 734, + "server/src/__tests__/opencode-local-adapter.test.ts": 777, + "server/src/__tests__/opencode-local-skill-sync.test.ts": 730, + "server/src/__tests__/paperclip-env.test.ts": 595, + "server/src/__tests__/paperclip-issue-update-helper.test.ts": 4367, + "server/src/__tests__/paperclip-semantic-conformance.test.ts": 7858, + "server/src/__tests__/paperclip-skill-utils.test.ts": 8266, + "server/src/__tests__/parse-status-filter.test.ts": 1753, + "server/src/__tests__/photon/channel.integration.test.ts": 17734, + "server/src/__tests__/photon/photon.test.ts": 1049, + "server/src/__tests__/pi-local-adapter-environment.test.ts": 819, + "server/src/__tests__/pi-local-execute.test.ts": 963, + "server/src/__tests__/pi-local-skill-sync.test.ts": 793, + "server/src/__tests__/pipelines-service.test.ts": 10897, + "server/src/__tests__/plugin-access-authorization-host-services.test.ts": 7771, + "server/src/__tests__/plugin-agent-sessions.test.ts": 1857, + "server/src/__tests__/plugin-config-startup-delivery.test.ts": 5604, + "server/src/__tests__/plugin-database.test.ts": 6829, + "server/src/__tests__/plugin-dev-watcher.test.ts": 243, + "server/src/__tests__/plugin-environment-driver-ready-recovery.test.ts": 5294, + "server/src/__tests__/plugin-environment-driver-sandbox-capabilities.test.ts": 191, + "server/src/__tests__/plugin-environment-driver-seam.test.ts": 764, + "server/src/__tests__/plugin-execution-workspace-bridge.test.ts": 160, + "server/src/__tests__/plugin-host-services-span.test.ts": 3086, + "server/src/__tests__/plugin-install-autobuild.test.ts": 7981, + "server/src/__tests__/plugin-install-guard.test.ts": 1254, + "server/src/__tests__/plugin-lifecycle-restart.test.ts": 1339, + "server/src/__tests__/plugin-loader-error-retry.test.ts": 1234, + "server/src/__tests__/plugin-local-folders.test.ts": 251, + "server/src/__tests__/plugin-managed-agents.test.ts": 7785, + "server/src/__tests__/plugin-managed-routines.test.ts": 6739, + "server/src/__tests__/plugin-managed-skills.test.ts": 8331, + "server/src/__tests__/plugin-orchestration-apis.test.ts": 10461, + "server/src/__tests__/plugin-sandbox-provider-config-validation.test.ts": 166, + "server/src/__tests__/plugin-sdk-orchestration-contract.test.ts": 696, + "server/src/__tests__/plugin-sdk-testing.test.ts": 719, + "server/src/__tests__/plugin-secrets-handler.test.ts": 5977, + "server/src/__tests__/plugin-telemetry-bridge.test.ts": 2401, + "server/src/__tests__/plugin-tenant-isolation.test.ts": 6827, + "server/src/__tests__/plugin-tool-dispatcher-pluginDbId.test.ts": 787, + "server/src/__tests__/plugin-ui-static.test.ts": 1076, + "server/src/__tests__/plugin-worker-manager-duplex.test.ts": 2338, + "server/src/__tests__/plugin-worker-manager.test.ts": 4215, + "server/src/__tests__/private-hostname-guard.test.ts": 254, + "server/src/__tests__/private-json-etag.test.ts": 246, + "server/src/__tests__/project-goal-validation.test.ts": 5592, + "server/src/__tests__/project-icon-persistence.test.ts": 5688, + "server/src/__tests__/project-list-metrics.test.ts": 1156, + "server/src/__tests__/project-repositories-persistence.test.ts": 6390, + "server/src/__tests__/project-repositories.test.ts": 2019, + "server/src/__tests__/project-shortname-resolution.test.ts": 1301, + "server/src/__tests__/provider-trace-store.test.ts": 764, + "server/src/__tests__/qa-routine-secrets-e2e.test.ts": 8308, + "server/src/__tests__/question-response-delivery.test.ts": 8581, + "server/src/__tests__/quota-windows-service.test.ts": 190, + "server/src/__tests__/quota-windows.test.ts": 811, + "server/src/__tests__/recovery-classifiers.test.ts": 1779, + "server/src/__tests__/recovery-observability.test.ts": 6511, + "server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts": 7764, + "server/src/__tests__/redact-sensitive.test.ts": 209, + "server/src/__tests__/remote-http-endpoint-guard.test.ts": 218, + "server/src/__tests__/remote-http-rebinding.test.ts": 864, + "server/src/__tests__/rerun-survival.integration.test.ts": 739, + "server/src/__tests__/reusable-leases-default.test.ts": 548, + "server/src/__tests__/routable-blocked.test.ts": 185, + "server/src/__tests__/routine-run-telemetry.test.ts": 8288, + "server/src/__tests__/routines-service.test.ts": 13600, + "server/src/__tests__/run-continuations.test.ts": 627, + "server/src/__tests__/run-failure-sentry.test.ts": 231, + "server/src/__tests__/run-identity.test.ts": 6630, + "server/src/__tests__/run-liveness.test.ts": 204, + "server/src/__tests__/run-secret-redaction.test.ts": 653, + "server/src/__tests__/run-trust-preset.test.ts": 5707, + "server/src/__tests__/runner-connection-eval-fixtures.test.ts": 9969, + "server/src/__tests__/runtime-api.test.ts": 209, + "server/src/__tests__/runtime-skill-cache.test.ts": 3370, + "server/src/__tests__/sandbox-orphan-cleanup-spool.test.ts": 250, + "server/src/__tests__/sandbox-provider-runtime.test.ts": 197, + "server/src/__tests__/secret-provider-registry.test.ts": 206, + "server/src/__tests__/secrets-service-dangling-user-secret.test.ts": 6065, + "server/src/__tests__/secrets-service-user-secret-owner-scoped.test.ts": 6087, + "server/src/__tests__/secrets-service.test.ts": 8564, + "server/src/__tests__/sentry-dsn.test.ts": 205, + "server/src/__tests__/sentry.test.ts": 1139, + "server/src/__tests__/server-info.test.ts": 252, + "server/src/__tests__/server-package-build-script.test.ts": 184, + "server/src/__tests__/server-package-otel-peer-metadata.test.ts": 188, + "server/src/__tests__/server-package-sentry-peer-metadata.test.ts": 182, + "server/src/__tests__/server-startup-feedback-export.test.ts": 1425, + "server/src/__tests__/service-version.test.ts": 202, + "server/src/__tests__/shared-telemetry-events.test.ts": 203, + "server/src/__tests__/skills-catalog-service.test.ts": 228, + "server/src/__tests__/smoke-lab.test.ts": 7255, + "server/src/__tests__/source-trust.test.ts": 5684, + "server/src/__tests__/startup-refusals.test.ts": 204, + "server/src/__tests__/static-index-html.test.ts": 284, + "server/src/__tests__/static-ui-cache.test.ts": 157, + "server/src/__tests__/status-card-update-engine.test.ts": 651, + "server/src/__tests__/status-cards.test.ts": 9392, + "server/src/__tests__/storage-local-provider.test.ts": 203, + "server/src/__tests__/summary-slots.test.ts": 8619, + "server/src/__tests__/task-search-quality.test.ts": 7470, + "server/src/__tests__/task-search-query.test.ts": 1158, + "server/src/__tests__/task-watchdogs-classifier.test.ts": 2008, + "server/src/__tests__/task-watchdogs-incident-fixture.test.ts": 158, + "server/src/__tests__/task-watchdogs-scheduler.test.ts": 8370, + "server/src/__tests__/teams-catalog-install-no-overrides.test.ts": 11259, + "server/src/__tests__/teams-catalog-service.test.ts": 720, + "server/src/__tests__/telemetry-client-flush.test.ts": 171, + "server/src/__tests__/tool-access-policy-service.test.ts": 8350, + "server/src/__tests__/tool-access-service.test.ts": 36320, + "server/src/__tests__/tool-connection-removal.test.ts": 8967, + "server/src/__tests__/tool-connection-visibility.test.ts": 2412, + "server/src/__tests__/tool-content-guards.test.ts": 222, + "server/src/__tests__/tool-gateway-service.test.ts": 13810, + "server/src/__tests__/tool-gateway.test.ts": 18820, + "server/src/__tests__/tool-oauth-legacy-backfill.test.ts": 6532, + "server/src/__tests__/tool-review-queue-unsigned-request.test.ts": 6766, + "server/src/__tests__/trust-preset-resolver.test.ts": 672, + "server/src/__tests__/trust-proxy.test.ts": 248, + "server/src/__tests__/ui-branding.test.ts": 186, + "server/src/__tests__/version.test.ts": 179, + "server/src/__tests__/vite-html-renderer.test.ts": 165, + "server/src/__tests__/vitest-chat-shards.test.ts": 7687, + "server/src/__tests__/work-product-runtime-reconciliation.test.ts": 2166, + "server/src/__tests__/work-products.test.ts": 1995, + "server/src/__tests__/work-timeline-service.test.ts": 7827, + "server/src/__tests__/workspace-git-inspection-cache.test.ts": 191, + "server/src/__tests__/workspace-instance-cleanup.test.ts": 192, + "server/src/__tests__/workspace-login-handoff-endpoint.test.ts": 1180, + "server/src/__tests__/workspace-login-handoff-issuer.test.ts": 654, + "server/src/__tests__/workspace-login-handoff.test.ts": 208, + "server/src/__tests__/workspace-operations-reconciliation.test.ts": 6083, + "server/src/__tests__/workspace-readiness.test.ts": 785, + "server/src/__tests__/workspace-reaper-cooldown-config.test.ts": 746, + "server/src/__tests__/workspace-runtime-control-recovery.test.ts": 6656, + "server/src/__tests__/workspace-runtime-exposure-reservation.test.ts": 11616, + "server/src/__tests__/workspace-runtime-leases.test.ts": 6220, + "server/src/__tests__/workspace-runtime-start-terminality.test.ts": 5581, + "server/src/__tests__/workspace-runtime.test.ts": 129167, + "server/src/__tests__/worktree-config.test.ts": 1201, + "server/src/__tests__/worktree-seed-server-spawn.test.ts": 2118, + "server/src/__tests__/write-build-stamp.test.ts": 208, + "server/src/__tests__/write-path-membership.test.ts": 1347, + "server/src/adapters/claude-agent-id-header.test.ts": 188, + "server/src/adapters/http/execute.test.ts": 725, + "server/src/adapters/plugin-loader.test.ts": 183, + "server/src/adapters/registry.test.ts": 1165, + "server/src/embedded-postgres-owner.test.ts": 204, + "server/src/instrumentation.test.ts": 203, + "server/src/lib/request-promise-memo.test.ts": 195, + "server/src/middleware/cloud-tenant-actor.test.ts": 1166, + "server/src/modules/active-run-watchdog/adapters/process.test.ts": 595, + "server/src/modules/active-run-watchdog/application/use-cases.test.ts": 196, + "server/src/modules/active-run-watchdog/domain/policy.test.ts": 193, + "server/src/modules/run-dispatch/adapters/postgres.test.ts": 8784, + "server/src/modules/run-dispatch/application/use-cases.test.ts": 164, + "server/src/modules/run-dispatch/domain/policy.test.ts": 204, + "server/src/modules/run-dispatch/domain/wake-context.test.ts": 184, + "server/src/modules/wake-queue/adapters/postgres.test.ts": 9247, + "server/src/modules/wake-queue/adapters/queued-comment-postgres.test.ts": 5681, + "server/src/modules/wake-queue/application/queued-comment-use-cases.test.ts": 201, + "server/src/modules/wake-queue/application/use-cases.test.ts": 2061, + "server/src/modules/wake-queue/domain/context.test.ts": 2149, + "server/src/modules/wake-queue/domain/policy.test.ts": 175, + "server/src/realtime/runner-prp-outbound.test.ts": 203, + "server/src/realtime/runner-prp-ws.test.ts": 1096, + "server/src/routes/adapters.test.ts": 1621, + "server/src/routes/chat-channels.identity.test.ts": 4004, + "server/src/routes/chat-channels.webhook.test.ts": 3139, + "server/src/routes/connection-intents.test.ts": 2045, + "server/src/routes/tool-access-connection-intent.test.ts": 2817, + "server/src/runtime-tools-token.test.ts": 208, + "server/src/services/__tests__/run-failure-report.test.ts": 5457, + "server/src/services/acknowledged-native-stop.test.ts": 709, + "server/src/services/adapter-execution-control.test.ts": 188, + "server/src/services/adapter-login-lease.test.ts": 162, "server/src/services/adapter-models-env.test.ts": 202, - "server/src/services/adapter-registry-bootstrap.reconcile.test.ts": 1133, - "server/src/services/adapter-registry-bootstrap.test.ts": 1116, - "server/src/services/attention-resolver-audience.test.ts": 1778, - "server/src/services/board-auth.test.ts": 738, - "server/src/services/environment-custom-image-terminal-sessions.test.ts": 217, - "server/src/services/execution-allowlist.test.ts": 205, - "server/src/services/execution-policy-bootstrap.test.ts": 1128, - "server/src/services/heartbeat-run-runtime-status.test.ts": 222, - "server/src/services/heartbeat-stop-metadata.test.ts": 195, - "server/src/services/hot-restart.test.ts": 295, - "server/src/services/issue-dependency-wakeups.test.ts": 751, - "server/src/services/issue-thread-interaction-resolution.test.ts": 622, - "server/src/services/issue-thread-interactions.test.ts": 4650, - "server/src/services/login-command.test.ts": 191, - "server/src/services/managed-environments.test.ts": 1711, - "server/src/services/managed-resource-drift.test.ts": 215, - "server/src/services/onboarding-greeting.test.ts": 204, - "server/src/services/recovery/model-profile-hint.test.ts": 203, - "server/src/services/recovery/provider-failure-classification.test.ts": 1712, - "server/src/services/recovery/review-path-recovery.test.ts": 216, - "server/src/services/recovery/service.pause-durability.test.ts": 1746, - "server/src/services/recovery/stranded-notice.test.ts": 758, - "server/src/services/recovery/successful-run-handoff.test.ts": 821, - "server/src/services/responsible-user-denial-run-outcomes.test.ts": 1130, - "server/src/services/routines-formatter-cache.test.ts": 2942, - "server/src/services/run-log-store.test.ts": 629, - "server/src/services/run-scratch.test.ts": 214, - "server/src/services/runtime-exposure/broker-client.test.ts": 381, - "server/src/services/runtime-exposure/exposure-manager.test.ts": 577, - "server/src/services/runtime-exposure/loopback-listener.test.ts": 686, - "server/src/services/runtime-exposure/port-pair.test.ts": 622, - "server/src/services/runtime-exposure/port-reservation.test.ts": 657, - "server/src/services/runtime-exposure/tailscale-hostname.test.ts": 214, - "server/src/services/session-workspace-cwd.test.ts": 199, - "server/src/services/setup-token-reaper.test.ts": 1726, - "server/src/services/setup-token-session.test.ts": 4809, - "server/src/services/setup-token-transport-binding.test.ts": 1995, - "server/src/services/workspace-git-operation-scheduler.test.ts": 1623, - "server/src/services/workspace-runtime-exposure-backfill.test.ts": 1618, - "server/src/services/workspace-runtime-exposure.test.ts": 13656, - "server/src/services/workspace-runtime-read-model.test.ts": 1139, - "server/src/services/workspace-runtime-ready-comment.test.ts": 1744, - "server/src/shutdown.test.ts": 247, - "server/src/url-utils.test.ts": 196 + "server/src/services/adapter-registry-bootstrap.reconcile.test.ts": 1260, + "server/src/services/adapter-registry-bootstrap.test.ts": 1138, + "server/src/services/approved-execution-wait.test.ts": 196, + "server/src/services/attention-resolver-audience.test.ts": 2010, + "server/src/services/board-auth.test.ts": 775, + "server/src/services/chat-control-admission-retry.test.ts": 1736, + "server/src/services/chat-discord-adapter-patch.test.ts": 3921, + "server/src/services/chat-discord-command-registration-store.test.ts": 6418, + "server/src/services/chat-discord-command-registration.test.ts": 349, + "server/src/services/chat-discord-modal-wire.test.ts": 1642, + "server/src/services/chat-discord-native-command.test.ts": 2105, + "server/src/services/chat-discord-native-commands.test.ts": 600, + "server/src/services/chat-discord-native-interaction.test.ts": 2629, + "server/src/services/chat-discord-native-modal.test.ts": 1649, + "server/src/services/chat-discord-question-forms.test.ts": 282, + "server/src/services/chat-discord.test.ts": 568, + "server/src/services/chat-github-attachments.test.ts": 2429, + "server/src/services/chat-github-provider-stress.test.ts": 1653, + "server/src/services/chat-github-receipt-reactions.test.ts": 446, + "server/src/services/chat-github-webhook-config.test.ts": 274, + "server/src/services/chat-inbound-wakeup-publications.test.ts": 769, + "server/src/services/chat-interaction-arbitration.test.ts": 4500, + "server/src/services/chat-interaction-publications.test.ts": 1271, + "server/src/services/chat-outbound-attachment-hydration.test.ts": 3704, + "server/src/services/chat-provider-inventory.test.ts": 244, + "server/src/services/chat-provider-lifecycle.test.ts": 183, + "server/src/services/chat-provider-links.test.ts": 189, + "server/src/services/chat-publication-batches.test.ts": 659, + "server/src/services/chat-publication-errors.test.ts": 228, + "server/src/services/chat-publication-projection.test.ts": 188, + "server/src/services/chat-publication-ready-stream.test.ts": 2342, + "server/src/services/chat-publication-reconciliation.test.ts": 203, + "server/src/services/chat-publication-stream.test.ts": 1167, + "server/src/services/chat-publication-text-parts.test.ts": 12813, + "server/src/services/chat-question-forms.test.ts": 1048, + "server/src/services/chat-run-publications.test.ts": 2040, + "server/src/services/chat-sdk-runtime.adapters.test.ts": 2659, + "server/src/services/chat-sdk-runtime.test.ts": 1841, + "server/src/services/chat-sdk-state.test.ts": 192, + "server/src/services/chat-slack-native-modal.test.ts": 2557, + "server/src/services/chat-slack-receipts.test.ts": 4349, + "server/src/services/chat-slack-sessions.test.ts": 176, + "server/src/services/chat-task-url.test.ts": 1997, + "server/src/services/chat-teams-credentials.test.ts": 661, + "server/src/services/chat-teams-file-consent-runtime.test.ts": 2178, + "server/src/services/chat-teams-file-consent.contract.test.ts": 718, + "server/src/services/chat-teams-file-consent.test.ts": 404, + "server/src/services/chat-teams-file-publication.test.ts": 6583, + "server/src/services/chat-teams-file-transfers.test.ts": 9209, + "server/src/services/chat-teams-inline-image-intake.test.ts": 1694, + "server/src/services/chat-teams-inline-images.test.ts": 2106, + "server/src/services/chat-teams-native-modal.test.ts": 2528, + "server/src/services/chat-teams-personal-recipient.test.ts": 835, + "server/src/services/chat-telegram-draft-stop.test.ts": 299, + "server/src/services/chat-telegram-ephemeral-runtime.test.ts": 1982, + "server/src/services/chat-telegram-ephemeral.test.ts": 243, + "server/src/services/chat-telegram-media-intake.test.ts": 2067, + "server/src/services/chat-telegram-photo.test.ts": 255, + "server/src/services/chat-telegram-rich-intake.test.ts": 1987, + "server/src/services/chat-telegram-stop-subscription.test.ts": 192, + "server/src/services/chat-telegram-video-note.test.ts": 2024, + "server/src/services/cloud-runtime-identity.test.ts": 619, + "server/src/services/composio.test.ts": 235, + "server/src/services/environment-custom-image-terminal-sessions.test.ts": 218, + "server/src/services/execution-allowlist.test.ts": 192, + "server/src/services/execution-continuation.test.ts": 6051, + "server/src/services/execution-control-reconciliation.test.ts": 6450, + "server/src/services/execution-policy-bootstrap.test.ts": 978, + "server/src/services/execution-projection.test.ts": 620, + "server/src/services/execution-recovery-attempt.test.ts": 189, + "server/src/services/explicit-native-continuation.test.ts": 14421, + "server/src/services/gmail-tool-governance.test.ts": 2225, + "server/src/services/heartbeat-run-runtime-status.test.ts": 205, + "server/src/services/heartbeat-runner-provider-config.test.ts": 175, + "server/src/services/heartbeat-stop-metadata.test.ts": 204, + "server/src/services/hot-restart.test.ts": 266, + "server/src/services/issue-comment-wakeup.test.ts": 180, + "server/src/services/issue-dependency-wakeups.test.ts": 629, + "server/src/services/issue-prefix.test.ts": 940, + "server/src/services/issue-queued-comment-queue.test.ts": 202, + "server/src/services/issue-thread-interaction-resolution.test.ts": 669, + "server/src/services/issue-thread-interactions.test.ts": 5185, + "server/src/services/legacy-controller-lease.test.ts": 7176, + "server/src/services/legacy-execution-recovery.test.ts": 1142, + "server/src/services/login-command.test.ts": 973, + "server/src/services/managed-environments.test.ts": 1786, + "server/src/services/managed-resource-drift.test.ts": 166, + "server/src/services/native-runtime/chat-attachment-read.test.ts": 8659, + "server/src/services/native-runtime/chat-attachment-reuse.test.ts": 7638, + "server/src/services/native-runtime/completion-contracts.test.ts": 618, + "server/src/services/native-runtime/continuation-contract.test.ts": 5616, + "server/src/services/native-runtime/current-wake-comments.test.ts": 7632, + "server/src/services/native-runtime/evidence-classifier.test.ts": 1067, + "server/src/services/native-runtime/external-chat-wait.integration.test.ts": 44215, + "server/src/services/native-runtime/native-chat-workspace.test.ts": 6604, + "server/src/services/native-runtime/native-codex-runner.integration.test.ts": 224015, + "server/src/services/native-runtime/native-codex-runner.test.ts": 2284, + "server/src/services/native-runtime/native-deliverable-feedback.test.ts": 762, + "server/src/services/native-runtime/native-execution-input.test.ts": 1287, + "server/src/services/native-runtime/native-maintenance-no-launch.test.ts": 546, + "server/src/services/native-runtime/native-provider-capacity.integration.test.ts": 6882, + "server/src/services/native-runtime/native-question-bridge.test.ts": 13420, + "server/src/services/native-runtime/native-replacement-evidence.test.ts": 163, + "server/src/services/native-runtime/native-restart-recovery.test.ts": 756, + "server/src/services/native-runtime/native-run-finalizer-telemetry.test.ts": 7647, + "server/src/services/native-runtime/native-run-trace.test.ts": 231, + "server/src/services/native-runtime/native-runner-attachment-staging.test.ts": 1732, + "server/src/services/native-runtime/native-runner-file-handoff.test.ts": 7340, + "server/src/services/native-runtime/native-runner-ownership.test.ts": 716, + "server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts": 7940, + "server/src/services/native-runtime/native-safe-replacement.test.ts": 23437, + "server/src/services/native-runtime/native-session-executor.test.ts": 14430, + "server/src/services/native-runtime/native-session-resume.test.ts": 7411, + "server/src/services/native-runtime/native-ssh-command-runner.test.ts": 1019, + "server/src/services/native-runtime/native-workspace-finalizer.test.ts": 6191, + "server/src/services/native-runtime/paperclip-control-plane-port.test.ts": 13807, + "server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts": 6501, + "server/src/services/native-runtime/remote-deliverable-file.test.ts": 753, + "server/src/services/native-runtime/remote-provider-pack.test.ts": 191, + "server/src/services/native-runtime/runner-api-files.test.ts": 211, + "server/src/services/native-runtime/runner-api-rollout.test.ts": 160, + "server/src/services/native-runtime/runner-api.integration.test.ts": 12758, + "server/src/services/native-runtime/runner-api.test.ts": 1456, + "server/src/services/native-runtime/runner-prp-coordinator.test.ts": 8049, + "server/src/services/native-runtime/runtime-context.test.ts": 1845, + "server/src/services/native-runtime/runtime-mode.test.ts": 211, + "server/src/services/native-runtime/runtime-request-resolution-authority.test.ts": 713, + "server/src/services/native-runtime/status-arbiter.test.ts": 204, + "server/src/services/native-runtime/stopped-codex-turn.test.ts": 173, + "server/src/services/onboarding-first-task-assets.test.ts": 650, + "server/src/services/onboarding-greeting.test.ts": 636, + "server/src/services/paperclip-cloud-connector-enrollment.test.ts": 1233, + "server/src/services/paperclip-cloud-connector.test.ts": 1277, + "server/src/services/provider-trace-workspace-diff-reprojection.test.ts": 874, + "server/src/services/recovery/native-runner-routing.test.ts": 2032, + "server/src/services/recovery/provider-failure-classification.test.ts": 2033, + "server/src/services/recovery/review-path-recovery.test.ts": 185, + "server/src/services/recovery/service.pause-durability.test.ts": 2026, + "server/src/services/recovery/status-only-context.test.ts": 186, + "server/src/services/recovery/stranded-notice.test.ts": 1140, + "server/src/services/recovery/successful-run-handoff.test.ts": 1153, + "server/src/services/remote-agent-profiles.test.ts": 768, + "server/src/services/remote-url-credentials.test.ts": 159, + "server/src/services/responsible-user-denial-run-outcomes.test.ts": 1132, + "server/src/services/routines-formatter-cache.test.ts": 3592, + "server/src/services/run-log-store.test.ts": 558, + "server/src/services/run-scratch.test.ts": 216, + "server/src/services/runner-goal-control-broker.test.ts": 186, + "server/src/services/runner-goals.test.ts": 6061, + "server/src/services/runtime-exposure/broker-client.test.ts": 353, + "server/src/services/runtime-exposure/exposure-manager.test.ts": 656, + "server/src/services/runtime-exposure/loopback-listener.test.ts": 232, + "server/src/services/runtime-exposure/port-pair.test.ts": 649, + "server/src/services/runtime-exposure/port-reservation.test.ts": 671, + "server/src/services/runtime-exposure/tailscale-hostname.test.ts": 188, + "server/src/services/safe-native-chat-progress.test.ts": 217, + "server/src/services/session-workspace-cwd.test.ts": 204, + "server/src/services/setup-token-reaper.test.ts": 1656, + "server/src/services/setup-token-session.test.ts": 7291, + "server/src/services/setup-token-transport-binding.test.ts": 2042, + "server/src/services/tool-profile-binding-precedence.test.ts": 160, + "server/src/services/vercel-connect.test.ts": 266, + "server/src/services/workspace-git-operation-scheduler.test.ts": 1567, + "server/src/services/workspace-runtime-exposure-backfill.test.ts": 2194, + "server/src/services/workspace-runtime-exposure.test.ts": 14621, + "server/src/services/workspace-runtime-read-model.test.ts": 958, + "server/src/services/workspace-runtime-ready-comment.test.ts": 2176, + "server/src/shutdown.test.ts": 331, + "server/src/url-utils.test.ts": 183 } } diff --git a/scripts/release.sh b/scripts/release.sh index c6720e9962..7238c4b293 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -314,21 +314,67 @@ VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}" release_info "" if [ "$dry_run" = true ]; then release_info "==> Step 5/7: Previewing publish payloads (--dry-run)..." + # Previews are independent per package (each works in its own temp dir and + # never touches npm), so run them concurrently in batches. Serial previews + # took ~78s of every PR Canary Dry Run job (run 35036001734, 2026-09-15). + # The real publish path below stays strictly serial. + preview_logs_dir="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-release-previews.XXXXXX")" + preview_batch_size=8 + preview_pids=() + preview_dirs=() + preview_logs=() + preview_failed=false + + wait_for_preview_batch() { + # Guard the expansion: "${preview_pids[@]}" on an empty array trips + # `set -u` under bash < 4.4 (the macOS system bash). + if [ "${#preview_pids[@]}" -eq 0 ]; then + return 0 + fi + for pid in "${preview_pids[@]}"; do + if ! wait "$pid"; then + preview_failed=true + fi + done + preview_pids=() + } + while IFS=$'\t' read -r pkg_dir _pkg_name _pkg_version; do [ -z "$pkg_dir" ] && continue - release_info " --- $pkg_dir ---" - cd "$REPO_ROOT/$pkg_dir" - publish_tool="$(package_publish_tool)" - if [ "$publish_tool" = "npm" ]; then - publish_dir="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-release-package.XXXXXX")" - node "$REPO_ROOT/scripts/prepare-bundled-package.mjs" "$REPO_ROOT/$pkg_dir" "$publish_dir" - cd "$publish_dir" - run_bundled_npm_pack pack --pack-destination "$publish_dir" 2>&1 | tail -3 - rm -rf "$publish_dir" - else - pnpm publish --dry-run --no-git-checks --tag "$DIST_TAG" 2>&1 | tail -3 + preview_log="$preview_logs_dir/$(printf '%03d' "${#preview_logs[@]}")-${pkg_dir//\//_}.log" + preview_dirs+=("$pkg_dir") + preview_logs+=("$preview_log") + ( + set -euo pipefail + cd "$REPO_ROOT/$pkg_dir" + publish_tool="$(package_publish_tool)" + if [ "$publish_tool" = "npm" ]; then + publish_dir="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-release-package.XXXXXX")" + node "$REPO_ROOT/scripts/prepare-bundled-package.mjs" "$REPO_ROOT/$pkg_dir" "$publish_dir" + cd "$publish_dir" + run_bundled_npm_pack pack --pack-destination "$publish_dir" 2>&1 | tail -3 + rm -rf "$publish_dir" + else + pnpm publish --dry-run --no-git-checks --tag "$DIST_TAG" 2>&1 | tail -3 + fi + ) > "$preview_log" 2>&1 & + preview_pids+=($!) + if [ "$(( ${#preview_pids[@]} % preview_batch_size ))" -eq 0 ]; then + wait_for_preview_batch fi done <<< "$VERSIONED_PACKAGE_INFO" + wait_for_preview_batch + + preview_index=0 + for pkg_dir in "${preview_dirs[@]}"; do + release_info " --- $pkg_dir ---" + cat "${preview_logs[$preview_index]}" + preview_index=$((preview_index + 1)) + done + rm -rf "$preview_logs_dir" + if [ "$preview_failed" = true ]; then + release_fail "one or more publish payload previews failed; see the package output above" + fi release_info " [dry-run] Would create git tag $tag_name on $CURRENT_SHA" else release_info "==> Step 5/7: Publishing packages to npm..." diff --git a/tests/e2e/agent-chat.spec.ts b/tests/e2e/agent-chat-projects.spec.ts similarity index 54% rename from tests/e2e/agent-chat.spec.ts rename to tests/e2e/agent-chat-projects.spec.ts index b3a1fb9d1b..e9c89067a3 100644 --- a/tests/e2e/agent-chat.spec.ts +++ b/tests/e2e/agent-chat-projects.spec.ts @@ -1,381 +1,16 @@ -import path from "node:path"; -import { createLocalAgentJwt } from "../../server/src/agent-auth-jwt"; -import { - test, - expect, - type APIRequestContext, - type Page, -} from "@playwright/test"; +import { expect, test } from "@playwright/test"; + +import { idle, json, send, setup } from "./agent-chat.shared"; test.use({ trace: "retain-on-failure" }); test.setTimeout(120_000); -async function json(response: Awaited>) { - expect(response.ok(), `${response.status()} ${await response.text()}`).toBe( - true, - ); - return response.json(); -} -async function setup(request: APIRequestContext) { - const company = await json( - await request.post("/api/companies", { - data: { name: `Agent Chat ${Date.now()}` }, - }), - ); - const original = await json( - await request.get("/api/instance/settings/experimental"), - ); - await json( - await request.patch("/api/instance/settings/experimental", { - data: { enableAgentChat: true, enableClassicTaskInterface: false }, - }), - ); - const agents = []; - for (const name of ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta"]) - agents.push( - await json( - await request.post(`/api/companies/${company.id}/agents`, { - data: { - name, - adapterType: "process", - adapterConfig: { - command: process.execPath, - args: [path.resolve("tests/e2e/fixtures/agent-chat.mjs")], - graceSec: 1, - }, - runtimeConfig: { - heartbeat: { enabled: false, wakeOnDemand: true }, - }, - }, - }), - ), - ); - const agent = agents[0]; - const chatPath = `/api/companies/${company.id}/chats/${agent.id}`; - const route = `/${company.issuePrefix}/chats/${agent.id}`; - return { - company, - agents, - agent, - chatPath, - route, - restore: async () => { - try { - const chat = await request.get(chatPath); - const history = chat.ok() ? await chat.json() : null; - const tasks = await json( - await request.get(`/api/companies/${company.id}/issues`), - ); - const projects = await json( - await request.get(`/api/companies/${company.id}/projects`), - ); - const ledger = await json( - await request.get(`/api/companies/${company.id}/heartbeat-runs`), - ); - const documents = await Promise.all( - [...(history ? [history] : []), ...tasks].map(async (task: any) => ({ - taskId: task.id, - documents: await json( - await request.get(`/api/issues/${task.id}/documents`), - ), - })), - ); - await test.info().attach("chat-persisted-state", { - contentType: "application/json", - body: Buffer.from( - JSON.stringify( - { - chat: history, - tasks, - projects, - documents, - comments: history - ? await json( - await request.get(`/api/issues/${history.id}/comments`), - ) - : [], - runs: ledger.map((run: any) => ({ - id: run.id, - status: run.status, - startedAt: run.startedAt, - finishedAt: run.finishedAt, - sessionIdBefore: run.sessionIdBefore, - sessionIdAfter: run.sessionIdAfter, - issueId: run.contextSnapshot?.issueId, - generation: - run.contextSnapshot?.conversationSessionGeneration, - reset: run.contextSnapshot?.conversationReset, - })), - }, - null, - 2, - ), - ), - }); - } finally { - const runs = await json( - await request.get(`/api/companies/${company.id}/live-runs`), - ); - for (const run of runs) - await json( - await request.post(`/api/heartbeat-runs/${run.id}/cancel`), - ); - await json( - await request.patch("/api/instance/settings/experimental", { - data: { - enableAgentChat: original.enableAgentChat, - enableClassicTaskInterface: original.enableClassicTaskInterface, - }, - }), - ); - } - }, - }; -} -async function send(page: Page, value: unknown) { - await page - .getByTestId("task-chat-composer-input") - .last() - .locator('[contenteditable="true"],textarea') - .first() - .fill( - typeof value === "string" - ? value - : `fixture:${Buffer.from(JSON.stringify(value)).toString("base64url")}`, - ); - await page.getByTestId("task-chat-composer-send").last().click(); -} -async function idle( - request: APIRequestContext, - chatPath: string, - minimumReplies = 1, -) { - let issue: any; - await expect - .poll( - async () => { - issue = await json(await request.get(chatPath)); - if (!issue) return false; - const replies = await json( - await request.get(`/api/issues/${issue.id}/comments`), - ); - const live = await json( - await request.get(`/api/issues/${issue.id}/live-runs`), - ); - return ( - live.length === 0 && - issue.conversationState === "waiting" && - issue.status === "in_review" && - replies.filter((c: any) => c.authorAgentId).length >= minimumReplies - ); - }, - { timeout: 60_000, intervals: [100, 250, 500] }, - ) - .toBe(true); - return issue; -} - -test("chat first open is read-only; concurrent first sends and retries share one task", async ({ - page, - context, - request, -}) => { - const f = await setup(request); - try { - await page.goto(f.route); - await expect(page.getByTestId("task-chat-composer-input")).toBeVisible(); - expect(await json(await request.get(f.chatPath))).toBeNull(); - expect( - await json( - await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), - ), - ).toHaveLength(0); - const other = await context.newPage(); - await other.goto(f.route); - await Promise.all([send(page, "Same first message"), send(other, "Same first message")]); - const issue = await idle(request, f.chatPath, 2); - const initialComments = await json(await request.get(`/api/issues/${issue.id}/comments`)); - expect(initialComments.filter((comment: any) => !comment.authorAgentId && comment.body === "Same first message")).toHaveLength(2); - const resolved = await Promise.all( - Array.from({ length: 4 }, () => - request.post(f.chatPath, { data: {} }).then(json), - ), - ); - expect(new Set(resolved.map((row) => row.id))).toEqual(new Set([issue.id])); - const body = { - body: "Retry once", - clientRequestId: "00000000-0000-4000-8000-000000000001", - }; - const replies = await Promise.all([ - request - .post(`/api/issues/${issue.id}/comments`, { data: body }) - .then(json), - request - .post(`/api/issues/${issue.id}/comments`, { data: body }) - .then(json), - ]); - expect(replies[0].id).toBe(replies[1].id); - await idle(request, f.chatPath, 3); - await page.reload(); - await expect( - page.getByText("Reply generation 0: Retry once", { exact: true }), - ).toBeVisible(); - expect( - await json(await request.get(`/api/companies/${f.company.id}/issues`)), - ).toHaveLength(0); - const dashboard = await json( - await request.get(`/api/companies/${f.company.id}/dashboard`), - ); - expect(dashboard.tasks).toEqual({ - open: 0, - inProgress: 0, - blocked: 0, - done: 0, - }); - const count = ( - await json( - await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), - ) - ).length; - await page.goto(`/${f.company.issuePrefix}/issues/${issue.identifier}`); - await page.goto(f.route); - expect( - await json( - await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), - ), - ).toHaveLength(count); - await other.close(); - } finally { - await f.restore(); - } -}); - -test("feature flag blocks new sends and resets while preserving existing history", async ({ - page, - request, -}) => { - const f = await setup(request); - try { - await page.goto(f.route); - await send(page, "Visible history"); - const issue = await idle(request, f.chatPath); - await json( - await request.patch("/api/instance/settings/experimental", { - data: { enableAgentChat: false }, - }), - ); - for (const body of ["blocked message", "/new"]) - expect( - ( - await request.post(`/api/issues/${issue.id}/comments`, { - data: { body }, - }) - ).status(), - ).toBe(404); - await page.reload(); - await expect(page.getByText(/Agent Chat is disabled/)).toBeVisible(); - expect( - (await json(await request.get(`/api/issues/${issue.id}/comments`))).some( - (c: any) => c.body.includes("Visible history"), - ), - ).toBe(true); - expect((await request.post(f.chatPath, { data: {} })).status()).toBe(404); - } finally { - await f.restore(); - } -}); - -test("Stop then queued /new resets unpause the conversation without losing history", async ({ - page, - request, -}) => { - const f = await setup(request); - try { - await page.goto(f.route); - await send(page, { action: "hold" }); - await expect - .poll(async () => { - const chat = await json(await request.get(f.chatPath)); - return ( - chat && - ( - await json(await request.get(`/api/issues/${chat.id}/comments`)) - ).some( - (c: any) => c.body === "Provider is streaming and ready to stop.", - ) - ); - }) - .toBe(true); - const issue = await json(await request.get(f.chatPath)); - const active = ( - await json(await request.get(`/api/issues/${issue.id}/live-runs`)) - )[0]; - await page.getByTestId("task-chat-composer-stop").click(); - await expect - .poll( - async () => - (await json(await request.get(`/api/heartbeat-runs/${active.id}`))) - .status, - ) - .toBe("cancelled"); - const staleToken = createLocalAgentJwt( - f.agent.id, - f.company.id, - "process", - active.id, - ); - expect(staleToken).toBeTruthy(); - const late = await request.post(`/api/issues/${issue.id}/comments`, { - headers: { Authorization: `Bearer ${staleToken}` }, - data: { body: "Forbidden late response" }, - }); - expect([403, 409]).toContain(late.status()); - const mutation = await request.post( - `/api/companies/${f.company.id}/projects`, - { - headers: { Authorization: `Bearer ${staleToken}` }, - data: { name: "Cancelled project" }, - }, - ); - expect([403, 409]).toContain(mutation.status()); - expect( - await json(await request.get(`/api/companies/${f.company.id}/projects`)), - ).toHaveLength(0); - await send(page, "/new"); - await send(page, "/new"); - await send(page, "Fresh followup"); - await idle(request, f.chatPath, 2); - const fresh = await json(await request.get(f.chatPath)); - expect(fresh.id).toBe(issue.id); - expect(fresh.conversationSessionGeneration).toBe(2); - await expect( - page.getByText("Reply generation 2: Fresh followup", { exact: true }), - ).toBeVisible(); - await page.reload(); - await expect(page.getByText("New session", { exact: true })).toHaveCount(2); - await expect( - page.getByRole("separator", { name: "Run completed", exact: true }), - ).toHaveCount(0); - const runs = await json( - await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), - ); - const detailed = await Promise.all( - runs.map((run: any) => - request.get(`/api/heartbeat-runs/${run.id}`).then(json), - ), - ); - expect( - detailed.filter((run) => run.contextSnapshot?.conversationReset), - ).toHaveLength(2); - expect( - detailed - .filter((run) => run.contextSnapshot?.conversationReset) - .every((run) => !run.sessionIdAfter), - ).toBe(true); - } finally { - await f.restore(); - } -}); +/** + * Agent chat project and content coverage: project cards, split handoff, + * uploads, Ask/Plan modes, repository selection, plan approval, and shared + * history. Shared fixtures live in ./agent-chat.shared.ts; session lifecycle + * flows run in agent-chat-sessions.spec.ts. + */ for (const direct of [false, true]) test(`project card through ${direct ? "direct API" : "dedicated tool"} persists and deduplicates retries`, async ({ page, @@ -554,93 +189,6 @@ for (const bad of [ } }); -test("sidebar discovery, stars, recent agents, configuration links, and drafts survive switching", async ({ - page, - request, -}) => { - const f = await setup(request); - try { - await page.goto(`/${f.company.issuePrefix}/dashboard`); - const nav = page.getByRole("navigation"); - const chatLinks = nav.locator('a[href*="/chats/"]'); - await expect(chatLinks).toHaveText(["Alpha"]); - const compose = nav.getByRole("button", { name: "Chat with an agent", exact: true }); - await compose.click(); - const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true }); - await expect(picker.getByRole("option")).toHaveCount(6); - await picker.getByRole("combobox").fill("Beta"); - await picker.getByRole("combobox").press("Enter"); - await expect(picker).not.toBeVisible(); - await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible(); - expect(await json(await request.get(`/api/companies/${f.company.id}/chats/${f.agents[1].id}`))).toBeNull(); - for (const agent of f.agents) { - await page.goto(`/${f.company.issuePrefix}/chats/${agent.id}`); - await expect(page.getByTestId("task-chat-composer-input")).toBeVisible(); - } - await expect(chatLinks).toHaveText(["Alpha", "Zeta", "Epsilon", "Delta", "Gamma"]); - const star = page.getByRole("button", { name: "Star Zeta", exact: true }); - await page.getByTestId("task-chat-composer-input").click(); - await expect(star).toHaveCSS("opacity", "0"); - await star.focus(); - await page.keyboard.press("Tab"); - await page.keyboard.press("Shift+Tab"); - await expect(star).toHaveCSS("opacity", "1"); - await star.click(); - await page.goto(f.route); - await page.getByRole("button", { name: "Star Alpha", exact: true }).click(); - await expect(nav.locator('a[href*="/chats/"]').first()).toHaveText("Alpha"); - await expect(nav.locator('a[href*="/chats/"]').nth(1)).toHaveText("Zeta"); - const editor = page - .getByTestId("task-chat-composer-input") - .locator('[contenteditable="true"]'); - await editor.fill("Unsent draft for Alpha"); - await nav.getByRole("link", { name: "Zeta", exact: true }).click(); - await expect(editor).toHaveText(""); - await nav.getByRole("link", { name: "Alpha", exact: true }).click(); - await expect(editor).toContainText("Unsent draft for Alpha"); - const recent = await page.evaluate(() => - Object.fromEntries( - Object.entries(localStorage).filter(([key]) => - key.startsWith("paperclip.recentAgentChats:"), - ), - ), - ); - await page.getByRole("link", { name: /Configure Alpha/ }).click(); - await expect(page).toHaveURL(/\/agents\/.*\/runtime/); - const backgroundPath = `/api/companies/${f.company.id}/chats/${f.agents[1].id}`; - const background = await json( - await request.post(backgroundPath, { data: {} }), - ); - await json( - await request.post(`/api/issues/${background.id}/comments`, { - data: { - body: "Background activity", - clientRequestId: "00000000-0000-4000-8000-000000000099", - }, - }), - ); - await idle(request, backgroundPath); - expect( - await page.evaluate(() => - Object.fromEntries( - Object.entries(localStorage).filter(([key]) => - key.startsWith("paperclip.recentAgentChats:"), - ), - ), - ), - ).toEqual(recent); - await compose.click(); - await expect(picker.getByRole("combobox")).toHaveValue(""); - await picker.getByRole("combobox").fill("Beta"); - await picker.getByRole("combobox").press("Enter"); - await expect(picker).not.toBeVisible(); - await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible(); - await expect(page.getByText("Background activity", { exact: true })).toBeVisible(); - } finally { - await f.restore(); - } -}); - test("first upload creates the chat without invoking its agent; shared attachments persist", async ({ page, request, diff --git a/tests/e2e/agent-chat-sessions.spec.ts b/tests/e2e/agent-chat-sessions.spec.ts new file mode 100644 index 0000000000..477bf7a10f --- /dev/null +++ b/tests/e2e/agent-chat-sessions.spec.ts @@ -0,0 +1,303 @@ +import { expect, test } from "@playwright/test"; + +import { createLocalAgentJwt } from "../../server/src/agent-auth-jwt"; + +import { idle, json, send, setup } from "./agent-chat.shared"; + +test.use({ trace: "retain-on-failure" }); +test.setTimeout(120_000); + +/** + * Agent chat session lifecycle coverage: first-open semantics, the feature + * flag, Stop + queued /new resets, and sidebar discovery. Shared fixtures + * live in ./agent-chat.shared.ts; project, attachment, and history flows run + * in agent-chat-projects.spec.ts. + */ +test("chat first open is read-only; concurrent first sends and retries share one task", async ({ + page, + context, + request, +}) => { + const f = await setup(request); + try { + await page.goto(f.route); + await expect(page.getByTestId("task-chat-composer-input")).toBeVisible(); + expect(await json(await request.get(f.chatPath))).toBeNull(); + expect( + await json( + await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), + ), + ).toHaveLength(0); + const other = await context.newPage(); + await other.goto(f.route); + await Promise.all([send(page, "Same first message"), send(other, "Same first message")]); + const issue = await idle(request, f.chatPath, 2); + const initialComments = await json(await request.get(`/api/issues/${issue.id}/comments`)); + expect(initialComments.filter((comment: any) => !comment.authorAgentId && comment.body === "Same first message")).toHaveLength(2); + const resolved = await Promise.all( + Array.from({ length: 4 }, () => + request.post(f.chatPath, { data: {} }).then(json), + ), + ); + expect(new Set(resolved.map((row) => row.id))).toEqual(new Set([issue.id])); + const body = { + body: "Retry once", + clientRequestId: "00000000-0000-4000-8000-000000000001", + }; + const replies = await Promise.all([ + request + .post(`/api/issues/${issue.id}/comments`, { data: body }) + .then(json), + request + .post(`/api/issues/${issue.id}/comments`, { data: body }) + .then(json), + ]); + expect(replies[0].id).toBe(replies[1].id); + await idle(request, f.chatPath, 3); + await page.reload(); + await expect( + page.getByText("Reply generation 0: Retry once", { exact: true }), + ).toBeVisible(); + expect( + await json(await request.get(`/api/companies/${f.company.id}/issues`)), + ).toHaveLength(0); + const dashboard = await json( + await request.get(`/api/companies/${f.company.id}/dashboard`), + ); + expect(dashboard.tasks).toEqual({ + open: 0, + inProgress: 0, + blocked: 0, + done: 0, + }); + const count = ( + await json( + await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), + ) + ).length; + await page.goto(`/${f.company.issuePrefix}/issues/${issue.identifier}`); + await page.goto(f.route); + expect( + await json( + await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), + ), + ).toHaveLength(count); + await other.close(); + } finally { + await f.restore(); + } +}); + +test("feature flag blocks new sends and resets while preserving existing history", async ({ + page, + request, +}) => { + const f = await setup(request); + try { + await page.goto(f.route); + await send(page, "Visible history"); + const issue = await idle(request, f.chatPath); + await json( + await request.patch("/api/instance/settings/experimental", { + data: { enableAgentChat: false }, + }), + ); + for (const body of ["blocked message", "/new"]) + expect( + ( + await request.post(`/api/issues/${issue.id}/comments`, { + data: { body }, + }) + ).status(), + ).toBe(404); + await page.reload(); + await expect(page.getByText(/Agent Chat is disabled/)).toBeVisible(); + expect( + (await json(await request.get(`/api/issues/${issue.id}/comments`))).some( + (c: any) => c.body.includes("Visible history"), + ), + ).toBe(true); + expect((await request.post(f.chatPath, { data: {} })).status()).toBe(404); + } finally { + await f.restore(); + } +}); + +test("Stop then queued /new resets unpause the conversation without losing history", async ({ + page, + request, +}) => { + const f = await setup(request); + try { + await page.goto(f.route); + await send(page, { action: "hold" }); + await expect + .poll(async () => { + const chat = await json(await request.get(f.chatPath)); + return ( + chat && + ( + await json(await request.get(`/api/issues/${chat.id}/comments`)) + ).some( + (c: any) => c.body === "Provider is streaming and ready to stop.", + ) + ); + }) + .toBe(true); + const issue = await json(await request.get(f.chatPath)); + const active = ( + await json(await request.get(`/api/issues/${issue.id}/live-runs`)) + )[0]; + await page.getByTestId("task-chat-composer-stop").click(); + await expect + .poll( + async () => + (await json(await request.get(`/api/heartbeat-runs/${active.id}`))) + .status, + ) + .toBe("cancelled"); + const staleToken = createLocalAgentJwt( + f.agent.id, + f.company.id, + "process", + active.id, + ); + expect(staleToken).toBeTruthy(); + const late = await request.post(`/api/issues/${issue.id}/comments`, { + headers: { Authorization: `Bearer ${staleToken}` }, + data: { body: "Forbidden late response" }, + }); + expect([403, 409]).toContain(late.status()); + const mutation = await request.post( + `/api/companies/${f.company.id}/projects`, + { + headers: { Authorization: `Bearer ${staleToken}` }, + data: { name: "Cancelled project" }, + }, + ); + expect([403, 409]).toContain(mutation.status()); + expect( + await json(await request.get(`/api/companies/${f.company.id}/projects`)), + ).toHaveLength(0); + await send(page, "/new"); + await send(page, "/new"); + await send(page, "Fresh followup"); + await idle(request, f.chatPath, 2); + const fresh = await json(await request.get(f.chatPath)); + expect(fresh.id).toBe(issue.id); + expect(fresh.conversationSessionGeneration).toBe(2); + await expect( + page.getByText("Reply generation 2: Fresh followup", { exact: true }), + ).toBeVisible(); + await page.reload(); + await expect(page.getByText("New session", { exact: true })).toHaveCount(2); + await expect( + page.getByRole("separator", { name: "Run completed", exact: true }), + ).toHaveCount(0); + const runs = await json( + await request.get(`/api/companies/${f.company.id}/heartbeat-runs`), + ); + const detailed = await Promise.all( + runs.map((run: any) => + request.get(`/api/heartbeat-runs/${run.id}`).then(json), + ), + ); + expect( + detailed.filter((run) => run.contextSnapshot?.conversationReset), + ).toHaveLength(2); + expect( + detailed + .filter((run) => run.contextSnapshot?.conversationReset) + .every((run) => !run.sessionIdAfter), + ).toBe(true); + } finally { + await f.restore(); + } +}); + +test("sidebar discovery, stars, recent agents, configuration links, and drafts survive switching", async ({ + page, + request, +}) => { + const f = await setup(request); + try { + await page.goto(`/${f.company.issuePrefix}/dashboard`); + const nav = page.getByRole("navigation"); + const chatLinks = nav.locator('a[href*="/chats/"]'); + await expect(chatLinks).toHaveText(["Alpha"]); + const compose = nav.getByRole("button", { name: "Chat with an agent", exact: true }); + await compose.click(); + const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true }); + await expect(picker.getByRole("option")).toHaveCount(6); + await picker.getByRole("combobox").fill("Beta"); + await picker.getByRole("combobox").press("Enter"); + await expect(picker).not.toBeVisible(); + await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible(); + expect(await json(await request.get(`/api/companies/${f.company.id}/chats/${f.agents[1].id}`))).toBeNull(); + for (const agent of f.agents) { + await page.goto(`/${f.company.issuePrefix}/chats/${agent.id}`); + await expect(page.getByTestId("task-chat-composer-input")).toBeVisible(); + } + await expect(chatLinks).toHaveText(["Alpha", "Zeta", "Epsilon", "Delta", "Gamma"]); + const star = page.getByRole("button", { name: "Star Zeta", exact: true }); + await page.getByTestId("task-chat-composer-input").click(); + await expect(star).toHaveCSS("opacity", "0"); + await star.focus(); + await page.keyboard.press("Tab"); + await page.keyboard.press("Shift+Tab"); + await expect(star).toHaveCSS("opacity", "1"); + await star.click(); + await page.goto(f.route); + await page.getByRole("button", { name: "Star Alpha", exact: true }).click(); + await expect(nav.locator('a[href*="/chats/"]').first()).toHaveText("Alpha"); + await expect(nav.locator('a[href*="/chats/"]').nth(1)).toHaveText("Zeta"); + const editor = page + .getByTestId("task-chat-composer-input") + .locator('[contenteditable="true"]'); + await editor.fill("Unsent draft for Alpha"); + await nav.getByRole("link", { name: "Zeta", exact: true }).click(); + await expect(editor).toHaveText(""); + await nav.getByRole("link", { name: "Alpha", exact: true }).click(); + await expect(editor).toContainText("Unsent draft for Alpha"); + const recent = await page.evaluate(() => + Object.fromEntries( + Object.entries(localStorage).filter(([key]) => + key.startsWith("paperclip.recentAgentChats:"), + ), + ), + ); + await page.getByRole("link", { name: /Configure Alpha/ }).click(); + await expect(page).toHaveURL(/\/agents\/.*\/runtime/); + const backgroundPath = `/api/companies/${f.company.id}/chats/${f.agents[1].id}`; + const background = await json( + await request.post(backgroundPath, { data: {} }), + ); + await json( + await request.post(`/api/issues/${background.id}/comments`, { + data: { + body: "Background activity", + clientRequestId: "00000000-0000-4000-8000-000000000099", + }, + }), + ); + await idle(request, backgroundPath); + expect( + await page.evaluate(() => + Object.fromEntries( + Object.entries(localStorage).filter(([key]) => + key.startsWith("paperclip.recentAgentChats:"), + ), + ), + ), + ).toEqual(recent); + await compose.click(); + await expect(picker.getByRole("combobox")).toHaveValue(""); + await picker.getByRole("combobox").fill("Beta"); + await picker.getByRole("combobox").press("Enter"); + await expect(picker).not.toBeVisible(); + await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible(); + await expect(page.getByText("Background activity", { exact: true })).toBeVisible(); + } finally { + await f.restore(); + } +}); diff --git a/tests/e2e/agent-chat.shared.ts b/tests/e2e/agent-chat.shared.ts new file mode 100644 index 0000000000..ab235494eb --- /dev/null +++ b/tests/e2e/agent-chat.shared.ts @@ -0,0 +1,176 @@ +import path from "node:path"; +import { createLocalAgentJwt } from "../../server/src/agent-auth-jwt"; +import { + test, + expect, + type APIRequestContext, + type Page, +} from "@playwright/test"; + +// Per-file Playwright configuration (test.use, test.setTimeout) must stay in +// the spec files themselves: calling test.use() from a shared helper makes +// Playwright fail the whole worker with "inconsistent test.use() options". +export async function json(response: Awaited>) { + expect(response.ok(), `${response.status()} ${await response.text()}`).toBe( + true, + ); + return response.json(); +} +export async function setup(request: APIRequestContext) { + const company = await json( + await request.post("/api/companies", { + data: { name: `Agent Chat ${Date.now()}` }, + }), + ); + const original = await json( + await request.get("/api/instance/settings/experimental"), + ); + await json( + await request.patch("/api/instance/settings/experimental", { + data: { enableAgentChat: true, enableClassicTaskInterface: false }, + }), + ); + const agents = []; + for (const name of ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta"]) + agents.push( + await json( + await request.post(`/api/companies/${company.id}/agents`, { + data: { + name, + adapterType: "process", + adapterConfig: { + command: process.execPath, + args: [path.resolve("tests/e2e/fixtures/agent-chat.mjs")], + graceSec: 1, + }, + runtimeConfig: { + heartbeat: { enabled: false, wakeOnDemand: true }, + }, + }, + }), + ), + ); + const agent = agents[0]; + const chatPath = `/api/companies/${company.id}/chats/${agent.id}`; + const route = `/${company.issuePrefix}/chats/${agent.id}`; + return { + company, + agents, + agent, + chatPath, + route, + restore: async () => { + try { + const chat = await request.get(chatPath); + const history = chat.ok() ? await chat.json() : null; + const tasks = await json( + await request.get(`/api/companies/${company.id}/issues`), + ); + const projects = await json( + await request.get(`/api/companies/${company.id}/projects`), + ); + const ledger = await json( + await request.get(`/api/companies/${company.id}/heartbeat-runs`), + ); + const documents = await Promise.all( + [...(history ? [history] : []), ...tasks].map(async (task: any) => ({ + taskId: task.id, + documents: await json( + await request.get(`/api/issues/${task.id}/documents`), + ), + })), + ); + await test.info().attach("chat-persisted-state", { + contentType: "application/json", + body: Buffer.from( + JSON.stringify( + { + chat: history, + tasks, + projects, + documents, + comments: history + ? await json( + await request.get(`/api/issues/${history.id}/comments`), + ) + : [], + runs: ledger.map((run: any) => ({ + id: run.id, + status: run.status, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + sessionIdBefore: run.sessionIdBefore, + sessionIdAfter: run.sessionIdAfter, + issueId: run.contextSnapshot?.issueId, + generation: + run.contextSnapshot?.conversationSessionGeneration, + reset: run.contextSnapshot?.conversationReset, + })), + }, + null, + 2, + ), + ), + }); + } finally { + const runs = await json( + await request.get(`/api/companies/${company.id}/live-runs`), + ); + for (const run of runs) + await json( + await request.post(`/api/heartbeat-runs/${run.id}/cancel`), + ); + await json( + await request.patch("/api/instance/settings/experimental", { + data: { + enableAgentChat: original.enableAgentChat, + enableClassicTaskInterface: original.enableClassicTaskInterface, + }, + }), + ); + } + }, + }; +} +export async function send(page: Page, value: unknown) { + await page + .getByTestId("task-chat-composer-input") + .last() + .locator('[contenteditable="true"],textarea') + .first() + .fill( + typeof value === "string" + ? value + : `fixture:${Buffer.from(JSON.stringify(value)).toString("base64url")}`, + ); + await page.getByTestId("task-chat-composer-send").last().click(); +} +export async function idle( + request: APIRequestContext, + chatPath: string, + minimumReplies = 1, +) { + let issue: any; + await expect + .poll( + async () => { + issue = await json(await request.get(chatPath)); + if (!issue) return false; + const replies = await json( + await request.get(`/api/issues/${issue.id}/comments`), + ); + const live = await json( + await request.get(`/api/issues/${issue.id}/live-runs`), + ); + return ( + live.length === 0 && + issue.conversationState === "waiting" && + issue.status === "in_review" && + replies.filter((c: any) => c.authorAgentId).length >= minimumReplies + ); + }, + { timeout: 60_000, intervals: [100, 250, 500] }, + ) + .toBe(true); + return issue; +} diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts new file mode 100644 index 0000000000..97ce3997b9 --- /dev/null +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -0,0 +1,1505 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +import { + fulfill, + installChatControlPlaneMock, + json, + seedCompanyAndAgent, + PROVIDERS, + bodyOf, + type Seed, +} from "./chat-adapters-ui.shared"; + +/** + * Native chat-connector messaging coverage: destination partial updates, + * board send delivery refresh, and exact failed-run retry. Shared fixtures + * and the chat-control-plane mock live in ./chat-adapters-ui.shared.ts; the + * provider-setup describes run in chat-adapters-ui-providers.spec.ts. + */ +test.describe("chat destination partial updates", () => { + let seed: Seed; + + test.beforeAll(async ({ request }) => { + seed = await seedCompanyAndAgent(request); + }); + + for (const initiallyEnabled of [false, true]) { + test(`a stale Settings view preserves another view's ${initiallyEnabled ? "revocation" : "grant"}`, async ({ + context, + page, + }) => { + const provider = PROVIDERS.find((item) => item.provider === "discord")!; + // Both real pages share one server fixture, but have separate query caches. + const mock = await installChatControlPlaneMock(context, provider, seed, { + enableChatConnectors: true, + }); + mock.setStatus("active"); + const settingsUrl = `/${seed.prefix}/apps/chat/endpoint-discord/settings`; + const primarySwitch = (view: Page) => + view.getByRole("switch", { name: `Enable ${provider.resourceLabel}` }); + const secondarySwitch = (view: Page) => + view.getByRole("switch", { + name: `Enable ${provider.secondaryResourceLabel}`, + }); + + await page.goto(settingsUrl); + await expect(primarySwitch(page)).not.toBeChecked(); + if (initiallyEnabled) { + await primarySwitch(page).click(); + await expect(primarySwitch(page)).toBeChecked(); + } + const staleView = await context.newPage(); + await staleView.goto(settingsUrl); + await expect(primarySwitch(staleView)).toBeChecked({ + checked: initiallyEnabled, + }); + await expect(secondarySwitch(staleView)).not.toBeChecked(); + + await primarySwitch(page).click(); + await expect(primarySwitch(page)).toBeChecked({ + checked: !initiallyEnabled, + }); + // Assert the stale prerequisite instead of assuming browser focus/refetch. + await expect(primarySwitch(staleView)).toBeChecked({ + checked: initiallyEnabled, + }); + await secondarySwitch(staleView).click(); + await expect(secondarySwitch(staleView)).toBeChecked(); + await expect(primarySwitch(staleView)).toBeChecked({ + checked: !initiallyEnabled, + }); + expect(mock.resourceUpdates.at(-1)).toEqual([ + { id: "resource-discord-secondary", enabled: true }, + ]); + + // The full response refreshes this page, and persisted state survives reload. + await page.reload(); + await expect(primarySwitch(page)).toBeChecked({ + checked: !initiallyEnabled, + }); + await expect(secondarySwitch(page)).toBeChecked(); + await staleView.close(); + }); + } + + test("a pending destination change stays truthful on rejection and explicit retry", async ({ + page, + }, testInfo) => { + const provider = PROVIDERS.find((item) => item.provider === "discord")!; + const mock = await installChatControlPlaneMock(page, provider, seed, { + enableChatConnectors: true, + }); + mock.setStatus("active"); + const attempts: unknown[] = []; + let holdFirstPut!: (route: Route) => void; + const firstPut = new Promise((resolve) => { + holdFirstPut = resolve; + }); + await page.route( + "**/api/chat-endpoints/endpoint-discord/resources", + async (route) => { + if (route.request().method() === "PUT") { + attempts.push(bodyOf(route)); + if (attempts.length === 1) { + holdFirstPut(route); + return; + } + } + await route.fallback(); + }, + ); + await page.goto(`/${seed.prefix}/apps/chat/endpoint-discord/settings`); + const primary = page.getByRole("switch", { name: "Enable #general" }); + const secondary = page.getByRole("switch", { name: "Enable #support" }); + await expect(primary).not.toBeChecked(); + await primary.click(); + const held = await firstPut; + try { + await expect(primary).toBeDisabled(); + await expect(secondary).toBeDisabled(); + await expect(primary).not.toBeChecked(); + await expect(secondary).not.toBeChecked(); + expect(mock.resourceUpdates).toEqual([]); + await page.screenshot({ + path: testInfo.outputPath("destination-pending.png"), + }); + } finally { + await fulfill( + held, + { error: "Destination is no longer available. Refresh and try again." }, + 409, + ); + } + await expect( + page.getByText("Couldn't update destination", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText( + "Destination is no longer available. Refresh and try again.", + { exact: true }, + ), + ).toBeVisible(); + await expect(primary).toBeEnabled(); + await expect(secondary).toBeEnabled(); + await expect(primary).not.toBeChecked(); + await expect(secondary).not.toBeChecked(); + expect(mock.resourceUpdates).toEqual([]); + expect(attempts).toEqual([ + { resources: [{ id: "resource-discord", enabled: true }] }, + ]); + // Visibility alone also matches the toast's initial transparent animation frame. + await expect( + page + .getByRole("listitem") + .filter({ hasText: "Couldn't update destination" }), + ).toHaveCSS("opacity", "1"); + await page.screenshot({ + path: testInfo.outputPath("destination-rejected.png"), + }); + await primary.click(); + await expect(primary).toBeChecked(); + await expect(secondary).not.toBeChecked(); + expect(attempts).toEqual([ + { resources: [{ id: "resource-discord", enabled: true }] }, + { resources: [{ id: "resource-discord", enabled: true }] }, + ]); + expect(mock.resourceUpdates).toEqual([ + [{ id: "resource-discord", enabled: true }], + ]); + await page.reload(); + await expect(primary).toBeChecked(); + await expect(secondary).not.toBeChecked(); + await page.screenshot({ + path: testInfo.outputPath("destination-retry-saved.png"), + }); + }); + + test("one toggle succeeds with more than 500 discovered destinations", async ({ + page, + }) => { + const provider = PROVIDERS.find((item) => item.provider === "discord")!; + const mock = await installChatControlPlaneMock(page, provider, seed, { + enableChatConnectors: true, + resourceCount: 501, + }); + mock.setStatus("active"); + await page.goto(`/${seed.prefix}/apps/chat/endpoint-discord/settings`); + await expect(page.getByRole("switch", { name: /^Enable / })).toHaveCount( + 501, + ); + const target = page.getByRole("switch", { + name: "Enable Extra destination 499", + }); + await expect(target).not.toBeChecked(); + await target.click(); + await expect(target).toBeChecked(); + expect(mock.resourceUpdates).toEqual([ + [{ id: "resource-discord-extra-498", enabled: true }], + ]); + await expect( + page.getByRole("switch", { name: "Enable #general" }), + ).not.toBeChecked(); + await page.reload(); + await expect(target).toBeChecked(); + await expect(page.getByRole("switch", { name: /^Enable / })).toHaveCount( + 501, + ); + }); +}); + +test.describe("Board send delivery refresh", () => { + for (const classic of [false, true]) { + for (const entry of [ + "uuid", + "uppercase-uuid", + "wrong-prefix-identifier", + ] as const) { + test(`opens external task links in their own organization and uploads safely (${entry}, classic=${classic})`, async ({ + page, + request, + }) => { + const selected = await seedCompanyAndAgent(request); + const target = await seedCompanyAndAgent(request); + const issue = await json<{ id: string; identifier: string }>( + await request.post(`/api/companies/${target.companyId}/issues`, { + data: { title: "Organization-bound task link", status: "backlog" }, + }), + "create linked task in another organization", + ); + await page.route("**/api/instance/settings/experimental", (route) => + fulfill(route, { + enableChatConnectors: true, + enableClassicTaskInterface: classic, + }), + ); + await page.goto(`/${selected.prefix}/issues`); + await expect + .poll(() => + page.evaluate(() => + localStorage.getItem("paperclip.selectedCompanyId"), + ), + ) + .toBe(selected.companyId); + await page.goto( + entry === "uuid" + ? `/issues/${issue.id}` + : entry === "uppercase-uuid" + ? `/issues/${issue.id.toUpperCase()}` + : `/${selected.prefix}/issues/${issue.identifier}?external=chat#files`, + ); + await expect( + page.getByRole("heading", { + name: "Organization-bound task link", + exact: true, + }), + ).toBeVisible(); + await expect + .soft(page) + .toHaveURL( + new RegExp( + `/${target.prefix}/issues/${issue.identifier}${ + entry === "wrong-prefix-identifier" + ? "\\?external=chat#files" + : "" + }$`, + ), + { timeout: 4_000 }, + ); + const file = { + name: classic ? "task-link-report.txt" : "task-link-image.png", + mimeType: classic ? "text/plain" : "image/png", + buffer: classic + ? Buffer.from("Synthetic cross-organization upload proof.\n") + : Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfFoAAAAASUVORK5CYII=", + "base64", + ), + }; + const chooserPromise = page.waitForEvent("filechooser"); + await page + .getByRole("button", { + name: classic ? "Upload attachment" : "Attach file", + exact: true, + }) + .click(); + const responsePromise = page.waitForResponse( + (response) => + response.request().method() === "POST" && + /\/api\/companies\/[^/]+\/issues\/[^/]+\/attachments$/.test( + new URL(response.url()).pathname, + ), + ); + await (await chooserPromise).setFiles(file); + const response = await responsePromise; + expect.soft(response.status(), await response.text()).toBe(201); + expect + .soft(new URL(response.url()).pathname) + .toBe( + `/api/companies/${target.companyId}/issues/${issue.id}/attachments`, + ); + const attachments = await json< + { + originalFilename: string; + contentPath: string; + issueCommentId: string | null; + }[] + >( + await request.get(`/api/issues/${issue.id}/attachments`), + "read linked task attachments", + ); + expect(attachments).toHaveLength(1); + expect(attachments[0]).toMatchObject({ + originalFilename: file.name, + issueCommentId: null, + }); + expect( + await (await request.get(attachments[0].contentPath)).body(), + ).toEqual(file.buffer); + expect( + await json( + await request.get(`/api/issues/${issue.id}/comments`), + "read linked task comments", + ), + ).toHaveLength(0); + await expect + .poll(() => + page.evaluate(() => + localStorage.getItem("paperclip.selectedCompanyId"), + ), + ) + .toBe(target.companyId); + }); + } + } + + test("keeps the connected-task banner readable in narrow task panes", async ({ + page, + request, + }) => { + const seed = await seedCompanyAndAgent(request); + const issue = await json<{ id: string; identifier: string }>( + await request.post(`/api/companies/${seed.companyId}/issues`, { + data: { title: "Connected banner layout", status: "backlog" }, + }), + "create connected banner task", + ); + await page.route("**/api/instance/settings/experimental", (route) => + fulfill(route, { enableChatConnectors: true }), + ); + await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => + fulfill(route, { + endpointId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + conversationId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + provider: "discord", + externalLabel: "#a-long-but-readable-qualification-channel-name", + externalUrl: "https://discord.com/channels/test-server/test-thread", + assignedAgentLocked: true, + }), + ); + await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); + const banner = page.getByRole("region", { + name: "External conversation", + exact: true, + }); + const heading = banner.getByText("Connected to Discord", { exact: true }); + await expect(heading).toBeVisible(); + // Exercise container widths independently of the operator's sidebar and + // Properties preferences. These are test constraints, not product styles. + for (const width of [340, 500, 760]) { + await banner.evaluate((element, value) => { + element.style.width = `${value}px`; + }, width); + await expect + .poll(() => + heading + .evaluate((element) => ({ + height: element.getBoundingClientRect().height, + lineHeight: Number.parseFloat( + getComputedStyle(element).lineHeight, + ), + })) + .then(({ height, lineHeight }) => height <= lineHeight * 1.5), + ) + .toBe(true); + const bounds = await banner.boundingBox(); + expect(bounds).not.toBeNull(); + for (const action of [ + banner.getByRole("link", { name: "Open Discord", exact: true }), + banner.getByRole("button", { name: "Send to channel", exact: true }), + banner.getByRole("link", { name: "Connection", exact: true }), + ]) { + await expect(action).toBeVisible(); + const box = await action.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(bounds!.x); + expect(box!.x + box!.width).toBeLessThanOrEqual( + bounds!.x + bounds!.width, + ); + expect(box!.y + box!.height).toBeLessThanOrEqual( + bounds!.y + bounds!.height, + ); + } + } + }); + + test("uploads images and files directly from an empty channel composer without publishing early", async ({ + page, + request, + }) => { + const seed = await seedCompanyAndAgent(request); + const issue = await json<{ id: string; identifier: string }>( + await request.post(`/api/companies/${seed.companyId}/issues`, { + data: { title: "Upload channel files", status: "backlog" }, + }), + "create upload task", + ); + const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const publicationId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + const sends: Record[] = []; + let releaseSend!: () => void; + const sendResponse = new Promise((resolve) => { + releaseSend = resolve; + }); + await page.route("**/api/instance/settings/experimental", (route) => + fulfill(route, { enableChatConnectors: true }), + ); + await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => + fulfill(route, { + endpointId, + conversationId, + provider: "discord", + externalLabel: "#upload-test", + assignedAgentLocked: true, + }), + ); + await page.route( + `**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`, + async (route) => { + sends.push(bodyOf(route)); + await sendResponse; + return fulfill( + route, + { id: publicationId, state: "streaming", attempts: 1 }, + 201, + ); + }, + ); + await page.route( + `**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications/${publicationId}/status`, + (route) => + fulfill(route, { + publication: { id: publicationId, state: "pending", attempts: 0 }, + total: 3, + published: 0, + }), + ); + await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); + await page + .getByRole("button", { name: "Send to channel", exact: true }) + .click(); + const input = page.getByLabel("Attach file to channel update", { + exact: true, + }); + const files = [ + { + name: "channel-image.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfFoAAAAASUVORK5CYII=", + "base64", + ), + }, + { + name: "channel-report.txt", + mimeType: "text/plain", + buffer: Buffer.from("Synthetic channel upload proof.\n"), + }, + ]; + for (const file of files) { + await input.setInputFiles(file); + await expect( + page + .getByRole("group", { name: "Include task files", exact: true }) + .getByRole("checkbox", { name: file.name, exact: true }), + ).toBeChecked(); + await expect(input).toBeEnabled(); + } + expect(sends).toHaveLength(0); + const stored = await json< + { + id: string; + originalFilename: string; + contentPath: string; + issueCommentId: string | null; + }[] + >( + await request.get(`/api/issues/${issue.id}/attachments`), + "read uploaded files", + ); + expect(stored).toHaveLength(2); + for (const file of files) { + const attachment = stored.find( + (item) => item.originalFilename === file.name, + )!; + expect(attachment.issueCommentId).toBeNull(); + const content = await request.get(attachment.contentPath); + expect(content.ok()).toBe(true); + expect(await content.body()).toEqual(file.buffer); + } + expect( + await json( + await request.get(`/api/issues/${issue.id}/comments`), + "read internal comments", + ), + ).toHaveLength(0); + await page + .getByRole("textbox", { name: "Board update", exact: true }) + .fill("Publish these two synthetic files."); + await page + .getByRole("button", { name: "Send to channel", exact: true }) + .last() + .click(); + await expect.poll(() => sends.length).toBe(1); + await expect( + page.getByRole("button", { name: "Sending…", exact: true }), + ).toBeDisabled(); + await expect( + page.getByText("Delivery result not confirmed", { exact: true }), + ).toHaveCount(0); + releaseSend(); + expect(sends[0].attachmentIds).toEqual( + files.map( + (file) => + stored.find((item) => item.originalFilename === file.name)!.id, + ), + ); + await expect(input).toBeDisabled(); + await page.reload(); + const retained = page.getByRole("group", { + name: "Files in this send", + exact: true, + }); + for (const file of files) { + await expect( + retained.getByRole("checkbox", { name: file.name, exact: true }), + ).toBeChecked(); + await expect( + retained.getByRole("checkbox", { name: file.name, exact: true }), + ).toBeDisabled(); + } + expect(sends).toHaveLength(1); + }); + + test("recovers an uncertain channel send into a durable rejection before an explicit correction", async ({ + page, + request, + }, testInfo) => { + const seed = await seedCompanyAndAgent(request); + const issue = await json<{ id: string; identifier: string }>( + await request.post(`/api/companies/${seed.companyId}/issues`, { + data: { title: "Rejected channel selection", status: "backlog" }, + }), + "create rejected-send task", + ); + const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + await page.route("**/api/instance/settings/experimental", (route) => + fulfill(route, { enableChatConnectors: true }), + ); + await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => + fulfill(route, { + endpointId, + conversationId, + provider: "github", + externalLabel: "test/rejected-send", + assignedAgentLocked: true, + }), + ); + const sends: Record[] = []; + let attachmentId = ""; + let privateComment: { id: string } | undefined; + await page.route( + `**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`, + async (route) => { + const input = bodyOf(route); + sends.push(input); + if (sends.length === 1) { + expect(input.attachmentIds).toEqual([attachmentId]); + // Consume the file only after the browser has captured its send. + // Binding it before Send lets live refresh remove it from the draft, + // making the mocked rejection inconsistent with the actual request. + privateComment = await json<{ id: string }>( + await request.post(`/api/issues/${issue.id}/comments`, { + data: { body: "Private Board file", attachmentIds: [attachmentId] }, + }), + "bind selected file to private comment", + ); + return route.abort("connectionreset"); + } + if (sends.length === 2) + return fulfill( + route, + { + error: "A selected file already belongs to another comment", + details: { + code: "chat_board_send_attachments_already_bound", + endpointId, + conversationId, + idempotencyKey: input.idempotencyKey, + attachmentIds: [attachmentId], + }, + }, + 409, + ); + return fulfill( + route, + { + id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + state: "published", + attempts: 1, + }, + 201, + ); + }, + ); + await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); + const banner = page.getByRole("region", { name: "External conversation" }); + await banner + .getByRole("button", { name: "Send to channel", exact: true }) + .click(); + await banner + .getByLabel("Attach file to channel update", { exact: true }) + .setInputFiles({ + name: "rejected-send.txt", + mimeType: "text/plain", + buffer: Buffer.from("Synthetic rejected send proof.\n"), + }); + await expect( + banner.getByRole("checkbox", { name: "rejected-send.txt" }), + ).toBeChecked(); + const [file] = await json<{ id: string }[]>( + await request.get(`/api/issues/${issue.id}/attachments`), + "read selected file", + ); + attachmentId = file!.id; + // The first intercepted send binds the file to a concurrent Board comment. + // The real TX/idempotency and delete/retry behavior are covered in PostgreSQL. + const draft = + "Share the verified result, without the private Board comment."; + await banner.getByRole("textbox", { name: "Board update" }).fill(draft); + await banner + .getByRole("button", { name: "Send to channel", exact: true }) + .last() + .click(); + await expect( + banner.getByText("Delivery result not confirmed", { exact: true }), + ).toBeVisible(); + await expect( + banner.getByRole("textbox", { name: "Board update" }), + ).toBeDisabled(); + await banner + .getByRole("button", { name: "Retry safely", exact: true }) + .click(); + await expect( + banner.getByText("Update was not sent", { exact: true }), + ).toBeVisible(); + expect(sends[1]).toEqual(sends[0]); + await page.reload(); + await expect( + banner.getByText("Update was not sent", { exact: true }), + ).toBeVisible(); + await expect( + banner.getByRole("textbox", { name: "Board update" }), + ).toHaveValue(draft); + expect(sends).toHaveLength(2); + await banner.screenshot({ + path: testInfo.outputPath("durable-rejected-channel-send.png"), + }); + await banner + .getByRole("button", { name: "Edit rejected send", exact: true }) + .click(); + await expect( + banner.getByRole("textbox", { name: "Board update" }), + ).toBeEnabled(); + await expect( + banner.getByText(/Attach a new copy or share the task link/), + ).toBeVisible(); + await expect(banner.getByRole("checkbox")).toHaveCount(0); + await banner + .getByRole("button", { name: "Send to channel", exact: true }) + .last() + .click(); + await expect( + banner.getByRole("textbox", { name: "Board update" }), + ).toHaveCount(0); + expect(sends).toHaveLength(3); + expect(sends[2]!.body).toBe(draft); + expect(sends[2]!.attachmentIds ?? []).toEqual([]); + expect(sends[2]!.idempotencyKey).not.toBe(sends[0]!.idempotencyKey); + const attachments = await json<{ id: string; issueCommentId: string }[]>( + await request.get(`/api/issues/${issue.id}/attachments`), + "verify immutable original binding", + ); + expect(attachments).toEqual([ + expect.objectContaining({ + id: attachmentId, + issueCommentId: privateComment!.id, + }), + ]); + }); + + for (const cancelledHead of [false, true]) { + test(`keeps Teams file consent anchored across reload (${cancelledHead ? "cancelled head with waiting tail" : "mixed terminal receipt and explicit dismiss"})`, async ({ + page, + request, + }, testInfo) => { + const seed = await seedCompanyAndAgent(request); + const issue = await json<{ id: string; identifier: string }>( + await request.post(`/api/companies/${seed.companyId}/issues`, { + data: { title: "Teams consent receipt", status: "backlog" }, + }), + "create consent receipt task", + ); + const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const anchor = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + const nextAnchor = "ffffffff-ffff-4fff-8fff-ffffffffffff"; + const filePublications = [ + "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + ]; + const filenames = ["consent-report.txt", "consent-notes.txt"]; + const posts: Record[] = []; + const statusReads: string[] = []; + let terminal = false; + const publicationsPath = `/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`; + const storageKey = `paperclip:board-send:v1:${JSON.stringify([seed.companyId, issue.id, endpointId, conversationId])}`; + await page.route("**/api/instance/settings/experimental", (route) => + fulfill(route, { enableChatConnectors: true }), + ); + await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => + fulfill(route, { + endpointId, + conversationId, + provider: "microsoft-teams", + externalLabel: "Personal file conversation", + assignedAgentLocked: true, + }), + ); + // Only the publication API is simulated. Task creation and safe file + // uploads use this test's isolated Paperclip instance, never Teams. + await page.route(`**${publicationsPath}`, (route) => { + expect(route.request().method()).toBe("POST"); + posts.push(bodyOf(route)); + return fulfill( + route, + { + id: posts.length === 1 ? anchor : nextAnchor, + state: "pending", + attempts: 0, + }, + 201, + ); + }); + await page.route(`**${publicationsPath}/*/status`, (route) => { + expect(route.request().method()).toBe("GET"); + const path = new URL(route.request().url()).pathname; + statusReads.push(path); + if (path.endsWith(`/${nextAnchor}/status`)) { + return fulfill(route, { + publication: { id: nextAnchor, state: "pending", attempts: 0 }, + total: 1, + published: 0, + awaitingConsent: 0, + declined: 0, + expired: 0, + cancelled: 0, + settled: 0, + canDismiss: false, + }); + } + expect(path).toBe(`${publicationsPath}/${anchor}/status`); + const parts = [ + { id: anchor, state: "published", attempts: 1 }, + ...filePublications.map((id, index) => ({ + id, + state: + terminal || (cancelledHead && index === 0) + ? "cancelled" + : "awaiting_consent", + attempts: 1, + fileTransfer: { + provider: "microsoft-teams", + filename: filenames[index], + phase: terminal + ? index === 0 + ? "declined" + : "expired" + : cancelledHead && index === 0 + ? "cancelled" + : "awaiting_consent", + version: terminal ? 3 : 2, + }, + })), + ]; + return fulfill(route, { + publication: parts[1], + parts, + total: 3, + published: 1, + awaitingConsent: terminal ? 0 : cancelledHead ? 1 : 2, + declined: terminal ? 1 : 0, + expired: terminal ? 1 : 0, + cancelled: !terminal && cancelledHead ? 1 : 0, + settled: terminal ? 3 : cancelledHead ? 2 : 1, + canDismiss: terminal, + }); + }); + await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); + const banner = page.getByRole("region", { + name: "External conversation", + exact: true, + }); + await banner + .getByRole("button", { name: "Send to channel", exact: true }) + .click(); + for (const name of filenames) { + await banner + .getByLabel("Attach file to channel update", { exact: true }) + .setInputFiles({ + name, + mimeType: "text/plain", + buffer: Buffer.from(`Synthetic consent fixture: ${name}.\n`), + }); + await expect( + banner.getByRole("checkbox", { name, exact: true }), + ).toBeChecked(); + await expect( + banner.getByLabel("Attach file to channel update", { exact: true }), + ).toBeEnabled(); + } + await expect( + banner.getByRole("group", { name: "Include task files", exact: true }), + ).toContainText( + "In personal Teams chats, recipients accept each file before upload. Channels and group chats receive supported images directly; other files stay on the task, with a task link or private-task notice.", + ); + await banner.screenshot({ + path: testInfo.outputPath("teams-file-guidance.png"), + }); + expect(posts).toHaveLength(0); + await banner + .getByRole("textbox", { name: "Board update", exact: true }) + .fill("Please review these two files."); + await banner + .getByRole("button", { name: "Send to channel", exact: true }) + .last() + .click(); + await expect.poll(() => posts.length).toBe(1); + expect(posts[0].attachmentIds).toHaveLength(2); + await expect( + banner.getByText( + cancelledHead + ? "1 published · 1 awaiting consent · 1 cancelled" + : "1 published · 2 awaiting consent", + { exact: true }, + ), + ).toBeVisible(); + const retainedBeforeReload = await page.evaluate( + (key) => sessionStorage.getItem(key), + storageKey, + ); + expect(JSON.parse(retainedBeforeReload!).publication.id).toBe(anchor); + const readsBeforeReload = statusReads.length; + await page.reload(); + await expect + .poll(() => statusReads.length) + .toBeGreaterThan(readsBeforeReload); + expect( + await page.evaluate((key) => sessionStorage.getItem(key), storageKey), + ).toBe(retainedBeforeReload); + expect( + statusReads.every( + (path) => path === `${publicationsPath}/${anchor}/status`, + ), + ).toBe(true); + await expect( + banner.getByRole("textbox", { name: "Board update", exact: true }), + ).toBeDisabled(); + await expect( + banner.getByLabel("Attach file to channel update", { exact: true }), + ).toBeDisabled(); + await expect( + banner + .getByRole("button", { name: "Send to channel", exact: true }) + .last(), + ).toBeDisabled(); + await expect( + banner.getByRole("button", { + name: "Dismiss delivery receipt", + exact: true, + }), + ).toHaveCount(0); + await expect( + banner.getByText( + cancelledHead + ? "Waiting for remaining file consent" + : "Waiting for file consent", + { exact: true }, + ), + ).toBeVisible(); + await expect( + banner.getByText("Channel delivery cancelled", { exact: true }), + ).toHaveCount(0); + await expect( + banner.getByText("Sent to channel", { exact: true }), + ).toHaveCount(0); + for (const name of filenames) { + await expect( + banner.getByRole("checkbox", { name, exact: true }), + ).toBeChecked(); + await expect( + banner.getByRole("checkbox", { name, exact: true }), + ).toBeDisabled(); + } + expect(posts).toHaveLength(1); + await banner.screenshot({ + path: testInfo.outputPath("consent-waiting.png"), + }); + if (cancelledHead) return; + + terminal = true; + await expect( + banner.getByText("Delivery settled with mixed outcomes", { + exact: true, + }), + ).toBeVisible({ timeout: 8_000 }); + await expect( + banner.getByText("1 published · 1 declined · 1 expired", { + exact: true, + }), + ).toBeVisible(); + await expect( + banner.getByText("consent-report.txt — Declined", { exact: true }), + ).toBeVisible(); + await expect( + banner.getByText("consent-notes.txt — Consent expired", { + exact: true, + }), + ).toBeVisible(); + await expect( + banner + .getByRole("button", { name: "Send to channel", exact: true }) + .last(), + ).toBeDisabled(); + await banner.screenshot({ + path: testInfo.outputPath("consent-mixed.png"), + }); + expect(posts).toHaveLength(1); + await banner + .getByRole("button", { name: "Dismiss delivery receipt", exact: true }) + .click(); + await expect( + banner.getByRole("textbox", { name: "Board update", exact: true }), + ).toHaveValue(""); + await expect( + banner.getByRole("textbox", { name: "Board update", exact: true }), + ).toBeEnabled(); + await expect( + banner + .getByRole("button", { name: "Send to channel", exact: true }) + .last(), + ).toBeDisabled(); + expect( + await page.evaluate((key) => sessionStorage.getItem(key), storageKey), + ).toBeNull(); + expect(posts).toHaveLength(1); + await expect( + page.getByText("Sent to channel", { exact: true }), + ).toHaveCount(0); + for (const name of filenames) { + await expect( + banner.getByRole("checkbox", { name, exact: true }), + ).not.toBeChecked(); + } + await banner + .getByRole("textbox", { name: "Board update", exact: true }) + .fill("A separate text-only update."); + expect(posts).toHaveLength(1); + await banner + .getByRole("button", { name: "Send to channel", exact: true }) + .last() + .click(); + await expect.poll(() => posts.length).toBe(2); + expect(posts[1]).toMatchObject({ + body: "A separate text-only update.", + }); + expect(posts[1]).not.toHaveProperty("attachmentIds"); + expect(posts[1].idempotencyKey).not.toBe(posts[0].idempotencyKey); + }); + } + + for (const outcome of [ + "published", + "failed", + "delivery_unknown", + "response_lost", + ] as const) { + test(`tracks the whole file batch across reload and ${outcome} without a new send identity`, async ({ + page, + request, + }) => { + const seed = await seedCompanyAndAgent(request); + const issue = await json<{ id: string; identifier: string }>( + await request.post(`/api/companies/${seed.companyId}/issues`, { + data: { title: "Board delivery refresh", status: "backlog" }, + }), + "create board-send task", + ); + const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const publicationId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + const attachmentId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + const statusPath = `/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications/${publicationId}/status`; + let sends = 0; + const submittedPayloads: Record[] = []; + let reads = 0; + let canonicalAttachmentReadsAfterSend = 0; + let canonicalCommentReadsAfterSend = 0; + let status = "streaming"; + await page.route("**/api/instance/settings/experimental", (route) => + fulfill(route, { enableChatConnectors: true }), + ); + await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => + fulfill(route, { + endpointId, + conversationId, + provider: "slack", + externalLabel: "#board-send-test", + assignedAgentLocked: true, + }), + ); + await page.route( + new RegExp( + `/api/issues/(${issue.id}|${issue.identifier})/comments(?:\\?|$)`, + ), + async (route) => { + if ( + sends && + route.request().url().includes(`/issues/${issue.identifier}/`) + ) + canonicalCommentReadsAfterSend += 1; + await fulfill( + route, + sends + ? [ + { + id: publicationId, + companyId: seed.companyId, + issueId: issue.id, + authorAgentId: null, + authorUserId: "local-board", + authorType: "user", + body: "Board batch must finish all files.", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ] + : [], + ); + }, + ); + await page.route( + new RegExp( + `/api/issues/(${issue.id}|${issue.identifier})/attachments$`, + ), + async (route) => { + if ( + sends && + route.request().url().includes(`/issues/${issue.identifier}/`) + ) + canonicalAttachmentReadsAfterSend += 1; + await fulfill(route, [ + { + id: attachmentId, + companyId: seed.companyId, + issueId: issue.id, + issueCommentId: sends ? publicationId : null, + assetId: attachmentId, + provider: "local_disk", + objectKey: "board-send-test.txt", + contentType: "text/plain", + byteSize: 12, + sha256: "a".repeat(64), + originalFilename: "board-send-test.txt", + createdByAgentId: null, + createdByUserId: "local-board", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + contentPath: `/api/attachments/${attachmentId}/content`, + }, + { + id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + companyId: seed.companyId, + issueId: issue.id, + issueCommentId: null, + assetId: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + provider: "local_disk", + objectKey: "internal-only.txt", + contentType: "text/plain", + byteSize: 8, + sha256: "b".repeat(64), + originalFilename: "internal-only.txt", + createdByAgentId: null, + createdByUserId: "local-board", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + contentPath: + "/api/attachments/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/content", + }, + ]); + }, + ); + await page.route( + `**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`, + async (route) => { + expect(route.request().method()).toBe("POST"); + expect(bodyOf(route).attachmentIds).toEqual([attachmentId]); + sends += 1; + submittedPayloads.push(bodyOf(route)); + if (outcome === "response_lost" && sends === 1) { + await route.abort("failed"); + return; + } + await fulfill( + route, + { id: publicationId, state: "streaming", attempts: 1 }, + 201, + ); + }, + ); + await page.route(`**${statusPath}`, async (route) => { + expect(route.request().method()).toBe("GET"); + reads += 1; + await fulfill(route, { + publication: { + id: status === "streaming" ? publicationId : attachmentId, + state: status, + attempts: 1, + }, + total: 2, + published: status === "published" ? 2 : 1, + }); + }); + await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); + await page + .getByRole("button", { name: "Send to channel", exact: true }) + .click(); + await page + .getByRole("textbox", { name: "Board update", exact: true }) + .fill("Board batch must finish all files."); + await page.getByRole("checkbox", { name: "board-send-test.txt" }).check(); + await page + .getByRole("button", { name: "Send to channel", exact: true }) + .last() + .click(); + const expectRetainedFiles = async () => { + const files = page.getByRole("group", { + name: "Files in this send", + exact: true, + }); + await expect(files).toBeVisible(); + await expect(files.getByRole("checkbox")).toHaveCount(1); + await expect( + files.getByRole("checkbox", { name: "board-send-test.txt" }), + ).toBeChecked(); + await expect( + files.getByRole("checkbox", { name: "board-send-test.txt" }), + ).toBeDisabled(); + await expect( + files.getByText("internal-only.txt", { exact: true }), + ).toHaveCount(0); + }; + if (outcome === "response_lost") { + await expect( + page.getByText("Delivery result not confirmed", { exact: true }), + ).toBeVisible(); + await page.reload(); + await expect( + page.getByRole("textbox", { name: "Board update", exact: true }), + ).toBeDisabled(); + await expectRetainedFiles(); + expect(sends).toBe(1); + expect(reads).toBe(0); + await page + .getByRole("button", { name: "Retry safely", exact: true }) + .click(); + await expect.poll(() => submittedPayloads.length).toBe(2); + expect(submittedPayloads[1]).toEqual(submittedPayloads[0]); + } else { + await expect( + page.getByText("Publishing to channel", { exact: true }).first(), + ).toBeVisible(); + // The canonical task view must refresh its comment/files before any reload. + await expect + .poll(() => canonicalCommentReadsAfterSend) + .toBeGreaterThan(0); + await expect + .poll(() => canonicalAttachmentReadsAfterSend) + .toBeGreaterThan(0); + await expectRetainedFiles(); + const readsBeforeReload = reads; + await page.reload(); + await expect + .poll(() => reads, { timeout: 8_000 }) + .toBeGreaterThan(readsBeforeReload); + expect(sends).toBe(1); + } + await expect.poll(() => reads, { timeout: 8_000 }).toBeGreaterThan(0); + await expectRetainedFiles(); + await expect( + page.getByRole("textbox", { name: "Board update", exact: true }), + ).toHaveValue("Board batch must finish all files."); + await expect( + page.getByRole("textbox", { name: "Board update", exact: true }), + ).toBeDisabled(); + status = outcome === "response_lost" ? "published" : outcome; + if (status === "published") { + await expect( + page.getByRole("textbox", { name: "Board update", exact: true }), + ).toHaveCount(0, { timeout: 8_000 }); + await expect( + page.getByText("Sent to channel", { exact: true }), + ).toBeVisible(); + } else { + await expect( + page.getByText( + outcome === "failed" + ? "Channel delivery failed" + : "Delivery not confirmed", + { exact: true }, + ), + ).toBeVisible({ timeout: 8_000 }); + await expect( + page.getByRole("textbox", { name: "Board update", exact: true }), + ).toHaveValue("Board batch must finish all files."); + await expect( + page + .getByRole("button", { name: "Send to channel", exact: true }) + .last(), + ).toBeDisabled(); + await expect( + page.getByRole("link", { name: "Open Activity", exact: true }), + ).toBeVisible(); + // An explicit resolution elsewhere may complete the batch; this surface only reads it. + status = "published"; + await expect( + page.getByRole("textbox", { name: "Board update", exact: true }), + ).toHaveCount(0, { timeout: 8_000 }); + } + expect(sends).toBe(outcome === "response_lost" ? 2 : 1); + expect(canonicalCommentReadsAfterSend).toBeGreaterThan(0); + expect(canonicalAttachmentReadsAfterSend).toBeGreaterThan(0); + await expect( + page.getByText("Board batch must finish all files.", { exact: true }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Send to channel", exact: true }) + .click(); + const newFiles = page.getByRole("group", { + name: "Include task files", + exact: true, + }); + await expect( + newFiles.getByRole("checkbox", { name: "board-send-test.txt" }), + ).toHaveCount(0); + await expect( + newFiles.getByRole("checkbox", { name: "internal-only.txt" }), + ).not.toBeChecked(); + await expect( + newFiles.getByRole("checkbox", { name: "internal-only.txt" }), + ).toBeEnabled(); + expect(sends).toBe(outcome === "response_lost" ? 2 : 1); + }); + } +}); + +test.describe("Exact failed chat run retry", () => { + let seed: Seed; + let issue: { id: string; identifier: string; title: string }; + + const failedRunId = "11111111-aaaa-4aaa-8aaa-111111111111"; + const actionId = "22222222-bbbb-4bbb-8bbb-222222222222"; + const denial = + "This chat source is no longer authorized. Open the task to review its current channel access before retrying."; + + test.beforeAll(async ({ request }) => { + seed = await seedCompanyAndAgent(request); + issue = await json( + await request.post(`/api/companies/${seed.companyId}/issues`, { + data: { title: "Exact chat retry destination", status: "backlog" }, + }), + "create exact-retry task", + ); + }); + + for (const surface of ["agent run", "Inbox", "Legacy Inbox"] as const) { + for (const outcome of ["queued", "deferred", "denied"] as const) { + test(`${surface}: selected run ${outcome} preserves exact retry authority and truthful feedback`, async ({ + page, + }, testInfo) => { + const now = Date.now(); + const run = { + id: failedRunId, + companyId: seed.companyId, + agentId: seed.agentId, + status: "failed", + invocationSource: "assignment", + triggerDetail: "system", + startedAt: new Date(now - 60_000).toISOString(), + finishedAt: new Date(now - 30_000).toISOString(), + createdAt: new Date(now - 60_000).toISOString(), + updatedAt: new Date(now - 30_000).toISOString(), + error: "The fixture provider turn failed.", + errorCode: "adapter_failed", + exitCode: 1, + signal: null, + responsibleUserId: null, + runtimeMode: "native", + driverKind: "codex_app_server", + nativeIssueId: issue.id, + usageJson: null, + resultJson: null, + sessionIdBefore: null, + sessionIdAfter: null, + logStore: null, + logRef: null, + logBytes: 0, + retryOfRunId: null, + scheduledRetryAt: null, + scheduledRetryAttempt: 0, + scheduledRetryReason: null, + contextSnapshot: { + source: "chat:slack", + issueId: issue.id, + taskId: "33333333-cccc-4ccc-8ccc-333333333333", + taskKey: "untrusted-copy-of-another-task", + wakeCommentId: "44444444-dddd-4ddd-8ddd-444444444444", + wakeCommentIds: ["44444444-dddd-4ddd-8ddd-444444444444"], + chatFailedRunRetry: { actionId: "untrusted-client-action" }, + }, + }; + const requests: Array<{ + companyId: string | null; + body: Record; + }> = []; + const destinations: string[] = []; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) destinations.push(frame.url()); + }); + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + const pathname = url.pathname; + if (pathname === "/api/instance/settings/experimental") { + await fulfill(route, { + enableChatConnectors: true, + enableStreamlinedUi: surface !== "Legacy Inbox", + }); + return; + } + if (pathname === `/api/companies/${seed.companyId}/heartbeat-runs`) { + await fulfill(route, [run]); + return; + } + if (pathname === `/api/heartbeat-runs/${failedRunId}`) { + await fulfill(route, run); + return; + } + if ( + pathname === `/api/heartbeat-runs/${failedRunId}/events` || + pathname === + `/api/heartbeat-runs/${failedRunId}/workspace-operations` || + pathname === `/api/companies/${seed.companyId}/provider-traces` + ) { + await fulfill(route, []); + return; + } + if (pathname === `/api/heartbeat-runs/${failedRunId}/issues`) { + await fulfill(route, [ + { + issueId: issue.id, + identifier: issue.identifier, + title: issue.title, + status: "backlog", + priority: "medium", + }, + ]); + return; + } + if (pathname === `/api/heartbeat-runs/${failedRunId}/log`) { + await fulfill(route, { + runId: failedRunId, + content: "", + nextOffset: 0, + }); + return; + } + if (pathname === `/api/agents/${seed.agentId}/wakeup`) { + expect(route.request().method()).toBe("POST"); + requests.push({ + companyId: url.searchParams.get("companyId"), + body: bodyOf(route), + }); + if (outcome === "denied") { + await fulfill( + route, + { + error: denial, + details: { code: "chat_failed_run_retry_source_denied" }, + }, + 409, + ); + } else { + // The durable action exists, but no run has been admitted yet. + await fulfill( + route, + { actionId, issueId: issue.id, runId: null, status: outcome }, + 202, + ); + } + return; + } + await route.continue(); + }); + + const startPath = + surface === "agent run" + ? `/${seed.prefix}/agents/${seed.agentId}/runs/${failedRunId}` + : `/${seed.prefix}/inbox/all`; + await page.goto(startPath); + const retry = page + .getByRole("button", { name: "Retry", exact: true }) + .filter({ visible: true }); + await expect(retry).toHaveCount(1); + await retry.click(); + await expect.poll(() => requests.length).toBe(1); + expect(requests[0]).toEqual({ + companyId: seed.companyId, + body: { + source: "on_demand", + triggerDetail: "manual", + reason: "retry_failed_run", + failedRunId, + }, + }); + if (outcome === "denied") { + await expect(page.getByText(denial, { exact: true })).toBeVisible(); + if (surface !== "agent run") { + await expect( + page.getByText("Run retry failed", { exact: true }), + ).toBeVisible(); + const toast = page.getByRole("listitem").filter({ + has: page.getByText("Run retry failed", { exact: true }), + }); + // Visibility alone accepts opacity:0 during the toast entrance. + // The operator must actually be able to read the denial. + await expect(toast).toHaveCSS("opacity", "1"); + await expect(toast).toBeInViewport(); + } + // Agent routes canonicalize the UUID to its human-readable URL key. + // The selected failed run must remain unchanged across that redirect. + await expect(page).toHaveURL( + surface === "agent run" + ? new RegExp( + `/${seed.prefix}/agents/(${seed.agentId}|maya)/runs/${failedRunId}$`, + ) + : new RegExp(`${startPath}$`), + ); + await expect(retry).toBeEnabled(); + await testInfo.attach(`${surface}-retry-denied`, { + body: await page.screenshot(), + contentType: "image/png", + }); + } else { + await expect(page).toHaveURL( + new RegExp( + `/${seed.prefix}/issues/(${issue.id}|${issue.identifier})$`, + ), + ); + await expect( + page.getByText(issue.title, { exact: true }).first(), + ).toBeVisible(); + await expect( + page.getByText("Run retry failed", { exact: true }), + ).toHaveCount(0); + } + expect(requests).toHaveLength(1); + expect( + destinations.some((url) => + /\/runs\/(null|undefined)(?:[/?#]|$)/.test(url), + ), + ).toBe(false); + }); + } + } +}); diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts new file mode 100644 index 0000000000..ce9a1cb370 --- /dev/null +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -0,0 +1,833 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { + expectMinimumProviderSetup, + expectProviderTryInstructions, + expectSetupRail, + fillProviderSetup, + installChatControlPlaneMock, + seedCompanyAndAgent, + selectMaya, + PROVIDER_LIFECYCLE_COPY, + PROVIDERS, + expectedCredentialKeys, + type ProviderCase, + type Seed, +} from "./chat-adapters-ui.shared"; + +/** + * Native chat-connector provider setup coverage: adapter install/lifecycle + * flows and the iMessage Photon variant. Shared fixtures and the + * chat-control-plane mock live in ./chat-adapters-ui.shared.ts; the + * messaging-flow describes run in chat-adapters-ui-messaging.spec.ts. + */ +test.describe.serial("native chat adapter UI", () => { + test.setTimeout(180_000); + + let seed: Seed; + + test.beforeAll(async ({ request }) => { + seed = await seedCompanyAndAgent(request); + }); + + test("GitHub: the default-off gate keeps direct tool setup and fences chat routes", async ({ + page, + }) => { + const github = PROVIDERS.find( + (provider) => provider.provider === "github", + )!; + const mock = await installChatControlPlaneMock(page, github, seed, { + enableChatConnectors: false, + }); + + await page.goto(`/${seed.prefix}/apps`); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible( + { timeout: 30_000 }, + ); + const connector = page.locator( + `[role="listitem"][data-app-slug="${github.slug}"]`, + ); + await expect(connector).toBeVisible(); + await connector.getByRole("button", { name: "Connect GitHub" }).click(); + + await expect(page).toHaveURL(/\/apps\/connect\?/); + expect(new URL(page.url()).searchParams.get("source")).toBe("github"); + await expect( + page.getByRole("heading", { name: "Connect GitHub as" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Choose how to connect" }), + ).toHaveCount(0); + await expect( + page.getByText("Chat with an agent", { exact: true }), + ).toHaveCount(0); + + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=github`); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`)); + await expect( + page.getByRole("heading", { name: "Connectors" }), + ).toBeVisible(); + await page.goto(`/${seed.prefix}/apps/chat/endpoint-github/settings`); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`)); + await expect.poll(() => mock.chatEndpointListReads).toBe(0); + expect(mock.createdWithAgentId).toBeNull(); + }); + + for (const enabled of [false, true]) { + test(`Agent Channels: one heading and current experiment gate (${enabled})`, async ({ + page, + }) => { + const github = PROVIDERS.find( + (provider) => provider.provider === "github", + )!; + const mock = await installChatControlPlaneMock(page, github, seed, { + enableChatConnectors: enabled, + }); + + await page.goto(`/${seed.prefix}/agents/${seed.agentId}/channels`); + const channelsHeading = page.getByRole("heading", { + name: "Channels", + exact: true, + }); + if (enabled) { + await expect(page).toHaveURL(/\/channels$/); + await expect(channelsHeading).toHaveCount(1); + await expect(channelsHeading).toBeVisible(); + await expect( + page.getByRole("link", { name: "Connect a channel" }), + ).toBeVisible(); + await expect.poll(() => mock.chatEndpointListReads).toBeGreaterThan(0); + } else { + await expect(page).toHaveURL(/\/overview$/); + await expect(channelsHeading).toHaveCount(0); + await expect( + page.getByRole("link", { name: "Channels", exact: true }), + ).toHaveCount(0); + expect(mock.chatEndpointListReads).toBe(0); + } + }); + } + + for (const provider of PROVIDERS) { + test(`${provider.name}: catalog, setup, and connection management tabs`, async ({ + page, + }) => { + const mock = await installChatControlPlaneMock(page, provider, seed, { + enableChatConnectors: true, + }); + + await page.goto(`/${seed.prefix}/apps`); + await expect( + page.getByRole("heading", { name: "Connectors" }), + ).toBeVisible({ timeout: 30_000 }); + const connector = page.locator( + `[role="listitem"][data-app-slug="${provider.slug}"]`, + ); + await expect(connector).toBeVisible({ timeout: 30_000 }); + await connector + .getByRole("button", { name: `Connect ${provider.name}` }) + .click(); + + if (provider.chatAndTool) { + await expect( + page.getByRole("heading", { name: "Choose how to connect" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /Chat with an agent/ }), + ).toBeVisible(); + await expect( + page.getByRole("button", { + name: /Use this connection as an agent tool/, + }), + ).toBeVisible(); + const chatSetupUrl = page.url(); + const toolHref = new URL(chatSetupUrl).searchParams.get("toolHref"); + expect(toolHref).toBeTruthy(); + await page + .getByRole("button", { + name: /Use this connection as an agent tool/, + }) + .click(); + await expect(page).toHaveURL(/\/apps\/connect\?/); + expect(new URL(page.url()).searchParams.get("source")).toBe( + provider.provider, + ); + if (provider.provider === "github") { + await expect( + page.getByRole("heading", { name: "Connect GitHub as" }), + ).toBeVisible(); + await expect( + page.getByText("Chat with an agent", { exact: true }), + ).toHaveCount(0); + } + await page.goto(chatSetupUrl); + await expect( + page.getByRole("heading", { name: "Choose how to connect" }), + ).toBeVisible(); + await page.getByRole("button", { name: /Chat with an agent/ }).click(); + } else { + const chatOnlySetupUrl = new URL(page.url()); + expect(chatOnlySetupUrl.searchParams.get("purpose")).toBe("chat"); + expect(chatOnlySetupUrl.searchParams.get("toolHref")).toBeNull(); + await expect( + page.getByRole("heading", { name: "Choose how to connect" }), + ).toHaveCount(0); + } + + await expect( + page.getByRole("heading", { + name: "Which agent do you want to chat with?", + }), + ).toBeVisible(); + await expectSetupRail(page); + await selectMaya(page); + await expect.poll(() => mock.createdWithAgentId).toBe(seed.agentId); + expect(mock.createdWithAgentId).not.toBe(seed.otherAgentId); + await expect( + page.getByRole("button", { name: "Choose an active agent" }), + ).toHaveCount(0); + await expect( + page.getByRole("heading", { name: provider.setupHeading }), + ).toBeVisible(); + await expect( + page.getByText(PROVIDER_LIFECYCLE_COPY[provider.provider].reconnect, { + exact: false, + }), + ).toHaveCount(0); + await expectSetupRail(page); + await expectMinimumProviderSetup(page, provider); + await fillProviderSetup(page, provider); + + if (provider.provider === "github") { + expect(mock.githubPrivateKeyMatchedFile).toBe(true); + expect(mock.githubPrivateKeyMatchedPaste).toBe(true); + expect(mock.setupAttempts).toBe(2); + } + + if (provider.provider === "telegram") { + const submittedToken = "123456:e2e-redacted"; + // The next step intentionally has its own identity-readiness alert. + // Assert that this failed setup attempt clears, not that all alerts + // disappear during the transition between two valid wizard states. + const setupAlert = page.getByRole("alert").filter({ + has: page.getByText("Connection failed", { exact: true }), + }); + await expect(setupAlert).toContainText("Connection failed"); + await expect(setupAlert).toContainText( + "Telegram rejected bot token [redacted]. Confirm the token in BotFather and try again.", + ); + await expect(page.locator("body")).not.toContainText(submittedToken); + const tokenInput = page.getByLabel("Bot token"); + await expect(tokenInput).toHaveAttribute("type", "password"); + await expect(tokenInput).toHaveValue(submittedToken); + await tokenInput.focus(); + await tokenInput.press("Tab"); + await expect(setupAlert).toBeVisible(); + + await page.getByRole("button", { name: provider.setupButton }).click(); + await expect( + page.getByRole("heading", { name: `Try Maya in ${provider.name}` }), + ).toBeVisible(); + await expect(setupAlert).toHaveCount(0); + expect(mock.setupAttempts).toBe(2); + } + + if (provider.provider === "slack") { + await expect( + page.getByRole("heading", { name: "Finish Slack setup" }), + ).toBeVisible(); + await expect( + page.getByText( + `https://paperclip.example.test/api/chat-webhooks/public-slack/slack`, + { exact: true }, + ), + ).toBeVisible(); + await expect( + page.getByText("/maya-public", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText( + /Slack's bare \/status command is not a Paperclip control/, + ), + ).toBeVisible(); + const saveChangesStep = page + .getByRole("listitem") + .filter({ hasText: "Save Changes" }); + await expect(saveChangesStep).toHaveCount(1); + await expect( + saveChangesStep.locator("..").getByRole("listitem"), + ).toHaveCount(1); + await expect(saveChangesStep).toHaveText( + "Return to App Manifest in Slack and click Save Changes. The copied manifest already contains the event, interaction, and slash-command URLs. Slack verifies the Events URL when you save; Paperclip records Interactivity and slash command health only after each signed callback is observed.", + ); + for (const removedManualStep of [ + "Event Subscriptions", + "Interactivity & Shortcuts", + "Slash Commands", + ]) { + await expect( + page.getByText(removedManualStep, { exact: true }), + ).toHaveCount(0); + } + await page + .getByRole("button", { name: "Start Slack message test" }) + .click(); + } + + await expect( + page.getByRole("heading", { name: `Try Maya in ${provider.name}` }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "I've sent the test message" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { + name: "Link the account you’re testing", + }), + ).toBeVisible(); + await expect( + page.getByText( + /An observed external account is unlinked, and isolated guest work is off, so it cannot safely start Maya/, + ), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Review identity access" }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Review identity access" }) + .click(); + await expect(page).toHaveURL( + new RegExp( + `/${seed.prefix}/apps/chat/endpoint-${provider.provider}/access$`, + ), + ); + await expect( + page.getByRole("button", { name: "Continue setup" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Continue setup" }).click(); + expect(new URL(page.url()).searchParams.get("reconnect")).toBeNull(); + await expect( + page.getByRole("heading", { name: `Try Maya in ${provider.name}` }), + ).toBeVisible(); + await expectSetupRail(page); + await expectProviderTryInstructions(page, provider); + expect(mock.configuredCredentialKeys).toEqual( + expectedCredentialKeys(provider.provider), + ); + await page + .getByRole("button", { name: "I've sent the test message" }) + .click(); + + await expect(page).toHaveURL( + new RegExp( + `/${seed.prefix}/apps/chat/endpoint-${provider.provider}/settings$`, + ), + ); + await expect( + page.getByRole("heading", { name: `Maya in ${provider.name}` }), + ).toBeVisible(); + await expect( + page.getByText(provider.accountLabel, { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Continue setup" }), + ).toHaveCount(0); + await expect(page.getByText("Change agent", { exact: true })).toHaveCount( + 0, + ); + await expect(page.getByRole("tab")).toHaveCount(4); + for (const tab of ["Settings", "Access", "Conversations", "Activity"]) { + await expect(page.getByRole("tab", { name: tab })).toBeVisible(); + } + await expect( + page.getByRole("heading", { name: "Where this agent can work" }), + ).toBeVisible(); + if (provider.provider === "slack") { + await expect( + page.getByRole("heading", { name: "Slack command" }), + ).toBeVisible(); + await expect( + page.getByText("/maya-public", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText( + /Slack's bare \/status command is not a Paperclip control/, + ), + ).toBeVisible(); + } + await expect( + page.getByRole("switch", { + name: `Enable ${provider.resourceLabel}`, + }), + ).toBeVisible(); + await expect( + page.getByText(provider.resourceLabel, { exact: true }), + ).toBeVisible(); + await expect( + page.getByText(provider.secondaryResourceLabel, { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("switch", { + name: `Enable ${provider.secondaryResourceLabel}`, + }), + ).not.toBeChecked(); + await page + .getByRole("switch", { name: `Enable ${provider.resourceLabel}` }) + .click(); + await expect.poll(() => mock.updatedResource).toBe(true); + expect(mock.resourceUpdates.at(-1)).toEqual([ + { id: `resource-${provider.provider}`, enabled: true }, + ]); + + if (provider.provider === "github") { + await expect( + page.getByRole("heading", { name: "Private conversations" }), + ).toHaveCount(0); + } else { + const directMessages = page.getByRole("switch", { + name: "Allow direct messages", + }); + await expect(directMessages).toBeVisible(); + if (provider.provider === "discord") { + await expect( + page.getByText( + "People must also enable Direct Messages in their shared Discord server’s Privacy Settings.", + ), + ).toBeVisible(); + } + await directMessages.click(); + await expect.poll(() => mock.allowDirectMessages).toBe(true); + } + if (provider.provider === "microsoft-teams") { + const groupChats = page.getByRole("switch", { + name: "Allow group chats", + }); + await expect(groupChats).toBeVisible(); + await groupChats.click(); + await expect.poll(() => mock.allowGroupChats).toBe(true); + } + for (const lifecycleAction of [ + "Pause", + "Resume", + "Reconnect", + "Remove connection", + ]) { + await expect( + page.getByRole("button", { + name: lifecycleAction, + exact: true, + }), + ).toHaveCount(0); + } + await expect( + page.getByRole("switch", { name: "Allow unlinked people" }), + ).toHaveCount(0); + + await page.getByRole("tab", { name: "Access" }).click(); + await expect( + page.getByRole("heading", { name: "External identity access" }), + ).toBeVisible(); + await expect( + page.getByText( + /Their tasks run only with an isolated workspace and sandbox environment; otherwise Paperclip safely refuses the request/, + ), + ).toBeVisible(); + const allowUnlinked = page.getByRole("switch", { + name: "Allow unlinked people", + }); + await expect(allowUnlinked).toBeVisible(); + await allowUnlinked.click(); + await expect.poll(() => mock.allowUnlinkedPeople).toBe(true); + await expect( + page.getByText("Ada Lovelace", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Grace Hopper", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Linked to Grace Hopper", { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Create private link" }), + ).toBeVisible(); + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"], { + origin: new URL(page.url()).origin, + }); + await page.getByRole("button", { name: "Create private link" }).click(); + await expect + .poll(() => mock.linkIntentPrincipalId) + .toBe(`principal-${provider.provider}`); + const confirmationUrl = `https://paperclip.example.test/${seed.prefix}/chat-identity/confirm?token=e2e-redacted`; + await expect( + page.getByText(confirmationUrl, { exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Copy link" }).click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(confirmationUrl); + await expect( + page.getByText("Confirmation link copied", { exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Revoke" }).click(); + await expect + .poll(() => mock.revokedPrincipalId) + .toBe(`principal-${provider.provider}-linked`); + await expect( + page.getByText(`grace@${provider.provider}`, { exact: true }), + ).toBeVisible(); + + await page.getByRole("tab", { name: "Conversations" }).click(); + await expect( + page.getByRole("heading", { name: "Conversations" }), + ).toBeVisible(); + await expect( + page.getByText(`CHAT-123 · Investigate ${provider.name} delivery`), + ).toBeVisible(); + await expect( + page.getByText(provider.resourceLabel, { exact: true }), + ).toHaveCount(1); + const providerLink = page.getByRole("link", { + name: `Open ${provider.name}`, + }); + await expect(providerLink).toHaveAttribute("href", provider.externalUrl); + const taskLink = page.getByRole("link", { name: "Open task" }); + await expect(taskLink).toHaveAttribute( + "href", + new RegExp(`/${seed.prefix}/issues/issue-${provider.provider}$`), + ); + mock.conversationState = "waiting"; + await expect(page.getByText("waiting", { exact: true })).toBeVisible({ + timeout: 8_000, + }); + + await page.getByRole("tab", { name: "Activity" }).click(); + await expect( + page.getByRole("heading", { name: "Connection activity" }), + ).toBeVisible(); + await expect( + page.getByText( + `Inbound ${provider.name} delivery could not be processed`, + ), + ).toBeVisible(); + await expect( + page.getByText(`Published safe output to ${provider.name}`), + ).toBeVisible(); + await expect( + page.getByText("Credential values and request bodies are redacted."), + ).toBeVisible(); + const deliveryTimestamp = page + .getByText(`Inbound ${provider.name} delivery could not be processed`) + .locator("..") + .locator("time"); + await expect(deliveryTimestamp).toHaveText( + /\w+ \d+, \d{4}, \d{1,2}:\d{2}:\d{2} [AP]M/, + ); + await expect(deliveryTimestamp).toHaveAttribute( + "datetime", + /\d{4}-\d{2}-\d{2}T/, + ); + await expect(deliveryTimestamp).toHaveAttribute( + "title", + (await deliveryTimestamp.getAttribute("datetime"))!, + ); + await expect(page.getByText("xoxb-e2e-redacted")).toHaveCount(0); + await expect(page.getByText("teams-client-secret")).toHaveCount(0); + await expect(page.getByText("github-webhook-secret")).toHaveCount(0); + + // A provider callback does not cause a Board mutation. Keep this tab + // mounted and focused: neither navigation nor Replay may refresh it. + mock.liveActivitySummary = `${provider.name} reaction removed while viewing Activity`; + mock.setStatus("paused"); + await expect( + page.getByText(mock.liveActivitySummary, { exact: true }), + ).toBeVisible({ timeout: 8_000 }); + await expect( + page.getByRole("button", { name: "Resume", exact: true }), + ).toBeVisible({ timeout: 8_000 }); + mock.setStatus("active"); + await expect( + page.getByRole("button", { name: "Pause", exact: true }), + ).toBeVisible({ timeout: 8_000 }); + + await page.getByRole("button", { name: "Replay" }).click(); + await expect.poll(() => mock.replayedDelivery).toBe(true); + + await expect( + page.getByRole("button", { name: "Pause", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Remove connection", exact: true }), + ).toBeVisible(); + await expect( + page.getByText(PROVIDER_LIFECYCLE_COPY[provider.provider].reconnect, { + exact: true, + }), + ).toBeVisible(); + await page.getByRole("button", { name: "Pause", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Resume", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Resume", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Pause", exact: true }), + ).toBeVisible(); + expect(mock.lifecycleActions).toEqual(["pause", "resume"]); + + mock.setStatus("attention"); + await page.reload(); + await expect( + page.getByRole("button", { name: "Reconnect", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Remove connection" }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Reconnect", exact: true }) + .click(); + await expect(page).toHaveURL( + new RegExp( + `/${seed.prefix}/apps/chat/connect\\?.*resume=endpoint-${provider.provider}`, + ), + ); + expect(new URL(page.url()).searchParams.get("reconnect")).toBe("1"); + await expect( + page.getByRole("heading", { + name: + provider.provider === "github" + ? "Reconnect GitHub App" + : provider.setupHeading, + }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Choose an active agent" }), + ).toHaveCount(0); + + if (provider.provider === "github") { + const connectButton = page.getByRole("button", { + name: provider.setupButton, + }); + await expect( + page.getByText( + /Under the target user or organization, create a new GitHub App/, + ), + ).toHaveCount(0); + await expect( + page.getByText( + /Leave App ID and private key blank to reuse saved credentials/, + ), + ).toBeVisible(); + await page.getByLabel("GitHub App ID").fill("123456"); + await page + .getByLabel("Private key (PEM)") + .fill("reconnect-private-key"); + await expect(connectButton).toBeEnabled(); + await page.evaluate((buttonName) => { + const state = window as typeof window & { + __githubConnectEnabledAfterRotation?: boolean; + }; + state.__githubConnectEnabledAfterRotation = false; + new MutationObserver(() => { + const button = [...document.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === buttonName, + ); + if (button instanceof HTMLButtonElement && !button.disabled) { + state.__githubConnectEnabledAfterRotation = true; + } + }).observe(document.body, { + attributes: true, + childList: true, + subtree: true, + }); + }, provider.setupButton); + await page + .getByRole("button", { name: "Regenerate webhook secret" }) + .click(); + await expect(connectButton).toBeDisabled(); + await page.waitForTimeout(100); + expect( + await page.evaluate( + () => + ( + window as typeof window & { + __githubConnectEnabledAfterRotation?: boolean; + } + ).__githubConnectEnabledAfterRotation, + ), + ).toBe(false); + + await page.goBack(); + await expect( + page.getByRole("heading", { name: "Connection activity" }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Reconnect", exact: true }) + .click(); + await expect( + page.getByRole("heading", { name: "Reconnect GitHub App" }), + ).toBeVisible(); + await page.getByLabel("GitHub App ID").fill("123456"); + await page + .getByLabel("Private key (PEM)") + .fill("reconnect-private-key"); + await expect( + page.getByRole("button", { name: provider.setupButton }), + ).toBeDisabled(); + mock.setGitHubWebhookVerified(); + await expect( + page.getByRole("button", { name: provider.setupButton }), + ).toBeEnabled(); + } + + mock.setStatus("active"); + await page.goto( + `/${seed.prefix}/apps/chat/endpoint-${provider.provider}/activity`, + ); + await page.getByRole("button", { name: "Remove connection" }).click(); + const confirmation = page.getByRole("alertdialog"); + await expect(confirmation).toContainText("Remove this connection?"); + await expect(confirmation).toContainText( + PROVIDER_LIFECYCLE_COPY[provider.provider].remove, + ); + await confirmation + .getByRole("button", { name: "Remove connection" }) + .click(); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`)); + await expect.poll(() => mock.removed).toBe(true); + expect(mock.lifecycleActions).toEqual(["pause", "resume", "remove"]); + }); + } +}); + +test.describe("iMessage Photon setup and management", () => { + let seed: Seed; + const photon: ProviderCase = { + provider: "imessage-photon", + slug: "imessage-photon", + name: "iMessage Photon", + accountLabel: "Photon Test", + botLabel: "Maya", + botUsername: "+15555550100", + resourceLabel: "Family project", + secondaryResourceLabel: "Second group", + resourceType: "group_chat", + externalUrl: "https://app.photon.codes/", + setupHeading: /Connect iMessage Photon/, + setupButton: "Connect selected number", + chatAndTool: false, + }; + test.beforeAll(async ({ request }) => { + seed = await seedCompanyAndAgent(request); + }); + test("connects Pro shared DMs without presenting a fake owned number or enabling groups", async ({page}) => { + await installChatControlPlaneMock(page, photon, seed, { enableChatConnectors: true, photonShared: true }); + await page.goto(`/${seed.prefix}/apps`); + await page.getByRole("button", {name:"Connect iMessage Photon",exact:true}).click(); + await selectMaya(page); + await page.getByLabel("Project ID").fill("project-e2e"); + await page.getByLabel("Project secret").fill("photon-test-secret"); + await page.getByRole("button", {name:"Inspect Photon project"}).click(); + await expect(page.getByText(/Groups cannot be enabled on this channel/)).toBeVisible(); + await page.getByRole("button", {name:"Connect shared DMs"}).click(); + await expect(page.getByRole("heading", {name:"Try Maya in iMessage Photon"})).toBeVisible(); + await page.reload(); + await expect(page.getByText(/enroll your sender in Users/)).toBeVisible(); + await expect(page.getByRole("button", {name:/Copy \+1555/})).toHaveCount(0); + await page.getByRole("button", {name:"I've sent the test message"}).click(); + await page.getByRole("tab", {name:"Settings"}).click(); + await expect(page.getByText(/Shared Photon project · direct messages only/)).toBeVisible(); + await expect(page.getByRole("switch", {name:"Enable Family project"})).toBeDisabled(); + await expect(page.getByRole("button", {name:"Copy dedicated number"})).toHaveCount(0); + }); + for (const theme of ["light", "dark"] as const) { + test(`discovers dedicated lines and completes the channel wizard (${theme})`, async ({ + page, + }, testInfo) => { + const mock = await installChatControlPlaneMock(page, photon, seed, { + enableChatConnectors: true, + }); + await page.addInitScript( + (value) => localStorage.setItem("paperclip.theme", value), + theme, + ); + await page.goto(`/${seed.prefix}/apps`); + const card = page.locator( + '[role="listitem"][data-app-slug="imessage-photon"]', + ); + await expect(card).toBeVisible(); + await card + .getByRole("button", { name: "Connect iMessage Photon" }) + .click(); + await selectMaya(page); + await expectSetupRail(page); + await expect( + page.getByRole("heading", { name: "Connect iMessage Photon" }), + ).toBeVisible(); + await page.getByLabel("Project ID").fill("project-e2e"); + await page.getByLabel("Project secret").fill("photon-test-secret"); + await expect(page.getByLabel("Project secret")).toHaveAttribute( + "type", + "password", + ); + await page + .getByRole("button", { name: "Inspect Photon project" }) + .click(); + await expect( + page.getByRole("button", { name: "Connect selected number" }), + ).toBeDisabled(); + await page + .getByRole("radio", { name: "+15555550100", exact: true }) + .focus(); + await page.keyboard.press("Space"); + await page + .getByRole("button", { name: "Connect selected number" }) + .click(); + await expect( + page.getByRole("heading", { name: "Try Maya in iMessage Photon" }), + ).toBeVisible(); + expect(mock.configuredCredentialKeys).toEqual(["projectSecret"]); + await expect( + page.getByRole("button", { name: "Copy +15555550100" }), + ).toBeVisible(); + await expect( + page.getByText("Link your Messages identity", { exact: true }), + ).toBeVisible(); + await page + .getByRole("button", { name: "I've sent the test message" }) + .click(); + await page.getByRole("tab", { name: "Settings" }).click(); + await expect( + page.getByText(/replies are visible to everyone in that group/), + ).toBeVisible(); + const group = page.getByRole("switch", { name: "Enable Family project" }); + await expect(group).not.toBeChecked(); + await group.click(); + await expect(group).toBeChecked(); + await page.setViewportSize({ width: 390, height: 844 }); + await page + .getByRole("combobox", { name: "Page section" }) + .selectOption("activity"); + await expect( + page.getByRole("button", { name: "Pause", exact: true }), + ).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath(`photon-${theme}-mobile.png`), + fullPage: true, + }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + ).toBe(true); + await page.getByRole("button", { name: "Pause", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Resume", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Resume", exact: true }).click(); + await expect( + page.getByRole("button", { name: "Pause", exact: true }), + ).toBeVisible(); + }); + } +}); diff --git a/tests/e2e/chat-adapters-ui.shared.ts b/tests/e2e/chat-adapters-ui.shared.ts new file mode 100644 index 0000000000..c222afa128 --- /dev/null +++ b/tests/e2e/chat-adapters-ui.shared.ts @@ -0,0 +1,1191 @@ +import { + expect, + test, + type APIRequestContext, + type BrowserContext, + type Page, + type Route, +} from "@playwright/test"; + +/** + * Deterministic browser coverage for the native chat-connector UI. + * + * Provider APIs are deliberately not contacted here. The shared Paperclip + * server supplies the company, agent, and connector catalog, while a small + * stateful route fixture emulates the chat-control-plane responses. Live + * provider webhook and credential qualification belongs in the manual runbook + * because those checks require real accounts and publicly reachable ingress. + */ + +export type Provider = "slack" | "github" | "discord" | "microsoft-teams" | "telegram" | "imessage-photon"; + +export const GITHUB_PRIVATE_KEY_FIXTURE = + "-----BEGIN PRIVATE KEY-----\nlocal-e2e-key\n-----END PRIVATE KEY-----\n"; +export const GITHUB_PRIVATE_KEY_PASTE_FIXTURE = + "-----BEGIN PRIVATE KEY-----\nlocal-e2e-pasted-key\n-----END PRIVATE KEY-----\n"; + +export type ProviderCase = { + provider: Provider; + slug: string; + name: string; + accountLabel: string; + botLabel: string; + botUsername: string; + resourceLabel: string; + secondaryResourceLabel: string; + resourceType: string; + externalUrl: string; + setupHeading: RegExp; + setupButton: string; + chatAndTool: boolean; +}; + +export const PROVIDERS: ProviderCase[] = [ + { + provider: "slack", + slug: "slack", + name: "Slack", + accountLabel: "Acme Workspace", + botLabel: "Maya", + botUsername: "maya-paperclip", + resourceLabel: "#product", + secondaryResourceLabel: "#support", + resourceType: "channel", + externalUrl: "https://app.slack.com/client/T-E2E/C-E2E/thread/C-E2E-1", + setupHeading: /Connect a Slack app/i, + setupButton: "Connect Slack app", + chatAndTool: true, + }, + { + provider: "github", + slug: "github", + name: "GitHub", + accountLabel: "paperclip-ai", + botLabel: "Maya", + // GitHub's API returns the actor login with this suffix, while people + // invoke the App with the bare slug. + botUsername: "maya-paperclip[bot]", + resourceLabel: "paperclip-ai/paperclip", + secondaryResourceLabel: "paperclip-ai/chat-e2e", + resourceType: "repository", + externalUrl: "https://github.com/paperclip-ai/paperclip/issues/123", + setupHeading: /Create or connect a GitHub App/i, + setupButton: "Connect and verify", + chatAndTool: true, + }, + { + provider: "microsoft-teams", + slug: "microsoft-teams", + name: "Microsoft Teams", + accountLabel: "Acme Tenant", + botLabel: "Maya", + botUsername: "maya-paperclip", + resourceLabel: "Product / General", + secondaryResourceLabel: "Product / Incidents", + resourceType: "channel", + externalUrl: "https://teams.microsoft.com/l/message/19:e2e@thread.tacv2/1", + setupHeading: /Connect Maya to Microsoft Teams/i, + setupButton: "Verify Microsoft credentials", + chatAndTool: false, + }, + { + provider: "discord", + slug: "discord", + name: "Discord", + accountLabel: "Clawd", + botLabel: "Maya", + botUsername: "maya-paperclip", + resourceLabel: "#general", + secondaryResourceLabel: "#support", + resourceType: "channel", + externalUrl: + "https://discord.com/channels/1457808928258658549/1457808933082108089", + setupHeading: /Connect Maya to Discord/i, + setupButton: "Connect Discord bot", + chatAndTool: false, + }, + { + provider: "telegram", + slug: "telegram", + name: "Telegram", + accountLabel: "@maya_paperclip_bot", + botLabel: "Maya", + botUsername: "maya_paperclip_bot", + resourceLabel: "Maya test chat", + secondaryResourceLabel: "Maya group chat", + resourceType: "direct_message", + externalUrl: "https://t.me/maya_paperclip_bot", + setupHeading: /Create Maya in Telegram/i, + setupButton: "Connect bot", + chatAndTool: false, + }, +]; + +export const PROVIDER_LIFECYCLE_COPY: Record< + Provider, + { reconnect: string; remove: string } +> = { + "imessage-photon": { reconnect: "Reconnect the same dedicated Photon number", remove: "does not delete the Photon project" }, + slack: { + reconnect: + "Reconnect verifies or replaces credentials for this same Slack app. It does not reinstall the app or change its workspace or channel membership.", + remove: "It does not uninstall the Slack app", + }, + github: { + reconnect: + "Reconnect verifies this same App and installation, then updates its webhook URL, secret, and secure delivery settings. It does not reinstall the App or change repository access.", + remove: "It does not uninstall the GitHub App", + }, + discord: { + reconnect: + "Reconnect verifies this same Discord application and server installation. It does not add or remove the bot from the server.", + remove: "It does not uninstall the bot", + }, + "microsoft-teams": { + reconnect: + "Reconnect verifies this same Microsoft app, tenant, and bot identity. It does not upload or reinstall the Teams app.", + remove: "It does not uninstall the Teams app", + }, + telegram: { + reconnect: + "Reconnect verifies this same BotFather bot and automatically refreshes its Paperclip webhook and command menu.", + remove: + "queues durable removal of its Telegram webhook and command menu. After Telegram confirms that cleanup, Paperclip retires the saved token", + }, +}; + +export type Seed = { + companyId: string; + prefix: string; + agentId: string; + otherAgentId: string; +}; + +export async function json( + response: Awaited>, + label: string, +): Promise { + expect( + response.ok(), + `${label} failed ${response.status()}: ${await response.text()}`, + ).toBe(true); + return (await response.json()) as T; +} + +export async function seedCompanyAndAgent(request: APIRequestContext): Promise { + const company = await json<{ id: string; issuePrefix: string }>( + await request.post("/api/companies", { + data: { name: `Chat adapters browser E2E ${Date.now()}` }, + }), + "create company", + ); + const agent = await json<{ id: string }>( + await request.post(`/api/companies/${company.id}/agents`, { + data: { + name: "Maya", + role: "qa", + title: "Chat connector test agent", + capabilities: "Exercises deterministic chat connector browser flows.", + adapterType: "process", + adapterConfig: { + command: process.execPath, + args: ["--input-type=module", "-e", "process.exit(0)"], + }, + }, + }), + "create agent", + ); + const otherAgent = await json<{ id: string }>( + await request.post(`/api/companies/${company.id}/agents`, { + data: { + name: "Nora", + role: "qa", + title: "Second chat connector test agent", + capabilities: "Proves a chat connection keeps its chosen agent.", + adapterType: "process", + adapterConfig: { + command: process.execPath, + args: ["--input-type=module", "-e", "process.exit(0)"], + }, + }, + }), + "create second agent", + ); + return { + companyId: company.id, + prefix: company.issuePrefix, + agentId: agent.id, + otherAgentId: otherAgent.id, + }; +} + +export function bodyOf(route: Route): Record { + return route.request().postDataJSON() as Record; +} + +export async function fulfill(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +export function endpointFixture(provider: ProviderCase, seed: Seed) { + const now = new Date().toISOString(); + return { + id: `endpoint-${provider.provider}`, + companyId: seed.companyId, + connectionId: `connection-${provider.provider}`, + provider: provider.provider, + publicId: `public-${provider.provider}`, + status: "draft", + deploymentMode: "direct", + assignedAgentId: seed.agentId, + assignedAgentName: "Maya", + sponsorUserId: null, + providerAccountId: null, + providerAccountLabel: null, + botExternalId: null, + botUsername: null, + botLabel: null, + allowDirectMessages: false, + allowGroupChats: false, + allowUnlinkedPeople: false, + replyMode: "subscribed", + capabilities: { + threads: provider.provider !== "telegram", + directMessages: provider.provider !== "github", + nativeStreaming: + provider.provider === "slack" || provider.provider === "telegram", + messageEdits: true, + messageDeletes: + provider.provider === "slack" || + provider.provider === "github" || + provider.provider === "discord", + reactions: true, + files: true, + cards: + provider.provider === "slack" || + provider.provider === "microsoft-teams", + actions: true, + modals: false, + slashCommands: + provider.provider !== "github" && provider.provider !== "discord", + ephemeralMessages: + provider.provider === "slack" || + provider.provider === "microsoft-teams", + proactiveDirectMessages: false, + }, + setup: { + step: "provider_setup", + authorizationUrl: + provider.provider === "telegram" + ? "https://t.me/BotFather" + : provider.provider === "microsoft-teams" + ? "https://dev.teams.microsoft.com/apps" + : provider.provider === "github" + ? "https://github.com/settings/apps" + : provider.provider === "discord" + ? "https://discord.com/developers/applications" + : "https://api.slack.com/apps", + providerUrl: provider.externalUrl, + webhookUrl: `https://paperclip.example.test/api/chat-webhooks/public-${provider.provider}/${provider.provider}`, + messagingEndpoint: `https://paperclip.example.test/api/chat-webhooks/public-${provider.provider}/microsoft-teams`, + command: provider.provider === "slack" ? "/maya-public" : undefined, + webhookVerifiedAt: null, + webhookSecretConfigured: false, + }, + healthMessage: null, + lastActivityAt: now, + lastPublicationAt: now, + activatedAt: null, + createdAt: now, + updatedAt: now, + }; +} + +export type ChatMock = { + chatEndpointListReads: number; + createdWithAgentId: string | null; + configuredCredentialKeys: string[]; + githubPrivateKeyMatchedFile: boolean | null; + githubPrivateKeyMatchedPaste: boolean | null; + githubSetupSecretRequests: number; + setupAttempts: number; + updatedResource: boolean; + resourceUpdates: Array>; + allowDirectMessages: boolean | null; + allowGroupChats: boolean | null; + allowUnlinkedPeople: boolean | null; + linkIntentPrincipalId: string | null; + revokedPrincipalId: string | null; + lifecycleActions: string[]; + replayedDelivery: boolean; + liveActivitySummary: string | null; + conversationState: "active" | "waiting"; + removed: boolean; + setStatus: (status: string) => void; + setGitHubWebhookVerified: () => void; +}; + +export async function installChatControlPlaneMock( + page: Page | BrowserContext, + provider: ProviderCase, + seed: Seed, + { + enableChatConnectors, + resourceCount = 2, + photonShared = false, + }: { enableChatConnectors: boolean; resourceCount?: number; photonShared?: boolean }, +): Promise { + const endpoint = endpointFixture(provider, seed); + const state: ChatMock & { + created: boolean; + failNextGitHubEndpointRead: boolean; + } = { + created: false, + failNextGitHubEndpointRead: false, + chatEndpointListReads: 0, + createdWithAgentId: null, + configuredCredentialKeys: [], + githubPrivateKeyMatchedFile: null, + githubPrivateKeyMatchedPaste: null, + githubSetupSecretRequests: 0, + setupAttempts: 0, + updatedResource: false, + resourceUpdates: [], + allowDirectMessages: null, + allowGroupChats: null, + allowUnlinkedPeople: null, + linkIntentPrincipalId: null, + revokedPrincipalId: null, + lifecycleActions: [], + replayedDelivery: false, + liveActivitySummary: null, + conversationState: "active", + removed: false, + setStatus: (status) => { + endpoint.status = status; + }, + setGitHubWebhookVerified: () => { + endpoint.setup.webhookVerifiedAt = new Date().toISOString(); + }, + }; + const resource = { + id: `resource-${provider.provider}`, + companyId: seed.companyId, + endpointId: endpoint.id, + type: provider.resourceType, + providerResourceId: `provider-resource-${provider.provider}`, + label: provider.resourceLabel, + detail: + provider.provider === "github" ? "Repository" : "Available at provider", + providerUrl: provider.externalUrl, + availability: "available", + enabled: false, + createdAt: endpoint.createdAt, + updatedAt: endpoint.updatedAt, + }; + const secondaryResource = { + ...resource, + id: `resource-${provider.provider}-secondary`, + providerResourceId: `provider-resource-${provider.provider}-secondary`, + label: provider.secondaryResourceLabel, + detail: + provider.provider === "github" ? "Repository" : "Available at provider", + }; + const resources = [ + resource, + secondaryResource, + ...Array.from({ length: Math.max(0, resourceCount - 2) }, (_, index) => ({ + ...resource, + id: `resource-${provider.provider}-extra-${index}`, + providerResourceId: `provider-resource-${provider.provider}-extra-${index}`, + label: `Extra destination ${index + 1}`, + })), + ]; + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + const method = request.method(); + + if (pathname === `/api/companies/${seed.companyId}/chat-endpoints`) { + if (method === "GET") { + state.chatEndpointListReads += 1; + await fulfill(route, { endpoints: state.created ? [endpoint] : [] }); + return; + } + if (method === "POST") { + const body = bodyOf(route); + expect(body).toMatchObject({ + provider: provider.provider, + assignedAgentId: seed.agentId, + }); + state.createdWithAgentId = String(body.assignedAgentId); + state.created = true; + await fulfill(route, endpoint, 201); + return; + } + } + + if (pathname === `/api/chat-endpoints/${endpoint.id}`) { + if (method === "GET") { + if ( + provider.provider === "github" && + state.failNextGitHubEndpointRead + ) { + state.failNextGitHubEndpointRead = false; + await fulfill(route, { error: "Temporary read failure." }, 503); + return; + } + await fulfill(route, endpoint); + return; + } + if (method === "PATCH") { + const body = bodyOf(route); + if (typeof body.allowDirectMessages === "boolean") + state.allowDirectMessages = body.allowDirectMessages; + if (typeof body.allowGroupChats === "boolean") + state.allowGroupChats = body.allowGroupChats; + if (typeof body.allowUnlinkedPeople === "boolean") + state.allowUnlinkedPeople = body.allowUnlinkedPeople; + Object.assign(endpoint, body, { + updatedAt: new Date().toISOString(), + }); + await fulfill(route, endpoint); + return; + } + } + + if ( + pathname === "/api/instance/settings/experimental" && + method === "GET" + ) { + await fulfill(route, { + enableChatConnectors, + enableIsolatedWorkspaces: false, + }); + return; + } + + if ( + pathname === `/api/chat-endpoints/${endpoint.id}/setup-secret` && + method === "POST" + ) { + state.githubSetupSecretRequests += 1; + endpoint.setup.webhookSecretConfigured = true; + endpoint.setup.step = "provider_setup"; + endpoint.setup.webhookVerifiedAt = + state.githubSetupSecretRequests === 1 ? new Date().toISOString() : null; + if (state.githubSetupSecretRequests > 1) { + state.failNextGitHubEndpointRead = true; + } + await fulfill(route, { webhookSecret: "github-webhook-secret" }, 201); + return; + } + + if ( + pathname === `/api/chat-endpoints/${endpoint.id}/setup` && + method === "POST" + ) { + const body = bodyOf(route); + const action = String(body.action); + if (action === "pause" || action === "resume" || action === "remove") { + state.lifecycleActions.push(action); + if (action === "pause") endpoint.status = "paused"; + if (action === "resume") endpoint.status = "active"; + if (action === "remove") { + endpoint.status = "archived"; + state.created = false; + state.removed = true; + } + await fulfill(route, endpoint); + return; + } + expect(["configure", "verify"]).toContain(action); + if (body.action === "configure") { + state.setupAttempts += 1; + state.configuredCredentialKeys = Object.keys( + (body.credentials ?? {}) as Record, + ).sort(); + if (provider.provider === "github") { + const privateKey = ( + (body.credentials ?? {}) as Record + ).privateKey; + state.githubPrivateKeyMatchedFile = + state.githubPrivateKeyMatchedFile === true || + privateKey === GITHUB_PRIVATE_KEY_FIXTURE; + state.githubPrivateKeyMatchedPaste = + state.githubPrivateKeyMatchedPaste === true || + privateKey === GITHUB_PRIVATE_KEY_PASTE_FIXTURE; + if (state.setupAttempts === 1) { + await fulfill( + route, + { error: "GitHub rejected the supplied App credentials." }, + 422, + ); + return; + } + } + if (provider.provider === "telegram" && state.setupAttempts === 1) { + const submittedToken = String( + ((body.credentials ?? {}) as Record).botToken ?? "", + ); + await fulfill( + route, + { + error: `Telegram rejected bot token ${submittedToken}. Confirm the token in BotFather and try again.`, + }, + 422, + ); + return; + } + } else { + expect(provider.provider).toBe("slack"); + } + if (provider.provider === "imessage-photon") { + expect(body.photon).toEqual(photonShared ? {allocation:"shared",projectId:"project-e2e"} : {allocation:"dedicated",projectId:"project-e2e",lineId:"line-one"}); + } + Object.assign(endpoint, { + ...(provider.provider === "imessage-photon" ? {photonAllocation: photonShared ? "shared" : "dedicated", allowGroupChats: !photonShared} : {}), + status: "verifying", + providerAccountId: `account-${provider.provider}`, + providerAccountLabel: provider.accountLabel, + botExternalId: provider.provider === "imessage-photon" ? photonShared ? "photon-project:project-e2e" : "+15555550100" : `bot-${provider.provider}`, + botUsername: photonShared ? null : provider.botUsername, + botLabel: provider.botLabel, + setup: { + ...endpoint.setup, + step: + provider.provider === "slack" && body.action === "configure" + ? "provider_setup" + : "test", + }, + }); + await fulfill(route, endpoint); + return; + } + + if (pathname === `/api/chat-endpoints/${endpoint.id}/photon/inspect` && method === "POST") { + expect(bodyOf(route)).toEqual({projectId:"project-e2e",projectSecret:"photon-test-secret"}); + await fulfill(route,{projectId:"project-e2e",projectName:"Photon Test",allocation:photonShared ? "shared" : "dedicated",eligible:true,lines:photonShared ? [] : [{lineId:"line-one",phoneNumber:"+15555550100",eligible:true},{lineId:"line-two",phoneNumber:"+15555550102",eligible:true}]}); return; + } + if ( + pathname === `/api/chat-endpoints/${endpoint.id}/test` && + method === "POST" + ) { + Object.assign(endpoint, { + status: "active", + activatedAt: new Date().toISOString(), + setup: { ...endpoint.setup, step: "complete" }, + }); + await fulfill(route, endpoint); + return; + } + + if (pathname === `/api/chat-endpoints/${endpoint.id}/resources`) { + if (method === "GET") { + await fulfill(route, { resources }); + return; + } + if (method === "PUT") { + const updates = (bodyOf(route).resources ?? []) as Array<{ + id: string; + enabled: boolean; + }>; + state.resourceUpdates.push(updates); + // Match the real request-size boundary without contacting a provider. + if (updates.length > 500) { + await fulfill(route, { error: "Too many resource updates." }, 400); + return; + } + for (const update of updates) { + const target = resources.find((item) => item.id === update.id); + if (target) target.enabled = update.enabled; + } + state.updatedResource = resource.enabled; + await fulfill(route, resources); + return; + } + } + + if ( + pathname === `/api/chat-endpoints/${endpoint.id}/principals` && + method === "GET" + ) { + await fulfill(route, { + principals: [ + { + id: `link-${provider.provider}`, + principalId: `principal-${provider.provider}`, + externalLabel: "Ada Lovelace", + externalDetail: `ada@${provider.provider}`, + paperclipUserId: null, + paperclipUserLabel: null, + status: "pending", + }, + { + id: `link-${provider.provider}-linked`, + principalId: `principal-${provider.provider}-linked`, + externalLabel: "Grace Hopper", + externalDetail: `grace@${provider.provider}`, + paperclipUserId: + state.revokedPrincipalId === + `principal-${provider.provider}-linked` + ? null + : "paperclip-user-grace", + paperclipUserLabel: + state.revokedPrincipalId === + `principal-${provider.provider}-linked` + ? null + : "Grace Hopper", + status: + state.revokedPrincipalId === + `principal-${provider.provider}-linked` + ? "revoked" + : "linked", + }, + ], + }); + return; + } + + if ( + pathname === + `/api/chat-endpoints/${endpoint.id}/principals/principal-${provider.provider}/link-intent` && + method === "POST" + ) { + state.linkIntentPrincipalId = `principal-${provider.provider}`; + await fulfill(route, { + confirmationUrl: `https://paperclip.example.test/${seed.prefix}/chat-identity/confirm?token=e2e-redacted`, + }); + return; + } + + if ( + pathname === + `/api/chat-endpoints/${endpoint.id}/principals/principal-${provider.provider}-linked/link` && + method === "DELETE" + ) { + state.revokedPrincipalId = `principal-${provider.provider}-linked`; + await route.fulfill({ status: 204, body: "" }); + return; + } + + if ( + pathname === `/api/chat-endpoints/${endpoint.id}/conversations` && + method === "GET" + ) { + await fulfill(route, { + conversations: [ + { + id: `conversation-${provider.provider}`, + companyId: seed.companyId, + endpointId: endpoint.id, + resourceId: resource.id, + issueId: `issue-${provider.provider}`, + issueIdentifier: "CHAT-123", + issueTitle: `Investigate ${provider.name} delivery`, + externalConversationId: `external-conversation-${provider.provider}`, + externalThreadId: `external-thread-${provider.provider}`, + externalLabel: provider.resourceLabel, + externalUrl: provider.provider === "imessage-photon" ? null : provider.externalUrl, + isDirectMessage: provider.provider === "telegram", + state: state.conversationState, + lastPublicationStatus: "published", + createdAt: endpoint.createdAt, + updatedAt: endpoint.updatedAt, + }, + ], + }); + return; + } + + if ( + pathname === `/api/chat-endpoints/${endpoint.id}/activity` && + method === "GET" + ) { + await fulfill(route, { + items: [ + ...(state.liveActivitySummary + ? [ + { + id: `live-reaction-${provider.provider}`, + kind: "delivery", + status: "processed", + summary: state.liveActivitySummary, + createdAt: endpoint.createdAt, + replayable: false, + }, + ] + : []), + { + id: `delivery-${provider.provider}`, + kind: "delivery", + status: "failed", + summary: `Inbound ${provider.name} delivery could not be processed`, + detail: "Credential values and request bodies are redacted.", + createdAt: endpoint.createdAt, + replayable: true, + }, + { + id: `publication-${provider.provider}`, + kind: "publication", + status: "published", + summary: `Published safe output to ${provider.name}`, + createdAt: endpoint.createdAt, + replayable: false, + }, + ], + }); + return; + } + + if ( + pathname === + `/api/chat-endpoints/${endpoint.id}/deliveries/delivery-${provider.provider}/replay` && + method === "POST" + ) { + state.replayedDelivery = true; + await fulfill(route, {}); + return; + } + + await route.continue(); + }); + + return state; +} + +export async function selectMaya(page: Page) { + await page.getByRole("button", { name: "Choose an active agent" }).click(); + await page.getByRole("button", { name: "Select Maya" }).click(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); +} + +export async function fillProviderSetup(page: Page, provider: ProviderCase) { + if (provider.provider === "slack") { + await page.getByLabel("Bot User OAuth Token").fill("xoxb-e2e-redacted"); + await page.getByLabel("Signing Secret").fill("slack-signing-secret"); + } else if (provider.provider === "github") { + await page.getByRole("button", { name: "Generate webhook secret" }).click(); + await expect(page.getByLabel("Generated webhook secret")).toHaveValue( + "github-webhook-secret", + ); + await page.getByLabel("GitHub App ID").fill("123456"); + const privateKeyFile = page.getByLabel( + "Choose GitHub App private key file", + ); + await privateKeyFile.setInputFiles({ + name: "paperclip-test.pem", + mimeType: "application/x-pem-file", + buffer: Buffer.alloc(64 * 1024 + 1, "x"), + }); + await expect(page.getByRole("alert")).toContainText( + "That file is too large. Choose a GitHub App private key smaller than 64 KB.", + ); + await page.getByLabel("GitHub App ID").focus(); + await page.getByLabel("GitHub App ID").press("Tab"); + await expect(page.getByRole("alert")).toBeVisible(); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Choose .pem file" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + name: "paperclip-test.pem", + mimeType: "application/x-pem-file", + buffer: Buffer.from(GITHUB_PRIVATE_KEY_FIXTURE), + }); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(page.getByRole("status")).toContainText( + "Private key loaded. It stays in this form until you connect.", + ); + await expect( + page.getByRole("button", { name: "Show private key" }), + ).toBeVisible(); + await expect(page.getByLabel("Private key (PEM)")).toHaveAttribute( + "type", + "password", + ); + await expect(page.locator("textarea#github-private-key")).toHaveCount(0); + await expect(page.locator("body")).not.toContainText( + GITHUB_PRIVATE_KEY_FIXTURE, + ); + await expect(page.locator("body")).not.toContainText("paperclip-test.pem"); + await page.getByRole("button", { name: provider.setupButton }).click(); + await expect(page.getByRole("alert")).toContainText("Connection failed"); + await page + .getByLabel("Private key (PEM)") + .evaluate((element, privateKey) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", privateKey); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, GITHUB_PRIVATE_KEY_PASTE_FIXTURE); + await expect(page.locator("textarea#github-private-key")).toHaveCount(0); + await expect(page.locator("body")).not.toContainText( + GITHUB_PRIVATE_KEY_PASTE_FIXTURE, + ); + await page.getByRole("button", { name: "Show private key" }).click(); + await expect(page.locator("textarea#github-private-key")).toHaveValue( + GITHUB_PRIVATE_KEY_PASTE_FIXTURE, + ); + await page.getByRole("button", { name: "Hide private key" }).click(); + await expect(page.locator("textarea#github-private-key")).toHaveCount(0); + await expect(page.getByLabel("Private key (PEM)")).toHaveAttribute( + "type", + "password", + ); + await expect(page.locator("body")).not.toContainText( + GITHUB_PRIVATE_KEY_PASTE_FIXTURE, + ); + } else if (provider.provider === "microsoft-teams") { + const clientId = "00000000-0000-4000-8000-000000000001"; + await page.getByLabel("Application / Client ID").fill(clientId); + await page + .getByLabel("Directory / Tenant ID") + .fill("00000000-0000-4000-8000-000000000002"); + await page.getByLabel("Client secret").fill("teams-client-secret"); + const manifest = JSON.parse( + await page.getByLabel("Required Teams app manifest block").inputValue(), + ) as { webApplicationInfo?: { id?: string; resource?: string } }; + expect(manifest.webApplicationInfo).toEqual({ + id: clientId, + resource: "https://paperclip.ing", + }); + await expect( + page.getByRole("button", { name: "Copy manifest settings" }), + ).toBeEnabled(); + } else if (provider.provider === "discord") { + await page.getByLabel("Application ID").fill("1457808928258658549"); + await page.getByLabel("Server ID").fill("1457808928258658549"); + await page.getByLabel("Bot token").fill("discord-e2e-redacted"); + } else { + await page.getByLabel("Bot token").fill("123456:e2e-redacted"); + } + await page.getByRole("button", { name: provider.setupButton }).click(); +} + +export function expectedCredentialKeys(provider: Provider): string[] { + if (provider === "slack") return ["botToken", "signingSecret"]; + if (provider === "github") return ["appId", "privateKey"]; + if (provider === "microsoft-teams") + return ["clientId", "clientSecret", "tenantId"]; + if (provider === "discord") return ["applicationId", "botToken", "guildId"]; + return ["botToken"]; +} + +export async function expectSetupRail(page: Page) { + const rail = page.getByRole("list", { name: "Connection setup progress" }); + await expect(rail).toBeVisible(); + await expect(rail.getByRole("listitem")).toHaveCount(3); + for (const label of ["Choose agent", "Connect provider", "Try it"]) { + await expect(rail.getByText(label, { exact: true })).toBeVisible(); + } +} + +export function expectedSlackManifest(webhookUrl: string) { + return `display_information: + name: "maya-paperclip" +features: + app_home: + home_tab_enabled: false + messages_tab_enabled: true + messages_tab_read_only_enabled: false + agent_view: + agent_description: "Work with a Paperclip agent in a task-backed conversation." + bot_user: + display_name: "maya" + slash_commands: + - command: "/maya-public" + description: Start or manage work with "Maya" + usage_hint: "status | new | close | " + should_escape: false + url: "${webhookUrl}" +oauth_config: + scopes: + bot: + - app_mentions:read + - assistant:write + - channels:history + - channels:read + - chat:write + - commands + - files:read + - files:write + - groups:history + - groups:read + - im:history + - im:read + - mpim:history + - mpim:read + - reactions:read + - reactions:write + - users:read +settings: + org_deploy_enabled: false + socket_mode_enabled: false + token_rotation_enabled: false + event_subscriptions: + request_url: "${webhookUrl}" + bot_events: + - agent_session_stopped + - app_mention + - message.channels + - message.groups + - message.im + - message.mpim + - member_joined_channel + - member_left_channel + - channel_left + - group_left + - reaction_added + - reaction_removed + - channel_archive + - group_archive + - channel_unarchive + - group_unarchive + - channel_deleted + - channel_rename + - group_rename + - app_uninstalled + - tokens_revoked + interactivity: + is_enabled: true + request_url: "${webhookUrl}"`; +} + +export async function expectMinimumProviderSetup(page: Page, provider: ProviderCase) { + const webhookUrl = `https://paperclip.example.test/api/chat-webhooks/public-${provider.provider}/${provider.provider}`; + if (provider.provider === "slack") { + await expect(page.getByText("From an app manifest")).toBeVisible(); + await expect(page.getByText("OAuth & Permissions")).toBeVisible(); + await expect(page.getByText("Basic Information")).toBeVisible(); + const manifest = await page.getByLabel("Slack app manifest").inputValue(); + expect(manifest).toBe(expectedSlackManifest(webhookUrl)); + await expect(page.getByLabel("Slack app manifest")).toHaveAttribute( + "readonly", + "", + ); + await expect(page.getByLabel("Bot User OAuth Token")).toHaveAttribute( + "type", + "password", + ); + await expect(page.getByLabel("Signing Secret")).toHaveAttribute( + "type", + "password", + ); + await expect( + page.getByRole("button", { name: "Open Slack app settings" }), + ).toBeVisible(); + return; + } + + if (provider.provider === "github") { + await expect(page.getByText(webhookUrl, { exact: true })).toBeVisible(); + await expect(page.getByText(/Metadata remains read-only/)).toBeVisible(); + await expect(page.getByText(/issue_comment/)).toBeVisible(); + await expect(page.getByText(/pull_request_review_comment/)).toBeVisible(); + await expect(page.getByText(/generate one private key/)).toBeVisible(); + await expect(page.getByText(/Enable SSL verification/)).toBeVisible(); + await expect(page.getByText(/Only on this account/)).toBeVisible(); + await expect(page.getByLabel("GitHub App ID")).toHaveAttribute( + "type", + "text", + ); + await expect( + page.getByRole("button", { name: "Generate webhook secret" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open new GitHub App form" }), + ).toBeVisible(); + return; + } + + if (provider.provider === "microsoft-teams") { + const messagingEndpoint = + "https://paperclip.example.test/api/chat-webhooks/public-microsoft-teams/microsoft-teams"; + await expect( + page.getByText(messagingEndpoint, { exact: true }), + ).toBeVisible(); + await expect( + page.getByText(/single-tenant app registration/), + ).toBeVisible(); + await expect( + page.getByText(/Microsoft 365 work or school organization/), + ).toBeVisible(); + await expect(page.getByText(/teams\.live\.com/)).toBeVisible(); + await expect( + page.getByText(/^In Azure, create an Azure Bot/), + ).toBeVisible(); + await expect(page.getByText("Microsoft portal field map")).toBeVisible(); + await expect( + page.getByText(/Accounts in this organizational directory only/), + ).toBeVisible(); + await expect(page.getByText(/Use existing app registration/)).toBeVisible(); + await expect( + page.getByText(/Configure · App features · Bot/), + ).toBeVisible(); + await expect( + page.getByText(/Upload an app · Upload a custom app/), + ).toBeVisible(); + const manifest = await page + .getByLabel("Required Teams app manifest block") + .inputValue(); + expect(manifest).toContain("ChannelMessage.Read.Group"); + expect(manifest).toContain("ChatMessage.Read.Chat"); + expect(manifest).toContain('"personal"'); + expect(manifest).toContain('"team"'); + expect(manifest).toContain('"groupChat"'); + expect(JSON.parse(manifest)).toMatchObject({ + bots: [ + { + commandLists: [ + { + scopes: ["personal", "groupChat"], + commands: [ + { title: "/status" }, + { title: "/new" }, + { title: "/close" }, + ], + }, + ], + }, + ], + webApplicationInfo: { + id: "", + resource: "https://paperclip.ing", + }, + }); + expect(manifest).not.toContain("api://paperclip-chat/"); + expect(manifest).not.toContain("supportsTargetedMessages"); + await expect( + page.getByRole("button", { name: "Copy manifest settings" }), + ).toBeDisabled(); + await expect(page.getByText(/not a complete app package/)).toBeVisible(); + await expect( + page.getByText(/does not use Teams single sign-on/), + ).toBeVisible(); + await expect( + page.getByText(/only associates the RSC permissions/), + ).toBeVisible(); + await expect(page.getByText(/receive every message/).first()).toBeVisible(); + await expect( + page.getByText(/One team install covers its standard channels/), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Open Microsoft Entra" }), + ).toHaveAttribute("href", /entra\.microsoft\.com/); + await expect( + page.getByRole("link", { name: "Create Azure Bot" }), + ).toHaveAttribute("href", /portal\.azure\.com/); + await expect( + page.getByRole("link", { name: "Open Teams Developer Portal" }), + ).toHaveAttribute("href", /dev\.teams\.microsoft\.com/); + await expect(page.getByLabel("Application / Client ID")).toHaveAttribute( + "type", + "text", + ); + await expect(page.getByLabel("Directory / Tenant ID")).toHaveAttribute( + "type", + "text", + ); + await expect(page.getByLabel("Client secret value")).toHaveAttribute( + "type", + "password", + ); + return; + } + + if (provider.provider === "discord") { + await expect(page.getByText(/enable Message Content Intent/)).toBeVisible(); + await expect(page.getByText(/copy its Server ID/)).toBeVisible(); + await expect(page.getByLabel("Application ID")).toHaveAttribute( + "type", + "text", + ); + await expect(page.getByLabel("Server ID")).toHaveAttribute("type", "text"); + await expect(page.getByLabel("Bot token")).toHaveAttribute( + "type", + "password", + ); + await expect( + page.getByRole("button", { name: "Open Discord Developer Portal" }), + ).toBeVisible(); + await page.getByLabel("Application ID").fill("1457808928258658549"); + await page.getByLabel("Server ID").fill("1457808928258658549"); + await expect( + page.getByRole("link", { name: "Install bot in this server" }), + ).toHaveAttribute( + "href", + /client_id=1457808928258658549&permissions=309237763136&scope=bot&guild_id=1457808928258658549&disable_guild_select=true/, + ); + await expect(page.getByText(/Create Public Threads/)).toBeVisible(); + return; + } + + await expect(page.getByText("/newbot", { exact: true })).toBeVisible(); + await expect(page.getByText(/username ending in/)).toBeVisible(); + await expect(page.getByText(/\/task@bot_username/)).toBeVisible(); + await expect( + page.getByText(/ordinary mentions are not delivered/), + ).toBeVisible(); + await expect(page.getByLabel("Bot token")).toHaveAttribute( + "type", + "password", + ); + await expect( + page.getByRole("button", { name: "Open BotFather" }), + ).toBeVisible(); +} + +export async function expectProviderTryInstructions( + page: Page, + provider: ProviderCase, +) { + const expected = + provider.provider === "slack" + ? [ + "Open a channel and invite the bot if needed.", + "Mention @maya-paperclip in a new channel message.", + "Reply once in Maya's thread.", + ] + : provider.provider === "github" + ? [ + "Open an installed issue or pull request.", + "Mention @maya-paperclip in a comment.", + "Add another comment to continue the same task.", + ] + : provider.provider === "microsoft-teams" + ? [ + "Open an installed channel and start a new post.", + "Mention @maya-paperclip in the post.", + "Reply once beneath the post.", + ] + : provider.provider === "discord" + ? [ + "Open a text channel where the bot is installed.", + "Mention @maya-paperclip in a new root message.", + "Reply once inside Maya's new Discord thread.", + ] + : [ + "Open the bot's private chat.", + "Tap Start.", + "Send “Help me test this”.", + ]; + for (const instruction of expected) { + await expect(page.getByText(instruction, { exact: true })).toBeVisible(); + } + await expect( + page.getByRole("link", { name: `Open ${provider.name}` }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: `Open ${provider.name}` }), + ).toHaveAttribute("href", provider.externalUrl); +} diff --git a/tests/e2e/chat-adapters-ui.spec.ts b/tests/e2e/chat-adapters-ui.spec.ts deleted file mode 100644 index e98d816d97..0000000000 --- a/tests/e2e/chat-adapters-ui.spec.ts +++ /dev/null @@ -1,3491 +0,0 @@ -import { - expect, - test, - type APIRequestContext, - type BrowserContext, - type Page, - type Route, -} from "@playwright/test"; - -/** - * Deterministic browser coverage for the native chat-connector UI. - * - * Provider APIs are deliberately not contacted here. The shared Paperclip - * server supplies the company, agent, and connector catalog, while a small - * stateful route fixture emulates the chat-control-plane responses. Live - * provider webhook and credential qualification belongs in the manual runbook - * because those checks require real accounts and publicly reachable ingress. - */ - -type Provider = "slack" | "github" | "discord" | "microsoft-teams" | "telegram" | "imessage-photon"; - -const GITHUB_PRIVATE_KEY_FIXTURE = - "-----BEGIN PRIVATE KEY-----\nlocal-e2e-key\n-----END PRIVATE KEY-----\n"; -const GITHUB_PRIVATE_KEY_PASTE_FIXTURE = - "-----BEGIN PRIVATE KEY-----\nlocal-e2e-pasted-key\n-----END PRIVATE KEY-----\n"; - -type ProviderCase = { - provider: Provider; - slug: string; - name: string; - accountLabel: string; - botLabel: string; - botUsername: string; - resourceLabel: string; - secondaryResourceLabel: string; - resourceType: string; - externalUrl: string; - setupHeading: RegExp; - setupButton: string; - chatAndTool: boolean; -}; - -const PROVIDERS: ProviderCase[] = [ - { - provider: "slack", - slug: "slack", - name: "Slack", - accountLabel: "Acme Workspace", - botLabel: "Maya", - botUsername: "maya-paperclip", - resourceLabel: "#product", - secondaryResourceLabel: "#support", - resourceType: "channel", - externalUrl: "https://app.slack.com/client/T-E2E/C-E2E/thread/C-E2E-1", - setupHeading: /Connect a Slack app/i, - setupButton: "Connect Slack app", - chatAndTool: true, - }, - { - provider: "github", - slug: "github", - name: "GitHub", - accountLabel: "paperclip-ai", - botLabel: "Maya", - // GitHub's API returns the actor login with this suffix, while people - // invoke the App with the bare slug. - botUsername: "maya-paperclip[bot]", - resourceLabel: "paperclip-ai/paperclip", - secondaryResourceLabel: "paperclip-ai/chat-e2e", - resourceType: "repository", - externalUrl: "https://github.com/paperclip-ai/paperclip/issues/123", - setupHeading: /Create or connect a GitHub App/i, - setupButton: "Connect and verify", - chatAndTool: true, - }, - { - provider: "microsoft-teams", - slug: "microsoft-teams", - name: "Microsoft Teams", - accountLabel: "Acme Tenant", - botLabel: "Maya", - botUsername: "maya-paperclip", - resourceLabel: "Product / General", - secondaryResourceLabel: "Product / Incidents", - resourceType: "channel", - externalUrl: "https://teams.microsoft.com/l/message/19:e2e@thread.tacv2/1", - setupHeading: /Connect Maya to Microsoft Teams/i, - setupButton: "Verify Microsoft credentials", - chatAndTool: false, - }, - { - provider: "discord", - slug: "discord", - name: "Discord", - accountLabel: "Clawd", - botLabel: "Maya", - botUsername: "maya-paperclip", - resourceLabel: "#general", - secondaryResourceLabel: "#support", - resourceType: "channel", - externalUrl: - "https://discord.com/channels/1457808928258658549/1457808933082108089", - setupHeading: /Connect Maya to Discord/i, - setupButton: "Connect Discord bot", - chatAndTool: false, - }, - { - provider: "telegram", - slug: "telegram", - name: "Telegram", - accountLabel: "@maya_paperclip_bot", - botLabel: "Maya", - botUsername: "maya_paperclip_bot", - resourceLabel: "Maya test chat", - secondaryResourceLabel: "Maya group chat", - resourceType: "direct_message", - externalUrl: "https://t.me/maya_paperclip_bot", - setupHeading: /Create Maya in Telegram/i, - setupButton: "Connect bot", - chatAndTool: false, - }, -]; - -const PROVIDER_LIFECYCLE_COPY: Record< - Provider, - { reconnect: string; remove: string } -> = { - "imessage-photon": { reconnect: "Reconnect the same dedicated Photon number", remove: "does not delete the Photon project" }, - slack: { - reconnect: - "Reconnect verifies or replaces credentials for this same Slack app. It does not reinstall the app or change its workspace or channel membership.", - remove: "It does not uninstall the Slack app", - }, - github: { - reconnect: - "Reconnect verifies this same App and installation, then updates its webhook URL, secret, and secure delivery settings. It does not reinstall the App or change repository access.", - remove: "It does not uninstall the GitHub App", - }, - discord: { - reconnect: - "Reconnect verifies this same Discord application and server installation. It does not add or remove the bot from the server.", - remove: "It does not uninstall the bot", - }, - "microsoft-teams": { - reconnect: - "Reconnect verifies this same Microsoft app, tenant, and bot identity. It does not upload or reinstall the Teams app.", - remove: "It does not uninstall the Teams app", - }, - telegram: { - reconnect: - "Reconnect verifies this same BotFather bot and automatically refreshes its Paperclip webhook and command menu.", - remove: - "queues durable removal of its Telegram webhook and command menu. After Telegram confirms that cleanup, Paperclip retires the saved token", - }, -}; - -type Seed = { - companyId: string; - prefix: string; - agentId: string; - otherAgentId: string; -}; - -async function json( - response: Awaited>, - label: string, -): Promise { - expect( - response.ok(), - `${label} failed ${response.status()}: ${await response.text()}`, - ).toBe(true); - return (await response.json()) as T; -} - -async function seedCompanyAndAgent(request: APIRequestContext): Promise { - const company = await json<{ id: string; issuePrefix: string }>( - await request.post("/api/companies", { - data: { name: `Chat adapters browser E2E ${Date.now()}` }, - }), - "create company", - ); - const agent = await json<{ id: string }>( - await request.post(`/api/companies/${company.id}/agents`, { - data: { - name: "Maya", - role: "qa", - title: "Chat connector test agent", - capabilities: "Exercises deterministic chat connector browser flows.", - adapterType: "process", - adapterConfig: { - command: process.execPath, - args: ["--input-type=module", "-e", "process.exit(0)"], - }, - }, - }), - "create agent", - ); - const otherAgent = await json<{ id: string }>( - await request.post(`/api/companies/${company.id}/agents`, { - data: { - name: "Nora", - role: "qa", - title: "Second chat connector test agent", - capabilities: "Proves a chat connection keeps its chosen agent.", - adapterType: "process", - adapterConfig: { - command: process.execPath, - args: ["--input-type=module", "-e", "process.exit(0)"], - }, - }, - }), - "create second agent", - ); - return { - companyId: company.id, - prefix: company.issuePrefix, - agentId: agent.id, - otherAgentId: otherAgent.id, - }; -} - -function bodyOf(route: Route): Record { - return route.request().postDataJSON() as Record; -} - -async function fulfill(route: Route, body: unknown, status = 200) { - await route.fulfill({ - status, - contentType: "application/json", - body: JSON.stringify(body), - }); -} - -function endpointFixture(provider: ProviderCase, seed: Seed) { - const now = new Date().toISOString(); - return { - id: `endpoint-${provider.provider}`, - companyId: seed.companyId, - connectionId: `connection-${provider.provider}`, - provider: provider.provider, - publicId: `public-${provider.provider}`, - status: "draft", - deploymentMode: "direct", - assignedAgentId: seed.agentId, - assignedAgentName: "Maya", - sponsorUserId: null, - providerAccountId: null, - providerAccountLabel: null, - botExternalId: null, - botUsername: null, - botLabel: null, - allowDirectMessages: false, - allowGroupChats: false, - allowUnlinkedPeople: false, - replyMode: "subscribed", - capabilities: { - threads: provider.provider !== "telegram", - directMessages: provider.provider !== "github", - nativeStreaming: - provider.provider === "slack" || provider.provider === "telegram", - messageEdits: true, - messageDeletes: - provider.provider === "slack" || - provider.provider === "github" || - provider.provider === "discord", - reactions: true, - files: true, - cards: - provider.provider === "slack" || - provider.provider === "microsoft-teams", - actions: true, - modals: false, - slashCommands: - provider.provider !== "github" && provider.provider !== "discord", - ephemeralMessages: - provider.provider === "slack" || - provider.provider === "microsoft-teams", - proactiveDirectMessages: false, - }, - setup: { - step: "provider_setup", - authorizationUrl: - provider.provider === "telegram" - ? "https://t.me/BotFather" - : provider.provider === "microsoft-teams" - ? "https://dev.teams.microsoft.com/apps" - : provider.provider === "github" - ? "https://github.com/settings/apps" - : provider.provider === "discord" - ? "https://discord.com/developers/applications" - : "https://api.slack.com/apps", - providerUrl: provider.externalUrl, - webhookUrl: `https://paperclip.example.test/api/chat-webhooks/public-${provider.provider}/${provider.provider}`, - messagingEndpoint: `https://paperclip.example.test/api/chat-webhooks/public-${provider.provider}/microsoft-teams`, - command: provider.provider === "slack" ? "/maya-public" : undefined, - webhookVerifiedAt: null, - webhookSecretConfigured: false, - }, - healthMessage: null, - lastActivityAt: now, - lastPublicationAt: now, - activatedAt: null, - createdAt: now, - updatedAt: now, - }; -} - -type ChatMock = { - chatEndpointListReads: number; - createdWithAgentId: string | null; - configuredCredentialKeys: string[]; - githubPrivateKeyMatchedFile: boolean | null; - githubPrivateKeyMatchedPaste: boolean | null; - githubSetupSecretRequests: number; - setupAttempts: number; - updatedResource: boolean; - resourceUpdates: Array>; - allowDirectMessages: boolean | null; - allowGroupChats: boolean | null; - allowUnlinkedPeople: boolean | null; - linkIntentPrincipalId: string | null; - revokedPrincipalId: string | null; - lifecycleActions: string[]; - replayedDelivery: boolean; - liveActivitySummary: string | null; - conversationState: "active" | "waiting"; - removed: boolean; - setStatus: (status: string) => void; - setGitHubWebhookVerified: () => void; -}; - -async function installChatControlPlaneMock( - page: Page | BrowserContext, - provider: ProviderCase, - seed: Seed, - { - enableChatConnectors, - resourceCount = 2, - photonShared = false, - }: { enableChatConnectors: boolean; resourceCount?: number; photonShared?: boolean }, -): Promise { - const endpoint = endpointFixture(provider, seed); - const state: ChatMock & { - created: boolean; - failNextGitHubEndpointRead: boolean; - } = { - created: false, - failNextGitHubEndpointRead: false, - chatEndpointListReads: 0, - createdWithAgentId: null, - configuredCredentialKeys: [], - githubPrivateKeyMatchedFile: null, - githubPrivateKeyMatchedPaste: null, - githubSetupSecretRequests: 0, - setupAttempts: 0, - updatedResource: false, - resourceUpdates: [], - allowDirectMessages: null, - allowGroupChats: null, - allowUnlinkedPeople: null, - linkIntentPrincipalId: null, - revokedPrincipalId: null, - lifecycleActions: [], - replayedDelivery: false, - liveActivitySummary: null, - conversationState: "active", - removed: false, - setStatus: (status) => { - endpoint.status = status; - }, - setGitHubWebhookVerified: () => { - endpoint.setup.webhookVerifiedAt = new Date().toISOString(); - }, - }; - const resource = { - id: `resource-${provider.provider}`, - companyId: seed.companyId, - endpointId: endpoint.id, - type: provider.resourceType, - providerResourceId: `provider-resource-${provider.provider}`, - label: provider.resourceLabel, - detail: - provider.provider === "github" ? "Repository" : "Available at provider", - providerUrl: provider.externalUrl, - availability: "available", - enabled: false, - createdAt: endpoint.createdAt, - updatedAt: endpoint.updatedAt, - }; - const secondaryResource = { - ...resource, - id: `resource-${provider.provider}-secondary`, - providerResourceId: `provider-resource-${provider.provider}-secondary`, - label: provider.secondaryResourceLabel, - detail: - provider.provider === "github" ? "Repository" : "Available at provider", - }; - const resources = [ - resource, - secondaryResource, - ...Array.from({ length: Math.max(0, resourceCount - 2) }, (_, index) => ({ - ...resource, - id: `resource-${provider.provider}-extra-${index}`, - providerResourceId: `provider-resource-${provider.provider}-extra-${index}`, - label: `Extra destination ${index + 1}`, - })), - ]; - - await page.route("**/api/**", async (route) => { - const request = route.request(); - const pathname = new URL(request.url()).pathname; - const method = request.method(); - - if (pathname === `/api/companies/${seed.companyId}/chat-endpoints`) { - if (method === "GET") { - state.chatEndpointListReads += 1; - await fulfill(route, { endpoints: state.created ? [endpoint] : [] }); - return; - } - if (method === "POST") { - const body = bodyOf(route); - expect(body).toMatchObject({ - provider: provider.provider, - assignedAgentId: seed.agentId, - }); - state.createdWithAgentId = String(body.assignedAgentId); - state.created = true; - await fulfill(route, endpoint, 201); - return; - } - } - - if (pathname === `/api/chat-endpoints/${endpoint.id}`) { - if (method === "GET") { - if ( - provider.provider === "github" && - state.failNextGitHubEndpointRead - ) { - state.failNextGitHubEndpointRead = false; - await fulfill(route, { error: "Temporary read failure." }, 503); - return; - } - await fulfill(route, endpoint); - return; - } - if (method === "PATCH") { - const body = bodyOf(route); - if (typeof body.allowDirectMessages === "boolean") - state.allowDirectMessages = body.allowDirectMessages; - if (typeof body.allowGroupChats === "boolean") - state.allowGroupChats = body.allowGroupChats; - if (typeof body.allowUnlinkedPeople === "boolean") - state.allowUnlinkedPeople = body.allowUnlinkedPeople; - Object.assign(endpoint, body, { - updatedAt: new Date().toISOString(), - }); - await fulfill(route, endpoint); - return; - } - } - - if ( - pathname === "/api/instance/settings/experimental" && - method === "GET" - ) { - await fulfill(route, { - enableChatConnectors, - enableIsolatedWorkspaces: false, - }); - return; - } - - if ( - pathname === `/api/chat-endpoints/${endpoint.id}/setup-secret` && - method === "POST" - ) { - state.githubSetupSecretRequests += 1; - endpoint.setup.webhookSecretConfigured = true; - endpoint.setup.step = "provider_setup"; - endpoint.setup.webhookVerifiedAt = - state.githubSetupSecretRequests === 1 ? new Date().toISOString() : null; - if (state.githubSetupSecretRequests > 1) { - state.failNextGitHubEndpointRead = true; - } - await fulfill(route, { webhookSecret: "github-webhook-secret" }, 201); - return; - } - - if ( - pathname === `/api/chat-endpoints/${endpoint.id}/setup` && - method === "POST" - ) { - const body = bodyOf(route); - const action = String(body.action); - if (action === "pause" || action === "resume" || action === "remove") { - state.lifecycleActions.push(action); - if (action === "pause") endpoint.status = "paused"; - if (action === "resume") endpoint.status = "active"; - if (action === "remove") { - endpoint.status = "archived"; - state.created = false; - state.removed = true; - } - await fulfill(route, endpoint); - return; - } - expect(["configure", "verify"]).toContain(action); - if (body.action === "configure") { - state.setupAttempts += 1; - state.configuredCredentialKeys = Object.keys( - (body.credentials ?? {}) as Record, - ).sort(); - if (provider.provider === "github") { - const privateKey = ( - (body.credentials ?? {}) as Record - ).privateKey; - state.githubPrivateKeyMatchedFile = - state.githubPrivateKeyMatchedFile === true || - privateKey === GITHUB_PRIVATE_KEY_FIXTURE; - state.githubPrivateKeyMatchedPaste = - state.githubPrivateKeyMatchedPaste === true || - privateKey === GITHUB_PRIVATE_KEY_PASTE_FIXTURE; - if (state.setupAttempts === 1) { - await fulfill( - route, - { error: "GitHub rejected the supplied App credentials." }, - 422, - ); - return; - } - } - if (provider.provider === "telegram" && state.setupAttempts === 1) { - const submittedToken = String( - ((body.credentials ?? {}) as Record).botToken ?? "", - ); - await fulfill( - route, - { - error: `Telegram rejected bot token ${submittedToken}. Confirm the token in BotFather and try again.`, - }, - 422, - ); - return; - } - } else { - expect(provider.provider).toBe("slack"); - } - if (provider.provider === "imessage-photon") { - expect(body.photon).toEqual(photonShared ? {allocation:"shared",projectId:"project-e2e"} : {allocation:"dedicated",projectId:"project-e2e",lineId:"line-one"}); - } - Object.assign(endpoint, { - ...(provider.provider === "imessage-photon" ? {photonAllocation: photonShared ? "shared" : "dedicated", allowGroupChats: !photonShared} : {}), - status: "verifying", - providerAccountId: `account-${provider.provider}`, - providerAccountLabel: provider.accountLabel, - botExternalId: provider.provider === "imessage-photon" ? photonShared ? "photon-project:project-e2e" : "+15555550100" : `bot-${provider.provider}`, - botUsername: photonShared ? null : provider.botUsername, - botLabel: provider.botLabel, - setup: { - ...endpoint.setup, - step: - provider.provider === "slack" && body.action === "configure" - ? "provider_setup" - : "test", - }, - }); - await fulfill(route, endpoint); - return; - } - - if (pathname === `/api/chat-endpoints/${endpoint.id}/photon/inspect` && method === "POST") { - expect(bodyOf(route)).toEqual({projectId:"project-e2e",projectSecret:"photon-test-secret"}); - await fulfill(route,{projectId:"project-e2e",projectName:"Photon Test",allocation:photonShared ? "shared" : "dedicated",eligible:true,lines:photonShared ? [] : [{lineId:"line-one",phoneNumber:"+15555550100",eligible:true},{lineId:"line-two",phoneNumber:"+15555550102",eligible:true}]}); return; - } - if ( - pathname === `/api/chat-endpoints/${endpoint.id}/test` && - method === "POST" - ) { - Object.assign(endpoint, { - status: "active", - activatedAt: new Date().toISOString(), - setup: { ...endpoint.setup, step: "complete" }, - }); - await fulfill(route, endpoint); - return; - } - - if (pathname === `/api/chat-endpoints/${endpoint.id}/resources`) { - if (method === "GET") { - await fulfill(route, { resources }); - return; - } - if (method === "PUT") { - const updates = (bodyOf(route).resources ?? []) as Array<{ - id: string; - enabled: boolean; - }>; - state.resourceUpdates.push(updates); - // Match the real request-size boundary without contacting a provider. - if (updates.length > 500) { - await fulfill(route, { error: "Too many resource updates." }, 400); - return; - } - for (const update of updates) { - const target = resources.find((item) => item.id === update.id); - if (target) target.enabled = update.enabled; - } - state.updatedResource = resource.enabled; - await fulfill(route, resources); - return; - } - } - - if ( - pathname === `/api/chat-endpoints/${endpoint.id}/principals` && - method === "GET" - ) { - await fulfill(route, { - principals: [ - { - id: `link-${provider.provider}`, - principalId: `principal-${provider.provider}`, - externalLabel: "Ada Lovelace", - externalDetail: `ada@${provider.provider}`, - paperclipUserId: null, - paperclipUserLabel: null, - status: "pending", - }, - { - id: `link-${provider.provider}-linked`, - principalId: `principal-${provider.provider}-linked`, - externalLabel: "Grace Hopper", - externalDetail: `grace@${provider.provider}`, - paperclipUserId: - state.revokedPrincipalId === - `principal-${provider.provider}-linked` - ? null - : "paperclip-user-grace", - paperclipUserLabel: - state.revokedPrincipalId === - `principal-${provider.provider}-linked` - ? null - : "Grace Hopper", - status: - state.revokedPrincipalId === - `principal-${provider.provider}-linked` - ? "revoked" - : "linked", - }, - ], - }); - return; - } - - if ( - pathname === - `/api/chat-endpoints/${endpoint.id}/principals/principal-${provider.provider}/link-intent` && - method === "POST" - ) { - state.linkIntentPrincipalId = `principal-${provider.provider}`; - await fulfill(route, { - confirmationUrl: `https://paperclip.example.test/${seed.prefix}/chat-identity/confirm?token=e2e-redacted`, - }); - return; - } - - if ( - pathname === - `/api/chat-endpoints/${endpoint.id}/principals/principal-${provider.provider}-linked/link` && - method === "DELETE" - ) { - state.revokedPrincipalId = `principal-${provider.provider}-linked`; - await route.fulfill({ status: 204, body: "" }); - return; - } - - if ( - pathname === `/api/chat-endpoints/${endpoint.id}/conversations` && - method === "GET" - ) { - await fulfill(route, { - conversations: [ - { - id: `conversation-${provider.provider}`, - companyId: seed.companyId, - endpointId: endpoint.id, - resourceId: resource.id, - issueId: `issue-${provider.provider}`, - issueIdentifier: "CHAT-123", - issueTitle: `Investigate ${provider.name} delivery`, - externalConversationId: `external-conversation-${provider.provider}`, - externalThreadId: `external-thread-${provider.provider}`, - externalLabel: provider.resourceLabel, - externalUrl: provider.provider === "imessage-photon" ? null : provider.externalUrl, - isDirectMessage: provider.provider === "telegram", - state: state.conversationState, - lastPublicationStatus: "published", - createdAt: endpoint.createdAt, - updatedAt: endpoint.updatedAt, - }, - ], - }); - return; - } - - if ( - pathname === `/api/chat-endpoints/${endpoint.id}/activity` && - method === "GET" - ) { - await fulfill(route, { - items: [ - ...(state.liveActivitySummary - ? [ - { - id: `live-reaction-${provider.provider}`, - kind: "delivery", - status: "processed", - summary: state.liveActivitySummary, - createdAt: endpoint.createdAt, - replayable: false, - }, - ] - : []), - { - id: `delivery-${provider.provider}`, - kind: "delivery", - status: "failed", - summary: `Inbound ${provider.name} delivery could not be processed`, - detail: "Credential values and request bodies are redacted.", - createdAt: endpoint.createdAt, - replayable: true, - }, - { - id: `publication-${provider.provider}`, - kind: "publication", - status: "published", - summary: `Published safe output to ${provider.name}`, - createdAt: endpoint.createdAt, - replayable: false, - }, - ], - }); - return; - } - - if ( - pathname === - `/api/chat-endpoints/${endpoint.id}/deliveries/delivery-${provider.provider}/replay` && - method === "POST" - ) { - state.replayedDelivery = true; - await fulfill(route, {}); - return; - } - - await route.continue(); - }); - - return state; -} - -async function selectMaya(page: Page) { - await page.getByRole("button", { name: "Choose an active agent" }).click(); - await page.getByRole("button", { name: "Select Maya" }).click(); - await page.getByRole("button", { name: "Continue", exact: true }).click(); -} - -async function fillProviderSetup(page: Page, provider: ProviderCase) { - if (provider.provider === "slack") { - await page.getByLabel("Bot User OAuth Token").fill("xoxb-e2e-redacted"); - await page.getByLabel("Signing Secret").fill("slack-signing-secret"); - } else if (provider.provider === "github") { - await page.getByRole("button", { name: "Generate webhook secret" }).click(); - await expect(page.getByLabel("Generated webhook secret")).toHaveValue( - "github-webhook-secret", - ); - await page.getByLabel("GitHub App ID").fill("123456"); - const privateKeyFile = page.getByLabel( - "Choose GitHub App private key file", - ); - await privateKeyFile.setInputFiles({ - name: "paperclip-test.pem", - mimeType: "application/x-pem-file", - buffer: Buffer.alloc(64 * 1024 + 1, "x"), - }); - await expect(page.getByRole("alert")).toContainText( - "That file is too large. Choose a GitHub App private key smaller than 64 KB.", - ); - await page.getByLabel("GitHub App ID").focus(); - await page.getByLabel("GitHub App ID").press("Tab"); - await expect(page.getByRole("alert")).toBeVisible(); - const fileChooserPromise = page.waitForEvent("filechooser"); - await page.getByRole("button", { name: "Choose .pem file" }).click(); - const fileChooser = await fileChooserPromise; - await fileChooser.setFiles({ - name: "paperclip-test.pem", - mimeType: "application/x-pem-file", - buffer: Buffer.from(GITHUB_PRIVATE_KEY_FIXTURE), - }); - await expect(page.getByRole("alert")).toHaveCount(0); - await expect(page.getByRole("status")).toContainText( - "Private key loaded. It stays in this form until you connect.", - ); - await expect( - page.getByRole("button", { name: "Show private key" }), - ).toBeVisible(); - await expect(page.getByLabel("Private key (PEM)")).toHaveAttribute( - "type", - "password", - ); - await expect(page.locator("textarea#github-private-key")).toHaveCount(0); - await expect(page.locator("body")).not.toContainText( - GITHUB_PRIVATE_KEY_FIXTURE, - ); - await expect(page.locator("body")).not.toContainText("paperclip-test.pem"); - await page.getByRole("button", { name: provider.setupButton }).click(); - await expect(page.getByRole("alert")).toContainText("Connection failed"); - await page - .getByLabel("Private key (PEM)") - .evaluate((element, privateKey) => { - const clipboardData = new DataTransfer(); - clipboardData.setData("text/plain", privateKey); - element.dispatchEvent( - new ClipboardEvent("paste", { - bubbles: true, - cancelable: true, - clipboardData, - }), - ); - }, GITHUB_PRIVATE_KEY_PASTE_FIXTURE); - await expect(page.locator("textarea#github-private-key")).toHaveCount(0); - await expect(page.locator("body")).not.toContainText( - GITHUB_PRIVATE_KEY_PASTE_FIXTURE, - ); - await page.getByRole("button", { name: "Show private key" }).click(); - await expect(page.locator("textarea#github-private-key")).toHaveValue( - GITHUB_PRIVATE_KEY_PASTE_FIXTURE, - ); - await page.getByRole("button", { name: "Hide private key" }).click(); - await expect(page.locator("textarea#github-private-key")).toHaveCount(0); - await expect(page.getByLabel("Private key (PEM)")).toHaveAttribute( - "type", - "password", - ); - await expect(page.locator("body")).not.toContainText( - GITHUB_PRIVATE_KEY_PASTE_FIXTURE, - ); - } else if (provider.provider === "microsoft-teams") { - const clientId = "00000000-0000-4000-8000-000000000001"; - await page.getByLabel("Application / Client ID").fill(clientId); - await page - .getByLabel("Directory / Tenant ID") - .fill("00000000-0000-4000-8000-000000000002"); - await page.getByLabel("Client secret").fill("teams-client-secret"); - const manifest = JSON.parse( - await page.getByLabel("Required Teams app manifest block").inputValue(), - ) as { webApplicationInfo?: { id?: string; resource?: string } }; - expect(manifest.webApplicationInfo).toEqual({ - id: clientId, - resource: "https://paperclip.ing", - }); - await expect( - page.getByRole("button", { name: "Copy manifest settings" }), - ).toBeEnabled(); - } else if (provider.provider === "discord") { - await page.getByLabel("Application ID").fill("1457808928258658549"); - await page.getByLabel("Server ID").fill("1457808928258658549"); - await page.getByLabel("Bot token").fill("discord-e2e-redacted"); - } else { - await page.getByLabel("Bot token").fill("123456:e2e-redacted"); - } - await page.getByRole("button", { name: provider.setupButton }).click(); -} - -function expectedCredentialKeys(provider: Provider): string[] { - if (provider === "slack") return ["botToken", "signingSecret"]; - if (provider === "github") return ["appId", "privateKey"]; - if (provider === "microsoft-teams") - return ["clientId", "clientSecret", "tenantId"]; - if (provider === "discord") return ["applicationId", "botToken", "guildId"]; - return ["botToken"]; -} - -async function expectSetupRail(page: Page) { - const rail = page.getByRole("list", { name: "Connection setup progress" }); - await expect(rail).toBeVisible(); - await expect(rail.getByRole("listitem")).toHaveCount(3); - for (const label of ["Choose agent", "Connect provider", "Try it"]) { - await expect(rail.getByText(label, { exact: true })).toBeVisible(); - } -} - -function expectedSlackManifest(webhookUrl: string) { - return `display_information: - name: "maya-paperclip" -features: - app_home: - home_tab_enabled: false - messages_tab_enabled: true - messages_tab_read_only_enabled: false - agent_view: - agent_description: "Work with a Paperclip agent in a task-backed conversation." - bot_user: - display_name: "maya" - slash_commands: - - command: "/maya-public" - description: Start or manage work with "Maya" - usage_hint: "status | new | close | " - should_escape: false - url: "${webhookUrl}" -oauth_config: - scopes: - bot: - - app_mentions:read - - assistant:write - - channels:history - - channels:read - - chat:write - - commands - - files:read - - files:write - - groups:history - - groups:read - - im:history - - im:read - - mpim:history - - mpim:read - - reactions:read - - reactions:write - - users:read -settings: - org_deploy_enabled: false - socket_mode_enabled: false - token_rotation_enabled: false - event_subscriptions: - request_url: "${webhookUrl}" - bot_events: - - agent_session_stopped - - app_mention - - message.channels - - message.groups - - message.im - - message.mpim - - member_joined_channel - - member_left_channel - - channel_left - - group_left - - reaction_added - - reaction_removed - - channel_archive - - group_archive - - channel_unarchive - - group_unarchive - - channel_deleted - - channel_rename - - group_rename - - app_uninstalled - - tokens_revoked - interactivity: - is_enabled: true - request_url: "${webhookUrl}"`; -} - -async function expectMinimumProviderSetup(page: Page, provider: ProviderCase) { - const webhookUrl = `https://paperclip.example.test/api/chat-webhooks/public-${provider.provider}/${provider.provider}`; - if (provider.provider === "slack") { - await expect(page.getByText("From an app manifest")).toBeVisible(); - await expect(page.getByText("OAuth & Permissions")).toBeVisible(); - await expect(page.getByText("Basic Information")).toBeVisible(); - const manifest = await page.getByLabel("Slack app manifest").inputValue(); - expect(manifest).toBe(expectedSlackManifest(webhookUrl)); - await expect(page.getByLabel("Slack app manifest")).toHaveAttribute( - "readonly", - "", - ); - await expect(page.getByLabel("Bot User OAuth Token")).toHaveAttribute( - "type", - "password", - ); - await expect(page.getByLabel("Signing Secret")).toHaveAttribute( - "type", - "password", - ); - await expect( - page.getByRole("button", { name: "Open Slack app settings" }), - ).toBeVisible(); - return; - } - - if (provider.provider === "github") { - await expect(page.getByText(webhookUrl, { exact: true })).toBeVisible(); - await expect(page.getByText(/Metadata remains read-only/)).toBeVisible(); - await expect(page.getByText(/issue_comment/)).toBeVisible(); - await expect(page.getByText(/pull_request_review_comment/)).toBeVisible(); - await expect(page.getByText(/generate one private key/)).toBeVisible(); - await expect(page.getByText(/Enable SSL verification/)).toBeVisible(); - await expect(page.getByText(/Only on this account/)).toBeVisible(); - await expect(page.getByLabel("GitHub App ID")).toHaveAttribute( - "type", - "text", - ); - await expect( - page.getByRole("button", { name: "Generate webhook secret" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Open new GitHub App form" }), - ).toBeVisible(); - return; - } - - if (provider.provider === "microsoft-teams") { - const messagingEndpoint = - "https://paperclip.example.test/api/chat-webhooks/public-microsoft-teams/microsoft-teams"; - await expect( - page.getByText(messagingEndpoint, { exact: true }), - ).toBeVisible(); - await expect( - page.getByText(/single-tenant app registration/), - ).toBeVisible(); - await expect( - page.getByText(/Microsoft 365 work or school organization/), - ).toBeVisible(); - await expect(page.getByText(/teams\.live\.com/)).toBeVisible(); - await expect( - page.getByText(/^In Azure, create an Azure Bot/), - ).toBeVisible(); - await expect(page.getByText("Microsoft portal field map")).toBeVisible(); - await expect( - page.getByText(/Accounts in this organizational directory only/), - ).toBeVisible(); - await expect(page.getByText(/Use existing app registration/)).toBeVisible(); - await expect( - page.getByText(/Configure · App features · Bot/), - ).toBeVisible(); - await expect( - page.getByText(/Upload an app · Upload a custom app/), - ).toBeVisible(); - const manifest = await page - .getByLabel("Required Teams app manifest block") - .inputValue(); - expect(manifest).toContain("ChannelMessage.Read.Group"); - expect(manifest).toContain("ChatMessage.Read.Chat"); - expect(manifest).toContain('"personal"'); - expect(manifest).toContain('"team"'); - expect(manifest).toContain('"groupChat"'); - expect(JSON.parse(manifest)).toMatchObject({ - bots: [ - { - commandLists: [ - { - scopes: ["personal", "groupChat"], - commands: [ - { title: "/status" }, - { title: "/new" }, - { title: "/close" }, - ], - }, - ], - }, - ], - webApplicationInfo: { - id: "", - resource: "https://paperclip.ing", - }, - }); - expect(manifest).not.toContain("api://paperclip-chat/"); - expect(manifest).not.toContain("supportsTargetedMessages"); - await expect( - page.getByRole("button", { name: "Copy manifest settings" }), - ).toBeDisabled(); - await expect(page.getByText(/not a complete app package/)).toBeVisible(); - await expect( - page.getByText(/does not use Teams single sign-on/), - ).toBeVisible(); - await expect( - page.getByText(/only associates the RSC permissions/), - ).toBeVisible(); - await expect(page.getByText(/receive every message/).first()).toBeVisible(); - await expect( - page.getByText(/One team install covers its standard channels/), - ).toBeVisible(); - await expect( - page.getByRole("link", { name: "Open Microsoft Entra" }), - ).toHaveAttribute("href", /entra\.microsoft\.com/); - await expect( - page.getByRole("link", { name: "Create Azure Bot" }), - ).toHaveAttribute("href", /portal\.azure\.com/); - await expect( - page.getByRole("link", { name: "Open Teams Developer Portal" }), - ).toHaveAttribute("href", /dev\.teams\.microsoft\.com/); - await expect(page.getByLabel("Application / Client ID")).toHaveAttribute( - "type", - "text", - ); - await expect(page.getByLabel("Directory / Tenant ID")).toHaveAttribute( - "type", - "text", - ); - await expect(page.getByLabel("Client secret value")).toHaveAttribute( - "type", - "password", - ); - return; - } - - if (provider.provider === "discord") { - await expect(page.getByText(/enable Message Content Intent/)).toBeVisible(); - await expect(page.getByText(/copy its Server ID/)).toBeVisible(); - await expect(page.getByLabel("Application ID")).toHaveAttribute( - "type", - "text", - ); - await expect(page.getByLabel("Server ID")).toHaveAttribute("type", "text"); - await expect(page.getByLabel("Bot token")).toHaveAttribute( - "type", - "password", - ); - await expect( - page.getByRole("button", { name: "Open Discord Developer Portal" }), - ).toBeVisible(); - await page.getByLabel("Application ID").fill("1457808928258658549"); - await page.getByLabel("Server ID").fill("1457808928258658549"); - await expect( - page.getByRole("link", { name: "Install bot in this server" }), - ).toHaveAttribute( - "href", - /client_id=1457808928258658549&permissions=309237763136&scope=bot&guild_id=1457808928258658549&disable_guild_select=true/, - ); - await expect(page.getByText(/Create Public Threads/)).toBeVisible(); - return; - } - - await expect(page.getByText("/newbot", { exact: true })).toBeVisible(); - await expect(page.getByText(/username ending in/)).toBeVisible(); - await expect(page.getByText(/\/task@bot_username/)).toBeVisible(); - await expect( - page.getByText(/ordinary mentions are not delivered/), - ).toBeVisible(); - await expect(page.getByLabel("Bot token")).toHaveAttribute( - "type", - "password", - ); - await expect( - page.getByRole("button", { name: "Open BotFather" }), - ).toBeVisible(); -} - -async function expectProviderTryInstructions( - page: Page, - provider: ProviderCase, -) { - const expected = - provider.provider === "slack" - ? [ - "Open a channel and invite the bot if needed.", - "Mention @maya-paperclip in a new channel message.", - "Reply once in Maya's thread.", - ] - : provider.provider === "github" - ? [ - "Open an installed issue or pull request.", - "Mention @maya-paperclip in a comment.", - "Add another comment to continue the same task.", - ] - : provider.provider === "microsoft-teams" - ? [ - "Open an installed channel and start a new post.", - "Mention @maya-paperclip in the post.", - "Reply once beneath the post.", - ] - : provider.provider === "discord" - ? [ - "Open a text channel where the bot is installed.", - "Mention @maya-paperclip in a new root message.", - "Reply once inside Maya's new Discord thread.", - ] - : [ - "Open the bot's private chat.", - "Tap Start.", - "Send “Help me test this”.", - ]; - for (const instruction of expected) { - await expect(page.getByText(instruction, { exact: true })).toBeVisible(); - } - await expect( - page.getByRole("link", { name: `Open ${provider.name}` }), - ).toBeVisible(); - await expect( - page.getByRole("link", { name: `Open ${provider.name}` }), - ).toHaveAttribute("href", provider.externalUrl); -} - -test.describe("chat destination partial updates", () => { - let seed: Seed; - - test.beforeAll(async ({ request }) => { - seed = await seedCompanyAndAgent(request); - }); - - for (const initiallyEnabled of [false, true]) { - test(`a stale Settings view preserves another view's ${initiallyEnabled ? "revocation" : "grant"}`, async ({ - context, - page, - }) => { - const provider = PROVIDERS.find((item) => item.provider === "discord")!; - // Both real pages share one server fixture, but have separate query caches. - const mock = await installChatControlPlaneMock(context, provider, seed, { - enableChatConnectors: true, - }); - mock.setStatus("active"); - const settingsUrl = `/${seed.prefix}/apps/chat/endpoint-discord/settings`; - const primarySwitch = (view: Page) => - view.getByRole("switch", { name: `Enable ${provider.resourceLabel}` }); - const secondarySwitch = (view: Page) => - view.getByRole("switch", { - name: `Enable ${provider.secondaryResourceLabel}`, - }); - - await page.goto(settingsUrl); - await expect(primarySwitch(page)).not.toBeChecked(); - if (initiallyEnabled) { - await primarySwitch(page).click(); - await expect(primarySwitch(page)).toBeChecked(); - } - const staleView = await context.newPage(); - await staleView.goto(settingsUrl); - await expect(primarySwitch(staleView)).toBeChecked({ - checked: initiallyEnabled, - }); - await expect(secondarySwitch(staleView)).not.toBeChecked(); - - await primarySwitch(page).click(); - await expect(primarySwitch(page)).toBeChecked({ - checked: !initiallyEnabled, - }); - // Assert the stale prerequisite instead of assuming browser focus/refetch. - await expect(primarySwitch(staleView)).toBeChecked({ - checked: initiallyEnabled, - }); - await secondarySwitch(staleView).click(); - await expect(secondarySwitch(staleView)).toBeChecked(); - await expect(primarySwitch(staleView)).toBeChecked({ - checked: !initiallyEnabled, - }); - expect(mock.resourceUpdates.at(-1)).toEqual([ - { id: "resource-discord-secondary", enabled: true }, - ]); - - // The full response refreshes this page, and persisted state survives reload. - await page.reload(); - await expect(primarySwitch(page)).toBeChecked({ - checked: !initiallyEnabled, - }); - await expect(secondarySwitch(page)).toBeChecked(); - await staleView.close(); - }); - } - - test("a pending destination change stays truthful on rejection and explicit retry", async ({ - page, - }, testInfo) => { - const provider = PROVIDERS.find((item) => item.provider === "discord")!; - const mock = await installChatControlPlaneMock(page, provider, seed, { - enableChatConnectors: true, - }); - mock.setStatus("active"); - const attempts: unknown[] = []; - let holdFirstPut!: (route: Route) => void; - const firstPut = new Promise((resolve) => { - holdFirstPut = resolve; - }); - await page.route( - "**/api/chat-endpoints/endpoint-discord/resources", - async (route) => { - if (route.request().method() === "PUT") { - attempts.push(bodyOf(route)); - if (attempts.length === 1) { - holdFirstPut(route); - return; - } - } - await route.fallback(); - }, - ); - await page.goto(`/${seed.prefix}/apps/chat/endpoint-discord/settings`); - const primary = page.getByRole("switch", { name: "Enable #general" }); - const secondary = page.getByRole("switch", { name: "Enable #support" }); - await expect(primary).not.toBeChecked(); - await primary.click(); - const held = await firstPut; - try { - await expect(primary).toBeDisabled(); - await expect(secondary).toBeDisabled(); - await expect(primary).not.toBeChecked(); - await expect(secondary).not.toBeChecked(); - expect(mock.resourceUpdates).toEqual([]); - await page.screenshot({ - path: testInfo.outputPath("destination-pending.png"), - }); - } finally { - await fulfill( - held, - { error: "Destination is no longer available. Refresh and try again." }, - 409, - ); - } - await expect( - page.getByText("Couldn't update destination", { exact: true }), - ).toBeVisible(); - await expect( - page.getByText( - "Destination is no longer available. Refresh and try again.", - { exact: true }, - ), - ).toBeVisible(); - await expect(primary).toBeEnabled(); - await expect(secondary).toBeEnabled(); - await expect(primary).not.toBeChecked(); - await expect(secondary).not.toBeChecked(); - expect(mock.resourceUpdates).toEqual([]); - expect(attempts).toEqual([ - { resources: [{ id: "resource-discord", enabled: true }] }, - ]); - // Visibility alone also matches the toast's initial transparent animation frame. - await expect( - page - .getByRole("listitem") - .filter({ hasText: "Couldn't update destination" }), - ).toHaveCSS("opacity", "1"); - await page.screenshot({ - path: testInfo.outputPath("destination-rejected.png"), - }); - await primary.click(); - await expect(primary).toBeChecked(); - await expect(secondary).not.toBeChecked(); - expect(attempts).toEqual([ - { resources: [{ id: "resource-discord", enabled: true }] }, - { resources: [{ id: "resource-discord", enabled: true }] }, - ]); - expect(mock.resourceUpdates).toEqual([ - [{ id: "resource-discord", enabled: true }], - ]); - await page.reload(); - await expect(primary).toBeChecked(); - await expect(secondary).not.toBeChecked(); - await page.screenshot({ - path: testInfo.outputPath("destination-retry-saved.png"), - }); - }); - - test("one toggle succeeds with more than 500 discovered destinations", async ({ - page, - }) => { - const provider = PROVIDERS.find((item) => item.provider === "discord")!; - const mock = await installChatControlPlaneMock(page, provider, seed, { - enableChatConnectors: true, - resourceCount: 501, - }); - mock.setStatus("active"); - await page.goto(`/${seed.prefix}/apps/chat/endpoint-discord/settings`); - await expect(page.getByRole("switch", { name: /^Enable / })).toHaveCount( - 501, - ); - const target = page.getByRole("switch", { - name: "Enable Extra destination 499", - }); - await expect(target).not.toBeChecked(); - await target.click(); - await expect(target).toBeChecked(); - expect(mock.resourceUpdates).toEqual([ - [{ id: "resource-discord-extra-498", enabled: true }], - ]); - await expect( - page.getByRole("switch", { name: "Enable #general" }), - ).not.toBeChecked(); - await page.reload(); - await expect(target).toBeChecked(); - await expect(page.getByRole("switch", { name: /^Enable / })).toHaveCount( - 501, - ); - }); -}); - -test.describe.serial("native chat adapter UI", () => { - test.setTimeout(180_000); - - let seed: Seed; - - test.beforeAll(async ({ request }) => { - seed = await seedCompanyAndAgent(request); - }); - - test("GitHub: the default-off gate keeps direct tool setup and fences chat routes", async ({ - page, - }) => { - const github = PROVIDERS.find( - (provider) => provider.provider === "github", - )!; - const mock = await installChatControlPlaneMock(page, github, seed, { - enableChatConnectors: false, - }); - - await page.goto(`/${seed.prefix}/apps`); - await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible( - { timeout: 30_000 }, - ); - const connector = page.locator( - `[role="listitem"][data-app-slug="${github.slug}"]`, - ); - await expect(connector).toBeVisible(); - await connector.getByRole("button", { name: "Connect GitHub" }).click(); - - await expect(page).toHaveURL(/\/apps\/connect\?/); - expect(new URL(page.url()).searchParams.get("source")).toBe("github"); - await expect( - page.getByRole("heading", { name: "Connect GitHub as" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Choose how to connect" }), - ).toHaveCount(0); - await expect( - page.getByText("Chat with an agent", { exact: true }), - ).toHaveCount(0); - - await page.goto(`/${seed.prefix}/apps/chat/connect?provider=github`); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`)); - await expect( - page.getByRole("heading", { name: "Connectors" }), - ).toBeVisible(); - await page.goto(`/${seed.prefix}/apps/chat/endpoint-github/settings`); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`)); - await expect.poll(() => mock.chatEndpointListReads).toBe(0); - expect(mock.createdWithAgentId).toBeNull(); - }); - - for (const enabled of [false, true]) { - test(`Agent Channels: one heading and current experiment gate (${enabled})`, async ({ - page, - }) => { - const github = PROVIDERS.find( - (provider) => provider.provider === "github", - )!; - const mock = await installChatControlPlaneMock(page, github, seed, { - enableChatConnectors: enabled, - }); - - await page.goto(`/${seed.prefix}/agents/${seed.agentId}/channels`); - const channelsHeading = page.getByRole("heading", { - name: "Channels", - exact: true, - }); - if (enabled) { - await expect(page).toHaveURL(/\/channels$/); - await expect(channelsHeading).toHaveCount(1); - await expect(channelsHeading).toBeVisible(); - await expect( - page.getByRole("link", { name: "Connect a channel" }), - ).toBeVisible(); - await expect.poll(() => mock.chatEndpointListReads).toBeGreaterThan(0); - } else { - await expect(page).toHaveURL(/\/overview$/); - await expect(channelsHeading).toHaveCount(0); - await expect( - page.getByRole("link", { name: "Channels", exact: true }), - ).toHaveCount(0); - expect(mock.chatEndpointListReads).toBe(0); - } - }); - } - - for (const provider of PROVIDERS) { - test(`${provider.name}: catalog, setup, and connection management tabs`, async ({ - page, - }) => { - const mock = await installChatControlPlaneMock(page, provider, seed, { - enableChatConnectors: true, - }); - - await page.goto(`/${seed.prefix}/apps`); - await expect( - page.getByRole("heading", { name: "Connectors" }), - ).toBeVisible({ timeout: 30_000 }); - const connector = page.locator( - `[role="listitem"][data-app-slug="${provider.slug}"]`, - ); - await expect(connector).toBeVisible({ timeout: 30_000 }); - await connector - .getByRole("button", { name: `Connect ${provider.name}` }) - .click(); - - if (provider.chatAndTool) { - await expect( - page.getByRole("heading", { name: "Choose how to connect" }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: /Chat with an agent/ }), - ).toBeVisible(); - await expect( - page.getByRole("button", { - name: /Use this connection as an agent tool/, - }), - ).toBeVisible(); - const chatSetupUrl = page.url(); - const toolHref = new URL(chatSetupUrl).searchParams.get("toolHref"); - expect(toolHref).toBeTruthy(); - await page - .getByRole("button", { - name: /Use this connection as an agent tool/, - }) - .click(); - await expect(page).toHaveURL(/\/apps\/connect\?/); - expect(new URL(page.url()).searchParams.get("source")).toBe( - provider.provider, - ); - if (provider.provider === "github") { - await expect( - page.getByRole("heading", { name: "Connect GitHub as" }), - ).toBeVisible(); - await expect( - page.getByText("Chat with an agent", { exact: true }), - ).toHaveCount(0); - } - await page.goto(chatSetupUrl); - await expect( - page.getByRole("heading", { name: "Choose how to connect" }), - ).toBeVisible(); - await page.getByRole("button", { name: /Chat with an agent/ }).click(); - } else { - const chatOnlySetupUrl = new URL(page.url()); - expect(chatOnlySetupUrl.searchParams.get("purpose")).toBe("chat"); - expect(chatOnlySetupUrl.searchParams.get("toolHref")).toBeNull(); - await expect( - page.getByRole("heading", { name: "Choose how to connect" }), - ).toHaveCount(0); - } - - await expect( - page.getByRole("heading", { - name: "Which agent do you want to chat with?", - }), - ).toBeVisible(); - await expectSetupRail(page); - await selectMaya(page); - await expect.poll(() => mock.createdWithAgentId).toBe(seed.agentId); - expect(mock.createdWithAgentId).not.toBe(seed.otherAgentId); - await expect( - page.getByRole("button", { name: "Choose an active agent" }), - ).toHaveCount(0); - await expect( - page.getByRole("heading", { name: provider.setupHeading }), - ).toBeVisible(); - await expect( - page.getByText(PROVIDER_LIFECYCLE_COPY[provider.provider].reconnect, { - exact: false, - }), - ).toHaveCount(0); - await expectSetupRail(page); - await expectMinimumProviderSetup(page, provider); - await fillProviderSetup(page, provider); - - if (provider.provider === "github") { - expect(mock.githubPrivateKeyMatchedFile).toBe(true); - expect(mock.githubPrivateKeyMatchedPaste).toBe(true); - expect(mock.setupAttempts).toBe(2); - } - - if (provider.provider === "telegram") { - const submittedToken = "123456:e2e-redacted"; - // The next step intentionally has its own identity-readiness alert. - // Assert that this failed setup attempt clears, not that all alerts - // disappear during the transition between two valid wizard states. - const setupAlert = page.getByRole("alert").filter({ - has: page.getByText("Connection failed", { exact: true }), - }); - await expect(setupAlert).toContainText("Connection failed"); - await expect(setupAlert).toContainText( - "Telegram rejected bot token [redacted]. Confirm the token in BotFather and try again.", - ); - await expect(page.locator("body")).not.toContainText(submittedToken); - const tokenInput = page.getByLabel("Bot token"); - await expect(tokenInput).toHaveAttribute("type", "password"); - await expect(tokenInput).toHaveValue(submittedToken); - await tokenInput.focus(); - await tokenInput.press("Tab"); - await expect(setupAlert).toBeVisible(); - - await page.getByRole("button", { name: provider.setupButton }).click(); - await expect( - page.getByRole("heading", { name: `Try Maya in ${provider.name}` }), - ).toBeVisible(); - await expect(setupAlert).toHaveCount(0); - expect(mock.setupAttempts).toBe(2); - } - - if (provider.provider === "slack") { - await expect( - page.getByRole("heading", { name: "Finish Slack setup" }), - ).toBeVisible(); - await expect( - page.getByText( - `https://paperclip.example.test/api/chat-webhooks/public-slack/slack`, - { exact: true }, - ), - ).toBeVisible(); - await expect( - page.getByText("/maya-public", { exact: true }), - ).toBeVisible(); - await expect( - page.getByText( - /Slack's bare \/status command is not a Paperclip control/, - ), - ).toBeVisible(); - const saveChangesStep = page - .getByRole("listitem") - .filter({ hasText: "Save Changes" }); - await expect(saveChangesStep).toHaveCount(1); - await expect( - saveChangesStep.locator("..").getByRole("listitem"), - ).toHaveCount(1); - await expect(saveChangesStep).toHaveText( - "Return to App Manifest in Slack and click Save Changes. The copied manifest already contains the event, interaction, and slash-command URLs. Slack verifies the Events URL when you save; Paperclip records Interactivity and slash command health only after each signed callback is observed.", - ); - for (const removedManualStep of [ - "Event Subscriptions", - "Interactivity & Shortcuts", - "Slash Commands", - ]) { - await expect( - page.getByText(removedManualStep, { exact: true }), - ).toHaveCount(0); - } - await page - .getByRole("button", { name: "Start Slack message test" }) - .click(); - } - - await expect( - page.getByRole("heading", { name: `Try Maya in ${provider.name}` }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "I've sent the test message" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { - name: "Link the account you’re testing", - }), - ).toBeVisible(); - await expect( - page.getByText( - /An observed external account is unlinked, and isolated guest work is off, so it cannot safely start Maya/, - ), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Review identity access" }), - ).toBeVisible(); - await page - .getByRole("button", { name: "Review identity access" }) - .click(); - await expect(page).toHaveURL( - new RegExp( - `/${seed.prefix}/apps/chat/endpoint-${provider.provider}/access$`, - ), - ); - await expect( - page.getByRole("button", { name: "Continue setup" }), - ).toBeVisible(); - await page.getByRole("button", { name: "Continue setup" }).click(); - expect(new URL(page.url()).searchParams.get("reconnect")).toBeNull(); - await expect( - page.getByRole("heading", { name: `Try Maya in ${provider.name}` }), - ).toBeVisible(); - await expectSetupRail(page); - await expectProviderTryInstructions(page, provider); - expect(mock.configuredCredentialKeys).toEqual( - expectedCredentialKeys(provider.provider), - ); - await page - .getByRole("button", { name: "I've sent the test message" }) - .click(); - - await expect(page).toHaveURL( - new RegExp( - `/${seed.prefix}/apps/chat/endpoint-${provider.provider}/settings$`, - ), - ); - await expect( - page.getByRole("heading", { name: `Maya in ${provider.name}` }), - ).toBeVisible(); - await expect( - page.getByText(provider.accountLabel, { exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Continue setup" }), - ).toHaveCount(0); - await expect(page.getByText("Change agent", { exact: true })).toHaveCount( - 0, - ); - await expect(page.getByRole("tab")).toHaveCount(4); - for (const tab of ["Settings", "Access", "Conversations", "Activity"]) { - await expect(page.getByRole("tab", { name: tab })).toBeVisible(); - } - await expect( - page.getByRole("heading", { name: "Where this agent can work" }), - ).toBeVisible(); - if (provider.provider === "slack") { - await expect( - page.getByRole("heading", { name: "Slack command" }), - ).toBeVisible(); - await expect( - page.getByText("/maya-public", { exact: true }), - ).toBeVisible(); - await expect( - page.getByText( - /Slack's bare \/status command is not a Paperclip control/, - ), - ).toBeVisible(); - } - await expect( - page.getByRole("switch", { - name: `Enable ${provider.resourceLabel}`, - }), - ).toBeVisible(); - await expect( - page.getByText(provider.resourceLabel, { exact: true }), - ).toBeVisible(); - await expect( - page.getByText(provider.secondaryResourceLabel, { exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("switch", { - name: `Enable ${provider.secondaryResourceLabel}`, - }), - ).not.toBeChecked(); - await page - .getByRole("switch", { name: `Enable ${provider.resourceLabel}` }) - .click(); - await expect.poll(() => mock.updatedResource).toBe(true); - expect(mock.resourceUpdates.at(-1)).toEqual([ - { id: `resource-${provider.provider}`, enabled: true }, - ]); - - if (provider.provider === "github") { - await expect( - page.getByRole("heading", { name: "Private conversations" }), - ).toHaveCount(0); - } else { - const directMessages = page.getByRole("switch", { - name: "Allow direct messages", - }); - await expect(directMessages).toBeVisible(); - if (provider.provider === "discord") { - await expect( - page.getByText( - "People must also enable Direct Messages in their shared Discord server’s Privacy Settings.", - ), - ).toBeVisible(); - } - await directMessages.click(); - await expect.poll(() => mock.allowDirectMessages).toBe(true); - } - if (provider.provider === "microsoft-teams") { - const groupChats = page.getByRole("switch", { - name: "Allow group chats", - }); - await expect(groupChats).toBeVisible(); - await groupChats.click(); - await expect.poll(() => mock.allowGroupChats).toBe(true); - } - for (const lifecycleAction of [ - "Pause", - "Resume", - "Reconnect", - "Remove connection", - ]) { - await expect( - page.getByRole("button", { - name: lifecycleAction, - exact: true, - }), - ).toHaveCount(0); - } - await expect( - page.getByRole("switch", { name: "Allow unlinked people" }), - ).toHaveCount(0); - - await page.getByRole("tab", { name: "Access" }).click(); - await expect( - page.getByRole("heading", { name: "External identity access" }), - ).toBeVisible(); - await expect( - page.getByText( - /Their tasks run only with an isolated workspace and sandbox environment; otherwise Paperclip safely refuses the request/, - ), - ).toBeVisible(); - const allowUnlinked = page.getByRole("switch", { - name: "Allow unlinked people", - }); - await expect(allowUnlinked).toBeVisible(); - await allowUnlinked.click(); - await expect.poll(() => mock.allowUnlinkedPeople).toBe(true); - await expect( - page.getByText("Ada Lovelace", { exact: true }), - ).toBeVisible(); - await expect( - page.getByText("Grace Hopper", { exact: true }), - ).toBeVisible(); - await expect( - page.getByText("Linked to Grace Hopper", { exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Create private link" }), - ).toBeVisible(); - await page - .context() - .grantPermissions(["clipboard-read", "clipboard-write"], { - origin: new URL(page.url()).origin, - }); - await page.getByRole("button", { name: "Create private link" }).click(); - await expect - .poll(() => mock.linkIntentPrincipalId) - .toBe(`principal-${provider.provider}`); - const confirmationUrl = `https://paperclip.example.test/${seed.prefix}/chat-identity/confirm?token=e2e-redacted`; - await expect( - page.getByText(confirmationUrl, { exact: true }), - ).toBeVisible(); - await page.getByRole("button", { name: "Copy link" }).click(); - await expect - .poll(() => page.evaluate(() => navigator.clipboard.readText())) - .toBe(confirmationUrl); - await expect( - page.getByText("Confirmation link copied", { exact: true }), - ).toBeVisible(); - await page.getByRole("button", { name: "Revoke" }).click(); - await expect - .poll(() => mock.revokedPrincipalId) - .toBe(`principal-${provider.provider}-linked`); - await expect( - page.getByText(`grace@${provider.provider}`, { exact: true }), - ).toBeVisible(); - - await page.getByRole("tab", { name: "Conversations" }).click(); - await expect( - page.getByRole("heading", { name: "Conversations" }), - ).toBeVisible(); - await expect( - page.getByText(`CHAT-123 · Investigate ${provider.name} delivery`), - ).toBeVisible(); - await expect( - page.getByText(provider.resourceLabel, { exact: true }), - ).toHaveCount(1); - const providerLink = page.getByRole("link", { - name: `Open ${provider.name}`, - }); - await expect(providerLink).toHaveAttribute("href", provider.externalUrl); - const taskLink = page.getByRole("link", { name: "Open task" }); - await expect(taskLink).toHaveAttribute( - "href", - new RegExp(`/${seed.prefix}/issues/issue-${provider.provider}$`), - ); - mock.conversationState = "waiting"; - await expect(page.getByText("waiting", { exact: true })).toBeVisible({ - timeout: 8_000, - }); - - await page.getByRole("tab", { name: "Activity" }).click(); - await expect( - page.getByRole("heading", { name: "Connection activity" }), - ).toBeVisible(); - await expect( - page.getByText( - `Inbound ${provider.name} delivery could not be processed`, - ), - ).toBeVisible(); - await expect( - page.getByText(`Published safe output to ${provider.name}`), - ).toBeVisible(); - await expect( - page.getByText("Credential values and request bodies are redacted."), - ).toBeVisible(); - const deliveryTimestamp = page - .getByText(`Inbound ${provider.name} delivery could not be processed`) - .locator("..") - .locator("time"); - await expect(deliveryTimestamp).toHaveText( - /\w+ \d+, \d{4}, \d{1,2}:\d{2}:\d{2} [AP]M/, - ); - await expect(deliveryTimestamp).toHaveAttribute( - "datetime", - /\d{4}-\d{2}-\d{2}T/, - ); - await expect(deliveryTimestamp).toHaveAttribute( - "title", - (await deliveryTimestamp.getAttribute("datetime"))!, - ); - await expect(page.getByText("xoxb-e2e-redacted")).toHaveCount(0); - await expect(page.getByText("teams-client-secret")).toHaveCount(0); - await expect(page.getByText("github-webhook-secret")).toHaveCount(0); - - // A provider callback does not cause a Board mutation. Keep this tab - // mounted and focused: neither navigation nor Replay may refresh it. - mock.liveActivitySummary = `${provider.name} reaction removed while viewing Activity`; - mock.setStatus("paused"); - await expect( - page.getByText(mock.liveActivitySummary, { exact: true }), - ).toBeVisible({ timeout: 8_000 }); - await expect( - page.getByRole("button", { name: "Resume", exact: true }), - ).toBeVisible({ timeout: 8_000 }); - mock.setStatus("active"); - await expect( - page.getByRole("button", { name: "Pause", exact: true }), - ).toBeVisible({ timeout: 8_000 }); - - await page.getByRole("button", { name: "Replay" }).click(); - await expect.poll(() => mock.replayedDelivery).toBe(true); - - await expect( - page.getByRole("button", { name: "Pause", exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Remove connection", exact: true }), - ).toBeVisible(); - await expect( - page.getByText(PROVIDER_LIFECYCLE_COPY[provider.provider].reconnect, { - exact: true, - }), - ).toBeVisible(); - await page.getByRole("button", { name: "Pause", exact: true }).click(); - await expect( - page.getByRole("button", { name: "Resume", exact: true }), - ).toBeVisible(); - await page.getByRole("button", { name: "Resume", exact: true }).click(); - await expect( - page.getByRole("button", { name: "Pause", exact: true }), - ).toBeVisible(); - expect(mock.lifecycleActions).toEqual(["pause", "resume"]); - - mock.setStatus("attention"); - await page.reload(); - await expect( - page.getByRole("button", { name: "Reconnect", exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Remove connection" }), - ).toBeVisible(); - await page - .getByRole("button", { name: "Reconnect", exact: true }) - .click(); - await expect(page).toHaveURL( - new RegExp( - `/${seed.prefix}/apps/chat/connect\\?.*resume=endpoint-${provider.provider}`, - ), - ); - expect(new URL(page.url()).searchParams.get("reconnect")).toBe("1"); - await expect( - page.getByRole("heading", { - name: - provider.provider === "github" - ? "Reconnect GitHub App" - : provider.setupHeading, - }), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Choose an active agent" }), - ).toHaveCount(0); - - if (provider.provider === "github") { - const connectButton = page.getByRole("button", { - name: provider.setupButton, - }); - await expect( - page.getByText( - /Under the target user or organization, create a new GitHub App/, - ), - ).toHaveCount(0); - await expect( - page.getByText( - /Leave App ID and private key blank to reuse saved credentials/, - ), - ).toBeVisible(); - await page.getByLabel("GitHub App ID").fill("123456"); - await page - .getByLabel("Private key (PEM)") - .fill("reconnect-private-key"); - await expect(connectButton).toBeEnabled(); - await page.evaluate((buttonName) => { - const state = window as typeof window & { - __githubConnectEnabledAfterRotation?: boolean; - }; - state.__githubConnectEnabledAfterRotation = false; - new MutationObserver(() => { - const button = [...document.querySelectorAll("button")].find( - (candidate) => candidate.textContent?.trim() === buttonName, - ); - if (button instanceof HTMLButtonElement && !button.disabled) { - state.__githubConnectEnabledAfterRotation = true; - } - }).observe(document.body, { - attributes: true, - childList: true, - subtree: true, - }); - }, provider.setupButton); - await page - .getByRole("button", { name: "Regenerate webhook secret" }) - .click(); - await expect(connectButton).toBeDisabled(); - await page.waitForTimeout(100); - expect( - await page.evaluate( - () => - ( - window as typeof window & { - __githubConnectEnabledAfterRotation?: boolean; - } - ).__githubConnectEnabledAfterRotation, - ), - ).toBe(false); - - await page.goBack(); - await expect( - page.getByRole("heading", { name: "Connection activity" }), - ).toBeVisible(); - await page - .getByRole("button", { name: "Reconnect", exact: true }) - .click(); - await expect( - page.getByRole("heading", { name: "Reconnect GitHub App" }), - ).toBeVisible(); - await page.getByLabel("GitHub App ID").fill("123456"); - await page - .getByLabel("Private key (PEM)") - .fill("reconnect-private-key"); - await expect( - page.getByRole("button", { name: provider.setupButton }), - ).toBeDisabled(); - mock.setGitHubWebhookVerified(); - await expect( - page.getByRole("button", { name: provider.setupButton }), - ).toBeEnabled(); - } - - mock.setStatus("active"); - await page.goto( - `/${seed.prefix}/apps/chat/endpoint-${provider.provider}/activity`, - ); - await page.getByRole("button", { name: "Remove connection" }).click(); - const confirmation = page.getByRole("alertdialog"); - await expect(confirmation).toContainText("Remove this connection?"); - await expect(confirmation).toContainText( - PROVIDER_LIFECYCLE_COPY[provider.provider].remove, - ); - await confirmation - .getByRole("button", { name: "Remove connection" }) - .click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`)); - await expect.poll(() => mock.removed).toBe(true); - expect(mock.lifecycleActions).toEqual(["pause", "resume", "remove"]); - }); - } -}); - -test.describe("Board send delivery refresh", () => { - for (const classic of [false, true]) { - for (const entry of [ - "uuid", - "uppercase-uuid", - "wrong-prefix-identifier", - ] as const) { - test(`opens external task links in their own organization and uploads safely (${entry}, classic=${classic})`, async ({ - page, - request, - }) => { - const selected = await seedCompanyAndAgent(request); - const target = await seedCompanyAndAgent(request); - const issue = await json<{ id: string; identifier: string }>( - await request.post(`/api/companies/${target.companyId}/issues`, { - data: { title: "Organization-bound task link", status: "backlog" }, - }), - "create linked task in another organization", - ); - await page.route("**/api/instance/settings/experimental", (route) => - fulfill(route, { - enableChatConnectors: true, - enableClassicTaskInterface: classic, - }), - ); - await page.goto(`/${selected.prefix}/issues`); - await expect - .poll(() => - page.evaluate(() => - localStorage.getItem("paperclip.selectedCompanyId"), - ), - ) - .toBe(selected.companyId); - await page.goto( - entry === "uuid" - ? `/issues/${issue.id}` - : entry === "uppercase-uuid" - ? `/issues/${issue.id.toUpperCase()}` - : `/${selected.prefix}/issues/${issue.identifier}?external=chat#files`, - ); - await expect( - page.getByRole("heading", { - name: "Organization-bound task link", - exact: true, - }), - ).toBeVisible(); - await expect - .soft(page) - .toHaveURL( - new RegExp( - `/${target.prefix}/issues/${issue.identifier}${ - entry === "wrong-prefix-identifier" - ? "\\?external=chat#files" - : "" - }$`, - ), - { timeout: 4_000 }, - ); - const file = { - name: classic ? "task-link-report.txt" : "task-link-image.png", - mimeType: classic ? "text/plain" : "image/png", - buffer: classic - ? Buffer.from("Synthetic cross-organization upload proof.\n") - : Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfFoAAAAASUVORK5CYII=", - "base64", - ), - }; - const chooserPromise = page.waitForEvent("filechooser"); - await page - .getByRole("button", { - name: classic ? "Upload attachment" : "Attach file", - exact: true, - }) - .click(); - const responsePromise = page.waitForResponse( - (response) => - response.request().method() === "POST" && - /\/api\/companies\/[^/]+\/issues\/[^/]+\/attachments$/.test( - new URL(response.url()).pathname, - ), - ); - await (await chooserPromise).setFiles(file); - const response = await responsePromise; - expect.soft(response.status(), await response.text()).toBe(201); - expect - .soft(new URL(response.url()).pathname) - .toBe( - `/api/companies/${target.companyId}/issues/${issue.id}/attachments`, - ); - const attachments = await json< - { - originalFilename: string; - contentPath: string; - issueCommentId: string | null; - }[] - >( - await request.get(`/api/issues/${issue.id}/attachments`), - "read linked task attachments", - ); - expect(attachments).toHaveLength(1); - expect(attachments[0]).toMatchObject({ - originalFilename: file.name, - issueCommentId: null, - }); - expect( - await (await request.get(attachments[0].contentPath)).body(), - ).toEqual(file.buffer); - expect( - await json( - await request.get(`/api/issues/${issue.id}/comments`), - "read linked task comments", - ), - ).toHaveLength(0); - await expect - .poll(() => - page.evaluate(() => - localStorage.getItem("paperclip.selectedCompanyId"), - ), - ) - .toBe(target.companyId); - }); - } - } - - test("keeps the connected-task banner readable in narrow task panes", async ({ - page, - request, - }) => { - const seed = await seedCompanyAndAgent(request); - const issue = await json<{ id: string; identifier: string }>( - await request.post(`/api/companies/${seed.companyId}/issues`, { - data: { title: "Connected banner layout", status: "backlog" }, - }), - "create connected banner task", - ); - await page.route("**/api/instance/settings/experimental", (route) => - fulfill(route, { enableChatConnectors: true }), - ); - await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => - fulfill(route, { - endpointId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - conversationId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", - provider: "discord", - externalLabel: "#a-long-but-readable-qualification-channel-name", - externalUrl: "https://discord.com/channels/test-server/test-thread", - assignedAgentLocked: true, - }), - ); - await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); - const banner = page.getByRole("region", { - name: "External conversation", - exact: true, - }); - const heading = banner.getByText("Connected to Discord", { exact: true }); - await expect(heading).toBeVisible(); - // Exercise container widths independently of the operator's sidebar and - // Properties preferences. These are test constraints, not product styles. - for (const width of [340, 500, 760]) { - await banner.evaluate((element, value) => { - element.style.width = `${value}px`; - }, width); - await expect - .poll(() => - heading - .evaluate((element) => ({ - height: element.getBoundingClientRect().height, - lineHeight: Number.parseFloat( - getComputedStyle(element).lineHeight, - ), - })) - .then(({ height, lineHeight }) => height <= lineHeight * 1.5), - ) - .toBe(true); - const bounds = await banner.boundingBox(); - expect(bounds).not.toBeNull(); - for (const action of [ - banner.getByRole("link", { name: "Open Discord", exact: true }), - banner.getByRole("button", { name: "Send to channel", exact: true }), - banner.getByRole("link", { name: "Connection", exact: true }), - ]) { - await expect(action).toBeVisible(); - const box = await action.boundingBox(); - expect(box).not.toBeNull(); - expect(box!.x).toBeGreaterThanOrEqual(bounds!.x); - expect(box!.x + box!.width).toBeLessThanOrEqual( - bounds!.x + bounds!.width, - ); - expect(box!.y + box!.height).toBeLessThanOrEqual( - bounds!.y + bounds!.height, - ); - } - } - }); - - test("uploads images and files directly from an empty channel composer without publishing early", async ({ - page, - request, - }) => { - const seed = await seedCompanyAndAgent(request); - const issue = await json<{ id: string; identifier: string }>( - await request.post(`/api/companies/${seed.companyId}/issues`, { - data: { title: "Upload channel files", status: "backlog" }, - }), - "create upload task", - ); - const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; - const publicationId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; - const sends: Record[] = []; - let releaseSend!: () => void; - const sendResponse = new Promise((resolve) => { - releaseSend = resolve; - }); - await page.route("**/api/instance/settings/experimental", (route) => - fulfill(route, { enableChatConnectors: true }), - ); - await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => - fulfill(route, { - endpointId, - conversationId, - provider: "discord", - externalLabel: "#upload-test", - assignedAgentLocked: true, - }), - ); - await page.route( - `**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`, - async (route) => { - sends.push(bodyOf(route)); - await sendResponse; - return fulfill( - route, - { id: publicationId, state: "streaming", attempts: 1 }, - 201, - ); - }, - ); - await page.route( - `**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications/${publicationId}/status`, - (route) => - fulfill(route, { - publication: { id: publicationId, state: "pending", attempts: 0 }, - total: 3, - published: 0, - }), - ); - await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); - await page - .getByRole("button", { name: "Send to channel", exact: true }) - .click(); - const input = page.getByLabel("Attach file to channel update", { - exact: true, - }); - const files = [ - { - name: "channel-image.png", - mimeType: "image/png", - buffer: Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfFoAAAAASUVORK5CYII=", - "base64", - ), - }, - { - name: "channel-report.txt", - mimeType: "text/plain", - buffer: Buffer.from("Synthetic channel upload proof.\n"), - }, - ]; - for (const file of files) { - await input.setInputFiles(file); - await expect( - page - .getByRole("group", { name: "Include task files", exact: true }) - .getByRole("checkbox", { name: file.name, exact: true }), - ).toBeChecked(); - await expect(input).toBeEnabled(); - } - expect(sends).toHaveLength(0); - const stored = await json< - { - id: string; - originalFilename: string; - contentPath: string; - issueCommentId: string | null; - }[] - >( - await request.get(`/api/issues/${issue.id}/attachments`), - "read uploaded files", - ); - expect(stored).toHaveLength(2); - for (const file of files) { - const attachment = stored.find( - (item) => item.originalFilename === file.name, - )!; - expect(attachment.issueCommentId).toBeNull(); - const content = await request.get(attachment.contentPath); - expect(content.ok()).toBe(true); - expect(await content.body()).toEqual(file.buffer); - } - expect( - await json( - await request.get(`/api/issues/${issue.id}/comments`), - "read internal comments", - ), - ).toHaveLength(0); - await page - .getByRole("textbox", { name: "Board update", exact: true }) - .fill("Publish these two synthetic files."); - await page - .getByRole("button", { name: "Send to channel", exact: true }) - .last() - .click(); - await expect.poll(() => sends.length).toBe(1); - await expect( - page.getByRole("button", { name: "Sending…", exact: true }), - ).toBeDisabled(); - await expect( - page.getByText("Delivery result not confirmed", { exact: true }), - ).toHaveCount(0); - releaseSend(); - expect(sends[0].attachmentIds).toEqual( - files.map( - (file) => - stored.find((item) => item.originalFilename === file.name)!.id, - ), - ); - await expect(input).toBeDisabled(); - await page.reload(); - const retained = page.getByRole("group", { - name: "Files in this send", - exact: true, - }); - for (const file of files) { - await expect( - retained.getByRole("checkbox", { name: file.name, exact: true }), - ).toBeChecked(); - await expect( - retained.getByRole("checkbox", { name: file.name, exact: true }), - ).toBeDisabled(); - } - expect(sends).toHaveLength(1); - }); - - test("recovers an uncertain channel send into a durable rejection before an explicit correction", async ({ - page, - request, - }, testInfo) => { - const seed = await seedCompanyAndAgent(request); - const issue = await json<{ id: string; identifier: string }>( - await request.post(`/api/companies/${seed.companyId}/issues`, { - data: { title: "Rejected channel selection", status: "backlog" }, - }), - "create rejected-send task", - ); - const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; - await page.route("**/api/instance/settings/experimental", (route) => - fulfill(route, { enableChatConnectors: true }), - ); - await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => - fulfill(route, { - endpointId, - conversationId, - provider: "github", - externalLabel: "test/rejected-send", - assignedAgentLocked: true, - }), - ); - const sends: Record[] = []; - let attachmentId = ""; - let privateComment: { id: string } | undefined; - await page.route( - `**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`, - async (route) => { - const input = bodyOf(route); - sends.push(input); - if (sends.length === 1) { - expect(input.attachmentIds).toEqual([attachmentId]); - // Consume the file only after the browser has captured its send. - // Binding it before Send lets live refresh remove it from the draft, - // making the mocked rejection inconsistent with the actual request. - privateComment = await json<{ id: string }>( - await request.post(`/api/issues/${issue.id}/comments`, { - data: { body: "Private Board file", attachmentIds: [attachmentId] }, - }), - "bind selected file to private comment", - ); - return route.abort("connectionreset"); - } - if (sends.length === 2) - return fulfill( - route, - { - error: "A selected file already belongs to another comment", - details: { - code: "chat_board_send_attachments_already_bound", - endpointId, - conversationId, - idempotencyKey: input.idempotencyKey, - attachmentIds: [attachmentId], - }, - }, - 409, - ); - return fulfill( - route, - { - id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", - state: "published", - attempts: 1, - }, - 201, - ); - }, - ); - await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); - const banner = page.getByRole("region", { name: "External conversation" }); - await banner - .getByRole("button", { name: "Send to channel", exact: true }) - .click(); - await banner - .getByLabel("Attach file to channel update", { exact: true }) - .setInputFiles({ - name: "rejected-send.txt", - mimeType: "text/plain", - buffer: Buffer.from("Synthetic rejected send proof.\n"), - }); - await expect( - banner.getByRole("checkbox", { name: "rejected-send.txt" }), - ).toBeChecked(); - const [file] = await json<{ id: string }[]>( - await request.get(`/api/issues/${issue.id}/attachments`), - "read selected file", - ); - attachmentId = file!.id; - // The first intercepted send binds the file to a concurrent Board comment. - // The real TX/idempotency and delete/retry behavior are covered in PostgreSQL. - const draft = - "Share the verified result, without the private Board comment."; - await banner.getByRole("textbox", { name: "Board update" }).fill(draft); - await banner - .getByRole("button", { name: "Send to channel", exact: true }) - .last() - .click(); - await expect( - banner.getByText("Delivery result not confirmed", { exact: true }), - ).toBeVisible(); - await expect( - banner.getByRole("textbox", { name: "Board update" }), - ).toBeDisabled(); - await banner - .getByRole("button", { name: "Retry safely", exact: true }) - .click(); - await expect( - banner.getByText("Update was not sent", { exact: true }), - ).toBeVisible(); - expect(sends[1]).toEqual(sends[0]); - await page.reload(); - await expect( - banner.getByText("Update was not sent", { exact: true }), - ).toBeVisible(); - await expect( - banner.getByRole("textbox", { name: "Board update" }), - ).toHaveValue(draft); - expect(sends).toHaveLength(2); - await banner.screenshot({ - path: testInfo.outputPath("durable-rejected-channel-send.png"), - }); - await banner - .getByRole("button", { name: "Edit rejected send", exact: true }) - .click(); - await expect( - banner.getByRole("textbox", { name: "Board update" }), - ).toBeEnabled(); - await expect( - banner.getByText(/Attach a new copy or share the task link/), - ).toBeVisible(); - await expect(banner.getByRole("checkbox")).toHaveCount(0); - await banner - .getByRole("button", { name: "Send to channel", exact: true }) - .last() - .click(); - await expect( - banner.getByRole("textbox", { name: "Board update" }), - ).toHaveCount(0); - expect(sends).toHaveLength(3); - expect(sends[2]!.body).toBe(draft); - expect(sends[2]!.attachmentIds ?? []).toEqual([]); - expect(sends[2]!.idempotencyKey).not.toBe(sends[0]!.idempotencyKey); - const attachments = await json<{ id: string; issueCommentId: string }[]>( - await request.get(`/api/issues/${issue.id}/attachments`), - "verify immutable original binding", - ); - expect(attachments).toEqual([ - expect.objectContaining({ - id: attachmentId, - issueCommentId: privateComment!.id, - }), - ]); - }); - - for (const cancelledHead of [false, true]) { - test(`keeps Teams file consent anchored across reload (${cancelledHead ? "cancelled head with waiting tail" : "mixed terminal receipt and explicit dismiss"})`, async ({ - page, - request, - }, testInfo) => { - const seed = await seedCompanyAndAgent(request); - const issue = await json<{ id: string; identifier: string }>( - await request.post(`/api/companies/${seed.companyId}/issues`, { - data: { title: "Teams consent receipt", status: "backlog" }, - }), - "create consent receipt task", - ); - const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; - const anchor = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; - const nextAnchor = "ffffffff-ffff-4fff-8fff-ffffffffffff"; - const filePublications = [ - "dddddddd-dddd-4ddd-8ddd-dddddddddddd", - "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", - ]; - const filenames = ["consent-report.txt", "consent-notes.txt"]; - const posts: Record[] = []; - const statusReads: string[] = []; - let terminal = false; - const publicationsPath = `/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`; - const storageKey = `paperclip:board-send:v1:${JSON.stringify([seed.companyId, issue.id, endpointId, conversationId])}`; - await page.route("**/api/instance/settings/experimental", (route) => - fulfill(route, { enableChatConnectors: true }), - ); - await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => - fulfill(route, { - endpointId, - conversationId, - provider: "microsoft-teams", - externalLabel: "Personal file conversation", - assignedAgentLocked: true, - }), - ); - // Only the publication API is simulated. Task creation and safe file - // uploads use this test's isolated Paperclip instance, never Teams. - await page.route(`**${publicationsPath}`, (route) => { - expect(route.request().method()).toBe("POST"); - posts.push(bodyOf(route)); - return fulfill( - route, - { - id: posts.length === 1 ? anchor : nextAnchor, - state: "pending", - attempts: 0, - }, - 201, - ); - }); - await page.route(`**${publicationsPath}/*/status`, (route) => { - expect(route.request().method()).toBe("GET"); - const path = new URL(route.request().url()).pathname; - statusReads.push(path); - if (path.endsWith(`/${nextAnchor}/status`)) { - return fulfill(route, { - publication: { id: nextAnchor, state: "pending", attempts: 0 }, - total: 1, - published: 0, - awaitingConsent: 0, - declined: 0, - expired: 0, - cancelled: 0, - settled: 0, - canDismiss: false, - }); - } - expect(path).toBe(`${publicationsPath}/${anchor}/status`); - const parts = [ - { id: anchor, state: "published", attempts: 1 }, - ...filePublications.map((id, index) => ({ - id, - state: - terminal || (cancelledHead && index === 0) - ? "cancelled" - : "awaiting_consent", - attempts: 1, - fileTransfer: { - provider: "microsoft-teams", - filename: filenames[index], - phase: terminal - ? index === 0 - ? "declined" - : "expired" - : cancelledHead && index === 0 - ? "cancelled" - : "awaiting_consent", - version: terminal ? 3 : 2, - }, - })), - ]; - return fulfill(route, { - publication: parts[1], - parts, - total: 3, - published: 1, - awaitingConsent: terminal ? 0 : cancelledHead ? 1 : 2, - declined: terminal ? 1 : 0, - expired: terminal ? 1 : 0, - cancelled: !terminal && cancelledHead ? 1 : 0, - settled: terminal ? 3 : cancelledHead ? 2 : 1, - canDismiss: terminal, - }); - }); - await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); - const banner = page.getByRole("region", { - name: "External conversation", - exact: true, - }); - await banner - .getByRole("button", { name: "Send to channel", exact: true }) - .click(); - for (const name of filenames) { - await banner - .getByLabel("Attach file to channel update", { exact: true }) - .setInputFiles({ - name, - mimeType: "text/plain", - buffer: Buffer.from(`Synthetic consent fixture: ${name}.\n`), - }); - await expect( - banner.getByRole("checkbox", { name, exact: true }), - ).toBeChecked(); - await expect( - banner.getByLabel("Attach file to channel update", { exact: true }), - ).toBeEnabled(); - } - await expect( - banner.getByRole("group", { name: "Include task files", exact: true }), - ).toContainText( - "In personal Teams chats, recipients accept each file before upload. Channels and group chats receive supported images directly; other files stay on the task, with a task link or private-task notice.", - ); - await banner.screenshot({ - path: testInfo.outputPath("teams-file-guidance.png"), - }); - expect(posts).toHaveLength(0); - await banner - .getByRole("textbox", { name: "Board update", exact: true }) - .fill("Please review these two files."); - await banner - .getByRole("button", { name: "Send to channel", exact: true }) - .last() - .click(); - await expect.poll(() => posts.length).toBe(1); - expect(posts[0].attachmentIds).toHaveLength(2); - await expect( - banner.getByText( - cancelledHead - ? "1 published · 1 awaiting consent · 1 cancelled" - : "1 published · 2 awaiting consent", - { exact: true }, - ), - ).toBeVisible(); - const retainedBeforeReload = await page.evaluate( - (key) => sessionStorage.getItem(key), - storageKey, - ); - expect(JSON.parse(retainedBeforeReload!).publication.id).toBe(anchor); - const readsBeforeReload = statusReads.length; - await page.reload(); - await expect - .poll(() => statusReads.length) - .toBeGreaterThan(readsBeforeReload); - expect( - await page.evaluate((key) => sessionStorage.getItem(key), storageKey), - ).toBe(retainedBeforeReload); - expect( - statusReads.every( - (path) => path === `${publicationsPath}/${anchor}/status`, - ), - ).toBe(true); - await expect( - banner.getByRole("textbox", { name: "Board update", exact: true }), - ).toBeDisabled(); - await expect( - banner.getByLabel("Attach file to channel update", { exact: true }), - ).toBeDisabled(); - await expect( - banner - .getByRole("button", { name: "Send to channel", exact: true }) - .last(), - ).toBeDisabled(); - await expect( - banner.getByRole("button", { - name: "Dismiss delivery receipt", - exact: true, - }), - ).toHaveCount(0); - await expect( - banner.getByText( - cancelledHead - ? "Waiting for remaining file consent" - : "Waiting for file consent", - { exact: true }, - ), - ).toBeVisible(); - await expect( - banner.getByText("Channel delivery cancelled", { exact: true }), - ).toHaveCount(0); - await expect( - banner.getByText("Sent to channel", { exact: true }), - ).toHaveCount(0); - for (const name of filenames) { - await expect( - banner.getByRole("checkbox", { name, exact: true }), - ).toBeChecked(); - await expect( - banner.getByRole("checkbox", { name, exact: true }), - ).toBeDisabled(); - } - expect(posts).toHaveLength(1); - await banner.screenshot({ - path: testInfo.outputPath("consent-waiting.png"), - }); - if (cancelledHead) return; - - terminal = true; - await expect( - banner.getByText("Delivery settled with mixed outcomes", { - exact: true, - }), - ).toBeVisible({ timeout: 8_000 }); - await expect( - banner.getByText("1 published · 1 declined · 1 expired", { - exact: true, - }), - ).toBeVisible(); - await expect( - banner.getByText("consent-report.txt — Declined", { exact: true }), - ).toBeVisible(); - await expect( - banner.getByText("consent-notes.txt — Consent expired", { - exact: true, - }), - ).toBeVisible(); - await expect( - banner - .getByRole("button", { name: "Send to channel", exact: true }) - .last(), - ).toBeDisabled(); - await banner.screenshot({ - path: testInfo.outputPath("consent-mixed.png"), - }); - expect(posts).toHaveLength(1); - await banner - .getByRole("button", { name: "Dismiss delivery receipt", exact: true }) - .click(); - await expect( - banner.getByRole("textbox", { name: "Board update", exact: true }), - ).toHaveValue(""); - await expect( - banner.getByRole("textbox", { name: "Board update", exact: true }), - ).toBeEnabled(); - await expect( - banner - .getByRole("button", { name: "Send to channel", exact: true }) - .last(), - ).toBeDisabled(); - expect( - await page.evaluate((key) => sessionStorage.getItem(key), storageKey), - ).toBeNull(); - expect(posts).toHaveLength(1); - await expect( - page.getByText("Sent to channel", { exact: true }), - ).toHaveCount(0); - for (const name of filenames) { - await expect( - banner.getByRole("checkbox", { name, exact: true }), - ).not.toBeChecked(); - } - await banner - .getByRole("textbox", { name: "Board update", exact: true }) - .fill("A separate text-only update."); - expect(posts).toHaveLength(1); - await banner - .getByRole("button", { name: "Send to channel", exact: true }) - .last() - .click(); - await expect.poll(() => posts.length).toBe(2); - expect(posts[1]).toMatchObject({ - body: "A separate text-only update.", - }); - expect(posts[1]).not.toHaveProperty("attachmentIds"); - expect(posts[1].idempotencyKey).not.toBe(posts[0].idempotencyKey); - }); - } - - for (const outcome of [ - "published", - "failed", - "delivery_unknown", - "response_lost", - ] as const) { - test(`tracks the whole file batch across reload and ${outcome} without a new send identity`, async ({ - page, - request, - }) => { - const seed = await seedCompanyAndAgent(request); - const issue = await json<{ id: string; identifier: string }>( - await request.post(`/api/companies/${seed.companyId}/issues`, { - data: { title: "Board delivery refresh", status: "backlog" }, - }), - "create board-send task", - ); - const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; - const publicationId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; - const attachmentId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; - const statusPath = `/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications/${publicationId}/status`; - let sends = 0; - const submittedPayloads: Record[] = []; - let reads = 0; - let canonicalAttachmentReadsAfterSend = 0; - let canonicalCommentReadsAfterSend = 0; - let status = "streaming"; - await page.route("**/api/instance/settings/experimental", (route) => - fulfill(route, { enableChatConnectors: true }), - ); - await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) => - fulfill(route, { - endpointId, - conversationId, - provider: "slack", - externalLabel: "#board-send-test", - assignedAgentLocked: true, - }), - ); - await page.route( - new RegExp( - `/api/issues/(${issue.id}|${issue.identifier})/comments(?:\\?|$)`, - ), - async (route) => { - if ( - sends && - route.request().url().includes(`/issues/${issue.identifier}/`) - ) - canonicalCommentReadsAfterSend += 1; - await fulfill( - route, - sends - ? [ - { - id: publicationId, - companyId: seed.companyId, - issueId: issue.id, - authorAgentId: null, - authorUserId: "local-board", - authorType: "user", - body: "Board batch must finish all files.", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ] - : [], - ); - }, - ); - await page.route( - new RegExp( - `/api/issues/(${issue.id}|${issue.identifier})/attachments$`, - ), - async (route) => { - if ( - sends && - route.request().url().includes(`/issues/${issue.identifier}/`) - ) - canonicalAttachmentReadsAfterSend += 1; - await fulfill(route, [ - { - id: attachmentId, - companyId: seed.companyId, - issueId: issue.id, - issueCommentId: sends ? publicationId : null, - assetId: attachmentId, - provider: "local_disk", - objectKey: "board-send-test.txt", - contentType: "text/plain", - byteSize: 12, - sha256: "a".repeat(64), - originalFilename: "board-send-test.txt", - createdByAgentId: null, - createdByUserId: "local-board", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - contentPath: `/api/attachments/${attachmentId}/content`, - }, - { - id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", - companyId: seed.companyId, - issueId: issue.id, - issueCommentId: null, - assetId: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", - provider: "local_disk", - objectKey: "internal-only.txt", - contentType: "text/plain", - byteSize: 8, - sha256: "b".repeat(64), - originalFilename: "internal-only.txt", - createdByAgentId: null, - createdByUserId: "local-board", - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - contentPath: - "/api/attachments/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/content", - }, - ]); - }, - ); - await page.route( - `**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`, - async (route) => { - expect(route.request().method()).toBe("POST"); - expect(bodyOf(route).attachmentIds).toEqual([attachmentId]); - sends += 1; - submittedPayloads.push(bodyOf(route)); - if (outcome === "response_lost" && sends === 1) { - await route.abort("failed"); - return; - } - await fulfill( - route, - { id: publicationId, state: "streaming", attempts: 1 }, - 201, - ); - }, - ); - await page.route(`**${statusPath}`, async (route) => { - expect(route.request().method()).toBe("GET"); - reads += 1; - await fulfill(route, { - publication: { - id: status === "streaming" ? publicationId : attachmentId, - state: status, - attempts: 1, - }, - total: 2, - published: status === "published" ? 2 : 1, - }); - }); - await page.goto(`/${seed.prefix}/issues/${issue.identifier}`); - await page - .getByRole("button", { name: "Send to channel", exact: true }) - .click(); - await page - .getByRole("textbox", { name: "Board update", exact: true }) - .fill("Board batch must finish all files."); - await page.getByRole("checkbox", { name: "board-send-test.txt" }).check(); - await page - .getByRole("button", { name: "Send to channel", exact: true }) - .last() - .click(); - const expectRetainedFiles = async () => { - const files = page.getByRole("group", { - name: "Files in this send", - exact: true, - }); - await expect(files).toBeVisible(); - await expect(files.getByRole("checkbox")).toHaveCount(1); - await expect( - files.getByRole("checkbox", { name: "board-send-test.txt" }), - ).toBeChecked(); - await expect( - files.getByRole("checkbox", { name: "board-send-test.txt" }), - ).toBeDisabled(); - await expect( - files.getByText("internal-only.txt", { exact: true }), - ).toHaveCount(0); - }; - if (outcome === "response_lost") { - await expect( - page.getByText("Delivery result not confirmed", { exact: true }), - ).toBeVisible(); - await page.reload(); - await expect( - page.getByRole("textbox", { name: "Board update", exact: true }), - ).toBeDisabled(); - await expectRetainedFiles(); - expect(sends).toBe(1); - expect(reads).toBe(0); - await page - .getByRole("button", { name: "Retry safely", exact: true }) - .click(); - await expect.poll(() => submittedPayloads.length).toBe(2); - expect(submittedPayloads[1]).toEqual(submittedPayloads[0]); - } else { - await expect( - page.getByText("Publishing to channel", { exact: true }).first(), - ).toBeVisible(); - // The canonical task view must refresh its comment/files before any reload. - await expect - .poll(() => canonicalCommentReadsAfterSend) - .toBeGreaterThan(0); - await expect - .poll(() => canonicalAttachmentReadsAfterSend) - .toBeGreaterThan(0); - await expectRetainedFiles(); - const readsBeforeReload = reads; - await page.reload(); - await expect - .poll(() => reads, { timeout: 8_000 }) - .toBeGreaterThan(readsBeforeReload); - expect(sends).toBe(1); - } - await expect.poll(() => reads, { timeout: 8_000 }).toBeGreaterThan(0); - await expectRetainedFiles(); - await expect( - page.getByRole("textbox", { name: "Board update", exact: true }), - ).toHaveValue("Board batch must finish all files."); - await expect( - page.getByRole("textbox", { name: "Board update", exact: true }), - ).toBeDisabled(); - status = outcome === "response_lost" ? "published" : outcome; - if (status === "published") { - await expect( - page.getByRole("textbox", { name: "Board update", exact: true }), - ).toHaveCount(0, { timeout: 8_000 }); - await expect( - page.getByText("Sent to channel", { exact: true }), - ).toBeVisible(); - } else { - await expect( - page.getByText( - outcome === "failed" - ? "Channel delivery failed" - : "Delivery not confirmed", - { exact: true }, - ), - ).toBeVisible({ timeout: 8_000 }); - await expect( - page.getByRole("textbox", { name: "Board update", exact: true }), - ).toHaveValue("Board batch must finish all files."); - await expect( - page - .getByRole("button", { name: "Send to channel", exact: true }) - .last(), - ).toBeDisabled(); - await expect( - page.getByRole("link", { name: "Open Activity", exact: true }), - ).toBeVisible(); - // An explicit resolution elsewhere may complete the batch; this surface only reads it. - status = "published"; - await expect( - page.getByRole("textbox", { name: "Board update", exact: true }), - ).toHaveCount(0, { timeout: 8_000 }); - } - expect(sends).toBe(outcome === "response_lost" ? 2 : 1); - expect(canonicalCommentReadsAfterSend).toBeGreaterThan(0); - expect(canonicalAttachmentReadsAfterSend).toBeGreaterThan(0); - await expect( - page.getByText("Board batch must finish all files.", { exact: true }), - ).toBeVisible(); - await page - .getByRole("button", { name: "Send to channel", exact: true }) - .click(); - const newFiles = page.getByRole("group", { - name: "Include task files", - exact: true, - }); - await expect( - newFiles.getByRole("checkbox", { name: "board-send-test.txt" }), - ).toHaveCount(0); - await expect( - newFiles.getByRole("checkbox", { name: "internal-only.txt" }), - ).not.toBeChecked(); - await expect( - newFiles.getByRole("checkbox", { name: "internal-only.txt" }), - ).toBeEnabled(); - expect(sends).toBe(outcome === "response_lost" ? 2 : 1); - }); - } -}); - -test.describe("Exact failed chat run retry", () => { - let seed: Seed; - let issue: { id: string; identifier: string; title: string }; - - const failedRunId = "11111111-aaaa-4aaa-8aaa-111111111111"; - const actionId = "22222222-bbbb-4bbb-8bbb-222222222222"; - const denial = - "This chat source is no longer authorized. Open the task to review its current channel access before retrying."; - - test.beforeAll(async ({ request }) => { - seed = await seedCompanyAndAgent(request); - issue = await json( - await request.post(`/api/companies/${seed.companyId}/issues`, { - data: { title: "Exact chat retry destination", status: "backlog" }, - }), - "create exact-retry task", - ); - }); - - for (const surface of ["agent run", "Inbox", "Legacy Inbox"] as const) { - for (const outcome of ["queued", "deferred", "denied"] as const) { - test(`${surface}: selected run ${outcome} preserves exact retry authority and truthful feedback`, async ({ - page, - }, testInfo) => { - const now = Date.now(); - const run = { - id: failedRunId, - companyId: seed.companyId, - agentId: seed.agentId, - status: "failed", - invocationSource: "assignment", - triggerDetail: "system", - startedAt: new Date(now - 60_000).toISOString(), - finishedAt: new Date(now - 30_000).toISOString(), - createdAt: new Date(now - 60_000).toISOString(), - updatedAt: new Date(now - 30_000).toISOString(), - error: "The fixture provider turn failed.", - errorCode: "adapter_failed", - exitCode: 1, - signal: null, - responsibleUserId: null, - runtimeMode: "native", - driverKind: "codex_app_server", - nativeIssueId: issue.id, - usageJson: null, - resultJson: null, - sessionIdBefore: null, - sessionIdAfter: null, - logStore: null, - logRef: null, - logBytes: 0, - retryOfRunId: null, - scheduledRetryAt: null, - scheduledRetryAttempt: 0, - scheduledRetryReason: null, - contextSnapshot: { - source: "chat:slack", - issueId: issue.id, - taskId: "33333333-cccc-4ccc-8ccc-333333333333", - taskKey: "untrusted-copy-of-another-task", - wakeCommentId: "44444444-dddd-4ddd-8ddd-444444444444", - wakeCommentIds: ["44444444-dddd-4ddd-8ddd-444444444444"], - chatFailedRunRetry: { actionId: "untrusted-client-action" }, - }, - }; - const requests: Array<{ - companyId: string | null; - body: Record; - }> = []; - const destinations: string[] = []; - page.on("framenavigated", (frame) => { - if (frame === page.mainFrame()) destinations.push(frame.url()); - }); - await page.route("**/api/**", async (route) => { - const url = new URL(route.request().url()); - const pathname = url.pathname; - if (pathname === "/api/instance/settings/experimental") { - await fulfill(route, { - enableChatConnectors: true, - enableStreamlinedUi: surface !== "Legacy Inbox", - }); - return; - } - if (pathname === `/api/companies/${seed.companyId}/heartbeat-runs`) { - await fulfill(route, [run]); - return; - } - if (pathname === `/api/heartbeat-runs/${failedRunId}`) { - await fulfill(route, run); - return; - } - if ( - pathname === `/api/heartbeat-runs/${failedRunId}/events` || - pathname === - `/api/heartbeat-runs/${failedRunId}/workspace-operations` || - pathname === `/api/companies/${seed.companyId}/provider-traces` - ) { - await fulfill(route, []); - return; - } - if (pathname === `/api/heartbeat-runs/${failedRunId}/issues`) { - await fulfill(route, [ - { - issueId: issue.id, - identifier: issue.identifier, - title: issue.title, - status: "backlog", - priority: "medium", - }, - ]); - return; - } - if (pathname === `/api/heartbeat-runs/${failedRunId}/log`) { - await fulfill(route, { - runId: failedRunId, - content: "", - nextOffset: 0, - }); - return; - } - if (pathname === `/api/agents/${seed.agentId}/wakeup`) { - expect(route.request().method()).toBe("POST"); - requests.push({ - companyId: url.searchParams.get("companyId"), - body: bodyOf(route), - }); - if (outcome === "denied") { - await fulfill( - route, - { - error: denial, - details: { code: "chat_failed_run_retry_source_denied" }, - }, - 409, - ); - } else { - // The durable action exists, but no run has been admitted yet. - await fulfill( - route, - { actionId, issueId: issue.id, runId: null, status: outcome }, - 202, - ); - } - return; - } - await route.continue(); - }); - - const startPath = - surface === "agent run" - ? `/${seed.prefix}/agents/${seed.agentId}/runs/${failedRunId}` - : `/${seed.prefix}/inbox/all`; - await page.goto(startPath); - const retry = page - .getByRole("button", { name: "Retry", exact: true }) - .filter({ visible: true }); - await expect(retry).toHaveCount(1); - await retry.click(); - await expect.poll(() => requests.length).toBe(1); - expect(requests[0]).toEqual({ - companyId: seed.companyId, - body: { - source: "on_demand", - triggerDetail: "manual", - reason: "retry_failed_run", - failedRunId, - }, - }); - if (outcome === "denied") { - await expect(page.getByText(denial, { exact: true })).toBeVisible(); - if (surface !== "agent run") { - await expect( - page.getByText("Run retry failed", { exact: true }), - ).toBeVisible(); - const toast = page.getByRole("listitem").filter({ - has: page.getByText("Run retry failed", { exact: true }), - }); - // Visibility alone accepts opacity:0 during the toast entrance. - // The operator must actually be able to read the denial. - await expect(toast).toHaveCSS("opacity", "1"); - await expect(toast).toBeInViewport(); - } - // Agent routes canonicalize the UUID to its human-readable URL key. - // The selected failed run must remain unchanged across that redirect. - await expect(page).toHaveURL( - surface === "agent run" - ? new RegExp( - `/${seed.prefix}/agents/(${seed.agentId}|maya)/runs/${failedRunId}$`, - ) - : new RegExp(`${startPath}$`), - ); - await expect(retry).toBeEnabled(); - await testInfo.attach(`${surface}-retry-denied`, { - body: await page.screenshot(), - contentType: "image/png", - }); - } else { - await expect(page).toHaveURL( - new RegExp( - `/${seed.prefix}/issues/(${issue.id}|${issue.identifier})$`, - ), - ); - await expect( - page.getByText(issue.title, { exact: true }).first(), - ).toBeVisible(); - await expect( - page.getByText("Run retry failed", { exact: true }), - ).toHaveCount(0); - } - expect(requests).toHaveLength(1); - expect( - destinations.some((url) => - /\/runs\/(null|undefined)(?:[/?#]|$)/.test(url), - ), - ).toBe(false); - }); - } - } -}); - - -test.describe("iMessage Photon setup and management", () => { - let seed: Seed; - const photon: ProviderCase = { - provider: "imessage-photon", - slug: "imessage-photon", - name: "iMessage Photon", - accountLabel: "Photon Test", - botLabel: "Maya", - botUsername: "+15555550100", - resourceLabel: "Family project", - secondaryResourceLabel: "Second group", - resourceType: "group_chat", - externalUrl: "https://app.photon.codes/", - setupHeading: /Connect iMessage Photon/, - setupButton: "Connect selected number", - chatAndTool: false, - }; - test.beforeAll(async ({ request }) => { - seed = await seedCompanyAndAgent(request); - }); - test("connects Pro shared DMs without presenting a fake owned number or enabling groups", async ({page}) => { - await installChatControlPlaneMock(page, photon, seed, { enableChatConnectors: true, photonShared: true }); - await page.goto(`/${seed.prefix}/apps`); - await page.getByRole("button", {name:"Connect iMessage Photon",exact:true}).click(); - await selectMaya(page); - await page.getByLabel("Project ID").fill("project-e2e"); - await page.getByLabel("Project secret").fill("photon-test-secret"); - await page.getByRole("button", {name:"Inspect Photon project"}).click(); - await expect(page.getByText(/Groups cannot be enabled on this channel/)).toBeVisible(); - await page.getByRole("button", {name:"Connect shared DMs"}).click(); - await expect(page.getByRole("heading", {name:"Try Maya in iMessage Photon"})).toBeVisible(); - await page.reload(); - await expect(page.getByText(/enroll your sender in Users/)).toBeVisible(); - await expect(page.getByRole("button", {name:/Copy \+1555/})).toHaveCount(0); - await page.getByRole("button", {name:"I've sent the test message"}).click(); - await page.getByRole("tab", {name:"Settings"}).click(); - await expect(page.getByText(/Shared Photon project · direct messages only/)).toBeVisible(); - await expect(page.getByRole("switch", {name:"Enable Family project"})).toBeDisabled(); - await expect(page.getByRole("button", {name:"Copy dedicated number"})).toHaveCount(0); - }); - for (const theme of ["light", "dark"] as const) { - test(`discovers dedicated lines and completes the channel wizard (${theme})`, async ({ - page, - }, testInfo) => { - const mock = await installChatControlPlaneMock(page, photon, seed, { - enableChatConnectors: true, - }); - await page.addInitScript( - (value) => localStorage.setItem("paperclip.theme", value), - theme, - ); - await page.goto(`/${seed.prefix}/apps`); - const card = page.locator( - '[role="listitem"][data-app-slug="imessage-photon"]', - ); - await expect(card).toBeVisible(); - await card - .getByRole("button", { name: "Connect iMessage Photon" }) - .click(); - await selectMaya(page); - await expectSetupRail(page); - await expect( - page.getByRole("heading", { name: "Connect iMessage Photon" }), - ).toBeVisible(); - await page.getByLabel("Project ID").fill("project-e2e"); - await page.getByLabel("Project secret").fill("photon-test-secret"); - await expect(page.getByLabel("Project secret")).toHaveAttribute( - "type", - "password", - ); - await page - .getByRole("button", { name: "Inspect Photon project" }) - .click(); - await expect( - page.getByRole("button", { name: "Connect selected number" }), - ).toBeDisabled(); - await page - .getByRole("radio", { name: "+15555550100", exact: true }) - .focus(); - await page.keyboard.press("Space"); - await page - .getByRole("button", { name: "Connect selected number" }) - .click(); - await expect( - page.getByRole("heading", { name: "Try Maya in iMessage Photon" }), - ).toBeVisible(); - expect(mock.configuredCredentialKeys).toEqual(["projectSecret"]); - await expect( - page.getByRole("button", { name: "Copy +15555550100" }), - ).toBeVisible(); - await expect( - page.getByText("Link your Messages identity", { exact: true }), - ).toBeVisible(); - await page - .getByRole("button", { name: "I've sent the test message" }) - .click(); - await page.getByRole("tab", { name: "Settings" }).click(); - await expect( - page.getByText(/replies are visible to everyone in that group/), - ).toBeVisible(); - const group = page.getByRole("switch", { name: "Enable Family project" }); - await expect(group).not.toBeChecked(); - await group.click(); - await expect(group).toBeChecked(); - await page.setViewportSize({ width: 390, height: 844 }); - await page - .getByRole("combobox", { name: "Page section" }) - .selectOption("activity"); - await expect( - page.getByRole("button", { name: "Pause", exact: true }), - ).toBeVisible(); - await page.screenshot({ - path: testInfo.outputPath(`photon-${theme}-mobile.png`), - fullPage: true, - }); - expect( - await page.evaluate( - () => document.documentElement.scrollWidth <= window.innerWidth, - ), - ).toBe(true); - await page.getByRole("button", { name: "Pause", exact: true }).click(); - await expect( - page.getByRole("button", { name: "Resume", exact: true }), - ).toBeVisible(); - await page.getByRole("button", { name: "Resume", exact: true }).click(); - await expect( - page.getByRole("button", { name: "Pause", exact: true }), - ).toBeVisible(); - }); - } -});