name: Tests on: pull_request: types: - opened - synchronize - reopened - ready_for_review paths: - .github/workflows/test.yml - packages/browseros-agent/** workflow_dispatch: permissions: contents: read env: BROWSEROS_APPIMAGE_URL: https://files.browseros.com/download/BrowserOS.AppImage jobs: test: name: Tests / ${{ matrix.suite }} runs-on: ubuntu-latest timeout-minutes: 20 defaults: run: working-directory: packages/browseros-agent strategy: fail-fast: false matrix: include: - suite: server-agent command: (cd apps/server && bun run test:agent) junit_path: test-results/server-agent.xml needs_browser: false - suite: server-api command: (cd apps/server && bun run test:api) junit_path: test-results/server-api.xml needs_browser: false - suite: server-skills command: (cd apps/server && bun run test:skills) junit_path: test-results/server-skills.xml needs_browser: false - suite: server-tools command: (cd apps/server && bun run test:tools) junit_path: test-results/server-tools.xml needs_browser: true - suite: server-browser command: (cd apps/server && bun run test:browser) junit_path: test-results/server-browser.xml needs_browser: false - suite: server-integration command: (cd apps/server && bun run test:integration) junit_path: test-results/server-integration.xml needs_browser: true - suite: server-lib command: (cd apps/server && bun run test:lib) junit_path: test-results/server-lib.xml needs_browser: false - suite: server-root command: (cd apps/server && bun run test:root) junit_path: test-results/server-root.xml needs_browser: false - suite: agent command: (cd apps/agent && bun run test) junit_path: test-results/agent.xml needs_browser: false - suite: eval command: (cd apps/eval && bun run test) junit_path: test-results/eval.xml needs_browser: false - suite: build command: bun run ./scripts/run-bun-test.ts ./scripts/build junit_path: test-results/build.xml needs_browser: false steps: - name: Checkout code uses: actions/checkout@v6 - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun ci - name: Resolve BrowserOS cache key if: matrix.needs_browser == true id: browseros-cache-key run: | set -euo pipefail headers="$(curl -fsSI "$BROWSEROS_APPIMAGE_URL")" etag="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^etag:/ {sub(/\r$/, "", $2); gsub(/"/, "", $2); print $2; exit}')" last_modified="$(printf '%s\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^last-modified:/ {$1=""; sub(/^ /, ""); sub(/\r$/, ""); print; exit}')" raw_key="${etag:-$last_modified}" if [ -z "$raw_key" ]; then raw_key="$BROWSEROS_APPIMAGE_URL" fi cache_key="$(printf '%s' "$raw_key" | shasum -a 256 | awk '{print $1}')" echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT" - name: Restore BrowserOS cache if: matrix.needs_browser == true id: browseros-cache uses: actions/cache@v4 with: path: packages/browseros-agent/.ci/bin/BrowserOS.AppImage key: ${{ steps.browseros-cache-key.outputs.key }} - name: Download BrowserOS if: matrix.needs_browser == true && steps.browseros-cache.outputs.cache-hit != 'true' run: | mkdir -p .ci/bin curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage chmod +x .ci/bin/BrowserOS.AppImage - name: Prepare BrowserOS wrapper if: matrix.needs_browser == true run: | mkdir -p .ci/bin cat > .ci/bin/browseros <<'EOF' #!/usr/bin/env bash set -euo pipefail export APPIMAGE_EXTRACT_AND_RUN=1 exec "$(dirname "$0")/BrowserOS.AppImage" "$@" EOF chmod +x .ci/bin/browseros - name: Create server env file working-directory: packages/browseros-agent/apps/server run: cp .env.example .env.development - name: Run ${{ matrix.suite }} tests id: test env: BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros BROWSEROS_TEST_HEADLESS: "true" BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage BROWSEROS_JUNIT_PATH: ${{ github.workspace }}/packages/browseros-agent/${{ matrix.junit_path }} run: | set +e mkdir -p test-results ${{ matrix.command }} exit_code=$? if [ ! -f "${{ matrix.junit_path }}" ]; then if [ "$exit_code" = "0" ]; then cat > "${{ matrix.junit_path }}" < EOF else cat > "${{ matrix.junit_path }}" < See workflow logs for details. EOF fi fi echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" - name: Upload JUnit XML if: always() uses: actions/upload-artifact@v4 with: name: junit-${{ matrix.suite }} path: packages/browseros-agent/${{ matrix.junit_path }} - name: Summarize suite result if: always() run: | if [ "${{ steps.test.outputs.exit_code }}" = "0" ]; then echo "### :white_check_mark: ${{ matrix.suite }} suite passed" >> "$GITHUB_STEP_SUMMARY" else echo "### :x: ${{ matrix.suite }} suite failed (exit code ${{ steps.test.outputs.exit_code }})" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." >> "$GITHUB_STEP_SUMMARY" exit 1 fi comment: name: PR test summary needs: test if: >- always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: pull-requests: write actions: read steps: - name: Download JUnit artifacts uses: actions/download-artifact@v4 continue-on-error: true with: path: junit pattern: junit-* - name: Build comment body run: | python3 <<'PY' import glob, os, xml.etree.ElementTree as ET run_url = f"{os.environ['GITHUB_SERVER_URL']}/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}" marker = "" suites = [] failed_cases = [] total_tests = total_failed = total_skipped = 0 for xml_path in sorted(glob.glob("junit/junit-*/*.xml")): suite_name = os.path.basename(os.path.dirname(xml_path)).removeprefix("junit-") try: root = ET.parse(xml_path).getroot() except ET.ParseError: suites.append({"name": suite_name, "passed": 0, "failed": 1, "skipped": 0, "total": 1}) total_tests += 1 total_failed += 1 failed_cases.append((suite_name, "(could not parse junit XML)")) continue testsuites = root.findall("testsuite") if root.tag == "testsuites" else [root] s_tests = s_fail = s_err = s_skip = 0 for ts in testsuites: s_tests += int(ts.get("tests") or 0) s_fail += int(ts.get("failures") or 0) s_err += int(ts.get("errors") or 0) s_skip += int(ts.get("skipped") or 0) for tc in ts.iter("testcase"): if tc.find("failure") is not None or tc.find("error") is not None: cls = tc.get("classname") or "" name = tc.get("name") or "(unnamed)" label = f"{cls} > {name}" if cls else name failed_cases.append((suite_name, label)) s_failed = s_fail + s_err s_passed = max(s_tests - s_failed - s_skip, 0) suites.append({"name": suite_name, "passed": s_passed, "failed": s_failed, "skipped": s_skip, "total": s_tests}) total_tests += s_tests total_failed += s_failed total_skipped += s_skip total_passed = max(total_tests - total_failed - total_skipped, 0) if total_tests == 0: header = "## :warning: No test results were produced" elif total_failed == 0: header = f"## :white_check_mark: Tests passed — {total_passed}/{total_tests}" else: header = f"## :x: Tests failed — {total_failed}/{total_tests} failed" lines = [marker, header, ""] if suites: lines.append("| Suite | Passed | Failed | Skipped |") lines.append("|-------|--------|--------|---------|") for s in suites: icon = ":white_check_mark:" if s["failed"] == 0 and s["total"] > 0 else ":warning:" if s["total"] == 0 else ":x:" lines.append(f"| {icon} `{s['name']}` | {s['passed']}/{s['total']} | {s['failed']} | {s['skipped']} |") if failed_cases: lines += ["", "
", "Failed tests", ""] for suite_name, label in failed_cases[:50]: lines.append(f"- **{suite_name}** — `{label}`") if len(failed_cases) > 50: lines.append(f"- …and {len(failed_cases) - 50} more") lines += ["", "
"] lines += ["", f"[View workflow run]({run_url})"] with open("comment.md", "w") as f: f.write("\n".join(lines) + "\n") PY - name: Upsert sticky PR comment uses: actions/github-script@v7 with: script: | const fs = require('fs'); const body = fs.readFileSync('comment.md', 'utf8'); const marker = ''; const { owner, repo } = context.repo; const issue_number = context.payload.pull_request.number; const triggerSha = context.payload.pull_request.head.sha; const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number }); if (pr.head.sha !== triggerSha) { core.info(`PR head has moved (${pr.head.sha} vs ${triggerSha}) — skipping stale comment.`); return; } const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100, }); const existing = comments.find(c => c.body && c.body.includes(marker)); if (existing) { await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); } else { await github.rest.issues.createComment({ owner, repo, issue_number, body }); }