Skip to content
Merged
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
17 changes: 16 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches: [development, master]
pull_request:

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

jobs:
backend:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -65,4 +69,15 @@ jobs:
run: uv run mypy .

- name: Pytest
run: uv run pytest
env:
PAPYRUS_TEST_TIMING: build/test-results/timing.json
run: uv run pytest --durations=20

- name: Save test timing report
if: ${{ !cancelled() && hashFiles('build/test-results/timing.json') != '' }}
uses: actions/upload-artifact@v4
with:
name: server-test-timing-${{ github.run_attempt }}
path: build/test-results/timing.*
if-no-files-found: error
retention-days: 14
4 changes: 4 additions & 0 deletions .github/workflows/release-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ on:
permissions:
contents: read

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

jobs:
checks:
runs-on: ubuntu-latest
Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ maintainer's reviewed examples. The rules below also apply in standalone checkou
- Add or update tests for every behavior change.
- Prefer route tests for HTTP contract changes and service tests for business logic.
- If database behavior changes, add the narrowest regression test that proves the query or transaction behavior.
- Mark schema-changing tests with `pytest.mark.migration`. Ordinary tests share the schema and truncate rows after each case; migration tests rebuild it before and after each case, including failures.
- Use `public_client` or `public_debug_client` for routes that do not access the database. These fixtures reject accidental database access.
- Set `PAPYRUS_TEST_TIMING=/tmp/pytest-timing.json` to export setup, call, and teardown timings. CI publishes these as a job summary and artifact.
- Do not claim success without running the most relevant checks, or explicitly stating why they were not run.

## Migration Rules
Expand Down Expand Up @@ -102,7 +105,7 @@ When checked out inside the Papyrus workspace, run from its root:
- `tools/papyrus check server` runs Ruff lint, non-mutating format checks, and Mypy.
- `tools/papyrus test server -- tests/services/test_sync.py` runs focused tests.

The shared fixtures drop and recreate test tables. Most route tests need PostgreSQL
The shared fixtures truncate test rows and rebuild tables for migration tests. Most route tests need PostgreSQL
without being marked `integration`. Use a separate local test database, never the
application database; do not run suites concurrently on the same database. The CLI
checks the database target and holds a workspace test lock. External `auth_smoke`
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ addopts = "-v --tb=short"
markers = [
"integration: tests that require a running database",
"auth_smoke: provider-backed authentication smoke tests",
"migration: tests that alter the database schema and require a full rebuild",
]

[tool.ruff]
Expand Down
12 changes: 6 additions & 6 deletions tests/api/routes/test_auth_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@
from papyrus.main import settings as app_settings


async def test_auth_sandbox_not_registered_in_production_mode(prod_client: AsyncClient):
async def test_auth_sandbox_not_registered_in_production_mode(public_client: AsyncClient):
"""Test the auth sandbox is absent when debug mode is disabled."""
response = await prod_client.get("/__dev/auth-sandbox")
response = await public_client.get("/__dev/auth-sandbox")
assert response.status_code == 404


async def test_auth_sandbox_registered_in_debug_vite_mode(
debug_client: AsyncClient,
public_debug_client: AsyncClient,
monkeypatch,
):
"""Test the auth sandbox page points at the Vite dev server when enabled."""
monkeypatch.setattr(app_settings, "dev_pages_use_vite", True)
monkeypatch.setattr(app_settings, "dev_pages_vite_url", "http://vite.test:5173")
response = await debug_client.get("/__dev/auth-sandbox")
response = await public_debug_client.get("/__dev/auth-sandbox")
assert response.status_code == 200
assert "Authentication sandbox" in response.text
assert "window.__PAPYRUS_DEV_PAGE_CONFIG__ =" in response.text
Expand All @@ -38,7 +38,7 @@ async def test_auth_sandbox_registered_in_debug_vite_mode(


async def test_auth_sandbox_renders_built_assets_when_manifest_exists(
debug_client: AsyncClient,
public_debug_client: AsyncClient,
monkeypatch,
tmp_path,
):
Expand All @@ -62,7 +62,7 @@ async def test_auth_sandbox_renders_built_assets_when_manifest_exists(
monkeypatch.setattr(app_settings, "dev_pages_use_vite", False)
monkeypatch.setattr(app_settings, "dev_pages_manifest_path", str(manifest_path))
dev_pages._load_manifest.cache_clear()
response = await debug_client.get("/__dev/auth-sandbox")
response = await public_debug_client.get("/__dev/auth-sandbox")
assert response.status_code == 200
assert 'href="/__dev/static/assets/auth-sandbox.css"' in response.text
assert 'src="/__dev/static/assets/auth-sandbox.js"' in response.text
Expand Down
12 changes: 6 additions & 6 deletions tests/api/routes/test_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
from httpx import AsyncClient


async def test_index_lists_available_pages(prod_client: AsyncClient):
async def test_index_lists_available_pages(public_client: AsyncClient):
"""Test root index returns the available page endpoints."""
response = await prod_client.get("/")
response = await public_client.get("/")
assert response.status_code == 200
data = response.json()
pages = {page["name"]: page["path"] for page in data["pages"]}
Expand All @@ -16,19 +16,19 @@ async def test_index_lists_available_pages(prod_client: AsyncClient):
assert "auth_sandbox" not in pages


async def test_index_lists_debug_pages(debug_client: AsyncClient):
async def test_index_lists_debug_pages(public_debug_client: AsyncClient):
"""Test root index includes the debug sandboxes in debug mode."""
response = await debug_client.get("/")
response = await public_debug_client.get("/")
assert response.status_code == 200
data = response.json()
pages = {page["name"]: page["path"] for page in data["pages"]}
assert pages["auth_sandbox"] == "http://localhost:8080/__dev/auth-sandbox"
assert pages["powersync_sandbox"] == "http://localhost:8080/__dev/powersync-sandbox"


async def test_health_check(client: AsyncClient):
async def test_health_check(public_client: AsyncClient):
"""Test health check endpoint."""
response = await client.get("/health")
response = await public_client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
28 changes: 15 additions & 13 deletions tests/api/routes/test_powersync_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,20 @@ async def _seed_demo_item(
return item


async def test_powersync_sandbox_not_registered_in_production_mode(prod_client: AsyncClient):
async def test_powersync_sandbox_not_registered_in_production_mode(public_client: AsyncClient):
"""The PowerSync sandbox page is absent when debug mode is disabled."""
response = await prod_client.get("/__dev/powersync-sandbox")
response = await public_client.get("/__dev/powersync-sandbox")
assert response.status_code == 404


async def test_powersync_sandbox_registered_in_debug_vite_mode(
debug_client: AsyncClient,
public_debug_client: AsyncClient,
monkeypatch,
):
"""The PowerSync sandbox page uses Vite-served assets when configured."""
monkeypatch.setattr(app_settings, "dev_pages_use_vite", True)
monkeypatch.setattr(app_settings, "dev_pages_vite_url", "http://vite.test:5173")
response = await debug_client.get("/__dev/powersync-sandbox")
response = await public_debug_client.get("/__dev/powersync-sandbox")
assert response.status_code == 200
assert "PowerSync Sandbox" in response.text
assert 'data-dev-page="powersync-sandbox"' in response.text
Expand All @@ -66,7 +66,7 @@ async def test_powersync_sandbox_registered_in_debug_vite_mode(


async def test_powersync_sandbox_renders_built_assets_when_manifest_exists(
debug_client: AsyncClient,
public_debug_client: AsyncClient,
monkeypatch,
tmp_path,
):
Expand All @@ -90,17 +90,17 @@ async def test_powersync_sandbox_renders_built_assets_when_manifest_exists(
monkeypatch.setattr(app_settings, "dev_pages_use_vite", False)
monkeypatch.setattr(app_settings, "dev_pages_manifest_path", str(manifest_path))
dev_pages._load_manifest.cache_clear()
response = await debug_client.get("/__dev/powersync-sandbox")
response = await public_debug_client.get("/__dev/powersync-sandbox")
assert response.status_code == 200
assert 'href="/__dev/static/assets/powersync-sandbox.css"' in response.text
assert 'src="/__dev/static/assets/powersync-sandbox.js"' in response.text
assert 'data-shell-marker="sticky-status-rail"' in response.text
assert "@vite/client" not in response.text


async def test_powersync_sandbox_config_returns_expected_urls(debug_client: AsyncClient):
async def test_powersync_sandbox_config_returns_expected_urls(public_debug_client: AsyncClient):
"""The sandbox config endpoint exposes the browser and backend integration URLs."""
response = await debug_client.get("/__dev/powersync-sandbox/config?client=Two Tabs")
response = await public_debug_client.get("/__dev/powersync-sandbox/config?client=Two Tabs")
assert response.status_code == 200
body = response.json()
assert body["register_url"] == "/v1/auth/register"
Expand All @@ -115,7 +115,7 @@ async def test_powersync_sandbox_config_returns_expected_urls(debug_client: Asyn


async def test_powersync_sandbox_worker_asset_is_served_from_backend_origin(
debug_client: AsyncClient,
public_debug_client: AsyncClient,
monkeypatch,
):
"""Worker assets and their nested chunks are proxied through the backend origin."""
Expand All @@ -125,19 +125,21 @@ async def test_powersync_sandbox_worker_asset_is_served_from_backend_origin(
lambda asset_path: f"// {asset_path}".encode(),
)

response = await debug_client.get("/__dev/powersync-sandbox/worker/WASQLiteDB.umd.js")
response = await public_debug_client.get("/__dev/powersync-sandbox/worker/WASQLiteDB.umd.js")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/javascript")
assert response.headers["x-papyrus-vendor-path"] == "WASQLiteDB.umd.js"
assert response.text == "// worker/WASQLiteDB.umd.js"
nested_response = await debug_client.get("/__dev/powersync-sandbox/worker/node_modules_pnpm_example_chunk.umd.js")
nested_response = await public_debug_client.get(
"/__dev/powersync-sandbox/worker/node_modules_pnpm_example_chunk.umd.js"
)
assert nested_response.status_code == 200
assert nested_response.headers["x-papyrus-vendor-path"] == "node_modules_pnpm_example_chunk.umd.js"
assert nested_response.text == "// worker/node_modules_pnpm_example_chunk.umd.js"


async def test_powersync_sandbox_root_asset_is_served_from_backend_origin(
debug_client: AsyncClient,
public_debug_client: AsyncClient,
monkeypatch,
):
"""Root dist assets such as WASM binaries are proxied through the backend origin."""
Expand All @@ -147,7 +149,7 @@ async def test_powersync_sandbox_root_asset_is_served_from_backend_origin(
lambda asset_path: f"// {asset_path}".encode(),
)

response = await debug_client.get("/__dev/powersync-sandbox/ca59e199e1138b553fad.wasm")
response = await public_debug_client.get("/__dev/powersync-sandbox/ca59e199e1138b553fad.wasm")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/wasm")
assert response.headers["x-papyrus-vendor-path"] == "ca59e199e1138b553fad.wasm"
Expand Down
Loading
Loading