feat(app): give first-run its own setup step instead of the settings page #2559
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Tests | |
| # A discover job computes which suites a change touches (via Turbo) and runs | |
| # only those, so unaffected suites are never spun up. Suites map from affected | |
| # packages in packages/browseros-agent/ci/affected-suites.ts; a gate job rolls | |
| # up pass/skip and the summary job posts the sticky per-suite table. | |
| on: | |
| pull_request: | |
| types: | |
| - opened | |
| - synchronize | |
| - reopened | |
| - ready_for_review | |
| paths: | |
| - .github/workflows/test.yml | |
| - .github/workflows/build-browseros.yml | |
| - .github/workflows/nightly*.yml | |
| - .github/workflows/publish-server-ota.yml | |
| - .github/workflows/release-*.yml | |
| - packages/browseros-agent/** | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| env: | |
| BROWSEROS_APPIMAGE_URL: https://files.browseros.com/download/BrowserOS.AppImage | |
| concurrency: | |
| group: tests-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| discover: | |
| name: Tests / discover | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| working-directory: packages/browseros-agent | |
| outputs: | |
| matrix: ${{ steps.affected.outputs.matrix }} | |
| has_any: ${{ steps.affected.outputs.has_any }} | |
| all_suites: ${{ steps.affected.outputs.all_suites }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| with: | |
| # Full history so Turbo's --affected has the merge base; a shallow | |
| # checkout would silently mark every package changed. | |
| fetch-depth: 0 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version-file: packages/browseros-agent/package.json | |
| - name: Install dependencies | |
| run: bun ci | |
| - name: Compute affected suites | |
| id: affected | |
| env: | |
| BROWSEROS_AFFECTED_BASE: ${{ github.event.pull_request.base.sha }} | |
| TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }} | |
| run: bun run ci/affected-suites.ts | |
| test: | |
| name: Tests / ${{ matrix.suite }} | |
| needs: discover | |
| if: needs.discover.outputs.has_any == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: ${{ matrix.needs_rust && 45 || 20 }} | |
| defaults: | |
| run: | |
| working-directory: packages/browseros-agent | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version-file: packages/browseros-agent/package.json | |
| - name: Setup Rust | |
| if: matrix.needs_rust == true | |
| # Pinned so the clippy lint set is deterministic and matches local dev; | |
| # a floating `stable` silently changes which lints fire across runs. | |
| uses: dtolnay/rust-toolchain@1.95.0 | |
| with: | |
| components: clippy,rustfmt | |
| - name: Cache Rust build | |
| if: matrix.needs_rust == true | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: packages/browseros-agent | |
| # Without this the key embeds GITHUB_JOB, so the warm cache produced | |
| # on main by a differently named job could never be restored here. | |
| shared-key: browseros-agent | |
| # rust-cache hashes every toolchain `rustup toolchain list` reports, | |
| # not just the pinned one, and the runner image ships its own Rust | |
| # alongside it. That set changes as images roll out, so the key | |
| # fragmented across runners and a warm cache was a coin flip. The | |
| # lockfile hash still keys the cache, so dependency changes bust it; | |
| # cargo fingerprints by rustc version itself, so a toolchain change | |
| # rebuilds rather than reusing the wrong artifacts. | |
| add-rust-environment-hash-key: false | |
| # Intermediate artifacts live outside the checkout so worktrees can | |
| # share them, and rust-cache only saves workspace target dirs plus the | |
| # registry and git caches by default. Name the build dir explicitly or | |
| # every run would recompile the dependency graph from scratch. | |
| cache-directories: ~/.cargo/build/browseros | |
| - 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@v6.1.0 | |
| 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 | |
| # Chromium's hidden-window APIs require a real X11 platform. | |
| if [ "${BROWSEROS_TEST_HEADLESS:-true}" = "false" ]; then | |
| export XDG_SESSION_TYPE=x11 | |
| exec xvfb-run --auto-servernum "$(dirname "$0")/BrowserOS.AppImage" "$@" | |
| fi | |
| exec "$(dirname "$0")/BrowserOS.AppImage" "$@" | |
| EOF | |
| chmod +x .ci/bin/browseros | |
| - name: Create dev env file | |
| working-directory: packages/browseros-agent | |
| run: cp .env.development.example .env.development | |
| - name: Run ${{ matrix.suite }} tests | |
| id: test | |
| env: | |
| BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros | |
| BROWSEROS_TEST_HEADLESS: ${{ matrix.suite == 'claw-mcp' && 'false' || '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 | |
| # Bun's TS-file parser has occasional races on Linux when many test | |
| # workers parse the same source concurrently, surfacing as | |
| # `SyntaxError: Export named 'X' not found in module ...` on files | |
| # whose source clearly exports X. It is not reproducible locally | |
| # and clears on rerun. Retry once on failure so a single flake does | |
| # not block PRs; a second consecutive failure still fails the job. | |
| attempt=0 | |
| max_attempts=2 | |
| exit_code=1 | |
| while [ "$attempt" -lt "$max_attempts" ]; do | |
| attempt=$((attempt + 1)) | |
| if [ "$attempt" -gt 1 ]; then | |
| echo "::warning::${{ matrix.suite }} attempt $attempt (previous exit $exit_code)" | |
| # Fresh junit slot per attempt so stale output does not mask the retry. | |
| rm -f "${{ matrix.junit_path }}" | |
| fi | |
| ${{ matrix.command }} | |
| exit_code=$? | |
| if [ "$exit_code" = "0" ]; then break; fi | |
| done | |
| if [ ! -f "${{ matrix.junit_path }}" ]; then | |
| if [ "$exit_code" = "0" ]; then | |
| cat > "${{ matrix.junit_path }}" <<EOF | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <testsuites tests="0" failures="0"> | |
| <testsuite name="${{ matrix.suite }}" tests="0" failures="0"> | |
| </testsuite> | |
| </testsuites> | |
| EOF | |
| else | |
| cat > "${{ matrix.junit_path }}" <<EOF | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <testsuites tests="1" failures="1"> | |
| <testsuite name="${{ matrix.suite }}" tests="1" failures="1"> | |
| <testcase classname="workflow" name="${{ matrix.suite }} setup"> | |
| <failure message="Test run failed before JUnit output was written">See workflow logs for details.</failure> | |
| </testcase> | |
| </testsuite> | |
| </testsuites> | |
| EOF | |
| fi | |
| fi | |
| echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" | |
| - name: Upload JUnit XML | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| 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 }})" | |
| echo "" | |
| echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| fi | |
| gate: | |
| name: Tests / gate | |
| needs: [discover, test] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Require every suite to pass or be skipped | |
| run: | | |
| echo 'needs = ${{ toJSON(needs) }}' | |
| jq -e 'to_entries | all(.value.result == "success" or .value.result == "skipped")' <<< '${{ toJSON(needs) }}' | |
| comment: | |
| name: Tests / summary | |
| needs: [discover, 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 | |
| env: | |
| ALL_SUITES: ${{ needs.discover.outputs.all_suites }} | |
| run: | | |
| python3 <<'PY' | |
| import glob, json, 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 = "<!-- browseros-agent-tests-summary -->" | |
| all_suites = json.loads(os.environ.get("ALL_SUITES") or "[]") | |
| ran = {} | |
| failed_cases = [] | |
| 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: | |
| ran[suite_name] = (0, 1, 0, 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)" | |
| failed_cases.append((suite_name, f"{cls} > {name}" if cls else name)) | |
| s_failed = s_fail + s_err | |
| s_passed = max(s_tests - s_failed - s_skip, 0) | |
| ran[suite_name] = (s_passed, s_failed, s_skip, s_tests) | |
| total_passed = sum(v[0] for v in ran.values()) | |
| total_failed = sum(v[1] for v in ran.values()) | |
| total_tests = sum(v[3] for v in ran.values()) | |
| order = all_suites or sorted(ran) | |
| n_total = len(order) | |
| n_ran = len(ran) | |
| n_skipped = max(n_total - n_ran, 0) | |
| if total_failed: | |
| header = f"## :x: Tests failed: {total_failed}/{total_tests} failed" | |
| elif n_ran == 0: | |
| header = "## :white_check_mark: No suites affected by this change" | |
| else: | |
| header = f"## :white_check_mark: Tests passed: {total_passed}/{total_tests}" | |
| lines = [marker, header, "", | |
| f"Ran {n_ran} of {n_total} suites ({n_skipped} not affected by this change).", ""] | |
| lines.append("| Suite | Passed | Failed | Skipped |") | |
| lines.append("|-------|--------|--------|---------|") | |
| gate_suites = [] | |
| for name in order: | |
| if name in ran: | |
| passed, failed, skipped, total = ran[name] | |
| if failed > 0: | |
| icon, passed_cell = ":x:", f"{passed}/{total}" | |
| elif total == 0: | |
| icon, passed_cell = ":white_check_mark:", "passed" | |
| gate_suites.append(name) | |
| else: | |
| icon, passed_cell = ":white_check_mark:", f"{passed}/{total}" | |
| lines.append(f"| {icon} `{name}` | {passed_cell} | {failed} | {skipped} |") | |
| else: | |
| lines.append(f"| :fast_forward: `{name}` | n/a | n/a | not affected |") | |
| if gate_suites: | |
| lines += ["", "> `passed` = ran successfully but emits no JUnit counts (a lint/format gate)."] | |
| if failed_cases: | |
| lines += ["", "<details open>", "<summary><b>Failed tests</b></summary>", ""] | |
| 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 += ["", "</details>"] | |
| lines += ["", f"[View workflow run]({run_url})"] | |
| with open("comment.md", "w") as fp: | |
| fp.write("\n".join(lines) + "\n") | |
| PY | |
| - name: Upsert sticky PR comment | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const body = fs.readFileSync('comment.md', 'utf8'); | |
| const marker = '<!-- browseros-agent-tests-summary -->'; | |
| 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 }); | |
| } |