Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/backend-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: backend-tests

# The backend pytest suite, run on every pull request and on pushes to the mainline branches. Until
# now nothing ran it in CI, so a regression only surfaced when someone ran it by hand.
on:
pull_request:
paths:
- 'backend/**'
- '.github/workflows/backend-tests.yml'
push:
branches: [main, dev]
paths:
- 'backend/**'
- '.github/workflows/backend-tests.yml'
workflow_dispatch:

# Runs checked-out project code on pull_request: the token stays read-only.
permissions:
contents: read

concurrency:
group: backend-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
pytest:
name: pytest (ubuntu)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: pip
cache-dependency-path: |
backend/requirements.lock
backend/requirements-dev.txt
- name: Install backend deps (locked runtime + dev)
run: |
python -m pip install --require-hashes --only-binary=:all: -r backend/requirements.lock
python -m pip install -r backend/requirements-dev.txt
# Two numbers that must agree. A test process that dies mid-run can exit 0 with no summary
# (a hard-exit shutdown path did exactly that once and silently skipped ~42% of the suite), so
# the job also asserts that every collected test was actually run: junit testcase count ==
# collect-only count. Green then means green, not "green as far as it got".
- name: Count the suite
run: |
python -m pytest backend/tests --ignore=backend/tests/formal --collect-only -q -p no:cacheprovider \
| tail -1 | tee collected.txt
# --timeout: a test that blocks forever (a bare ws.receive_json() waiting for an event that never
# comes, say) otherwise stalls the run at 99% until timeout-minutes with no summary and no junit.
# With the cap it fails by name, the rest of the suite runs, and the assertion below still holds.
- name: Run the backend suite
run: |
python -m pytest backend/tests --ignore=backend/tests/formal -q -p no:cacheprovider \
--timeout=300 \
--junitxml "${RUNNER_TEMP}/pytest.xml"
- name: Every collected test ran
# Runs after a red suite too, so a failure report also says whether the run was complete.
if: ${{ !cancelled() }}
run: |
python - "${RUNNER_TEMP}/pytest.xml" collected.txt <<'PY'
import re, sys, xml.etree.ElementTree as ET
ran = sum(1 for _ in ET.parse(sys.argv[1]).getroot().iter('testcase'))
m = re.search(r'(\d+) tests? collected', open(sys.argv[2]).read())
collected = int(m.group(1)) if m else -1
print(f'collected={collected} ran={ran}')
if collected < 1 or ran != collected:
sys.exit(f'FAIL: {ran} of {collected} collected tests reached the report; the run was truncated')
PY
44 changes: 44 additions & 0 deletions .github/workflows/edge-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: edge-tests

# The openswarm-edge pytest suite, on every pull request that touches it and on pushes to the
# mainline branches.
on:
pull_request:
paths:
- 'openswarm-edge/**'
- '.github/workflows/edge-tests.yml'
push:
branches: [main, dev]
paths:
- 'openswarm-edge/**'
- '.github/workflows/edge-tests.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: edge-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
pytest:
name: pytest (openswarm-edge)
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: openswarm-edge
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: pip
cache-dependency-path: openswarm-edge/requirements.txt
- name: Install edge deps
run: |
python -m pip install -r requirements.txt
python -m pip install pytest pytest-asyncio
- name: Run the edge suite
run: python -m pytest tests -q -p no:cacheprovider
43 changes: 43 additions & 0 deletions .github/workflows/frontend-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: frontend-tests

# Typecheck plus the renderer's node:test suite, on every pull request and on pushes to the mainline
# branches. Until now neither ran in CI; the tests were run by hand, one file at a time.
on:
pull_request:
paths:
- 'frontend/**'
- '.github/workflows/frontend-tests.yml'
push:
branches: [main, dev]
paths:
- 'frontend/**'
- '.github/workflows/frontend-tests.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: frontend-tests-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
typecheck-and-tests:
name: tsc + node:test
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.18.1'
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- name: Typecheck
run: npx tsc --noEmit -p tsconfig.json
- name: Unit tests (node:test via tsx)
run: node scripts/run-tests.mjs
3 changes: 3 additions & 0 deletions backend/requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

pytest==8.3.4
pytest-asyncio==0.25.2
# Per-test wall-clock cap for CI (see .github/workflows/backend-tests.yml): a test that
# blocks forever fails by name instead of stalling the whole run until the job cap.
pytest-timeout==2.4.0

# Used by linter/lint.py (the vulture dead-code check). watchfiles, also
# needed by lint.py, already comes in transitively via uvicorn[standard].
Expand Down
48 changes: 46 additions & 2 deletions backend/tests/test_ws_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
The SDK and WS auth are mocked; everything else is the real running stack."""

import asyncio
import queue
import threading

import pytest

Expand All @@ -17,6 +19,29 @@
import backend.main as main_mod
from backend.apps.agents.agent_manager import agent_manager
from backend.apps.agents.core.models import AgentSession
import backend.apps.agents.manager.run.RunOptions as run_options_mod


def p_receive_json(ws, timeout: float = 5.0):
"""ws.receive_json() blocks forever when the event never comes; a regression in the loop then
hangs the whole pytest run instead of failing this test. Bound every receive."""
out = queue.Queue(maxsize=1)

def p_recv():
try:
out.put((True, ws.receive_json()))
except BaseException as exc:
out.put((False, exc))

thread = threading.Thread(target=p_recv, daemon=True)
thread.start()
try:
ok, value = out.get(timeout=timeout)
except queue.Empty as exc:
raise AssertionError(f"timed out waiting for websocket event after {timeout}s") from exc
if ok:
return value
raise value


def p_assistant():
Expand All @@ -33,6 +58,20 @@ def p_result():
def test_ws_endpoint_streams_a_full_turn_end_to_end(monkeypatch):
monkeypatch.setattr(main_mod, "p_ws_auth_ok", lambda ws: True, raising=True)

# The contract of this test is "SDK and WS auth mocked, everything else real", but two things on
# the turn path reach outside the process and must not decide the outcome: configure_provider_env
# can wander into 9Router revival (spawn/npm install, serialized on a module-level lock) whenever
# earlier tests left provider evidence behind, and the background turn-label aux call does the
# same. Pin both out; the persistent-client path is pinned off suite-wide in conftest.
async def p_noop_provider_env(*args, **kwargs):
return None

async def p_noop_turn_label(*args, **kwargs):
return None

monkeypatch.setattr(run_options_mod, "configure_provider_env", p_noop_provider_env, raising=True)
monkeypatch.setattr(agent_manager, "generate_turn_label", p_noop_turn_label, raising=True)

async def fake_query(*args, **kwargs):
yield p_assistant()
yield p_result()
Expand All @@ -49,10 +88,15 @@ async def fake_query(*args, **kwargs):
ws.send_json({"event": "agent:send_message", "data": {"prompt": "hi"}})
seen = []
for _ in range(40):
ev = ws.receive_json()
ev = p_receive_json(ws)
seen.append(ev.get("event"))
if ev.get("event") == "agent:message" and "hello from the loop" in str(ev.get("data", {})):
if (
ev.get("event") == "agent:status"
and ev.get("data", {}).get("status") == "completed"
):
break
else:
raise AssertionError(f"did not receive completed status; saw events={seen}")
# the real loop's assistant reply made it all the way back over the WS
assert "agent:message" in seen
assert any(m.role == "assistant" and "hello from the loop" in str(m.content)
Expand Down
5 changes: 5 additions & 0 deletions frontend/scripts/css-stub.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Run: node --import tsx --require ./scripts/css-stub.cjs --test <files>
// A stylesheet import resolves to an empty module under node:test. Vite handles CSS imports in the
// renderer bundle; under node (tsx compiles these modules to CommonJS) they would be parsed as
// JavaScript and throw.
require.extensions['.css'] = () => {};
35 changes: 35 additions & 0 deletions frontend/scripts/run-tests.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env node
// Runs every renderer unit test (src/**/*.test.ts, *.test.tsx) under node:test, with tsx doing the
// TypeScript. One command for CI and for a dev machine: `node scripts/run-tests.mjs`, optionally
// followed by file paths to run a subset. Exits non-zero if any test fails or nothing was found.
import { spawnSync } from 'node:child_process';
import { readdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const testFile = (p) => /\.test\.tsx?$/.test(p);

function walk(dir, out) {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === 'dist') continue;
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p, out);
else if (testFile(p)) out.push(p);
}
return out;
}

const files = process.argv.length > 2 ? process.argv.slice(2) : walk(join(root, 'src'), []).sort();
if (files.length === 0) {
console.error('run-tests: no test files found under src/');
process.exit(1);
}
// --require css-stub: some units live in component files that import a stylesheet; under node those
// imports must resolve to nothing (Vite handles them in the bundle).
const result = spawnSync(
process.execPath,
['--import', 'tsx', '--require', './scripts/css-stub.cjs', '--test', ...files],
{ cwd: root, stdio: 'inherit' },
);
process.exit(result.status ?? 1);
Loading