opencode-test-writer #3
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: opencode-test-writer | |
| on: | |
| schedule: | |
| - cron: '0 4 * * *' # 04:00 UTC | |
| - cron: '0 12 * * *' # 12:00 UTC | |
| - cron: '0 20 * * *' # 20:00 UTC | |
| workflow_dispatch: | |
| inputs: | |
| module: | |
| description: 'Module to write tests for (e.g. graphics/vulkan-device, world/meshing)' | |
| required: false | |
| type: string | |
| jobs: | |
| write-tests: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| id-token: write | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - name: Select module to test | |
| id: select-module | |
| env: | |
| INPUT_MODULE: ${{ inputs.module }} | |
| run: | | |
| GRAPHICS_MODULES=( | |
| "graphics/vulkan-device" | |
| "graphics/vulkan-resources" | |
| "graphics/vulkan-pipelines" | |
| "graphics/vulkan-swapchain" | |
| "graphics/vulkan-frame" | |
| "graphics/rhi-types" | |
| "graphics/shadows" | |
| "graphics/post-process" | |
| ) | |
| OTHER_MODULES=( | |
| "engine/core" | |
| "engine/math" | |
| "engine/input" | |
| "world" | |
| "world/meshing" | |
| "world/worldgen" | |
| "engine/ecs" | |
| "game" | |
| ) | |
| if [ -n "$INPUT_MODULE" ]; then | |
| if ! echo "$INPUT_MODULE" | grep -qE '^[a-zA-Z0-9_/-]+$'; then | |
| echo "ERROR: Invalid module input: '$INPUT_MODULE'" >&2 | |
| exit 1 | |
| fi | |
| SELECTED="$INPUT_MODULE" | |
| echo "Manual dispatch: writing tests for ${SELECTED}" | |
| else | |
| GRAPHICS_COUNT=${#GRAPHICS_MODULES[@]} | |
| OTHER_COUNT=${#OTHER_MODULES[@]} | |
| TOTAL=$((GRAPHICS_COUNT + OTHER_COUNT)) | |
| SLOT=$(($(date +%s) / 28800)) | |
| PARITY=$((SLOT % 2)) | |
| if [ "$PARITY" -eq 0 ]; then | |
| INDEX=$((SLOT % GRAPHICS_COUNT)) | |
| SELECTED="${GRAPHICS_MODULES[${INDEX}]}" | |
| echo "Graphics slot: parity=${PARITY} index=${INDEX} -> ${SELECTED}" | |
| else | |
| INDEX=$((SLOT % OTHER_COUNT)) | |
| SELECTED="${OTHER_MODULES[${INDEX}]}" | |
| echo "Other slot: parity=${PARITY} index=${INDEX} -> ${SELECTED}" | |
| fi | |
| fi | |
| BRANCH_NAME="test/$(echo "${SELECTED}" | tr '/' '-')" | |
| echo "module=${SELECTED}" >> "$GITHUB_OUTPUT" | |
| echo "branch=${BRANCH_NAME}" >> "$GITHUB_OUTPUT" | |
| # NOTE: These scan paths must be kept in sync with actual source layout. | |
| # When adding/removing/renaming source files, update the corresponding case entry. | |
| case "$SELECTED" in | |
| graphics/vulkan-device) | |
| echo "scan_paths=src/engine/graphics/vulkan_device.zig src/engine/graphics/vulkan/device.zig src/engine/graphics/vulkan/rhi_state_control.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| graphics/vulkan-resources) | |
| echo "scan_paths=src/engine/graphics/vulkan/resource_manager.zig src/engine/graphics/vulkan/resource_texture_ops.zig src/engine/graphics/vulkan/rhi_resource_lifecycle.zig src/engine/graphics/vulkan/rhi_resource_setup.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| graphics/vulkan-pipelines) | |
| echo "scan_paths=src/engine/graphics/vulkan/pipeline_manager.zig src/engine/graphics/vulkan/pipeline_specialized.zig src/engine/graphics/vulkan/shader_registry.zig src/engine/graphics/vulkan/descriptor_manager.zig src/engine/graphics/vulkan/descriptor_bindings.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| graphics/vulkan-swapchain) | |
| echo "scan_paths=src/engine/graphics/vulkan/swapchain.zig src/engine/graphics/vulkan/swapchain_presenter.zig src/engine/graphics/vulkan_swapchain.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| graphics/vulkan-frame) | |
| echo "scan_paths=src/engine/graphics/vulkan/frame_manager.zig src/engine/graphics/vulkan/rhi_frame_orchestration.zig src/engine/graphics/vulkan/render_pass_manager.zig src/engine/graphics/vulkan/rhi_pass_orchestration.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| graphics/rhi-types) | |
| echo "scan_paths=src/engine/graphics/rhi.zig src/engine/graphics/rhi_types.zig src/engine/graphics/rhi_vulkan.zig src/engine/graphics/rhi_tests.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| graphics/shadows) | |
| echo "scan_paths=src/engine/graphics/shadow_system.zig src/engine/graphics/csm.zig src/engine/graphics/vulkan/shadow_system.zig src/engine/graphics/vulkan/rhi_shadow_bridge.zig src/engine/graphics/shadow_scene.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| graphics/post-process) | |
| echo "scan_paths=src/engine/graphics/vulkan/bloom_system.zig src/engine/graphics/vulkan/fxaa_system.zig src/engine/graphics/vulkan/taa_system.zig src/engine/graphics/vulkan/ssao_system.zig src/engine/graphics/vulkan/post_process_system.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| engine/core) | |
| echo "scan_paths=src/engine/core/job_system.zig src/engine/core/ring_buffer.zig src/engine/core/time.zig src/engine/core/log.zig src/engine/core/window.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| engine/math) | |
| echo "scan_paths=src/engine/math/vec3.zig src/engine/math/mat4.zig src/engine/math/frustum.zig src/engine/math/utils.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| engine/input) | |
| echo "scan_paths=src/engine/input/input.zig src/engine/input/interfaces.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| world) | |
| echo "scan_paths=src/world/chunk.zig src/world/block.zig src/world/block_registry.zig src/world/chunk_storage.zig src/world/chunk_allocator.zig src/world/chunk_mesh.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| world/meshing) | |
| echo "scan_paths=src/world/meshing/greedy_mesher.zig src/world/meshing/ao_calculator.zig src/world/meshing/lighting_sampler.zig src/world/meshing/biome_color_sampler.zig src/world/meshing/boundary.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| world/worldgen) | |
| echo "scan_paths=src/world/worldgen/overworld_generator.zig src/world/worldgen/biome.zig src/world/worldgen/caves.zig src/world/worldgen/terrain_shape_generator.zig src/world/worldgen/coastal_generator.zig src/world/worldgen/noise_sampler.zig src/world/worldgen/height_sampler.zig src/world/worldgen/surface_builder.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| engine/ecs) | |
| echo "scan_paths=src/engine/ecs/storage.zig src/engine/ecs/manager.zig src/engine/ecs/entity.zig src/engine/ecs/components.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| game) | |
| echo "scan_paths=src/game/player.zig src/game/screen.zig src/game/inventory.zig src/game/settings/ src/game/input_mapper.zig src/game/session.zig" >> "$GITHUB_OUTPUT" | |
| ;; | |
| *) | |
| echo "scan_paths=src/${SELECTED}" >> "$GITHUB_OUTPUT" | |
| ;; | |
| esac | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.event.repository.default_branch }} | |
| fetch-depth: 0 | |
| - name: Ensure test label exists | |
| run: | | |
| if ! gh label list --json name --jq '.[].name' | grep -q '^automated-test$'; then | |
| gh label create "automated-test" \ | |
| --description "Test PRs created by automated opencode test writer" \ | |
| --color "0E8A16" | |
| fi | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Check for existing PR | |
| id: check-existing | |
| run: | | |
| MODULE="${{ steps.select-module.outputs.module }}" | |
| EXISTING=$(gh pr list \ | |
| --base ${{ github.event.repository.default_branch }} \ | |
| --label "automated-test" \ | |
| --state open \ | |
| --limit 20 \ | |
| --json title,headRefName \ | |
| --jq --arg m "$MODULE" '.[] | select(.title | startswith("test: [\($m)]")) | .headRefName' || echo "") | |
| if [ -n "$EXISTING" ]; then | |
| echo "skip=true" >> "$GITHUB_OUTPUT" | |
| echo "Existing PR found on branch: ${EXISTING}" | |
| else | |
| echo "skip=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Validate scan paths exist | |
| if: steps.check-existing.outputs.skip != 'true' | |
| run: | | |
| MISSING=0 | |
| for path in ${{ steps.select-module.outputs.scan_paths }}; do | |
| if [ ! -e "$path" ]; then | |
| echo "WARNING: scan path does not exist: $path" | |
| MISSING=$((MISSING + 1)) | |
| fi | |
| done | |
| if [ "$MISSING" -gt 0 ]; then | |
| echo "::warning::$MISSING scan path(s) missing — the scan_paths case statement in this workflow may need updating" | |
| fi | |
| - name: Install Nix | |
| if: steps.check-existing.outputs.skip != 'true' | |
| uses: DeterminateSystems/nix-installer-action@v16 | |
| - name: Cache Nix Store | |
| if: steps.check-existing.outputs.skip != 'true' | |
| continue-on-error: true | |
| uses: nix-community/cache-nix-action@v7 | |
| with: | |
| primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.nix', 'flake.lock') }} | |
| restore-prefixes-first-match: nix-${{ runner.os }}- | |
| - name: Run opencode test writer | |
| if: steps.check-existing.outputs.skip != 'true' | |
| uses: anomalyco/opencode/github@v1.3.3 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} | |
| with: | |
| model: kimi-for-coding/k2p5 | |
| prompt: | | |
| You are a senior Zig systems programmer writing unit tests for a voxel engine. Your job is to find untested code, write thorough tests, and submit a pull request. | |
| ## YOUR TASK | |
| Analyze the module `${{ steps.select-module.outputs.module }}` for testing gaps and write 3-8 high-quality unit tests that increase coverage. Focus on untested public functions, error paths, edge cases, and crash-prone areas. | |
| **CRITICAL CONSTRAINTS:** | |
| 1. You MUST create a git branch, write test files, and open a pull request targeting `${{ github.event.repository.default_branch }}`. | |
| 2. Your tests MUST pass: run `nix develop --command zig build test` and verify zero failures before pushing. | |
| 3. You MUST format your code: run `nix develop --command zig fmt src/` before committing. | |
| 4. Only write tests for logic that can be tested WITHOUT a real GPU/window. Use mocks, stubs, or test pure logic only. | |
| 5. Do NOT modify any non-test source files. Only add or modify test files. | |
| 6. If the module has no testable logic (e.g., pure Vulkan dispatch), write what you can and explain limitations in the PR body. | |
| ## CODEBASE CONTEXT | |
| ZigCraft is a high-performance Minecraft-style voxel engine built with: | |
| - **Zig 0.14+** with strict memory management (explicit allocators, defer/errdefer) | |
| - **SDL3** for windowing and input | |
| - **Vulkan** for rendering (only backend, via RHI abstraction) | |
| - **Nix** for reproducible builds (`nix develop --command zig build`) | |
| - **GLSL shaders** validated via glslangValidator | |
| - **Custom job system** for multithreaded world generation and meshing | |
| Build commands: | |
| - `nix develop --command zig build test` — unit tests + shader validation | |
| - `nix develop --command zig fmt src/` — format code | |
| - `nix develop --command zig build -Doptimize=ReleaseFast` — release build | |
| ## FILES TO SCAN | |
| Read these source files to find untested code: | |
| ${{ steps.select-module.outputs.scan_paths }} | |
| Also read any existing test files in the same directories (look for `*_tests.zig` patterns). | |
| ## WHERE TO WRITE TESTS | |
| - Create or extend `*_tests.zig` files ALONGSIDE the source files (same directory). | |
| - For example, tests for `src/engine/graphics/vulkan/swapchain.zig` go in `src/engine/graphics/vulkan/swapchain_tests.zig`. | |
| - Each test file should follow this pattern: | |
| ```zig | |
| const std = @import("std"); | |
| const testing = std.testing; | |
| // Import the module being tested | |
| const swapchain = @import("swapchain.zig"); | |
| test "descriptive test name" { | |
| // test body | |
| } | |
| ``` | |
| - After creating a new test file, register it in `src/tests.zig` by adding a line to the `test {}` block: | |
| `_ = @import("path/to/new_tests.zig");` | |
| - Use a relative import path from the tests.zig location, e.g. `_ = @import("engine/graphics/vulkan/swapchain_tests.zig");` | |
| ## WHAT TO TEST — PRIORITIES | |
| ### For ALL modules: | |
| 1. **Untested public functions** — Any `pub fn` with no corresponding test | |
| 2. **Error paths** — Functions that return `!T` or `RhiError!T` but have no test exercising error branches | |
| 3. **Edge cases** — Zero values, max values, negative values, empty inputs, boundary conditions | |
| 4. **State transitions** — Init -> use -> deinit cycles, state machine correctness | |
| 5. **Determinism** — Same inputs produce same outputs across multiple calls | |
| 6. **Invariants** — Properties that should always hold (e.g., `normalize(v).length() == 1.0`) | |
| ### For graphics/vulkan modules (HIGH PRIORITY): | |
| 1. **Vulkan error code mapping** — Every `VkResult` error code must map to the correct Zig error via `checkVk`. Test unmapped codes return `error.Unknown`. | |
| 2. **GPU crash paths** — Device loss (`VK_ERROR_DEVICE_LOST`), surface loss (`VK_ERROR_SURFACE_LOST_KHR`), out-of-memory. Test that these produce the right errors and don't panic. | |
| 3. **Resource handle validation** — Invalid handles (0, max u32), null pointers, double-destroy, use-after-destroy patterns. | |
| 4. **Buffer/Texture lifecycle** — Create/destroy ordering, deletion queue behavior, MAX_FRAMES_IN_FLIGHT double-buffering correctness. | |
| 5. **RhiError propagation** — Every function that can return `RhiError` should have at least one test verifying the error path. | |
| 6. **Synchronization safety** — Fence/semaphore patterns, command buffer lifecycle, frame-in-flight tracking. | |
| 7. **Struct layout** — `packed struct` alignment, `extern struct` field offsets, GPU data layout correctness. | |
| 8. **Pipeline state** — Shader compilation error handling, pipeline creation failure recovery. | |
| ### For world/worldgen modules: | |
| 1. **Chunk boundary conditions** — Negative coordinates, coordinate transforms, edge-of-world behavior | |
| 2. **Determinism** — Same seed always produces identical output | |
| 3. **Noise range guarantees** — Output stays within documented bounds | |
| 4. **Biome selection** — Climate parameter edge cases, transition rules completeness | |
| ### For engine/core modules: | |
| 1. **Job system** — Work distribution correctness, edge cases with 0 or 1 jobs | |
| 2. **Ring buffer** — Full/empty boundary, wrap-around behavior, capacity edge cases | |
| 3. **Time** — Precision, overflow at large values, delta time calculations | |
| ## HOW TO TEST VULKAN CODE WITHOUT A GPU | |
| Many Vulkan types are opaque pointers that can be `null` in tests. Use these strategies: | |
| 1. **Test pure logic**: Functions that don't call Vulkan APIs directly (e.g., `checkVk`, struct constructors, validation functions, math in rendering code). | |
| 2. **Partial struct initialization**: Initialize structs with `null` Vulkan handles and test the non-Vulkan fields and logic. | |
| 3. **Mock interfaces**: Follow the pattern in `src/engine/graphics/rhi_tests.zig` — create mock structs with function pointers that track calls. | |
| 4. **Error mapping tests**: Test `checkVk` and similar functions by calling them with specific `VkResult` constants. | |
| 5. **Struct layout tests**: Verify `@sizeOf`, `@offsetOf`, and `@bitSizeOf` for GPU-facing structs. | |
| 6. **State machine tests**: Test state transitions without actually calling Vulkan functions. | |
| Example patterns: | |
| ```zig | |
| // Error mapping test | |
| test "checkVk maps VK_ERROR_OUT_OF_DATE_KHR" { | |
| const checkVk = @import("vulkan_device.zig").checkVk; | |
| try testing.expectError(error.OutOfDate, checkVk(c.VK_ERROR_OUT_OF_DATE_KHR)); | |
| } | |
| // Partial struct test | |
| test "VulkanDevice fault count tracking" { | |
| var device = VulkanDevice{ | |
| .allocator = testing.allocator, | |
| .vk_device = null, | |
| .queue = null, | |
| .fault_count = 0, | |
| }; | |
| try testing.expectEqual(@as(u32, 0), device.fault_count); | |
| } | |
| // Mock-based test | |
| test "ResourceManager rejects invalid handle" { | |
| var manager = ResourceManager{ ... }; | |
| try testing.expectError(error.ResourceNotFound, manager.getBuffer(0)); | |
| } | |
| ``` | |
| ## GIT WORKFLOW | |
| 1. Create a branch: `git checkout -b ${{ steps.select-module.outputs.branch }}` | |
| 2. Write your test files | |
| 3. Register new test files in `src/tests.zig` | |
| 4. Format: `nix develop --command zig fmt src/` | |
| 5. Run tests: `nix develop --command zig build test` — ALL tests must pass, not just yours | |
| 6. Commit with message: `test: add {area} tests for {module}` (e.g., `test: add error mapping tests for vulkan-device`) | |
| 7. Push the branch | |
| 8. Create a PR targeting `${{ github.event.repository.default_branch }}`: | |
| ``` | |
| gh pr create \ | |
| --base ${{ github.event.repository.default_branch }} \ | |
| --title "test: [${{ steps.select-module.outputs.module }}] {brief description}" \ | |
| --body "..." \ | |
| --label "automated-test" | |
| ``` | |
| ## PR BODY TEMPLATE | |
| Your PR body MUST follow this format: | |
| ``` | |
| ## Module | |
| `${{ steps.select-module.outputs.module }}` (automated test coverage) | |
| ## Summary | |
| 1-2 sentences describing what areas are now tested. | |
| ## Tests Added | |
| - `test "descriptive name"` — What it verifies | |
| - `test "descriptive name"` — What it verifies | |
| - ... | |
| ## Testing Gaps Remaining | |
| - List functions or paths that still need tests and why (e.g., requires GPU, needs mock refactor) | |
| - This helps future runs prioritize correctly | |
| ## Verification | |
| - [x] `nix develop --command zig fmt src/` passes | |
| - [x] `nix develop --command zig build test` passes (all tests, not just new ones) | |
| - [x] No non-test source files were modified | |
| ``` | |
| ## FINAL REMINDERS | |
| - **Tests MUST pass.** Run `nix develop --command zig build test` and fix any failures before pushing. This is non-negotiable. | |
| - **Format before commit.** Run `nix develop --command zig fmt src/`. | |
| - **No non-test changes.** Do not modify any file that isn't a test file or the test registry in `src/tests.zig`. | |
| - **3-8 tests per run.** Quality over quantity. Each test should meaningfully increase coverage. | |
| - **Descriptive test names.** Use `test "ModuleName.functionName edge case description"` pattern. | |
| - **One PR per run.** Do not create multiple PRs. | |
| - **Skip if nothing to test.** If the module is already well-tested or has no testable logic, do not create a PR. It's better to skip than to write trivial tests. |