Add live Agent Server E2E framework (#195)
* Add demo flow E2E coverage * Add live Agent Server E2E * Stabilize live E2E CI * Stabilize live Agent Server E2E * Use default pull request workflow triggers * Organize Playwright E2E tests * Comment live E2E results on PR * Fix live E2E comment permissions * Stabilize live E2E PR reporting * Add collapsible live E2E evidence * Embed live E2E media in PR report * Use release assets for live E2E media * Stabilize live E2E media and auth * Use raw URLs for live E2E media * Update tests for SDK workspace API * Use PR artifacts for live E2E media * Move live E2E scripts under tests * chore: Update PR QA artifacts * Clarify live E2E test layout * Document and simplify live E2E local runs * Remove unrelated non-test diffs * Preserve HEAD git ref in workspace client * Strengthen live Agent Server E2E * chore: Update PR QA artifacts * Remove workspace session URL normalization * Remove obsolete mock E2E regressions * Disable live E2E trace capture * Harden live E2E workflow * Harden live E2E review fixes * Fix live E2E manual checkout * chore: Update PR QA artifacts * Address live E2E re-review feedback * Address live E2E security review feedback * Address live E2E approval suggestions * chore: address PR review feedback (#195) * chore: address live e2e review followups (#195) * chore: Remove PR-only artifacts * chore: address latest live e2e review * fix(ci): drop --ignore-scripts so typescript-client git dep builds After merging main (PR #278), source files import directly from @openhands/typescript-client subpath exports (e.g. /clients, /workspace/remote-workspace). These resolve to dist/ files that are generated by the package's prepare script. The --ignore-scripts flag on npm ci prevented that script from running, so CI's typecheck failed with TS2307 'Cannot find module' for every subpath import. Main's CI uses plain 'npm ci' (no --ignore-scripts) and passes. Align this branch to match. Co-authored-by: openhands <openhands@all-hands.dev> * fix: restore avatar-menu and css-isolation regression tests These were moved from tests/ to tests/e2e/regressions/ in 08b8e12 but then mistakenly deleted in 5c5a39b. The live E2E framework is additive — it should not remove existing browser regression coverage. The placeholder.spec.ts is not restored since it was a no-op stub. Co-authored-by: openhands <openhands@all-hands.dev> --------- Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com> Co-authored-by: openhands <openhands@all-hands.dev>
@@ -5,6 +5,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: PR number to run live QA against when running manually.
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -51,3 +56,390 @@ jobs:
|
||||
|
||||
- name: Verify package contents
|
||||
run: npm pack --dry-run
|
||||
|
||||
live-e2e:
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
contains(github.event.pull_request.labels.*.name, 'live-e2e'))
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: pr-artifacts-live-e2e-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
env:
|
||||
LIVE_E2E_LLM_BASE_URL: ${{ vars.LIVE_E2E_LLM_BASE_URL || 'https://llm-proxy.app.all-hands.dev' }}
|
||||
LIVE_E2E_LLM_MODEL: ${{ vars.LIVE_E2E_LLM_MODEL || 'openhands/claude-haiku-4-5-20251001' }}
|
||||
LIVE_E2E_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number || '' }}
|
||||
LIVE_E2E_REPORT_PATH: live-e2e-report.md
|
||||
LIVE_E2E_RECORD_VIDEO: "on"
|
||||
LIVE_E2E_WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
LIVE_E2E_PR_ARTIFACT_KEEP_RUNS: 3
|
||||
|
||||
steps:
|
||||
- name: Resolve live E2E PR context
|
||||
id: pr_context
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$LIVE_E2E_PR_NUMBER" ]; then
|
||||
echo "::error::Live E2E QA must run against a PR. Provide workflow_dispatch.pr_number or add the live-e2e label to a PR."
|
||||
exit 1
|
||||
fi
|
||||
case "$LIVE_E2E_PR_NUMBER" in
|
||||
*[!0-9]*)
|
||||
echo "::error::Invalid PR number: $LIVE_E2E_PR_NUMBER"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
pr_api="repos/$GITHUB_REPOSITORY/pulls/$LIVE_E2E_PR_NUMBER"
|
||||
head_repo="$(gh api "$pr_api" --jq '.head.repo.full_name')"
|
||||
head_ref="$(gh api "$pr_api" --jq '.head.ref')"
|
||||
head_sha="$(gh api "$pr_api" --jq '.head.sha')"
|
||||
base_repo="$(gh api "$pr_api" --jq '.base.repo.full_name')"
|
||||
|
||||
if [ -z "$head_repo" ] || [ -z "$head_ref" ] || [ -z "$head_sha" ] || [ -z "$base_repo" ]; then
|
||||
echo "::error::Failed to fetch complete PR data from GitHub API."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "has_pr=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head_repo=$head_repo" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=$head_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
|
||||
if [ "$head_repo" = "$base_repo" ]; then
|
||||
echo "is_fork=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_fork=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Skip live E2E for fork PRs
|
||||
if: steps.pr_context.outputs.is_fork == 'true'
|
||||
run: echo "::notice::Skipping live E2E for fork PRs so secrets are never exposed to untrusted code."
|
||||
|
||||
- name: Check live E2E credential
|
||||
id: live_credential
|
||||
if: steps.pr_context.outputs.is_fork != 'true'
|
||||
env:
|
||||
LIVE_E2E_LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
if [ -n "$LIVE_E2E_LLM_API_KEY" ]; then
|
||||
echo "has_key=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_key=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check out PR head
|
||||
if: steps.pr_context.outputs.is_fork != 'true'
|
||||
env:
|
||||
PR_HEAD_REPO: ${{ steps.pr_context.outputs.head_repo }}
|
||||
PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote remove pr-head >/dev/null 2>&1 || true
|
||||
git remote add pr-head "https://github.com/$PR_HEAD_REPO.git"
|
||||
git fetch pr-head "$PR_HEAD_SHA"
|
||||
git checkout --detach FETCH_HEAD
|
||||
actual_sha="$(git rev-parse HEAD)"
|
||||
if [ "$actual_sha" != "$PR_HEAD_SHA" ]; then
|
||||
echo "::error::SHA mismatch: expected $PR_HEAD_SHA, got $actual_sha"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Detect PR artifact-only commit
|
||||
id: latest_commit
|
||||
if: steps.pr_context.outputs.is_fork != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
changed_files="$(git diff-tree --no-commit-id --name-only -r --root HEAD)"
|
||||
|
||||
artifact_only=false
|
||||
if [ -n "$changed_files" ]; then
|
||||
artifact_only=true
|
||||
while IFS= read -r changed_file; do
|
||||
[ -n "$changed_file" ] || continue
|
||||
case "$changed_file" in
|
||||
.pr/*) ;;
|
||||
*) artifact_only=false ;;
|
||||
esac
|
||||
done < <(printf '%s\n' "$changed_files")
|
||||
fi
|
||||
|
||||
echo "pr_artifact_only=$artifact_only" >> "$GITHUB_OUTPUT"
|
||||
if [ "$artifact_only" = "true" ]; then
|
||||
echo "::notice::Skipping live E2E because the latest commit only changes .pr artifacts."
|
||||
fi
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.12.0
|
||||
cache: npm
|
||||
|
||||
- name: Create live E2E PR comment
|
||||
if: env.LIVE_E2E_PR_NUMBER != '' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node tests/e2e/live/scripts/render-live-e2e-report.mjs \
|
||||
--status running \
|
||||
--model "$LIVE_E2E_LLM_MODEL" \
|
||||
--workflow-url "$LIVE_E2E_WORKFLOW_URL" \
|
||||
--commit "${{ steps.pr_context.outputs.head_sha }}" \
|
||||
--timestamp "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--output "$LIVE_E2E_REPORT_PATH"
|
||||
node tests/e2e/live/scripts/upsert-pr-comment.mjs \
|
||||
--issue-number "$LIVE_E2E_PR_NUMBER" \
|
||||
--body-file "$LIVE_E2E_REPORT_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install uv
|
||||
if: steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run live Agent Server E2E
|
||||
id: live_test
|
||||
if: steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
env:
|
||||
LIVE_E2E_LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
run: |
|
||||
set +e
|
||||
npm run test:e2e:live
|
||||
exit_code=$?
|
||||
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
- name: Skip live Agent Server E2E
|
||||
if: steps.live_credential.outputs.has_key != 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
run: echo "Skipping live Agent Server E2E because LLM_API_KEY is not configured."
|
||||
|
||||
- name: Extract live E2E media
|
||||
id: live_media
|
||||
if: always() && steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
run: |
|
||||
node tests/e2e/live/scripts/extract-live-e2e-media.mjs \
|
||||
--results "test-results-live/results.json" \
|
||||
--output-dir "test-results-live/media"
|
||||
|
||||
- name: Create live E2E video preview
|
||||
id: live_video_preview
|
||||
if: always() && steps.live_media.outputs.video_path != '' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
video_path="${{ steps.live_media.outputs.video_path }}"
|
||||
if [ ! -f "$video_path" ]; then
|
||||
echo "::warning::Live E2E video file was not found, skipping GIF preview generation."
|
||||
exit 0
|
||||
fi
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg
|
||||
ffmpeg -y \
|
||||
-i "$video_path" \
|
||||
-vf "fps=4,scale=960:-1:flags=lanczos" \
|
||||
-loop 0 \
|
||||
test-results-live/media/live-agent-recording.gif
|
||||
echo "video_preview_path=test-results-live/media/live-agent-recording.gif" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish live E2E media
|
||||
id: publish_live_media
|
||||
if: always() && steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
env:
|
||||
MEDIA_KEEP_RUNS: ${{ env.LIVE_E2E_PR_ARTIFACT_KEEP_RUNS }}
|
||||
MEDIA_RUN_DIR: ${{ github.run_id }}
|
||||
PR_ARTIFACT_PUSH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
PR_HEAD_IS_FORK: ${{ steps.pr_context.outputs.is_fork }}
|
||||
PR_HEAD_REF: ${{ steps.pr_context.outputs.head_ref }}
|
||||
PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }}
|
||||
SCREENSHOT_PATH: ${{ steps.live_media.outputs.screenshot_path || '' }}
|
||||
VIDEO_PATH: ${{ steps.live_media.outputs.video_path || '' }}
|
||||
VIDEO_PREVIEW_PATH: ${{ steps.live_video_preview.outputs.video_preview_path || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "$SCREENSHOT_PATH" ] && [ -z "$VIDEO_PREVIEW_PATH" ] && [ -z "$VIDEO_PATH" ]; then
|
||||
echo "No live E2E media was produced."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$PR_HEAD_IS_FORK" = "true" ]; then
|
||||
echo "::notice::Skipping .pr media publishing for fork PRs."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
media_dir=".pr/live-e2e/$MEDIA_RUN_DIR"
|
||||
mkdir -p "$media_dir"
|
||||
{
|
||||
echo "# PR Artifacts"
|
||||
echo
|
||||
echo "This directory contains generated PR-only QA artifacts. The PR Artifacts workflow removes it after approval so these files do not enter the final squash merge."
|
||||
} > .pr/README.md
|
||||
|
||||
if [ -n "$SCREENSHOT_PATH" ]; then
|
||||
cp "$SCREENSHOT_PATH" "$media_dir/live-agent-response.png"
|
||||
fi
|
||||
if [ -n "$VIDEO_PREVIEW_PATH" ]; then
|
||||
cp "$VIDEO_PREVIEW_PATH" "$media_dir/live-agent-recording.gif"
|
||||
fi
|
||||
if [ -n "$VIDEO_PATH" ]; then
|
||||
cp "$VIDEO_PATH" "$media_dir/live-agent-recording.webm"
|
||||
fi
|
||||
|
||||
if [ -d ".pr/live-e2e" ]; then
|
||||
find ".pr/live-e2e" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' |
|
||||
sort -nr |
|
||||
tail -n "+$((MEDIA_KEEP_RUNS + 1))" |
|
||||
while IFS= read -r stale_run; do
|
||||
[ -n "$stale_run" ] || continue
|
||||
if [[ ! "$stale_run" =~ ^[0-9]+$ ]]; then
|
||||
echo "::warning::Skipping invalid live E2E media directory name: $stale_run"
|
||||
continue
|
||||
fi
|
||||
rm -rf ".pr/live-e2e/$stale_run"
|
||||
done
|
||||
fi
|
||||
|
||||
git config user.name "allhands-bot"
|
||||
git config user.email "allhands-bot@users.noreply.github.com"
|
||||
git config credential.helper '!f() { echo "username=x-access-token"; echo "password=$PR_ARTIFACT_PUSH_TOKEN"; }; f'
|
||||
git remote remove pr-artifacts >/dev/null 2>&1 || true
|
||||
git remote add pr-artifacts "https://github.com/${GITHUB_REPOSITORY}.git"
|
||||
git add .pr
|
||||
if git diff --cached --quiet; then
|
||||
echo "No .pr media changes to publish."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore: Update PR QA artifacts"
|
||||
push_succeeded=false
|
||||
for attempt in 1 2 3; do
|
||||
if git push pr-artifacts "HEAD:refs/heads/$PR_HEAD_REF"; then
|
||||
push_succeeded=true
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
echo "::notice::Failed to push .pr media, rebasing and retrying."
|
||||
git fetch pr-artifacts "$PR_HEAD_REF"
|
||||
if ! git rebase FETCH_HEAD; then
|
||||
echo "::error::Failed to rebase .pr media commit. Manual resolution required."
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$PR_HEAD_SHA" HEAD; then
|
||||
echo "::error::Rebased .pr media commit no longer descends from the tested PR head $PR_HEAD_SHA."
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
if [ "$push_succeeded" != "true" ]; then
|
||||
echo "::error::Failed to push .pr media after retries."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
media_commit="$(git rev-parse HEAD)"
|
||||
media_url() {
|
||||
printf 'https://raw.githubusercontent.com/%s/%s/%s/%s\n' "$GITHUB_REPOSITORY" "$media_commit" "$media_dir" "$1"
|
||||
}
|
||||
|
||||
if [ -f "$media_dir/live-agent-response.png" ]; then
|
||||
echo "screenshot_url=$(media_url "live-agent-response.png")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if [ -f "$media_dir/live-agent-recording.gif" ]; then
|
||||
echo "video_preview_url=$(media_url "live-agent-recording.gif")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if [ -f "$media_dir/live-agent-recording.webm" ]; then
|
||||
echo "video_url=$(media_url "live-agent-recording.webm")" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Upload live E2E artifacts
|
||||
id: upload_live_artifacts
|
||||
if: always() && steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: live-agent-server-e2e
|
||||
if-no-files-found: ignore
|
||||
path: |
|
||||
playwright-report-live/
|
||||
test-results-live/
|
||||
|
||||
- name: Determine live E2E result
|
||||
id: live_status
|
||||
if: always() && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
run: |
|
||||
if [ "${{ steps.live_credential.outputs.has_key }}" != "true" ]; then
|
||||
echo "status=skipped" >> "$GITHUB_OUTPUT"
|
||||
echo "reason=LLM_API_KEY is not configured." >> "$GITHUB_OUTPUT"
|
||||
echo "exit_code=0" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit_code="${{ steps.live_test.outputs.exit_code }}"
|
||||
if [ -z "$exit_code" ]; then
|
||||
exit_code=1
|
||||
fi
|
||||
|
||||
if [ "$exit_code" = "0" ]; then
|
||||
echo "status=passed" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "status=failed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "reason=" >> "$GITHUB_OUTPUT"
|
||||
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Render live E2E report
|
||||
if: always() && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
env:
|
||||
LIVE_E2E_ARTIFACT_URL: ${{ steps.upload_live_artifacts.outputs.artifact-url || '' }}
|
||||
LIVE_E2E_SCREENSHOT_URL: ${{ steps.publish_live_media.outputs.screenshot_url || '' }}
|
||||
LIVE_E2E_STATUS: ${{ steps.live_status.outputs.status }}
|
||||
LIVE_E2E_REASON: ${{ steps.live_status.outputs.reason }}
|
||||
LIVE_E2E_VIDEO_PREVIEW_URL: ${{ steps.publish_live_media.outputs.video_preview_url || '' }}
|
||||
LIVE_E2E_VIDEO_URL: ${{ steps.publish_live_media.outputs.video_url || '' }}
|
||||
run: |
|
||||
node tests/e2e/live/scripts/render-live-e2e-report.mjs \
|
||||
--status "$LIVE_E2E_STATUS" \
|
||||
--reason "$LIVE_E2E_REASON" \
|
||||
--results "test-results-live/results.json" \
|
||||
--model "$LIVE_E2E_LLM_MODEL" \
|
||||
--workflow-url "$LIVE_E2E_WORKFLOW_URL" \
|
||||
--artifact-url "$LIVE_E2E_ARTIFACT_URL" \
|
||||
--screenshot-url "$LIVE_E2E_SCREENSHOT_URL" \
|
||||
--video-preview-url "$LIVE_E2E_VIDEO_PREVIEW_URL" \
|
||||
--video-url "$LIVE_E2E_VIDEO_URL" \
|
||||
--commit "${{ steps.pr_context.outputs.head_sha }}" \
|
||||
--timestamp "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--output "$LIVE_E2E_REPORT_PATH"
|
||||
cat "$LIVE_E2E_REPORT_PATH" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Update live E2E PR comment
|
||||
if: always() && env.LIVE_E2E_PR_NUMBER != '' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node tests/e2e/live/scripts/upsert-pr-comment.mjs \
|
||||
--issue-number "$LIVE_E2E_PR_NUMBER" \
|
||||
--body-file "$LIVE_E2E_REPORT_PATH"
|
||||
|
||||
- name: Fail live E2E job when tests fail
|
||||
if: always() && steps.live_credential.outputs.has_key == 'true' && steps.pr_context.outputs.is_fork != 'true' && steps.latest_commit.outputs.pr_artifact_only != 'true'
|
||||
run: exit "${{ steps.live_status.outputs.exit_code }}"
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
name: PR Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- main
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup-on-approval:
|
||||
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: pr-artifacts-live-e2e-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if fork PR
|
||||
id: check_fork
|
||||
run: |
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
|
||||
echo "is_fork=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Fork PR detected - skipping automatic .pr cleanup."
|
||||
else
|
||||
echo "is_fork=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Resolve PR context
|
||||
id: pr_context
|
||||
if: steps.check_fork.outputs.is_fork == 'false'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr_api="repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}"
|
||||
head_ref="$(gh api "$pr_api" --jq '.head.ref')"
|
||||
head_sha="$(gh api "$pr_api" --jq '.head.sha')"
|
||||
echo "head_ref=$head_ref" >> "$GITHUB_OUTPUT"
|
||||
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
|
||||
if gh api "repos/${{ github.repository }}/contents/.pr?ref=$head_sha" --silent >/dev/null 2>&1; then
|
||||
echo "has_pr_dir=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_pr_dir=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No .pr directory exists at the approved PR head; skipping artifact cleanup."
|
||||
fi
|
||||
|
||||
- name: Check out PR branch
|
||||
if: steps.check_fork.outputs.is_fork == 'false' && steps.pr_context.outputs.has_pr_dir == 'true'
|
||||
env:
|
||||
PR_ARTIFACT_PUSH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git"
|
||||
git fetch origin "$PR_HEAD_SHA"
|
||||
git checkout --detach FETCH_HEAD
|
||||
actual_sha="$(git rev-parse HEAD)"
|
||||
if [ "$actual_sha" != "$PR_HEAD_SHA" ]; then
|
||||
echo "::error::SHA mismatch: expected $PR_HEAD_SHA, got $actual_sha"
|
||||
exit 1
|
||||
fi
|
||||
git config credential.helper '!f() { echo "username=x-access-token"; echo "password=$PR_ARTIFACT_PUSH_TOKEN"; }; f'
|
||||
|
||||
- name: Remove .pr directory
|
||||
id: remove
|
||||
if: steps.check_fork.outputs.is_fork == 'false' && steps.pr_context.outputs.has_pr_dir == 'true'
|
||||
env:
|
||||
PR_ARTIFACT_PUSH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
PR_HEAD_REF: ${{ steps.pr_context.outputs.head_ref }}
|
||||
PR_HEAD_SHA: ${{ steps.pr_context.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -d ".pr" ]; then
|
||||
echo "removed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No .pr directory to remove."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "allhands-bot"
|
||||
git config user.email "allhands-bot@users.noreply.github.com"
|
||||
git rm -rf --ignore-unmatch .pr/
|
||||
if git diff --cached --quiet; then
|
||||
echo "removed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No tracked .pr files to remove."
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: Remove PR-only artifacts"
|
||||
push_succeeded=false
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin "HEAD:refs/heads/$PR_HEAD_REF"; then
|
||||
push_succeeded=true
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt 3 ]; then
|
||||
echo "::notice::Failed to push cleanup commit, rebasing and retrying."
|
||||
git fetch origin "$PR_HEAD_REF"
|
||||
if ! git rebase FETCH_HEAD; then
|
||||
echo "::error::Failed to rebase .pr cleanup commit. Manual resolution required."
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$PR_HEAD_SHA" HEAD; then
|
||||
echo "::error::Rebased .pr cleanup commit no longer descends from the approved PR head $PR_HEAD_SHA."
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
if [ "$push_succeeded" != "true" ]; then
|
||||
echo "::error::Failed to push .pr cleanup commit after retries."
|
||||
exit 1
|
||||
fi
|
||||
echo "removed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update PR artifacts comment
|
||||
if: steps.check_fork.outputs.is_fork == 'false' && steps.pr_context.outputs.has_pr_dir == 'true' && steps.remove.outputs.removed == 'true'
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = [
|
||||
marker,
|
||||
'**PR Artifacts Cleaned Up**',
|
||||
'',
|
||||
'The `.pr/` directory has been removed after approval.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find((comment) => comment.body.includes(marker));
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
check-pr-artifacts:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Check for .pr directory
|
||||
id: check
|
||||
run: |
|
||||
if [ -d ".pr" ]; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::.pr directory exists and will be automatically removed when the PR is approved. Fork PRs require manual cleanup before merging."
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post or update PR artifacts comment
|
||||
if: steps.check.outputs.exists == 'true'
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = [
|
||||
marker,
|
||||
'**PR Artifacts Notice**',
|
||||
'',
|
||||
'This PR contains a `.pr/` directory with PR-specific artifacts. This directory will be **automatically removed** when the PR is approved.',
|
||||
'',
|
||||
'> Fork PRs require manual cleanup before merging.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find((comment) => comment.body.includes(marker));
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -5,11 +5,14 @@ src/i18n/declaration.ts
|
||||
.envrc
|
||||
node_modules/
|
||||
build/
|
||||
/.tmp/live-e2e-state/
|
||||
dist/
|
||||
|
||||
|
||||
/test-results/
|
||||
/test-results-live/
|
||||
/playwright-report/
|
||||
/playwright-report-live/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.react-router/
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 54 KiB |
@@ -1,62 +0,0 @@
|
||||
# Visual proof of ACP tool-call rendering — addendum to demo.gif
|
||||
|
||||
This addresses the reviewer note that the original demo did not show ACP
|
||||
events being rendered.
|
||||
|
||||
## Why a synthetic injection
|
||||
|
||||
`openhands-agent-server@1.20.1` cannot drive an ACP sub-agent inside the
|
||||
sandbox (no Claude Code / Codex / Gemini CLI binaries, no ACP transport
|
||||
configured), so the only way to exercise this code path against
|
||||
`npm run dev` is to inject `ACPToolCallEvent` objects directly into the
|
||||
running `useEventStore`. The injection is gated by
|
||||
`if (import.meta.env.DEV)` and tree-shaken from production builds — it
|
||||
only re-exports the existing Zustand store onto `window.__OH_EVENT_STORE__`
|
||||
for fixture/preview tooling. No production behaviour changes.
|
||||
|
||||
## What the GIF shows
|
||||
|
||||
`demo.gif` alternates between two real screenshots from the dev server:
|
||||
|
||||
1. **`04-acp-cards-collapsed.png`** — Three `ACPToolCallEvent` cards
|
||||
rendered under a synthetic user message:
|
||||
- `Running gh pr diff 14246` (tool_kind=`execute`, status=`completed`)
|
||||
- `Reading src/components/v1/chat/event-message.tsx` (tool_kind=`read`, status=`completed`)
|
||||
- `Editing src/utils/handle-event-for-ui.ts` (tool_kind=`edit`, status=`failed`)
|
||||
The first two show the green "completed" success indicator; the third
|
||||
has no indicator (failed terminal state). All three render through
|
||||
the same `GenericEventMessageWrapper` path that ports OpenHands#13994 +
|
||||
#14246 + #14247 added.
|
||||
2. **`05-acp-cards-expanded.png`** — Same conversation with the first
|
||||
two cards expanded:
|
||||
- The `execute` card surfaces `Command:` and `Output:` blocks with
|
||||
the SHTTP-style code block treatment.
|
||||
- The `read` card surfaces `Input:` (JSON) and `Output:` (file
|
||||
contents) — the same shape `getACPToolCallContent` produces for
|
||||
read tools.
|
||||
The localized titles are pulled from `ACTION_MESSAGE$ACP_RUN`,
|
||||
`ACTION_MESSAGE$ACP_READ`, `ACTION_MESSAGE$ACP_EDIT` etc.; the
|
||||
`make-i18n` step generated the matching declaration enum and locale
|
||||
bundles for all 15 locales.
|
||||
|
||||
## Functional verification
|
||||
|
||||
```
|
||||
$ npx vitest run \
|
||||
__tests__/components/v1/chat/event-content-helpers/get-acp-tool-call-content.test.ts \
|
||||
__tests__/components/v1/chat/event-message-acp-tool-call.test.tsx
|
||||
…
|
||||
Test Files 2 passed (2)
|
||||
Tests 20 passed (20)
|
||||
```
|
||||
|
||||
Plus the existing handle-event-for-ui and type-guard suites already in
|
||||
the PR.
|
||||
|
||||
## Capture script
|
||||
|
||||
The capture is reproducible via `tmp-capture-acp.mjs` (committed
|
||||
alongside this note for traceability) — it stubs the conversation
|
||||
events endpoint, calls `useEventStore.setState({...})` with three
|
||||
synthetic `ACPToolCallEvent`s plus a lead-in user `MessageEvent`, then
|
||||
screenshots collapsed and expanded states.
|
||||
|
Before Width: | Height: | Size: 91 KiB |
@@ -1,180 +0,0 @@
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
mkdirSync(".pr/issue-132", { recursive: true });
|
||||
|
||||
const URL = "https://work-1-yrsrggnfhzzshrxo.prod-runtime.all-hands.dev";
|
||||
const OUT = ".pr/issue-132";
|
||||
const CONV = "2c9236f8-7405-469a-afbe-f91788ba7e93";
|
||||
|
||||
const acpEvents = [
|
||||
// Lead-in user message so v1UserEventsExist passes and the chat renders.
|
||||
{
|
||||
id: "user-evt-0",
|
||||
timestamp: "2026-05-07T14:29:55.000Z",
|
||||
source: "user",
|
||||
kind: "MessageEvent",
|
||||
llm_message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Use Claude Code to inspect the diff for upstream PR 14246, peek at event-message.tsx, then try to patch handle-event-for-ui.ts." },
|
||||
],
|
||||
},
|
||||
activated_skills: [],
|
||||
},
|
||||
// Execute (shell command, completed) — should render as
|
||||
// "ACPToolCallEvent · gh pr diff 14246" with Command/Output blocks
|
||||
{
|
||||
id: "acp-evt-1",
|
||||
timestamp: "2026-05-07T14:30:00.000Z",
|
||||
source: "agent",
|
||||
kind: "ACPToolCallEvent",
|
||||
tool_call_id: "tc_exec_1",
|
||||
title: "gh pr diff 14246",
|
||||
status: "completed",
|
||||
tool_kind: "execute",
|
||||
raw_input: { command: "gh pr diff 14246 --repo OpenHands/OpenHands" },
|
||||
raw_output:
|
||||
"diff --git a/frontend/src/components/.../acp-tool-call-card.tsx\n@@ ...\n+import { ACPToolCallEvent } from ...\n+\n+const result = getACPToolCallResult(event);\n+return <GenericEventMessage status={result} ... />;",
|
||||
content: null,
|
||||
is_error: false,
|
||||
},
|
||||
// Read (file read, completed) — rendered with JSON Input: block
|
||||
{
|
||||
id: "acp-evt-2",
|
||||
timestamp: "2026-05-07T14:30:05.000Z",
|
||||
source: "agent",
|
||||
kind: "ACPToolCallEvent",
|
||||
tool_call_id: "tc_read_1",
|
||||
title: "src/components/v1/chat/event-message.tsx",
|
||||
status: "completed",
|
||||
tool_kind: "read",
|
||||
raw_input: { path: "src/components/v1/chat/event-message.tsx", limit: 80 },
|
||||
raw_output:
|
||||
"import React from 'react';\nimport { isACPToolCallEvent } from '#/types/v1/type-guards';\n…\nif (isACPToolCallEvent(event)) {\n return <GenericEventMessageWrapper event={event} … />;\n}",
|
||||
content: null,
|
||||
is_error: false,
|
||||
},
|
||||
// Edit (failed) — rendered with **Error:** block instead of Output:
|
||||
{
|
||||
id: "acp-evt-3",
|
||||
timestamp: "2026-05-07T14:30:10.000Z",
|
||||
source: "agent",
|
||||
kind: "ACPToolCallEvent",
|
||||
tool_call_id: "tc_edit_1",
|
||||
title: "src/utils/handle-event-for-ui.ts",
|
||||
status: "failed",
|
||||
tool_kind: "edit",
|
||||
raw_input: { path: "src/utils/handle-event-for-ui.ts", anchor: "isACPToolCallEvent" },
|
||||
raw_output: "anchor not found: isACPToolCallEvent",
|
||||
content: null,
|
||||
is_error: true,
|
||||
},
|
||||
];
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
// Intercept the events stream so the live conversation can't overwrite our
|
||||
// injected uiEvents. The frontend uses an SSE-style endpoint
|
||||
// /api/conversations/<id>/events for backfill. Cancel WebSocket / EventSource
|
||||
// traffic too.
|
||||
await page.route("**/api/conversations/*/events*", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], next_page_id: null, has_more: false }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${URL}/conversations/${CONV}`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// Inject the synthetic events into the dev-only window.__OH_EVENT_STORE__.
|
||||
// We replace the uiEvents directly so the chat renders only our cards
|
||||
// (clean demo) without interference from existing conversation traffic.
|
||||
page.on("console", (msg) => console.log("[browser]", msg.type(), msg.text()));
|
||||
|
||||
const injected = await page.evaluate((events) => {
|
||||
const store = (window).__OH_EVENT_STORE__;
|
||||
if (!store?.setState) return { ok: false, reason: "store not exposed on window" };
|
||||
store.setState({
|
||||
events,
|
||||
eventIds: new Set(events.map((e) => e.id)),
|
||||
uiEvents: events,
|
||||
});
|
||||
console.log("OH-DEBUG store after setState:", JSON.stringify({
|
||||
eventCount: store.getState().events.length,
|
||||
uiEventCount: store.getState().uiEvents.length,
|
||||
sample: store.getState().uiEvents[0],
|
||||
}));
|
||||
return { ok: true, count: events.length };
|
||||
}, acpEvents);
|
||||
console.log("inject:", injected);
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Debug: re-read store
|
||||
const after = await page.evaluate(() => {
|
||||
const s = (window).__OH_EVENT_STORE__?.getState();
|
||||
return {
|
||||
events: s?.events?.length,
|
||||
uiEvents: s?.uiEvents?.length,
|
||||
sampleKind: s?.uiEvents?.[0]?.kind,
|
||||
chatChildren:
|
||||
document.querySelector("main, [role='main']")?.innerHTML?.slice(0, 500),
|
||||
};
|
||||
});
|
||||
console.log("post-wait:", after);
|
||||
|
||||
// Hide the right-side Changes panel if open so the chat takes more width
|
||||
const changesBtn = page.getByRole("button", { name: /changes/i });
|
||||
if (await changesBtn.count()) {
|
||||
try {
|
||||
await changesBtn.first().click();
|
||||
await page.waitForTimeout(400);
|
||||
} catch {}
|
||||
}
|
||||
await page.screenshot({ path: `${OUT}/04-acp-cards-collapsed.png`, fullPage: false });
|
||||
|
||||
// Expand the cards by clicking each cursor-pointer header button
|
||||
const headers = page.locator("button.cursor-pointer.text-left");
|
||||
const count = await headers.count();
|
||||
console.log("headers found:", count);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
try {
|
||||
await headers.nth(i).click();
|
||||
await page.waitForTimeout(200);
|
||||
} catch (e) {
|
||||
console.log("click err:", String(e).slice(0, 200));
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/05-acp-cards-expanded.png`, fullPage: false });
|
||||
|
||||
// Frame 3: re-collapse the first two cards so the failed Edit card moves
|
||||
// into the viewport, capturing the Error block.
|
||||
await page.evaluate(() => {
|
||||
const headers = Array.from(document.querySelectorAll("button.cursor-pointer.text-left"));
|
||||
const collapse = headers.filter(
|
||||
(b) =>
|
||||
b.textContent?.includes("gh pr diff 14246") ||
|
||||
b.textContent?.includes("event-message.tsx"),
|
||||
);
|
||||
collapse.forEach((h) => h.click());
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
// Then expand the failed card if it isn't already
|
||||
await page.evaluate(() => {
|
||||
const headers = Array.from(document.querySelectorAll("button.cursor-pointer.text-left"));
|
||||
const edit = headers.find((b) => b.textContent?.includes("handle-event-for-ui"));
|
||||
// ensure expanded — if collapsed (chevron rotated), click to open
|
||||
const chev = edit?.querySelector("[aria-expanded='false'], .rotate-0, svg");
|
||||
if (edit && chev) edit.click();
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${OUT}/06-acp-card-failed.png`, fullPage: false });
|
||||
|
||||
await browser.close();
|
||||
console.log("done");
|
||||
|
Before Width: | Height: | Size: 113 KiB |
@@ -1,83 +0,0 @@
|
||||
# Visual verification — Issue #132 (ACP tool-call rendering)
|
||||
|
||||
This port mirrors three upstream OpenHands frontend PRs:
|
||||
|
||||
- [OpenHands#13994](https://github.com/OpenHands/OpenHands/pull/13994) — initial ACP tool-call event support.
|
||||
- [OpenHands#14246](https://github.com/OpenHands/OpenHands/pull/14246) — drop the `ACP · ` prefix from titles.
|
||||
- [OpenHands#14247](https://github.com/OpenHands/OpenHands/pull/14247) — suppress `in_progress` events so empty-args cards don't flash.
|
||||
|
||||
## Live verification (`npm run dev`)
|
||||
|
||||
`agent-canvas` was rebuilt from the `port/acp-tool-call-rendering` branch
|
||||
and run against a local `openhands-agent-server` 1.20.1 (started by
|
||||
`scripts/dev-safe.mjs`). The frontend started cleanly on port `12000` and
|
||||
served a working chat UI with the new ACP code path active.
|
||||
|
||||
Frames in `demo.gif`:
|
||||
|
||||
1. **`01-home.png`** — Agent Canvas home page (`Let's Start Building!`)
|
||||
confirming `npm run dev` is up and the bundle loaded with the new
|
||||
`ACPToolCallEvent` type, type-guard, and rendering path.
|
||||
2. **`02-conversation-changes.png`** — Inside an existing conversation
|
||||
with the `Changes` panel open. The panel lists every new and modified
|
||||
file in this port:
|
||||
- New: `src/types/v1/core/events/acp-tool-call-event.ts`
|
||||
- New: `src/components/v1/chat/event-content-helpers/get-acp-tool-call-content.ts`
|
||||
- Modified: `event-message.tsx`, `should-render-event.ts`,
|
||||
`generic-event-message-wrapper.tsx`, `get-event-content.tsx`,
|
||||
`get-observation-result.ts`, `handle-event-for-ui.ts`,
|
||||
`type-guards.ts`, `openhands-event.ts`, `events/index.ts`,
|
||||
`i18n/translation.json`
|
||||
- Plus four matching test files under `__tests__/`.
|
||||
|
||||
## Functional verification (unit tests)
|
||||
|
||||
39 new/modified ACP-specific test cases pass:
|
||||
|
||||
```
|
||||
$ npx vitest run \
|
||||
__tests__/components/v1/chat/event-content-helpers/get-acp-tool-call-content.test.ts \
|
||||
__tests__/components/v1/chat/event-content-helpers/should-render-event.test.ts \
|
||||
__tests__/utils/handle-event-for-ui.test.ts \
|
||||
__tests__/components/v1/chat/event-message-acp-tool-call.test.tsx
|
||||
…
|
||||
Test Files 4 passed (4)
|
||||
Tests 39 passed (39)
|
||||
```
|
||||
|
||||
Coverage:
|
||||
|
||||
- `getACPToolCallTitleKey` — picks the right `ACTION_MESSAGE$ACP_*` key
|
||||
for every `tool_kind` (execute / edit / read / fetch / other / null).
|
||||
- `getACPToolCallContent` — formats execute calls with `Command:` +
|
||||
`Output:` blocks (matching `getTerminalObservationContent`), formats
|
||||
non-execute calls with a JSON `Input:` block, swaps `Output:` for
|
||||
`**Error:**` when `is_error`, falls back to the shared
|
||||
`OBSERVATION$COMMAND_NO_OUTPUT` copy, truncates >`MAX_CONTENT_LENGTH`,
|
||||
and serialises structured outputs as JSON.
|
||||
- `getACPToolCallResult` — maps `status` + `is_error` to the same
|
||||
`success | error | undefined` triplet used for observation events;
|
||||
`in_progress` returns `undefined` so no check mark renders mid-call.
|
||||
- `shouldRenderEvent` — suppresses `in_progress` ACP events (matches
|
||||
upstream PR #14247) while still rendering `completed` / `failed` /
|
||||
`null` (backwards-compat) statuses.
|
||||
- `handleEventForUI` — dedupes ACP events by `tool_call_id`: the first
|
||||
event is appended; later events with the same id replace the existing
|
||||
entry at its original position; events with different ids are kept
|
||||
separate (matches upstream PR #14246's discussion of streaming
|
||||
state transitions).
|
||||
- `EventMessage` dispatch — confirms ACPToolCallEvent renders through
|
||||
the same `GenericEventMessageWrapper` as observation events, shows
|
||||
the success indicator on completed calls, hides it for in-progress
|
||||
calls, and exposes the markdown body when the card is expanded.
|
||||
|
||||
## Why no live ACP capture in the GIF
|
||||
|
||||
Triggering a real ACP sub-agent (Claude Code, Codex, Gemini CLI) in this
|
||||
sandbox is out of scope — those require a separately configured ACP
|
||||
backend in the agent server. The upstream PR #14246 includes a
|
||||
Storybook story (`ACPToolCallCard.stories.tsx`) that exercises the
|
||||
exact component path this port now uses; that story plus the 39
|
||||
agent-canvas unit cases above provide the rendering verification, and
|
||||
the `demo.gif` here verifies the unmodified non-ACP UI still renders
|
||||
cleanly with the new code in place (no regressions).
|
||||
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,41 +0,0 @@
|
||||
# Visual verification — Issue #134 (app-settings polish)
|
||||
|
||||
## Frames in `demo.gif`
|
||||
|
||||
1. **`01-app-settings-en.png`** — `Settings → Application` page rendered by
|
||||
`npm run dev`. The Git Username and Git Email inputs are emptied so
|
||||
their placeholders are visible. Both placeholders now resolve through
|
||||
`t(I18nKey.SETTINGS$GIT_USERNAME_PLACEHOLDER)` and
|
||||
`t(I18nKey.SETTINGS$GIT_EMAIL_PLACEHOLDER)` instead of the previous
|
||||
hardcoded English strings.
|
||||
2. **`03-mcp-timeout-label.png`** — `Settings → MCP` with the "Add MCP
|
||||
Server" form open and `Server Type = SHTTP`. The "Timeout (seconds)"
|
||||
label now resolves through `t(I18nKey.SETTINGS$MCP_TIMEOUT_LABEL)`
|
||||
instead of the previous hardcoded English label.
|
||||
|
||||
The page boots, all related queries succeed, and the new keys round-trip
|
||||
through `make-i18n`'s declaration generator with no missing translations.
|
||||
|
||||
## Functional verification
|
||||
|
||||
- `npm run typecheck` — clean.
|
||||
- `npm run build` — clean (`build/server/index.js 1,136.20 kB`).
|
||||
- `npx vitest run __tests__/routes/app-settings.test.tsx __tests__/routes/mcp-settings.test.tsx __tests__/components/features/settings/mcp-settings/` — **10 / 10 passed**.
|
||||
- `make-i18n` regenerates `src/i18n/declaration.ts` with the three new
|
||||
enum entries (`SETTINGS$GIT_USERNAME_PLACEHOLDER`,
|
||||
`SETTINGS$GIT_EMAIL_PLACEHOLDER`, `SETTINGS$MCP_TIMEOUT_LABEL`).
|
||||
|
||||
## On the sandbox-grouping half (#14291)
|
||||
|
||||
Upstream PR #14291 drops the `ENABLE_SANDBOX_GROUPING()` feature-flag
|
||||
gate around the sandbox-grouping-strategy dropdown in
|
||||
`app-settings.tsx`. That dropdown — and the `sandbox_grouping_strategy`
|
||||
setting it edits — was already stripped from agent-canvas during the
|
||||
original OSS port (the field is not present in `src/types/settings.ts`,
|
||||
not exposed by either `/api/settings/agent-schema` or
|
||||
`/api/settings/conversation-schema` on `openhands-agent-server` 1.20.1,
|
||||
and `feature-flags.ts` no longer carries the `SANDBOX_GROUPING` flag).
|
||||
|
||||
Per the issue's own acceptance criteria ("if the field isn't part of
|
||||
the schema, gate on schema presence rather than re-introducing the
|
||||
flag"), this PR is a no-op for #14291: there is nothing to ungate.
|
||||
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 677 KiB |
@@ -1,26 +0,0 @@
|
||||
# Issue #135 — Show full model name in conversation header
|
||||
|
||||
Port of [OpenHands/OpenHands#14284](https://github.com/OpenHands/OpenHands/pull/14284).
|
||||
|
||||
## Recording setup
|
||||
|
||||
* Branch: `port/full-model-name-header`
|
||||
* Backend: `npm run dev` (spawns isolated `openhands-agent-server@main` on `:18000`)
|
||||
* Frontend: `localhost:12000` (exposed at `https://work-1-yrsrggnfhzzshrxo.prod-runtime.all-hands.dev/` for the recording)
|
||||
* LLM configured via `PATCH /api/settings` with `agent_settings_diff.llm.model = litellm_proxy/claude-sonnet-4-5-20250929` and the live `LLM_API_KEY`.
|
||||
|
||||
The recordings were produced by `node scripts/record-demo.mjs full-model-name <out>.webm`, then converted to GIF with the ffmpeg snippet documented in that script.
|
||||
|
||||
## Visual diff
|
||||
|
||||
`comparison.png` stacks the same conversation header band before and after the fix:
|
||||
|
||||
* **Before** (`max-w-[150px] overflow-hidden` + inner `truncate`): `litellm_proxy/claude…` cuts off after the first 150 px.
|
||||
* **After** (`whitespace-nowrap`, no inner truncate): the full `litellm_proxy/claude-sonnet-4-5-20250929` renders inline.
|
||||
|
||||
`before.png` / `after.png` are full 1280×720 captures of the conversation page in each state. `demo.gif` is the recorded "after" flow (home → New Conversation → header rendered with the un-truncated badge).
|
||||
|
||||
## Tests
|
||||
|
||||
* `npm test -- conversation-name` → 25/25 passing (includes the new assertions that the outer span has `whitespace-nowrap` and no `max-w-[150px]` / `overflow-hidden`, and the inner span has no `truncate`).
|
||||
* `npm run typecheck` → clean.
|
||||
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 78 KiB |
@@ -1,22 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "home",
|
||||
"url": "http://127.0.0.1:4176/",
|
||||
"title": "OpenHands"
|
||||
},
|
||||
{
|
||||
"name": "settings-llm",
|
||||
"url": "http://127.0.0.1:4176/settings",
|
||||
"title": "OpenHands"
|
||||
},
|
||||
{
|
||||
"name": "settings-app",
|
||||
"url": "http://127.0.0.1:4176/settings/app",
|
||||
"title": "OpenHands"
|
||||
},
|
||||
{
|
||||
"name": "conversation",
|
||||
"url": "http://127.0.0.1:4176/conversations/1",
|
||||
"title": "My New Project | OpenHands"
|
||||
}
|
||||
]
|
||||
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 59 KiB |
@@ -1,37 +0,0 @@
|
||||
# Issue #44 visual verification
|
||||
|
||||
This change was visually spot-checked against the existing `.pr/issue-44/before/` artifacts after upgrading HeroUI to v3.
|
||||
|
||||
## Artifacts
|
||||
|
||||
- Homepage
|
||||
- Before: `.pr/issue-44/before/home.png`
|
||||
- After: `.pr/issue-44/after/home.png`
|
||||
- LLM settings
|
||||
- Before: `.pr/issue-44/before/settings-llm.png`
|
||||
- After: `.pr/issue-44/after/settings-llm.png`
|
||||
- Application settings
|
||||
- Before: `.pr/issue-44/before/settings-app.png`
|
||||
- After: `.pr/issue-44/after/settings-app.png`
|
||||
- Conversation view
|
||||
- Before: `.pr/issue-44/before/conversation.png`
|
||||
- After: `.pr/issue-44/after/conversation.png`
|
||||
|
||||
## Verification notes
|
||||
|
||||
- Built a static mock bundle with `npm run build:mock`.
|
||||
- Served `build/` locally and captured the after screenshots from the built app.
|
||||
- Spot-checked the homepage, LLM settings, application settings, and conversation view.
|
||||
- Confirmed the migrated HeroUI v3 combobox-based provider/model selectors render correctly and preserve the expected selected values.
|
||||
- No obvious layout regressions were observed in the checked screens.
|
||||
|
||||
## Post-merge verification
|
||||
|
||||
- Merged the latest `main` into the PR branch and resolved the resulting `AGENTS.md` conflict.
|
||||
- Re-ran `npm run make-i18n`, `npm run typecheck`, `npm test`, `npm run build`, and `npm run build:mock` on the merge-resolved branch.
|
||||
- Reused the checked-in `.pr/issue-44/after/` screenshots as the visual baseline because the merge introduced no product-code conflicts or UI changes beyond the already-reviewed HeroUI v3 migration.
|
||||
|
||||
|
||||
## Caveats
|
||||
|
||||
- The mock conversation screenshot still shows the expected mock-mode disconnected banner/state in the conversation view.
|
||||
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 80 KiB |
@@ -1,5 +1,7 @@
|
||||
# Repository Notes
|
||||
|
||||
## General
|
||||
|
||||
- This repository is a near-direct port of the OpenHands frontend, adapted to talk straight to `software-agent-sdk` / `agent_server` without the usual OpenHands app backend.
|
||||
- Frontend API adaptation lives mainly in `src/api/`:
|
||||
- `option-service` fabricates an OSS web-client config and reads models/providers through `@openhands/typescript-client` LLM endpoints.
|
||||
@@ -16,6 +18,29 @@
|
||||
- The UI keeps most OpenHands routes/layout intact, but hosted-only behavior (org, account management, integrations) has been removed via the fabricated OSS config because there is no separate app backend.
|
||||
- Verification command: `npm run typecheck && npm run build`.
|
||||
- GitHub automation now includes `.github/workflows/ci.yml` for `npm ci`, `npm test`, and `npm run build`, plus `.github/dependabot.yml` with weekly npm/github-actions updates gated by a 7-day cooldown.
|
||||
|
||||
## Live End-to-End Test Framework
|
||||
|
||||
- The live QA path is intentionally separate from ordinary mocked Playwright coverage. If ordinary browser tests are added, keep them outside `tests/e2e/live/` so `playwright.config.ts` can run them while ignoring `**/live/**`; live LLM-backed tests must never run as part of `npm run test:e2e`.
|
||||
- Live tests live under `tests/e2e/live/` and are run only through `npm run test:e2e:live`, which uses `playwright.live.config.ts`. Keep the spec names descriptive; the primary conversation smoke test is `tests/e2e/live/real-agent-server-conversation.spec.ts`.
|
||||
- `npm run test:e2e:live` loads `.env` through Node's `--env-file-if-exists` flag and invokes `tests/e2e/live/scripts/run-live-e2e.mjs`. The runner validates the required local environment, explains missing credentials/prerequisites, and then runs `playwright test --config=playwright.live.config.ts`. Use `npm run test:e2e:live -- --check` to validate local setup without running the test, and pass Playwright flags after `--` (for example `npm run test:e2e:live -- --headed`).
|
||||
- Local live E2E requires one LLM credential: `LIVE_E2E_LLM_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `LLM_API_KEY`. Optional overrides are `LIVE_E2E_LLM_BASE_URL`, `LIVE_E2E_LLM_MODEL`, `LIVE_E2E_SESSION_API_KEY`, `LIVE_E2E_BACKEND_URL`, and `LIVE_E2E_FRONTEND_PORT`. The local runner prints which variables are missing without printing secret values.
|
||||
- Live-test-only helpers belong under `tests/e2e/live/utils/`. The current helper module is `tests/e2e/live/utils/agent-server-conversation.ts`; do not put live-only helpers in the shared `tests/e2e/support/` directory.
|
||||
- `playwright.live.config.ts` starts the real local Agent Server/UI stack via `npm run dev:minimal`, not MSW mocks. It uses `LIVE_E2E_SESSION_API_KEY` when set, otherwise generates a per-run random session key and passes it through `SESSION_API_KEY`, `OH_SESSION_API_KEYS_0`, and `VITE_SESSION_API_KEY`; specs that need direct backend requests must inject `X-Session-API-Key` only for the configured backend origin through `routeBackendSessionApiKey(page)`, never through global Playwright `extraHTTPHeaders`. Live tests default to frontend port `3101` and Agent Server `http://127.0.0.1:18100` so they do not accidentally reuse a normal local dev stack.
|
||||
- `tests/e2e/live/utils/agent-server-conversation.ts` configures the running Agent Server before each live conversation by PATCHing `${LIVE_E2E_BACKEND_URL ?? "http://127.0.0.1:18100"}/api/settings` with LLM settings and low-risk conversation settings. LLM credentials are read from `LIVE_E2E_LLM_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `LLM_API_KEY`; CI defaults use `LIVE_E2E_LLM_BASE_URL` (default `https://llm-proxy.app.all-hands.dev`) and `LIVE_E2E_LLM_MODEL` (default `openhands/claude-haiku-4-5-20251001`).
|
||||
- The live conversation test should stay cheap and as deterministic as possible while still exercising one real tool call: it asks the model to run the exact `EXPECTED_BASH_COMMAND`, waits for the bash output token to appear outside the user's message in the UI, confirms a successful `ExecuteBashObservation`/`TerminalObservation` through the real Agent Server events API, and then waits for the final `EXPECTED_REPLY_TOKEN`. This exercises the real UI, Agent Server settings API, conversation creation, websocket/event path, terminal tool execution, and LLM response path. Because LLM behavior is not perfectly deterministic even at temperature 0, CI keeps one retry for live E2E; future live tests should document any expected variance and avoid prompts that require unnecessary formatting obedience.
|
||||
- Live E2E must not pollute analytics. `playwright.live.config.ts` starts the app with `VITE_DO_NOT_TRACK=1`; the live helper seeds local storage with telemetry/analytics opt-out values before app code runs; and each live spec should install `guardAgainstPostHogRequests(page)` before navigation so any attempted request to `*.posthog.com` or `z.openhands.dev` is blocked locally and fails the test.
|
||||
- Live Playwright videos are intentionally recorded for PR QA debugging when `LIVE_E2E_RECORD_VIDEO=on` is set by CI; local default video mode is `retain-on-failure`. Do not add live tests that render API keys, tokens, secret values, or credential-bearing error messages in the browser. Screenshots should target a safe app/chat region such as `data-testid="chat-interface"` instead of `page.screenshot({ fullPage: true })`, and should apply `getLiveArtifactMask(page)` for text/field redaction; if a future live test must exercise sensitive UI, change that test/media path to redact the sensitive output or retain video only on failure.
|
||||
- `.github/workflows/ci.yml` runs live E2E only at PR level: either manually via `workflow_dispatch` with a required `pr_number`, or from a same-repository PR carrying the `live-e2e` label. The live job must skip fork PRs before checking out PR code so LLM credentials and artifact-push tokens are never exposed to untrusted code. Do not broaden it to run on every PR push unless cost and credential policy are explicitly revisited.
|
||||
- Keep live E2E secrets out of job-level `env`. The workflow should check whether credentials exist before checkout, but inject the LLM key only into the trusted step that actually runs the live test.
|
||||
- The live job uploads the Playwright HTML report plus screenshot/video output as a GitHub Actions artifact, and also extracts the primary screenshot/video attachments. It converts the WebM recording to a GIF preview with `ffmpeg` so GitHub PR comments can inline the preview. Keep Playwright trace capture disabled for live tests because the setup flow sends LLM credentials to the Agent Server settings API, and traces can record request bodies. Failure messages around live Agent Server settings must not print response bodies from credential-bearing requests.
|
||||
- Inline PR-comment media is stored as PR-only files under `.pr/live-e2e/<github_run_id>/` on the PR branch, not on a long-lived orphan media branch. The comment uses `raw.githubusercontent.com/<repo>/<artifact_commit>/.pr/live-e2e/...` URLs for the GIF and PNG so GitHub can render them inline. The WebM is linked as the full recording because GitHub comments do not reliably inline WebM.
|
||||
- `.github/workflows/pr-artifacts.yml` owns `.pr/` cleanup. It comments when `.pr/` artifacts exist and removes `.pr/` after PR approval for same-repo PRs, keeping generated QA media out of the final squash merge. Fork PRs require manual cleanup.
|
||||
- The live reporting scripts live beside the live tests under `tests/e2e/live/scripts/`: `run-live-e2e.mjs`, `extract-live-e2e-media.mjs`, `render-live-e2e-report.mjs`, and `upsert-pr-comment.mjs`. Keep report/comment/local-runner logic there rather than in top-level `scripts/`, because these scripts are part of the live E2E framework.
|
||||
- When changing any part of this framework — live workflow triggers, artifact publishing, `.pr` cleanup, live Playwright config, live test file layout, helper locations, local runner behavior, or report/comment scripts — update this `AGENTS.md` section in the same PR so future agents have the current operating model.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
- Direct `dependencies` and `devDependencies` in `package.json` are exact-pinned (no caret ranges); reproducible installs should use the committed `package-lock.json` plus `npm ci`, and targeted transitive fixes still belong in `overrides`.
|
||||
- `package-lock.json` must also retain the optional peer entry for `node_modules/vite-tsconfig-paths/node_modules/typescript@5.9.3`; without that nested lock entry, clean `npm ci` installs on CI fail with `Missing: typescript@5.9.3 from lock file`.
|
||||
- `npm test` now runs `npm run make-i18n` first so clean environments generate `src/i18n/declaration.ts` before Vitest loads aliased imports.
|
||||
@@ -54,7 +79,6 @@
|
||||
- `ChatInterface` continuity tests should assert that conversation messages render without the full `chat-messages-skeleton`, not that `data-testid="loading-spinner"` is absent: the lazy older-events indicator reuses the shared `LoadingSpinner` component and legitimately renders that inner test id while history backfill is running.
|
||||
- `useConversationHistory` now mirrors the older-events pagination fallback when the first page is exactly `INITIAL_HISTORY_PAGE_SIZE`: treat `next_page_id` **or** a full page as `hasMore`, so older agent-server variants that omit `next_page_id` still allow one more backfill request. The hook and `useLoadOlderEvents` also defensively reject mocked/malformed `page.items` responses before reversing them.
|
||||
|
||||
|
||||
- `/server_info` tool capability metadata from `software-agent-sdk` PR #3028 ended up shipping as `usable_tools` (not `available_tools`). Frontend browser-tool gating should key off `usable_tools`, and still default to allowing tools when the server does not advertise tool metadata.
|
||||
|
||||
- Useful regression tests for mock mode live in `__tests__/api/option-service.test.ts`, `__tests__/api/mock-conversation-handlers.test.ts`, and `__tests__/api/mock-settings-handlers.test.ts`.
|
||||
@@ -92,7 +116,6 @@
|
||||
- `BackendSelector`'s cloud-org switch paths should never rethrow from the dropdown `onChange` handler: unexpected non-Axios failures need a generic error toast instead of an unhandled promise rejection, and the malformed `(cloud backend, null org)` self-heal path should fall back to the bundled backend if `/switch` fails.
|
||||
- `NewConversationButton` should support keyboard dismissal (`Escape`) for its inline popover, while still keeping the popover open when its modal children (`FolderBrowserModal`, `ManageWorkspacesModal`) are active.
|
||||
|
||||
|
||||
- README expectation: keep the first section as a concrete, chronological from-scratch quickstart for running this frontend against a real `openhands-agent-server` (clone, install uv, optional `.env`, run `npm run dev`).
|
||||
- Keep README user-focused and move contributor/developer-specific workflows (`dev:safe`, mock mode, detailed env vars/build-test notes) into `DEVELOPMENT.md`.
|
||||
- `scripts/dev-safe.mjs` uses `uvx` for temporary agent-server installation — no permanent `uv tool install` needed. Environment variables (highest precedence first):
|
||||
@@ -150,7 +173,7 @@
|
||||
- Public embedding entry points should use `AgentServerUIProviders` (scoped root on by default) or `AgentServerUIRoot` for manual control. The standalone app already renders its own scoped root in `src/root.tsx`, so `src/entry.client.tsx` must pass `withStyleRoot={false}` to avoid nesting duplicate shells. Keep `AgentServerUIRoot` and the scoping constants re-exported from `src/lib/index.ts` so library consumers can customize the host wrapper without reaching into private paths.
|
||||
- `AgentServerUIRoot`'s themed inner wrapper must set a default `color: var(--foreground)` in addition to the `dark` / `data-theme` markers; otherwise inherited text and `currentColor` SVG icons fall back to dark browser defaults after CSS scoping, causing dark-on-dark regressions on pages like the home screen.
|
||||
- Theme/customization tokens for the embedded shell are exposed as `--oh-*` CSS variables. Override them through `styleOverrides`, `style`, or host CSS targeting `[data-agent-server-ui]`; Tailwind theme tokens in `src/tailwind.css` should continue to reference those variables with `@theme inline` so host apps can restyle the UI without reworking component class names.
|
||||
- Regression coverage for the CSS isolation work lives in `__tests__/agent-server-ui-providers.test.tsx`, `__tests__/agent-server-ui-style-scope.test.ts`, and the browser-level `tests/css-isolation.spec.ts` Playwright test.
|
||||
- Regression coverage for the CSS isolation work lives in `__tests__/agent-server-ui-providers.test.tsx`, `__tests__/agent-server-ui-style-scope.test.ts`, and the browser-level `tests/e2e/regressions/css-isolation.spec.ts` Playwright test.
|
||||
|
||||
- Conversation history is loaded lazily, REST-first then WebSocket:
|
||||
- `useConversationHistory` (in `src/hooks/query/use-conversation-history.ts`) fetches only the most recent `INITIAL_HISTORY_PAGE_SIZE` (default 50) events using `sort_order='TIMESTAMP_DESC'`, then reverses to chronological order. Older pages are paginated in via `useLoadOlderEvents` when the user scrolls near the top of the chat.
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
"build:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router build",
|
||||
"start": "npx sirv-cli build/ --single",
|
||||
"test": "npm run make-i18n && vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e": "playwright test --pass-with-no-tests",
|
||||
"test:e2e:live": "node --env-file-if-exists=.env tests/e2e/live/scripts/run-live-e2e.mjs",
|
||||
"test:coverage": "npm run make-i18n && vitest run --coverage",
|
||||
"dev_wsl": "VITE_WATCH_USE_POLLING=true vite",
|
||||
"preview": "vite preview",
|
||||
|
||||
@@ -12,7 +12,8 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testDir: "./tests/e2e",
|
||||
testIgnore: "**/e2e/live/**",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const configuredLiveE2ESessionApiKey =
|
||||
process.env.LIVE_E2E_SESSION_API_KEY?.trim();
|
||||
const liveE2ESessionApiKey =
|
||||
configuredLiveE2ESessionApiKey || randomBytes(32).toString("hex");
|
||||
process.env.LIVE_E2E_SESSION_API_KEY = liveE2ESessionApiKey;
|
||||
const liveE2EFrontendPort = process.env.LIVE_E2E_FRONTEND_PORT ?? "3101";
|
||||
const liveE2EBackendURL =
|
||||
process.env.LIVE_E2E_BACKEND_URL ?? "http://127.0.0.1:18100";
|
||||
let liveE2EBackendPort: string;
|
||||
try {
|
||||
liveE2EBackendPort = new URL(liveE2EBackendURL).port || "18100";
|
||||
} catch {
|
||||
throw new Error("Invalid LIVE_E2E_BACKEND_URL. Expected an absolute URL.");
|
||||
}
|
||||
const liveE2EFrontendURL = `http://localhost:${liveE2EFrontendPort}/`;
|
||||
const liveE2EVideoMode =
|
||||
process.env.LIVE_E2E_RECORD_VIDEO === "on" ? "on" : "retain-on-failure";
|
||||
|
||||
function shellQuote(value: string) {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function envAssignment(name: string, value: string) {
|
||||
return `${name}=${shellQuote(value)}`;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/live",
|
||||
testMatch: /.*\.spec\.ts/,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
timeout: 180_000,
|
||||
reporter: [
|
||||
["line"],
|
||||
["json", { outputFile: "test-results-live/results.json" }],
|
||||
["html", { outputFolder: "playwright-report-live", open: "never" }],
|
||||
],
|
||||
outputDir: "test-results-live",
|
||||
use: {
|
||||
baseURL: liveE2EFrontendURL,
|
||||
screenshot: "only-on-failure",
|
||||
trace: "off",
|
||||
video: liveE2EVideoMode,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
// This live stack is intended for macOS/Linux shells and Ubuntu CI runners;
|
||||
// the command below intentionally uses POSIX environment assignment syntax.
|
||||
webServer: {
|
||||
command:
|
||||
"node -e \"const fs=require('node:fs'); for (const p of ['.tmp/live-e2e-state','node_modules/.vite']) fs.rmSync(p,{recursive:true,force:true});\" && " +
|
||||
[
|
||||
"OH_CANVAS_SAFE_STATE_DIR=.tmp/live-e2e-state",
|
||||
envAssignment("SESSION_API_KEY", liveE2ESessionApiKey),
|
||||
envAssignment("OH_SESSION_API_KEYS_0", liveE2ESessionApiKey),
|
||||
envAssignment("OH_CANVAS_SAFE_BACKEND_PORT", liveE2EBackendPort),
|
||||
envAssignment("VITE_SESSION_API_KEY", liveE2ESessionApiKey),
|
||||
"VITE_DO_NOT_TRACK=1",
|
||||
"VITE_ENABLE_BROWSER_TOOLS=false",
|
||||
envAssignment("VITE_FRONTEND_PORT", liveE2EFrontendPort),
|
||||
"npm run dev:minimal",
|
||||
].join(" "),
|
||||
url: liveE2EFrontendURL,
|
||||
timeout: 120_000,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
});
|
||||
@@ -104,7 +104,7 @@ class ConversationService {
|
||||
index += FILE_UPLOAD_CONCURRENCY
|
||||
) {
|
||||
const batch = files.slice(index, index + FILE_UPLOAD_CONCURRENCY);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
results.push(...(await Promise.all(batch.map(uploadFile))));
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ async function withRetry<T>(
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (attempt >= maxRetries - 1) {
|
||||
@@ -27,7 +26,7 @@ async function withRetry<T>(
|
||||
}
|
||||
|
||||
const delay = baseDelayMs * 2 ** attempt;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,6 @@ async function withRetry<T>(
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (attempt >= maxRetries - 1) {
|
||||
@@ -68,7 +67,7 @@ async function withRetry<T>(
|
||||
}
|
||||
|
||||
const delay = baseDelayMs * 2 ** attempt;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
|
||||
@@ -385,7 +385,10 @@ export function ChatInterface() {
|
||||
|
||||
return (
|
||||
<ScrollProvider value={scrollProviderValue}>
|
||||
<div className="h-full flex flex-col justify-between pr-0 md:pr-4 relative">
|
||||
<div
|
||||
className="h-full flex flex-col justify-between pr-0 md:pr-4 relative"
|
||||
data-testid="chat-interface"
|
||||
>
|
||||
{!hasSubstantiveAgentActions &&
|
||||
!hasPendingUserMessages &&
|
||||
!userEventsExist &&
|
||||
|
||||
@@ -295,7 +295,6 @@ export function GitRepoDropdown({
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled,
|
||||
placeholder,
|
||||
|
||||
@@ -271,7 +271,6 @@ export function SdkSectionPage({
|
||||
return { ...base, ...initialValueOverrides };
|
||||
// overridesSignature keeps the memo reactive without depending on
|
||||
// a (potentially recreated) object reference each render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings, filteredSchema, settingsSource, overridesSignature]);
|
||||
|
||||
const initialView = React.useMemo(() => {
|
||||
@@ -323,7 +322,6 @@ export function SdkSectionPage({
|
||||
// initialValueOverrides is intentionally tracked via
|
||||
// overridesSignature on initialValues; including the object ref
|
||||
// here would re-fire the effect every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialValues, initialView]);
|
||||
|
||||
const visibleSections = React.useMemo(() => {
|
||||
@@ -415,7 +413,6 @@ export function SdkSectionPage({
|
||||
isSaving: isPending,
|
||||
isDirty: saveControlIsDirty,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPending, saveControlIsDirty]);
|
||||
|
||||
if (isLoading || isFetching || isSchemaLoading) {
|
||||
|
||||
@@ -54,7 +54,6 @@ import useMetricsStore from "#/stores/metrics-store";
|
||||
import { useConversationHistory } from "#/hooks/query/use-conversation-history";
|
||||
import { setConversationState } from "#/utils/conversation-local-storage";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type WebSocketConnectionState =
|
||||
| "CONNECTING"
|
||||
| "OPEN"
|
||||
@@ -343,7 +342,6 @@ export function ConversationWebSocketProvider({
|
||||
setPlanContent(fileContent);
|
||||
},
|
||||
onError: (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to read conversation file:", error);
|
||||
},
|
||||
},
|
||||
@@ -498,7 +496,6 @@ export function ConversationWebSocketProvider({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to parse WebSocket message as JSON:", error);
|
||||
}
|
||||
},
|
||||
@@ -658,7 +655,6 @@ export function ConversationWebSocketProvider({
|
||||
setPlanContent(fileContent);
|
||||
},
|
||||
onError: (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"Failed to read conversation file:",
|
||||
error,
|
||||
@@ -672,7 +668,6 @@ export function ConversationWebSocketProvider({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to parse WebSocket message as JSON:", error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -109,7 +109,6 @@ export function useResolvedWorkspaces(): UseResolvedWorkspacesResult {
|
||||
});
|
||||
|
||||
return Array.from(byPath.values());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaces, workspaceParents, queriesFingerprint]);
|
||||
|
||||
return { workspaces: merged, isLoading, isError };
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
import {
|
||||
BACKEND_URL,
|
||||
clickButtonByTestId,
|
||||
clickButtonByTestIdOrText,
|
||||
configureLiveAgentServer,
|
||||
dismissAnalyticsModal,
|
||||
enableLiveE2EFlags,
|
||||
EXPECTED_BASH_COMMAND,
|
||||
EXPECTED_BASH_OUTPUT_TOKEN,
|
||||
EXPECTED_REPLY_TOKEN,
|
||||
expandVisibleEventDetails,
|
||||
fillChatInput,
|
||||
getLiveArtifactMask,
|
||||
getConversationIdFromURL,
|
||||
getOptionalConversationIdFromURL,
|
||||
guardAgainstPostHogRequests,
|
||||
hasLiveLLMConfig,
|
||||
missingLiveLLMConfigMessage,
|
||||
openCreatedConversation,
|
||||
routeBackendSessionApiKey,
|
||||
sessionApiKey,
|
||||
waitForAgentReply,
|
||||
waitForNonUserMessageText,
|
||||
waitForSuccessfulBashObservation,
|
||||
waitForTestId,
|
||||
} from "./utils/agent-server-conversation";
|
||||
|
||||
test.describe("live Agent Server terminal conversation", () => {
|
||||
const createdConversationIds = new Set<string>();
|
||||
|
||||
async function deleteConversation(
|
||||
request: APIRequestContext,
|
||||
conversationId: string,
|
||||
) {
|
||||
const response = await request.delete(
|
||||
`${BACKEND_URL}/api/conversations/${encodeURIComponent(conversationId)}`,
|
||||
{
|
||||
headers: {
|
||||
"X-Session-API-Key": sessionApiKey,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.ok() || response.status() === 404) {
|
||||
createdConversationIds.delete(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to clean up live E2E conversation ${conversationId}: ${response.status()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupKnownConversations(request: APIRequestContext) {
|
||||
const cleanupErrors: string[] = [];
|
||||
|
||||
for (const conversationId of Array.from(createdConversationIds)) {
|
||||
try {
|
||||
await deleteConversation(request, conversationId);
|
||||
} catch (error) {
|
||||
cleanupErrors.push(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanupErrors.length > 0) {
|
||||
throw new Error(cleanupErrors.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await enableLiveE2EFlags(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page, request }) => {
|
||||
const conversationId = getOptionalConversationIdFromURL(page);
|
||||
if (conversationId) {
|
||||
createdConversationIds.add(conversationId);
|
||||
}
|
||||
|
||||
await cleanupKnownConversations(request);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await cleanupKnownConversations(request);
|
||||
});
|
||||
|
||||
test("runs a real LLM-backed Agent Server terminal conversation through the UI", async ({
|
||||
page,
|
||||
request,
|
||||
}, testInfo) => {
|
||||
test.skip(!hasLiveLLMConfig, missingLiveLLMConfigMessage);
|
||||
|
||||
await configureLiveAgentServer(request);
|
||||
await routeBackendSessionApiKey(page);
|
||||
const postHogGuard = await guardAgainstPostHogRequests(page);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await dismissAnalyticsModal(page);
|
||||
await clickButtonByTestIdOrText(
|
||||
page,
|
||||
"launch-new-conversation-button",
|
||||
"New Conversation",
|
||||
);
|
||||
await openCreatedConversation(page);
|
||||
const conversationId = getConversationIdFromURL(page);
|
||||
createdConversationIds.add(conversationId);
|
||||
await waitForTestId(page, "app-route");
|
||||
await waitForTestId(page, "chat-interface");
|
||||
await waitForTestId(page, "interactive-chat-box");
|
||||
|
||||
await fillChatInput(
|
||||
page,
|
||||
[
|
||||
"Use the terminal/bash tool exactly once.",
|
||||
`Run this exact command: ${EXPECTED_BASH_COMMAND}`,
|
||||
`After the command succeeds, reply with exactly this token and then finish: ${EXPECTED_REPLY_TOKEN}`,
|
||||
"Do not use any other tools. Do not add any other text in the final reply.",
|
||||
].join("\n"),
|
||||
);
|
||||
await clickButtonByTestId(page, "submit-button");
|
||||
|
||||
await waitForAgentReply(page);
|
||||
await waitForSuccessfulBashObservation(request, conversationId);
|
||||
await expandVisibleEventDetails(page);
|
||||
await waitForNonUserMessageText(page, EXPECTED_BASH_OUTPUT_TOKEN);
|
||||
|
||||
const screenshotPath = testInfo.outputPath("live-agent-response.png");
|
||||
await page.getByTestId("chat-interface").screenshot({
|
||||
path: screenshotPath,
|
||||
mask: getLiveArtifactMask(page),
|
||||
});
|
||||
await testInfo.attach("live-agent-response", {
|
||||
path: screenshotPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await postHogGuard.expectNoRequests();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import {
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
|
||||
const key = rawKey.replaceAll("-", "_");
|
||||
if (inlineValue !== undefined) {
|
||||
args[key] = inlineValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
args[key] = next;
|
||||
index += 1;
|
||||
} else {
|
||||
args[key] = "";
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function assertWithinCwd(label, path) {
|
||||
const relativePath = relative(realpathSync(resolve(".")), path);
|
||||
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
||||
throw new Error(`${label} must be within the current working directory.`);
|
||||
}
|
||||
}
|
||||
|
||||
function nearestExistingPath(path) {
|
||||
let currentPath = path;
|
||||
while (!existsSync(currentPath)) {
|
||||
const parentPath = dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
throw new Error(`No existing parent found for ${path}`);
|
||||
}
|
||||
currentPath = parentPath;
|
||||
}
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
function resolveWithinCwd(label, filePath, options = {}) {
|
||||
const resolvedPath = resolve(filePath);
|
||||
assertWithinCwd(label, resolvedPath);
|
||||
|
||||
if (existsSync(resolvedPath)) {
|
||||
assertWithinCwd(label, realpathSync(resolvedPath));
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
if (options.mustExist) {
|
||||
throw new Error(`${label} does not exist: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
const existingParent = nearestExistingPath(dirname(resolvedPath));
|
||||
const checkedPath = resolve(
|
||||
realpathSync(existingParent),
|
||||
relative(existingParent, resolvedPath),
|
||||
);
|
||||
assertWithinCwd(label, checkedPath);
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
if (!path || !existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function collectPlaywrightAttachments(results) {
|
||||
const attachments = [];
|
||||
|
||||
function visitSuites(suites) {
|
||||
for (const suite of suites ?? []) {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const result of test.results ?? []) {
|
||||
attachments.push(...(result.attachments ?? []));
|
||||
}
|
||||
}
|
||||
}
|
||||
visitSuites(suite.suites);
|
||||
}
|
||||
}
|
||||
|
||||
visitSuites(results?.suites);
|
||||
return attachments
|
||||
.map((attachment) => ({
|
||||
contentType: attachment.contentType || "",
|
||||
path: resolveExistingMediaPath("attachment", attachment.path || ""),
|
||||
}))
|
||||
.filter((attachment) => attachment.path);
|
||||
}
|
||||
|
||||
function resolveExistingMediaPath(label, path) {
|
||||
if (!path) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return resolveWithinCwd(label, path, { mustExist: true });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function collectFiles(dir) {
|
||||
const safeDir = resolveExistingMediaPath("dir", dir);
|
||||
if (!safeDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
for (const entry of readdirSync(safeDir)) {
|
||||
const path = join(safeDir, entry);
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...collectFiles(path));
|
||||
} else if (stat.isFile()) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function isImage(path, contentType = "") {
|
||||
return (
|
||||
contentType.startsWith("image/") || /\.(gif|jpe?g|png|svg)$/i.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
function isVideo(path, contentType = "") {
|
||||
return contentType.startsWith("video/") || /\.(mp4|mov|webm)$/i.test(path);
|
||||
}
|
||||
|
||||
function copyMedia(sourcePath, outputDir, targetBase, fallbackExt) {
|
||||
if (!sourcePath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const safeSourcePath = resolveWithinCwd("media source", sourcePath, {
|
||||
mustExist: true,
|
||||
});
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const ext = extname(safeSourcePath) || fallbackExt;
|
||||
const targetPath = join(outputDir, `${targetBase}${ext}`);
|
||||
copyFileSync(safeSourcePath, targetPath);
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
function writeOutput(key, value) {
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (outputPath) {
|
||||
const escaped = String(value).replaceAll("\n", "%0A");
|
||||
writeFileSync(outputPath, `${key}=${escaped}\n`, { flag: "a" });
|
||||
}
|
||||
console.log(`${key}=${value}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const resultsPath = resolveWithinCwd(
|
||||
"results",
|
||||
args.results || "test-results-live/results.json",
|
||||
);
|
||||
const testResultsDir = resolveWithinCwd(
|
||||
"test-results-dir",
|
||||
args.test_results_dir || "test-results-live",
|
||||
);
|
||||
const outputDir = resolveWithinCwd(
|
||||
"output-dir",
|
||||
args.output_dir || "test-results-live/media",
|
||||
);
|
||||
const results = readJson(resultsPath);
|
||||
|
||||
const attachments = collectPlaywrightAttachments(results);
|
||||
const files = collectFiles(testResultsDir).map((path) => ({
|
||||
contentType: "",
|
||||
path,
|
||||
}));
|
||||
const candidates = [...attachments, ...files];
|
||||
|
||||
const screenshot = candidates.find((candidate) =>
|
||||
isImage(candidate.path, candidate.contentType),
|
||||
);
|
||||
const video = candidates.find((candidate) =>
|
||||
isVideo(candidate.path, candidate.contentType),
|
||||
);
|
||||
|
||||
const screenshotPath = copyMedia(
|
||||
screenshot?.path,
|
||||
outputDir,
|
||||
"live-agent-response",
|
||||
".png",
|
||||
);
|
||||
const videoPath = copyMedia(
|
||||
video?.path,
|
||||
outputDir,
|
||||
"live-agent-recording",
|
||||
".webm",
|
||||
);
|
||||
|
||||
writeOutput("screenshot_path", screenshotPath);
|
||||
writeOutput("video_path", videoPath);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,487 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
const DEFAULT_MARKER = "<!-- agent-canvas-live-e2e-report -->";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
|
||||
const key = rawKey.replaceAll("-", "_");
|
||||
if (inlineValue !== undefined) {
|
||||
args[key] = inlineValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
args[key] = next;
|
||||
index += 1;
|
||||
} else {
|
||||
args[key] = "";
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function assertWithinCwd(label, path) {
|
||||
const relativePath = relative(realpathSync(resolve(".")), path);
|
||||
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
||||
throw new Error(`${label} must be within the current working directory.`);
|
||||
}
|
||||
}
|
||||
|
||||
function nearestExistingPath(path) {
|
||||
let currentPath = path;
|
||||
while (!existsSync(currentPath)) {
|
||||
const parentPath = dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
throw new Error(`No existing parent found for ${path}`);
|
||||
}
|
||||
currentPath = parentPath;
|
||||
}
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
function resolveWithinCwd(label, filePath, options = {}) {
|
||||
const resolvedPath = resolve(filePath);
|
||||
if (existsSync(resolvedPath)) {
|
||||
assertWithinCwd(label, realpathSync(resolvedPath));
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
if (options.mustExist) {
|
||||
throw new Error(`${label} does not exist: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
const existingParent = nearestExistingPath(dirname(resolvedPath));
|
||||
const checkedPath = resolve(
|
||||
realpathSync(existingParent),
|
||||
relative(existingParent, resolvedPath),
|
||||
);
|
||||
assertWithinCwd(label, checkedPath);
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function collectSpecs(suites, parents = []) {
|
||||
const specs = [];
|
||||
for (const suite of suites ?? []) {
|
||||
const titles = [...parents, suite.title].filter(Boolean);
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
const results = test.results ?? [];
|
||||
const lastResult = results.at(-1);
|
||||
const duration = results.reduce(
|
||||
(total, result) => total + (Number(result.duration) || 0),
|
||||
0,
|
||||
);
|
||||
specs.push({
|
||||
title: [...titles, spec.title].filter(Boolean).join(" > "),
|
||||
project: test.projectName || "",
|
||||
status: lastResult?.status ?? (spec.ok ? "passed" : "unknown"),
|
||||
durationMs: duration,
|
||||
retryCount: Math.max(0, results.length - 1),
|
||||
attachments: collectAttachments(results),
|
||||
error: formatError(lastResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
specs.push(...collectSpecs(suite.suites, titles));
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
function collectAttachments(results) {
|
||||
return results.flatMap((result) =>
|
||||
(result.attachments ?? [])
|
||||
.filter((attachment) => attachment.path || attachment.name)
|
||||
.map((attachment) => ({
|
||||
name: attachment.name || "attachment",
|
||||
contentType: attachment.contentType || "",
|
||||
path: displayAttachmentPath(attachment.path || ""),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function formatError(result) {
|
||||
const errorMessages = Array.isArray(result?.errors)
|
||||
? result.errors
|
||||
.map((error) => error.message)
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: "";
|
||||
const message = result?.error?.message ?? errorMessages;
|
||||
return sanitizeForComment(message).slice(0, 2000);
|
||||
}
|
||||
|
||||
function sanitizeForComment(value) {
|
||||
return stripAnsi(value)
|
||||
.replaceAll("@OpenHands", "@\u200BOpenHands")
|
||||
.replaceAll("@openhands", "@\u200Bopenhands");
|
||||
}
|
||||
|
||||
function stripAnsi(value) {
|
||||
return String(value).replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
||||
}
|
||||
|
||||
function escapeCell(value) {
|
||||
return sanitizeForComment(value)
|
||||
.replaceAll("|", "\\|")
|
||||
.replaceAll("\n", "<br>");
|
||||
}
|
||||
|
||||
function escapeCodeCell(value) {
|
||||
return escapeCell(value).replaceAll("`", "\\`");
|
||||
}
|
||||
|
||||
function displayAttachmentPath(path) {
|
||||
const normalizedPath = sanitizeForComment(path).replaceAll("\\", "/");
|
||||
for (const anchor of ["test-results-live/", "playwright-report-live/"]) {
|
||||
const index = normalizedPath.indexOf(anchor);
|
||||
if (index >= 0) {
|
||||
return normalizedPath.slice(index);
|
||||
}
|
||||
}
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (!Number.isFinite(ms) || ms <= 0) {
|
||||
return "--";
|
||||
}
|
||||
|
||||
const seconds = ms / 1000;
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
const rounded = Math.round(seconds);
|
||||
const minutes = Math.floor(rounded / 60);
|
||||
const remainingSeconds = rounded % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
function formatStatus(status) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "Running";
|
||||
case "passed":
|
||||
return "Passed";
|
||||
case "failed":
|
||||
return "Failed";
|
||||
case "skipped":
|
||||
return "Skipped";
|
||||
case "timedOut":
|
||||
return "Timed out";
|
||||
default:
|
||||
return status ? status[0].toUpperCase() + status.slice(1) : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function inferOverallStatus(requestedStatus, stats) {
|
||||
if (requestedStatus && requestedStatus !== "auto") {
|
||||
return requestedStatus;
|
||||
}
|
||||
if (!stats) {
|
||||
return "unknown";
|
||||
}
|
||||
if ((stats.unexpected ?? 0) > 0 || (stats.interrupted ?? 0) > 0) {
|
||||
return "failed";
|
||||
}
|
||||
if ((stats.expected ?? 0) > 0 || (stats.flaky ?? 0) > 0) {
|
||||
return "passed";
|
||||
}
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
function buildSummary(results, status, reason) {
|
||||
const stats = results?.stats;
|
||||
const specs = collectSpecs(results?.suites);
|
||||
const passed = specs.filter((spec) => spec.status === "passed").length;
|
||||
const failed = specs.filter((spec) =>
|
||||
["failed", "timedOut", "interrupted"].includes(spec.status),
|
||||
).length;
|
||||
const skipped = specs.filter((spec) => spec.status === "skipped").length;
|
||||
const durationMs =
|
||||
Number(stats?.duration) ||
|
||||
specs.reduce((total, spec) => total + spec.durationMs, 0);
|
||||
|
||||
return {
|
||||
status,
|
||||
reason,
|
||||
specs,
|
||||
passed,
|
||||
failed,
|
||||
skipped,
|
||||
total: specs.length,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
function metadataLines(args) {
|
||||
const lines = [];
|
||||
if (args.model) {
|
||||
lines.push(`- Model: \`${sanitizeForComment(args.model)}\``);
|
||||
}
|
||||
if (args.commit) {
|
||||
lines.push(`- Commit: \`${sanitizeForComment(args.commit)}\``);
|
||||
}
|
||||
if (args.workflow_url) {
|
||||
lines.push(
|
||||
`- Workflow run: [open run](${sanitizeForComment(args.workflow_url)})`,
|
||||
);
|
||||
}
|
||||
if (args.artifact_url) {
|
||||
lines.push(
|
||||
`- Artifacts: [Playwright report, videos, screenshots](${sanitizeForComment(args.artifact_url)})`,
|
||||
);
|
||||
}
|
||||
if (args.timestamp) {
|
||||
lines.push(`- Generated: ${sanitizeForComment(args.timestamp)}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function testTable(specs) {
|
||||
const lines = [
|
||||
"| Test | Project | Status | Duration | Retries |",
|
||||
"|------|---------|--------|----------|---------|",
|
||||
];
|
||||
for (const spec of specs) {
|
||||
lines.push(
|
||||
`| ${escapeCell(spec.title)} | ${escapeCell(spec.project)} | ${formatStatus(
|
||||
spec.status,
|
||||
)} | ${formatDuration(spec.durationMs)} | ${spec.retryCount} |`,
|
||||
);
|
||||
}
|
||||
if (specs.length === 0) {
|
||||
lines.push(
|
||||
"| _No Playwright test result JSON found_ | -- | -- | -- | -- |",
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function failureDetails(specs) {
|
||||
const failures = specs.filter((spec) => spec.error);
|
||||
if (failures.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = ["", "### Failures", ""];
|
||||
for (const failure of failures) {
|
||||
lines.push(`<details><summary>${escapeCell(failure.title)}</summary>`);
|
||||
lines.push("");
|
||||
lines.push("```text");
|
||||
lines.push(failure.error);
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
lines.push("</details>");
|
||||
lines.push("");
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function attachmentRows(specs) {
|
||||
return specs.flatMap((spec) =>
|
||||
(spec.attachments ?? []).map((attachment) => ({
|
||||
test: spec.title,
|
||||
...attachment,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function isVideoAttachment(attachment) {
|
||||
return (
|
||||
attachment.contentType.startsWith("video/") ||
|
||||
/\.(mp4|mov|webm)$/i.test(attachment.path)
|
||||
);
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment) {
|
||||
return (
|
||||
attachment.contentType.startsWith("image/") ||
|
||||
/\.(gif|jpe?g|png|svg)$/i.test(attachment.path)
|
||||
);
|
||||
}
|
||||
|
||||
function evidenceDetails(specs, args) {
|
||||
const attachments = attachmentRows(specs);
|
||||
const videos = attachments.filter(isVideoAttachment);
|
||||
const images = attachments.filter(isImageAttachment);
|
||||
|
||||
if (
|
||||
attachments.length === 0 &&
|
||||
!args.artifact_url &&
|
||||
!args.video_url &&
|
||||
!args.video_preview_url &&
|
||||
!args.screenshot_url
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = [
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>View Playwright video and artifacts</summary>",
|
||||
"",
|
||||
];
|
||||
|
||||
if (args.video_preview_url) {
|
||||
lines.push(
|
||||
"**Recorded video:**",
|
||||
"",
|
||||
`})`,
|
||||
"",
|
||||
);
|
||||
if (args.video_url) {
|
||||
lines.push(
|
||||
`[Open full WebM recording](${sanitizeForComment(args.video_url)})`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
} else if (args.video_url) {
|
||||
lines.push(sanitizeForComment(args.video_url), "");
|
||||
} else if (videos.length > 0) {
|
||||
lines.push(`**Recorded video:** \`${videos[0].path}\``);
|
||||
if (args.artifact_url) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"The Playwright video is inside the uploaded artifact. GitHub Actions artifacts are downloadable archives, so they are linked here instead of embedded inline.",
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (args.screenshot_url) {
|
||||
lines.push(
|
||||
`})`,
|
||||
"",
|
||||
);
|
||||
} else if (images.length > 0) {
|
||||
lines.push(`**Screenshot:** \`${images[0].path}\``, "");
|
||||
}
|
||||
|
||||
if (args.artifact_url) {
|
||||
lines.push(
|
||||
`- Full artifact: [Playwright report, videos, screenshots](${sanitizeForComment(args.artifact_url)})`,
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"- HTML report path in artifact: `playwright-report-live/index.html`",
|
||||
);
|
||||
if (args.workflow_url) {
|
||||
lines.push(
|
||||
`- Workflow run: [open run](${sanitizeForComment(args.workflow_url)})`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
if (
|
||||
attachments.length > 0 &&
|
||||
!args.video_url &&
|
||||
!args.video_preview_url &&
|
||||
!args.screenshot_url
|
||||
) {
|
||||
lines.push(
|
||||
"| Test | Attachment | Type | Location |",
|
||||
"|------|------------|------|----------|",
|
||||
);
|
||||
for (const attachment of attachments) {
|
||||
lines.push(
|
||||
`| ${escapeCell(attachment.test)} | ${escapeCell(
|
||||
attachment.name,
|
||||
)} | ${escapeCell(attachment.contentType || "--")} | \`${escapeCodeCell(
|
||||
attachment.path || "--",
|
||||
)}\` |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("</details>");
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildReport(args) {
|
||||
const resultsPath = args.results
|
||||
? resolveWithinCwd("results", args.results)
|
||||
: null;
|
||||
const results = readJson(resultsPath);
|
||||
const status = inferOverallStatus(args.status, results?.stats);
|
||||
const summary = buildSummary(results, status, args.reason);
|
||||
|
||||
const lines = [
|
||||
args.marker || DEFAULT_MARKER,
|
||||
"## Agent Canvas Live E2E",
|
||||
"",
|
||||
`**Status:** ${formatStatus(summary.status)}`,
|
||||
];
|
||||
|
||||
if (summary.reason) {
|
||||
lines.push(`**Reason:** ${sanitizeForComment(summary.reason)}`);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
`**Summary:** ${summary.passed}/${summary.total} passed, ${summary.failed} failed, ${summary.skipped} skipped, ${formatDuration(
|
||||
summary.durationMs,
|
||||
)} total`,
|
||||
"",
|
||||
);
|
||||
|
||||
const metadata = metadataLines(args);
|
||||
if (metadata.length > 0) {
|
||||
lines.push(...metadata, "");
|
||||
}
|
||||
|
||||
if (summary.status === "running") {
|
||||
lines.push(
|
||||
"The live Agent Server E2E run is in progress. This comment will be updated with the final result.",
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (results?.error) {
|
||||
lines.push(
|
||||
`_Could not read Playwright JSON results: ${sanitizeForComment(results.error)}_`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(...testTable(summary.specs));
|
||||
lines.push(...evidenceDetails(summary.specs, args));
|
||||
lines.push(...failureDetails(summary.specs));
|
||||
|
||||
return lines.join("\n").trimEnd() + "\n";
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const report = buildReport(args);
|
||||
|
||||
if (args.output) {
|
||||
writeFileSync(resolveWithinCwd("output", args.output), report);
|
||||
} else {
|
||||
process.stdout.write(report);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const REQUIRED_LLM_API_KEY_ENV_VARS = [
|
||||
"LIVE_E2E_LLM_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"LLM_API_KEY",
|
||||
];
|
||||
const DEFAULT_PROXY_BASE_URL = "https://llm-proxy.app.all-hands.dev";
|
||||
const DEFAULT_PROXY_MODEL = "openhands/claude-haiku-4-5-20251001";
|
||||
const DEFAULT_OPENAI_MODEL = "openai/gpt-5.4-mini";
|
||||
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-haiku-4-5-20251001";
|
||||
const DEFAULT_BACKEND_URL = "http://127.0.0.1:18100";
|
||||
const DEFAULT_FRONTEND_PORT = "3101";
|
||||
const PLAYWRIGHT_CONFIG = "playwright.live.config.ts";
|
||||
|
||||
let generatedSessionApiKey = false;
|
||||
|
||||
function hasValue(name) {
|
||||
return Boolean(process.env[name]?.trim());
|
||||
}
|
||||
|
||||
function firstConfiguredEnvVar(names) {
|
||||
return names.find((name) => hasValue(name)) ?? "";
|
||||
}
|
||||
|
||||
function commandExists(command) {
|
||||
const result = spawnSync(platformCommand(command), ["--version"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
function platformCommand(command) {
|
||||
return process.platform === "win32" ? `${command}.cmd` : command;
|
||||
}
|
||||
|
||||
function localPlaywrightExists() {
|
||||
const executable =
|
||||
process.platform === "win32" ? "playwright.cmd" : "playwright";
|
||||
return existsSync(
|
||||
path.join(process.cwd(), "node_modules", ".bin", executable),
|
||||
);
|
||||
}
|
||||
|
||||
function usesProxyDefaults(apiKeySource) {
|
||||
return (
|
||||
apiKeySource === "LIVE_E2E_LLM_API_KEY" ||
|
||||
(!hasValue("OPENAI_API_KEY") &&
|
||||
!hasValue("ANTHROPIC_API_KEY") &&
|
||||
apiKeySource === "LLM_API_KEY")
|
||||
);
|
||||
}
|
||||
|
||||
function resolvedLLMBaseUrl(apiKeySource) {
|
||||
if (hasValue("LIVE_E2E_LLM_BASE_URL")) {
|
||||
return process.env.LIVE_E2E_LLM_BASE_URL.trim();
|
||||
}
|
||||
return usesProxyDefaults(apiKeySource) ? DEFAULT_PROXY_BASE_URL : "(unset)";
|
||||
}
|
||||
|
||||
function resolvedLLMModel(apiKeySource) {
|
||||
if (!apiKeySource) {
|
||||
return "(depends on credential source)";
|
||||
}
|
||||
if (hasValue("LIVE_E2E_LLM_MODEL")) {
|
||||
return process.env.LIVE_E2E_LLM_MODEL.trim();
|
||||
}
|
||||
if (resolvedLLMBaseUrl(apiKeySource) !== "(unset)") {
|
||||
return DEFAULT_PROXY_MODEL;
|
||||
}
|
||||
if (apiKeySource === "OPENAI_API_KEY") {
|
||||
return DEFAULT_OPENAI_MODEL;
|
||||
}
|
||||
return DEFAULT_ANTHROPIC_MODEL;
|
||||
}
|
||||
|
||||
function redactUrlForLog(value) {
|
||||
if (!value || value.startsWith("(")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.username || url.password) {
|
||||
url.username = "redacted";
|
||||
url.password = "redacted";
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return value.replace(/\/\/[^/@\s]+@/g, "//redacted:redacted@");
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSessionApiKey() {
|
||||
if (hasValue("LIVE_E2E_SESSION_API_KEY")) {
|
||||
return;
|
||||
}
|
||||
process.env.LIVE_E2E_SESSION_API_KEY = randomBytes(32).toString("hex");
|
||||
generatedSessionApiKey = true;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
Run the live Agent Server end-to-end test locally.
|
||||
|
||||
Usage:
|
||||
npm run test:e2e:live
|
||||
npm run test:e2e:live -- --headed
|
||||
npm run test:e2e:live -- --debug
|
||||
npm run test:e2e:live -- --check
|
||||
|
||||
Required:
|
||||
Set one LLM credential before running:
|
||||
- LIVE_E2E_LLM_API_KEY
|
||||
- OPENAI_API_KEY
|
||||
- ANTHROPIC_API_KEY
|
||||
- LLM_API_KEY
|
||||
|
||||
Optional:
|
||||
- LIVE_E2E_LLM_BASE_URL
|
||||
- LIVE_E2E_LLM_MODEL
|
||||
- LIVE_E2E_SESSION_API_KEY (generated per run when unset)
|
||||
- LIVE_E2E_BACKEND_URL
|
||||
- LIVE_E2E_FRONTEND_PORT
|
||||
|
||||
The npm script loads .env automatically through Node's --env-file-if-exists flag.
|
||||
`);
|
||||
}
|
||||
|
||||
function printConfiguration(apiKeySource) {
|
||||
const llmBaseUrl = resolvedLLMBaseUrl(apiKeySource);
|
||||
console.log("Live Agent Server E2E configuration:");
|
||||
console.log(`- LLM API key source: ${apiKeySource || "(missing)"}`);
|
||||
console.log(`- LIVE_E2E_LLM_BASE_URL: ${redactUrlForLog(llmBaseUrl)}`);
|
||||
console.log(`- LIVE_E2E_LLM_MODEL: ${resolvedLLMModel(apiKeySource)}`);
|
||||
console.log(
|
||||
`- LIVE_E2E_SESSION_API_KEY: ${
|
||||
hasValue("LIVE_E2E_SESSION_API_KEY")
|
||||
? generatedSessionApiKey
|
||||
? "(generated for this run)"
|
||||
: "(configured)"
|
||||
: "(missing)"
|
||||
}`,
|
||||
);
|
||||
console.log(
|
||||
`- LIVE_E2E_BACKEND_URL: ${
|
||||
hasValue("LIVE_E2E_BACKEND_URL")
|
||||
? redactUrlForLog(process.env.LIVE_E2E_BACKEND_URL.trim())
|
||||
: `(default: ${DEFAULT_BACKEND_URL})`
|
||||
}`,
|
||||
);
|
||||
console.log(
|
||||
`- LIVE_E2E_FRONTEND_PORT: ${
|
||||
hasValue("LIVE_E2E_FRONTEND_PORT")
|
||||
? redactUrlForLog(process.env.LIVE_E2E_FRONTEND_PORT.trim())
|
||||
: `(default: ${DEFAULT_FRONTEND_PORT})`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function validateEnvironment() {
|
||||
ensureSessionApiKey();
|
||||
const apiKeySource = firstConfiguredEnvVar(REQUIRED_LLM_API_KEY_ENV_VARS);
|
||||
const errors = [];
|
||||
|
||||
if (!apiKeySource) {
|
||||
errors.push(
|
||||
[
|
||||
"Missing LLM credential.",
|
||||
`Set one of: ${REQUIRED_LLM_API_KEY_ENV_VARS.join(", ")}.`,
|
||||
"For the hosted LLM proxy, use LIVE_E2E_LLM_API_KEY or LLM_API_KEY.",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
if (!localPlaywrightExists()) {
|
||||
errors.push(
|
||||
"Missing local Playwright install. Run `npm ci` before running live E2E.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!commandExists("uvx")) {
|
||||
errors.push(
|
||||
[
|
||||
"Missing `uvx`, which `npm run dev:minimal` uses to start the real Agent Server.",
|
||||
"Install uv with: `curl -LsSf https://astral.sh/uv/install.sh | sh`.",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
printConfiguration(apiKeySource);
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log("Environment check passed.");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error("");
|
||||
console.error("Live Agent Server E2E is not ready to run:");
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
console.error("");
|
||||
console.error("After fixing the above, run `npm run test:e2e:live` again.");
|
||||
return false;
|
||||
}
|
||||
|
||||
async function runPlaywright(args) {
|
||||
const child = spawn(
|
||||
platformCommand("npx"),
|
||||
["playwright", "test", ...args, `--config=${PLAYWRIGHT_CONFIG}`],
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
const exitCode = await new Promise((resolve) => {
|
||||
child.on("exit", (code) => resolve(code ?? 1));
|
||||
child.on("error", () => resolve(1));
|
||||
});
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const checkOnly = args.includes("--check");
|
||||
const playwrightArgs = args.filter((arg) => arg !== "--check");
|
||||
const isValid = validateEnvironment();
|
||||
|
||||
if (!isValid) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (checkOnly) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await runPlaywright(playwrightArgs);
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
const DEFAULT_MARKER = "<!-- agent-canvas-live-e2e-report -->";
|
||||
const API_ROOT = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
|
||||
const key = rawKey.replaceAll("-", "_");
|
||||
if (inlineValue !== undefined) {
|
||||
args[key] = inlineValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith("--")) {
|
||||
args[key] = next;
|
||||
index += 1;
|
||||
} else {
|
||||
args[key] = "";
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function requireValue(name, value) {
|
||||
if (!value) {
|
||||
throw new Error(`Missing required value: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertWithinCwd(label, path) {
|
||||
const relativePath = relative(realpathSync(resolve(".")), path);
|
||||
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
||||
throw new Error(`${label} must be within the current working directory.`);
|
||||
}
|
||||
}
|
||||
|
||||
function nearestExistingPath(path) {
|
||||
let currentPath = path;
|
||||
while (!existsSync(currentPath)) {
|
||||
const parentPath = dirname(currentPath);
|
||||
if (parentPath === currentPath) {
|
||||
throw new Error(`No existing parent found for ${path}`);
|
||||
}
|
||||
currentPath = parentPath;
|
||||
}
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
function resolveWithinCwd(label, filePath, options = {}) {
|
||||
const resolvedPath = resolve(filePath);
|
||||
if (existsSync(resolvedPath)) {
|
||||
assertWithinCwd(label, realpathSync(resolvedPath));
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
if (options.mustExist) {
|
||||
throw new Error(`${label} does not exist: ${resolvedPath}`);
|
||||
}
|
||||
|
||||
const existingParent = nearestExistingPath(dirname(resolvedPath));
|
||||
const checkedPath = resolve(
|
||||
realpathSync(existingParent),
|
||||
relative(existingParent, resolvedPath),
|
||||
);
|
||||
assertWithinCwd(label, checkedPath);
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function validateRepo(repo) {
|
||||
if (
|
||||
!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\/[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(
|
||||
repo,
|
||||
)
|
||||
) {
|
||||
throw new Error(`Invalid repo format: ${repo}`);
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
function validateIssueNumber(issueNumber) {
|
||||
if (!/^\d+$/.test(String(issueNumber))) {
|
||||
throw new Error(`Invalid issue number: ${issueNumber}`);
|
||||
}
|
||||
return String(issueNumber);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function retryDelayMs(attempt, response) {
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
if (retryAfter && /^\d+$/.test(retryAfter)) {
|
||||
return Number(retryAfter) * 1000;
|
||||
}
|
||||
return 1000 * 2 ** attempt;
|
||||
}
|
||||
|
||||
function parseGitHubPayload(method, path, status, text) {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`GitHub API ${method} ${path} returned invalid JSON with status ${status}: ${text.slice(
|
||||
0,
|
||||
500,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function githubRequest(method, path, token, body) {
|
||||
const retryableStatuses = new Set([429, 502, 503, 504]);
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const response = await fetch(`${API_ROOT}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
if (!response.ok && retryableStatuses.has(response.status) && attempt < 4) {
|
||||
await sleep(retryDelayMs(attempt, response));
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = parseGitHubPayload(method, path, response.status, text);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API ${method} ${path} failed with ${response.status}: ${text}`,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
throw new Error(`GitHub API ${method} ${path} failed after retries.`);
|
||||
}
|
||||
|
||||
async function listIssueComments(repo, issueNumber, token) {
|
||||
const comments = [];
|
||||
for (let page = 1; page <= 10; page += 1) {
|
||||
const batch = await githubRequest(
|
||||
"GET",
|
||||
`/repos/${repo}/issues/${issueNumber}/comments?per_page=100&page=${page}`,
|
||||
token,
|
||||
);
|
||||
comments.push(...batch);
|
||||
if (batch.length < 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return comments;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const issueNumber =
|
||||
args.issue_number ??
|
||||
process.env.PR_NUMBER ??
|
||||
process.env.ISSUE_NUMBER ??
|
||||
"";
|
||||
|
||||
if (!issueNumber) {
|
||||
console.log("Skipping PR comment because no PR number was provided.");
|
||||
return;
|
||||
}
|
||||
|
||||
const repo = validateRepo(
|
||||
requireValue("repo", args.repo ?? process.env.GITHUB_REPOSITORY),
|
||||
);
|
||||
const validatedIssueNumber = validateIssueNumber(issueNumber);
|
||||
const token = requireValue(
|
||||
"token",
|
||||
args.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
|
||||
);
|
||||
const marker = args.marker ?? DEFAULT_MARKER;
|
||||
const bodyFile = resolveWithinCwd(
|
||||
"body-file",
|
||||
requireValue("body-file", args.body_file),
|
||||
{ mustExist: true },
|
||||
);
|
||||
let body = readFileSync(bodyFile, "utf8");
|
||||
|
||||
if (!body.includes(marker)) {
|
||||
body = `${marker}\n${body}`;
|
||||
}
|
||||
|
||||
const comments = await listIssueComments(repo, validatedIssueNumber, token);
|
||||
const existing = comments.find((comment) => comment.body?.includes(marker));
|
||||
|
||||
if (existing) {
|
||||
await githubRequest(
|
||||
"PATCH",
|
||||
`/repos/${repo}/issues/comments/${existing.id}`,
|
||||
token,
|
||||
{ body },
|
||||
);
|
||||
console.log(`Updated PR comment ${existing.id}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await githubRequest(
|
||||
"POST",
|
||||
`/repos/${repo}/issues/${validatedIssueNumber}/comments`,
|
||||
token,
|
||||
{ body },
|
||||
);
|
||||
console.log(`Created PR comment ${created.id}.`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,493 @@
|
||||
import {
|
||||
expect,
|
||||
type APIRequestContext,
|
||||
type Locator,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
export const BACKEND_URL =
|
||||
process.env.LIVE_E2E_BACKEND_URL ?? "http://127.0.0.1:18100";
|
||||
export const EXPECTED_BASH_OUTPUT_TOKEN = "LIVE_AGENT_CANVAS_E2E_BASH_OK";
|
||||
export const EXPECTED_BASH_COMMAND = `printf '${EXPECTED_BASH_OUTPUT_TOKEN}\\n'`;
|
||||
export const EXPECTED_REPLY_TOKEN = "LIVE_AGENT_CANVAS_E2E_OK";
|
||||
const POSTHOG_URL_PATTERN =
|
||||
/^https?:\/\/(?:(?:[^/]+\.)*posthog\.com|z\.openhands\.dev)(?:\/|$)/;
|
||||
|
||||
function firstNonEmpty(...values: Array<string | undefined>) {
|
||||
return values.find((value) => value?.trim()) ?? "";
|
||||
}
|
||||
|
||||
const liveLLMApiKey = process.env.LIVE_E2E_LLM_API_KEY;
|
||||
const openAIKey = process.env.OPENAI_API_KEY;
|
||||
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
const proxyLLMKey = process.env.LLM_API_KEY;
|
||||
const llmApiKey = firstNonEmpty(
|
||||
liveLLMApiKey,
|
||||
openAIKey,
|
||||
anthropicKey,
|
||||
proxyLLMKey,
|
||||
);
|
||||
const usesProxyKey = Boolean(
|
||||
liveLLMApiKey?.trim() ||
|
||||
(!openAIKey?.trim() && !anthropicKey?.trim() && proxyLLMKey?.trim()),
|
||||
);
|
||||
const llmBaseUrl =
|
||||
process.env.LIVE_E2E_LLM_BASE_URL ??
|
||||
(usesProxyKey ? "https://llm-proxy.app.all-hands.dev" : "");
|
||||
const llmModel =
|
||||
process.env.LIVE_E2E_LLM_MODEL ??
|
||||
(llmBaseUrl
|
||||
? "openhands/claude-haiku-4-5-20251001"
|
||||
: openAIKey?.trim()
|
||||
? "openai/gpt-5.4-mini"
|
||||
: "anthropic/claude-haiku-4-5-20251001");
|
||||
export const sessionApiKey = firstNonEmpty(
|
||||
process.env.LIVE_E2E_SESSION_API_KEY,
|
||||
process.env.SESSION_API_KEY,
|
||||
process.env.OH_SESSION_API_KEYS_0,
|
||||
process.env.VITE_SESSION_API_KEY,
|
||||
);
|
||||
|
||||
if (!sessionApiKey) {
|
||||
throw new Error("LIVE_E2E_SESSION_API_KEY must be set for live E2E.");
|
||||
}
|
||||
|
||||
export const hasLiveLLMConfig = Boolean(llmApiKey);
|
||||
export const missingLiveLLMConfigMessage =
|
||||
"Set LIVE_E2E_LLM_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, or LLM_API_KEY to run live E2E.";
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export async function routeBackendSessionApiKey(page: Page) {
|
||||
const backendOrigin = new URL(BACKEND_URL).origin;
|
||||
await page.route(
|
||||
new RegExp(`^${escapeRegExp(backendOrigin)}(?:/|$)`),
|
||||
async (route) => {
|
||||
await route.continue({
|
||||
headers: {
|
||||
...route.request().headers(),
|
||||
"X-Session-API-Key": sessionApiKey,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getLiveArtifactMask(page: Page): Locator[] {
|
||||
return [
|
||||
page.locator('input[type="password"]'),
|
||||
page.locator('[data-sensitive="true"]'),
|
||||
page.locator('[data-testid*="secret" i]'),
|
||||
page.locator('[data-testid*="token" i]'),
|
||||
page.locator('[data-testid*="api-key" i]'),
|
||||
page.getByText(
|
||||
/(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|LIVE_E2E_LLM_API_KEY|LLM_API_KEY|SESSION_API_KEY|X-Session-API-Key)\s*[:=]\s*\S+/i,
|
||||
),
|
||||
page.getByText(
|
||||
/(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9_]{8,}|xox[baprs]-[A-Za-z0-9-]{8,}|(?:api[_ -]?key|secret|password|token)\s*[:=]\s*\S+)/i,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export async function configureLiveAgentServer(request: APIRequestContext) {
|
||||
if (!llmApiKey.trim()) {
|
||||
throw new Error(missingLiveLLMConfigMessage);
|
||||
}
|
||||
|
||||
const llmSettings: Record<string, string | number> = {
|
||||
model: llmModel,
|
||||
api_key: llmApiKey,
|
||||
extended_thinking_budget: 1024,
|
||||
max_output_tokens: 2048,
|
||||
temperature: 0,
|
||||
};
|
||||
if (llmBaseUrl) {
|
||||
llmSettings.base_url = llmBaseUrl;
|
||||
}
|
||||
|
||||
const settingsResponse = await request.patch(`${BACKEND_URL}/api/settings`, {
|
||||
headers: {
|
||||
"X-Session-API-Key": sessionApiKey,
|
||||
},
|
||||
data: {
|
||||
agent_settings_diff: {
|
||||
llm: llmSettings,
|
||||
condenser: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
conversation_settings_diff: {
|
||||
confirmation_mode: false,
|
||||
max_iterations: 6,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
settingsResponse.ok(),
|
||||
`PATCH /api/settings failed with ${settingsResponse.status()}; response body omitted because live LLM credentials are configured in this request.`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
export async function enableLiveE2EFlags(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("analytics-consent", "false");
|
||||
window.localStorage.setItem("openhands-telemetry-consent", "denied");
|
||||
window.localStorage.setItem("openhands-telemetry-first-use", "true");
|
||||
window.localStorage.setItem("openhands-onboarded", "1");
|
||||
window.localStorage.setItem("FEATURE_AUTOMATIONS", "true");
|
||||
});
|
||||
}
|
||||
|
||||
export async function guardAgainstPostHogRequests(page: Page) {
|
||||
const postHogRequests: string[] = [];
|
||||
|
||||
await page.route(POSTHOG_URL_PATTERN, async (route) => {
|
||||
postHogRequests.push(route.request().url());
|
||||
await route.fulfill({ status: 204, body: "" });
|
||||
});
|
||||
|
||||
return {
|
||||
expectNoRequests() {
|
||||
expect(
|
||||
postHogRequests,
|
||||
[
|
||||
"Live E2E must not send analytics to PostHog.",
|
||||
"Keep VITE_DO_NOT_TRACK=1 and the live-test storage opt-out in place.",
|
||||
].join(" "),
|
||||
).toEqual([]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForPath(
|
||||
page: Page,
|
||||
pattern: RegExp,
|
||||
timeout = 60_000,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.evaluate(() => window.location.pathname).catch(() => ""),
|
||||
{ timeout },
|
||||
)
|
||||
.toMatch(pattern);
|
||||
}
|
||||
|
||||
export async function openCreatedConversation(page: Page) {
|
||||
const conversationPathPattern = /\/conversations\/.+/;
|
||||
|
||||
try {
|
||||
await waitForPath(page, conversationPathPattern, 10_000);
|
||||
return;
|
||||
} catch {
|
||||
await page.locator('a[href^="/conversations/"]').first().click();
|
||||
await waitForPath(page, conversationPathPattern);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForTestId(
|
||||
page: Page,
|
||||
testId: string,
|
||||
timeout = 60_000,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page
|
||||
.evaluate(
|
||||
(testId) =>
|
||||
document.querySelector(`[data-testid="${testId}"]`) != null,
|
||||
testId,
|
||||
)
|
||||
.catch(() => false),
|
||||
{ timeout },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function dismissAnalyticsModal(page: Page) {
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page
|
||||
.evaluate(() => {
|
||||
const hasAnalyticsDialog = Array.from(
|
||||
document.querySelectorAll('[role="dialog"]'),
|
||||
).some((dialog) =>
|
||||
dialog.textContent?.includes("Help improve OpenHands"),
|
||||
);
|
||||
if (!hasAnalyticsDialog) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const confirmButton = Array.from(
|
||||
document.querySelectorAll("button"),
|
||||
).find(
|
||||
(button) => button.textContent?.trim() === "Confirm preferences",
|
||||
);
|
||||
if (confirmButton instanceof HTMLButtonElement) {
|
||||
confirmButton.click();
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.catch(() => false),
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function clickButtonByTestId(page: Page, testId: string) {
|
||||
await waitForTestId(page, testId);
|
||||
|
||||
await page.evaluate((testId) => {
|
||||
const button = document.querySelector(`[data-testid="${testId}"]`);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Button not found: ${testId}`);
|
||||
}
|
||||
button.click();
|
||||
}, testId);
|
||||
}
|
||||
|
||||
export async function clickButtonByTestIdOrText(
|
||||
page: Page,
|
||||
testId: string,
|
||||
text: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page.evaluate(
|
||||
({ testId, text }) => {
|
||||
const byTestId = document.querySelector(
|
||||
`[data-testid="${testId}"]`,
|
||||
);
|
||||
if (byTestId instanceof HTMLButtonElement) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.from(document.querySelectorAll("button")).some(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
);
|
||||
},
|
||||
{ testId, text },
|
||||
),
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await page.evaluate(
|
||||
({ testId, text }) => {
|
||||
const byTestId = document.querySelector(`[data-testid="${testId}"]`);
|
||||
const button =
|
||||
byTestId instanceof HTMLButtonElement
|
||||
? byTestId
|
||||
: Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Button not found: ${text}`);
|
||||
}
|
||||
button.click();
|
||||
},
|
||||
{ testId, text },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fillChatInput(page: Page, text: string) {
|
||||
await waitForTestId(page, "chat-input");
|
||||
|
||||
await page.evaluate((text) => {
|
||||
const input = document.querySelector('[data-testid="chat-input"]');
|
||||
if (!(input instanceof HTMLElement)) {
|
||||
throw new Error("Chat input not found");
|
||||
}
|
||||
input.focus();
|
||||
input.textContent = text;
|
||||
input.dispatchEvent(
|
||||
new InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: text,
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
}, text);
|
||||
}
|
||||
|
||||
export async function waitForNonUserMessageText(page: Page, text: string) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page
|
||||
.evaluate((text) => {
|
||||
const body = document.body.cloneNode(true);
|
||||
if (!(body instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
body
|
||||
.querySelectorAll('[data-testid="user-message"]')
|
||||
.forEach((node) => node.remove());
|
||||
return body.textContent?.includes(text) ?? false;
|
||||
}, text)
|
||||
.catch(() => false),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function expandVisibleEventDetails(page: Page) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page
|
||||
.evaluate(() => {
|
||||
const isVisible = (element: Element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
|
||||
const buttons = Array.from(
|
||||
document.querySelectorAll('button[aria-label="Expand"]'),
|
||||
).filter(isVisible);
|
||||
buttons.forEach((button) => {
|
||||
if (button instanceof HTMLButtonElement) {
|
||||
button.click();
|
||||
}
|
||||
});
|
||||
return buttons.length;
|
||||
})
|
||||
.catch(() => 0),
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe(0);
|
||||
}
|
||||
|
||||
export async function waitForAgentReply(page: Page) {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
page
|
||||
.evaluate((expectedReplyToken) => {
|
||||
const hasReply = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-message"]'),
|
||||
).some((element) =>
|
||||
element.textContent?.includes(expectedReplyToken),
|
||||
);
|
||||
if (hasReply) {
|
||||
return "reply";
|
||||
}
|
||||
if (document.body.textContent?.includes("Error occurred")) {
|
||||
return "error";
|
||||
}
|
||||
return "pending";
|
||||
}, EXPECTED_REPLY_TOKEN)
|
||||
.catch(() => "pending"),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
.toBe("reply");
|
||||
}
|
||||
|
||||
export function getConversationIdFromURL(page: Page) {
|
||||
const match = page.url().match(/\/conversations\/([^/?#]+)/);
|
||||
expect(
|
||||
match?.[1],
|
||||
`Could not read conversation id from ${page.url()}`,
|
||||
).toBeTruthy();
|
||||
return decodeURIComponent(match![1]);
|
||||
}
|
||||
|
||||
export function getOptionalConversationIdFromURL(page: Page) {
|
||||
const match = page.url().match(/\/conversations\/([^/?#]+)/);
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : "";
|
||||
}
|
||||
|
||||
function eventTextContent(event: unknown) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const content = (event as { observation?: { content?: unknown } }).observation
|
||||
?.content;
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return content
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return "";
|
||||
}
|
||||
const text = (item as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : "";
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isSuccessfulBashObservation(event: unknown) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const observation = (event as { observation?: Record<string, unknown> })
|
||||
.observation;
|
||||
if (!observation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const kind = observation.kind;
|
||||
const isTerminalObservation =
|
||||
kind === "ExecuteBashObservation" || kind === "TerminalObservation";
|
||||
if (!isTerminalObservation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const command =
|
||||
typeof observation.command === "string" ? observation.command : "";
|
||||
const output = eventTextContent(event);
|
||||
const exitCode = observation.exit_code;
|
||||
const failed =
|
||||
observation.error === true ||
|
||||
observation.is_error === true ||
|
||||
observation.timeout === true;
|
||||
|
||||
return (
|
||||
command.includes(EXPECTED_BASH_OUTPUT_TOKEN) &&
|
||||
output.includes(EXPECTED_BASH_OUTPUT_TOKEN) &&
|
||||
exitCode === 0 &&
|
||||
!failed
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForSuccessfulBashObservation(
|
||||
request: APIRequestContext,
|
||||
conversationId: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const response = await request.get(
|
||||
`${BACKEND_URL}/api/conversations/${encodeURIComponent(conversationId)}/events/search`,
|
||||
{
|
||||
headers: {
|
||||
"X-Session-API-Key": sessionApiKey,
|
||||
},
|
||||
params: {
|
||||
limit: "100",
|
||||
sort_order: "TIMESTAMP_DESC",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { items?: unknown[] };
|
||||
return body.items?.some(isSuccessfulBashObservation) ?? false;
|
||||
},
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
@@ -6,13 +6,16 @@ import test, { expect } from "@playwright/test";
|
||||
* This test verifies that the user can move their cursor diagonally from the
|
||||
* avatar to the context menu without the menu closing unexpectedly.
|
||||
*
|
||||
* The component supports both CSS hover and click-to-toggle for the menu.
|
||||
* We use click-to-toggle which is more reliable in automated tests than
|
||||
* CSS hover simulation.
|
||||
* The component opens the menu on hover and keeps it open during short
|
||||
* diagonal movement through the hover bridge.
|
||||
*/
|
||||
test("avatar context menu stays open when moving cursor diagonally to menu", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("analytics-consent", "true");
|
||||
});
|
||||
|
||||
// Intercept GET /api/settings to return settings with a configured provider.
|
||||
// In OSS mode, the user context menu only renders when providers are configured.
|
||||
await page.route("**/api/settings", async (route) => {
|
||||
@@ -48,6 +51,15 @@ test("avatar context menu stays open when moving cursor diagonally to menu", asy
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
const consentDialog = page.getByRole("dialog", {
|
||||
name: "Help improve OpenHands",
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "Confirm preferences" })
|
||||
.click({ timeout: 5000 })
|
||||
.catch(() => undefined);
|
||||
await expect(consentDialog).toHaveCount(0, { timeout: 5000 });
|
||||
|
||||
// Wait for the page to be fully loaded and check for AI config modal
|
||||
// The modal may appear for new users in OSS mode without settings
|
||||
const aiConfigModal = page.getByTestId("ai-config-modal");
|
||||
@@ -65,11 +77,8 @@ test("avatar context menu stays open when moving cursor diagonally to menu", asy
|
||||
const userAvatar = page.getByTestId("user-avatar");
|
||||
await expect(userAvatar).toBeVisible();
|
||||
|
||||
// Use force:true to bypass the hover bridge pseudo-element that can
|
||||
// intercept clicks when the mouse triggers group-hover state
|
||||
await userAvatar.click({ force: true });
|
||||
await userAvatar.hover();
|
||||
|
||||
// The context menu should appear via CSS group-hover
|
||||
const contextMenu = page.getByTestId("user-context-menu");
|
||||
await expect(contextMenu).toBeVisible();
|
||||
|
||||
@@ -5,7 +5,7 @@ test("scopes standalone styles to the agent server UI shell", async ({
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.locator("[data-agent-server-ui]")).toBeVisible();
|
||||
await expect(page.locator("[data-agent-server-ui]").first()).toBeVisible();
|
||||
const layout = page.getByTestId("root-layout");
|
||||
await expect(layout).toBeVisible();
|
||||
|
||||
@@ -17,7 +17,7 @@ test("scopes standalone styles to the agent server UI shell", async ({
|
||||
const hostProbe = document.createElement("div");
|
||||
hostProbe.className = "bg-base text-content-2";
|
||||
hostProbe.textContent = "host";
|
||||
document.body.appendChild(hostProbe);
|
||||
document.documentElement.appendChild(hostProbe);
|
||||
|
||||
const styles = getComputedStyle(hostProbe);
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
// Placeholder test to ensure CI passes until real E2E tests are added
|
||||
test("placeholder", () => {});
|
||||