name: Test Suite

on:
  push:
    branches: [main]
    paths-ignore:
      - docs/**
      - .agent/**
      - .github/ISSUE_TEMPLATE/**
      - AGENTS.md
      - CLAUDE.md
      - COMMUNICATION.md
      - CONTRIBUTING.md
      - README.md
      - SECURITY.md
      - webui/README.md
  pull_request:
    branches: [main]
    paths-ignore:
      - docs/**
      - .agent/**
      - .github/ISSUE_TEMPLATE/**
      - AGENTS.md
      - CLAUDE.md
      - COMMUNICATION.md
      - CONTRIBUTING.md
      - README.md
      - SECURITY.md
      - webui/README.md

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  changes:
    name: Detect changes
    runs-on: ubuntu-latest
    outputs:
      python_required: ${{ steps.paths.outputs.python_required }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Detect Python-relevant changes
        id: paths
        shell: bash
        env:
          EVENT_NAME: ${{ github.event_name }}
          BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
          HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
        run: |
          python_required=true

          if [[ "$EVENT_NAME" == "pull_request" ]]; then
            diff_range="${BASE_SHA}...${HEAD_SHA}"
          else
            diff_range="${BASE_SHA}..${HEAD_SHA}"
          fi

          if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null &&
            changed_files="$(git diff --name-only --no-renames "$diff_range")" &&
            [[ -n "$changed_files" ]] &&
            ! grep -qvE '^(webui/|nanobot/channels/[^/]+/webui/|docs/)' <<< "$changed_files"; then
            python_required=false
          fi

          echo "python_required=$python_required" >> "$GITHUB_OUTPUT"

  test:
    name: Python (${{ matrix.name }})
    needs: changes
    if: needs.changes.outputs.python_required == 'true'
    runs-on: ${{ matrix.os }}
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        include:
          - name: minimum, 3.11
            os: ubuntu-latest
            python-version: "3.11"
            coverage: false
            pytest_args: ""
          - name: latest, 3.14 + coverage
            os: ubuntu-latest
            python-version: "3.14"
            coverage: true
            pytest_args: ""
          - name: Windows, 3.14
            os: windows-latest
            python-version: "3.14"
            coverage: false
            # Real PowerShell/process-tree tests run serially below. Keep
            # them out of xdist so workers never share a Windows console.
            pytest_args: "-n 2 --dist loadfile --ignore=tests/tools/test_exec_platform.py"

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install uv
        uses: astral-sh/setup-uv@v4

      - name: Install system dependencies (Linux)
        if: runner.os == 'Linux'
        run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential

      - name: Install dependencies
        run: uv sync --all-extras --dev

      - name: Install channel dependencies
        run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels

      - name: Verify dependency consistency
        run: uv pip check

      # Channel requirements live in manifests rather than uv.lock. Avoid a
      # later uv run sync pruning the packages installed by the previous step.
      - name: Lint with ruff
        if: matrix.coverage
        run: uv run --no-sync ruff check nanobot tests conftest.py

      - name: Type check with BasedPyright (strict)
        if: matrix.coverage
        run: uv run --no-sync basedpyright

      - name: Run tests with coverage
        if: matrix.coverage
        run: >-
          uv run --no-sync python -m pytest
          --cov=nanobot --cov-report=term-missing:skip-covered
          --durations=25 --durations-min=1.0

      - name: Run compatibility tests
        if: ${{ !matrix.coverage }}
        run: >-
          uv run --no-sync python -m pytest
          ${{ matrix.pytest_args }}
          --durations=25 --durations-min=1.0

      - name: Run Windows process compatibility tests
        if: runner.os == 'Windows'
        run: >-
          uv run --no-sync python -m pytest
          tests/tools/test_exec_platform.py
          --durations=25 --durations-min=1.0

  webui:
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - uses: actions/checkout@v4

      - name: Set up Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: 1.3.6

      - name: Verify npm lockfile
        working-directory: webui
        run: npm ci --ignore-scripts --dry-run

      - name: Install WebUI dependencies
        working-directory: webui
        run: bun install --frozen-lockfile

      - name: Lint WebUI
        working-directory: webui
        run: bun run lint

      - name: Test WebUI
        working-directory: webui
        run: bun run test:coverage

      - name: Build WebUI
        working-directory: webui
        run: bun run build

  tui:
    name: ${{ matrix.name }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 10
    strategy:
      fail-fast: false
      matrix:
        include:
          - name: Terminal UI
            os: ubuntu-latest
          - name: Terminal UI (Windows)
            os: windows-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: 1.3.13

      - name: Install TUI dependencies
        working-directory: tui
        run: bun install --frozen-lockfile

      - name: Check TUI
        working-directory: tui
        run: bun run check

      - name: Test TUI
        working-directory: tui
        run: bun run test

      - name: Test TUI in a real pseudo-terminal
        if: runner.os != 'Windows'
        working-directory: tui
        run: python3 scripts/pty_smoke.py

      - name: Set up Python for ConPTY smoke test
        if: runner.os == 'Windows'
        uses: actions/setup-python@v5
        with:
          python-version: '3.13'

      - name: Test TUI in a real ConPTY terminal
        if: runner.os == 'Windows'
        working-directory: tui
        shell: pwsh
        run: |
          python -m pip install --disable-pip-version-check pywinpty==3.0.5
          python scripts/conpty_smoke.py

      - name: Build TUI
        working-directory: tui
        run: bun run build

      - name: Verify licensed Linux release archive
        if: runner.os == 'Linux'
        working-directory: tui
        run: |
          bun scripts/release-notices.ts linux-x64
          python3 scripts/package-release.py linux-x64

      - name: Verify licensed Windows release archive
        if: runner.os == 'Windows'
        working-directory: tui
        shell: pwsh
        run: |
          bun scripts/release-notices.ts win32-x64
          python scripts/package-release.py win32-x64

  docker:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v4

      - name: Build image with default channel dependencies
        run: docker build -t nanobot:test .

      - name: Verify Docker Compose startup and privilege boundary
        env:
          HOME: ${{ runner.temp }}
        run: |
          docker compose run --rm --no-deps --build -T nanobot-cli status
          docker compose run --rm --no-deps -T --entrypoint sh nanobot-cli -s <<'OUTER'
          set -eu
          field() {
            awk -v key="$1:" '$1 == key { print $2 }' /proc/self/status
          }
          test "$(id -u)" = "0"
          test "$(field NoNewPrivs)" = "1"
          setpriv --reuid=nanobot --regid=nanobot --init-groups sh -s <<'INNER'
          set -eu
          field() {
            awk -v key="$1:" '$1 == key { print $2 }' /proc/self/status
          }
          test "$(id -u)" = "1000"
          test "$(field NoNewPrivs)" = "1"
          for capability_set in CapInh CapPrm CapEff CapAmb; do
            test "$(field "$capability_set")" = "0000000000000000"
          done
          INNER
          OUTER
          docker compose -f docker-compose.yml -f docker-compose.bwrap.yml --profile cli \
            config --format json > "${RUNNER_TEMP}/bwrap-compose.json"
          python - <<'PY'
          import json
          import os
          from pathlib import Path

          config = json.loads(Path(os.environ["RUNNER_TEMP"], "bwrap-compose.json").read_text())
          for service_name in ("nanobot-gateway", "nanobot-api", "nanobot-cli"):
              service = config["services"][service_name]
              assert {"CHOWN", "SETGID", "SETUID", "SYS_ADMIN"} <= set(service["cap_add"])
              assert "no-new-privileges:true" in service["security_opt"]
          PY

      - name: Verify default WhatsApp dependencies
        run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"

      - name: Verify runtime dependency permissions
        run: >-
          docker run --rm --user 1000:1000 --entrypoint sh nanobot:test -c
          'test -w /app/.venv && test ! -w /app && test ! -w /app/nanobot &&
          python -m scripts.install_channel_dependencies discord && python -c "import discord"'
