diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 000000000..f0ce1ad65 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,33 @@ +# Python packages sit outside the Bun test graph; this runs their pytest suites +# under uv on change. Only apps/bugsink (the Bugsink R2 storage adapter) has one. +name: Python tests + +on: + pull_request: + paths: + - 'apps/bugsink/**' + - '.github/workflows/python-tests.yml' + +permissions: + contents: read + +jobs: + pytest: + name: pytest + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Run adapter tests + working-directory: apps/bugsink + run: bun run test:adapter diff --git a/.gitignore b/.gitignore index 8e81ed7a7..976c46c57 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,8 @@ todo.md apps/web/src/styled-system/ libs/design/design-system/src/styled-system/ libs/design/styled-system/styled-system/ -styled-system-studio \ No newline at end of file +styled-system-studio +# python (apps/bugsink adapter) +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/AGENTS.md b/AGENTS.md index 1b3159f7e..e2a81bf50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -306,7 +306,7 @@ Screens and routes are built workable-only: - `bun install` — whole workspace (`--frozen-lockfile` in CI; `bun.lock` is committed). - `bun run typecheck` — `turbo run typecheck`; one project via `--filter=@vers/`. - `bun run test` — `turbo run test`, each project's own runner; one project via `--filter`. Every - package runs on `bun test` — never add vitest. Postgres-backed suites need + JS/TS package runs on `bun test` — never add vitest. Postgres-backed suites need `bun run pg:test-container:start` first. bunfig is read from cwd, not merged up, but root-invoked `bun test ` still resolves jest-extended matchers from the root preload. - `bun run lint` / `bun run lint:fix` — `turbo run codegen typegen`, then type-aware oxlint over the @@ -323,6 +323,8 @@ Screens and routes are built workable-only: present; a dependency knip can't see gets a `knip.json` ignore in the PR that introduces it. - Git hooks: lefthook (`lefthook.yml`, installed by `prepare`). Pre-push tests changed files only (`turbo run test --affected`); `LEFTHOOK=0` skips all hooks. +- Python (`apps/bugsink` only) runs `pytest` under `uv` via its `test:adapter` script, outside the + Bun graph; the `python-tests` workflow runs it on change. ## Lint policy diff --git a/agents/project.md b/agents/project.md index 5d3e9c351..2e9132c15 100644 --- a/agents/project.md +++ b/agents/project.md @@ -161,7 +161,7 @@ Screens and routes are built workable-only: - `bun install` — whole workspace (`--frozen-lockfile` in CI; `bun.lock` is committed). - `bun run typecheck` — `turbo run typecheck`; one project via `--filter=@vers/`. - `bun run test` — `turbo run test`, each project's own runner; one project via `--filter`. Every - package runs on `bun test` — never add vitest. Postgres-backed suites need + JS/TS package runs on `bun test` — never add vitest. Postgres-backed suites need `bun run pg:test-container:start` first. bunfig is read from cwd, not merged up, but root-invoked `bun test ` still resolves jest-extended matchers from the root preload. - `bun run lint` / `bun run lint:fix` — `turbo run codegen typegen`, then type-aware oxlint over the @@ -178,6 +178,8 @@ Screens and routes are built workable-only: present; a dependency knip can't see gets a `knip.json` ignore in the PR that introduces it. - Git hooks: lefthook (`lefthook.yml`, installed by `prepare`). Pre-push tests changed files only (`turbo run test --affected`); `LEFTHOOK=0` skips all hooks. +- Python (`apps/bugsink` only) runs `pytest` under `uv` via its `test:adapter` script, outside the + Bun graph; the `python-tests` workflow runs it on change. ## Lint policy diff --git a/apps/bugsink/README.md b/apps/bugsink/README.md index d09fff236..34eb07925 100644 --- a/apps/bugsink/README.md +++ b/apps/bugsink/README.md @@ -11,6 +11,10 @@ manual roll is `fly deploy --config apps/bugsink/fly.toml`. `bugsink_conf.py` im image's config and overrides only the file object storage, so the rest of Bugsink's env-driven settings stay upstream's. +The Python here (`r2_storage.py`) sits outside the Bun test graph, so `@vers/bugsink` carries a +`test:adapter` script that runs its pytest suite (moto-mocked S3) under `uv`, and the `python-tests` +workflow runs it on changes to this directory. + ## Storage Event data lives in a dedicated database in the shared Neon project (`DATABASE_URL` secret) — no app diff --git a/apps/bugsink/package.json b/apps/bugsink/package.json new file mode 100644 index 000000000..85dc07106 --- /dev/null +++ b/apps/bugsink/package.json @@ -0,0 +1,9 @@ +{ + "name": "@vers/bugsink", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "test:adapter": "uv run --with boto3==1.43.46 --with moto --with pytest pytest r2_storage_test.py" + } +} diff --git a/apps/bugsink/r2_storage_test.py b/apps/bugsink/r2_storage_test.py new file mode 100644 index 000000000..f779c6545 --- /dev/null +++ b/apps/bugsink/r2_storage_test.py @@ -0,0 +1,75 @@ +""" +Adapter tests for r2_storage against a mocked S3 (moto). Bugsink's ObjectStorage +base is stubbed here — the real one only raises NotImplementedError — so the +adapter imports without installing Bugsink; these exercise the concrete R2/S3 +behaviour, which is the part that can break. +""" + +import os +import sys +import types + +import boto3 +import pytest +from moto import mock_aws + +_storage = types.ModuleType("files.storage") + + +class ObjectStorage: + def __init__(self, name, object_kind, **options): + self.name = name + self.object_kind = object_kind + + +_storage.ObjectStorage = ObjectStorage +sys.modules.setdefault("files", types.ModuleType("files")) +sys.modules["files.storage"] = _storage + +os.environ.setdefault("R2_ENDPOINT_URL", "https://s3.amazonaws.com") +os.environ.setdefault("R2_BUCKET", "test-bucket") +os.environ.setdefault("R2_ACCESS_KEY_ID", "test-key") +os.environ.setdefault("R2_SECRET_ACCESS_KEY", "test-secret") + +import r2_storage # noqa: E402 + + +@pytest.fixture +def storage(): + with mock_aws(): + boto3.client("s3", region_name="us-east-1").create_bucket(Bucket="test-bucket") + yield r2_storage.R2ObjectStorage("r2", object_kind="file") + + +def test_write_then_read_roundtrips(storage): + with storage.open("bundle/app.js.map", "wb") as f: + f.write(b"sourcemap-bytes") + with storage.open("bundle/app.js.map", "rb") as f: + assert f.read() == b"sourcemap-bytes" + + +def test_exists_reflects_presence(storage): + assert storage.exists("bundle/app.js.map") is False + with storage.open("bundle/app.js.map", "wb") as f: + f.write(b"x") + assert storage.exists("bundle/app.js.map") is True + + +def test_list_yields_written_keys(storage): + for key in ("a", "b/c"): + with storage.open(key, "wb") as f: + f.write(b"x") + assert set(storage.list()) == {"a", "b/c"} + + +def test_delete_removes_the_object(storage): + with storage.open("gone", "wb") as f: + f.write(b"x") + storage.delete("gone") + assert storage.exists("gone") is False + + +def test_open_rejects_unknown_mode(storage): + with pytest.raises(ValueError): + with storage.open("k", "ab"): + pass diff --git a/apps/bugsink/turbo.json b/apps/bugsink/turbo.json new file mode 100644 index 000000000..5917af972 --- /dev/null +++ b/apps/bugsink/turbo.json @@ -0,0 +1,4 @@ +{ + "extends": ["//"], + "tags": ["app"] +} diff --git a/bun.lock b/bun.lock index a337e4b23..21f5c2e71 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,10 @@ "zod": "catalog:", }, }, + "apps/bugsink": { + "name": "@vers/bugsink", + "version": "0.0.0", + }, "apps/web": { "name": "@vers/web", "version": "0.0.0", @@ -2198,6 +2202,8 @@ "@ver0/deep-equal": ["@ver0/deep-equal@1.0.1", "", {}, "sha512-XSvL5wKXBZIv7fflMqhQx936sRpEtzxeV25xAEt0rLLXzbF6RCQaRA1jrVIn8JCubMyn/y0TaidphUtilLzl1A=="], + "@vers/bugsink": ["@vers/bugsink@workspace:apps/bugsink"], + "@vers/client-test-utils": ["@vers/client-test-utils@workspace:libs/testing/client-test-utils"], "@vers/contract-activity": ["@vers/contract-activity@workspace:contracts/activity"], diff --git a/knip.json b/knip.json index 95dad3bf1..6d0241d00 100644 --- a/knip.json +++ b/knip.json @@ -6,7 +6,7 @@ "libs/game/worldmap-core/generate-graph.ts", ".claude/**" ], - "ignoreBinaries": ["flyctl", "gh", "ladle", "op", "pulumi"], + "ignoreBinaries": ["flyctl", "gh", "ladle", "op", "pulumi", "uv"], "rules": { "enumMembers": "off" },