Skip to content

opencode-test-writer #6

opencode-test-writer

opencode-test-writer #6

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.
- name: Trigger PR review workflow
if: steps.check-existing.outputs.skip != 'true'
run: |
BRANCH="${{ steps.select-module.outputs.branch }}"
PR_DATA=$(gh pr list --head "$BRANCH" --state open --json number,headRefOid --jq '.[0]')
PR_NUMBER=$(echo "$PR_DATA" | jq -r '.number // empty')
if [ -n "$PR_NUMBER" ]; then
HEAD_SHA=$(echo "$PR_DATA" | jq -r '.headRefOid')
echo "Triggering review for PR #${PR_NUMBER} (SHA: ${HEAD_SHA})"
gh workflow run opencode-pr.yml \
--ref "${{ github.event.repository.default_branch }}" \
-f pr_number="$PR_NUMBER" \
-f head_sha="$HEAD_SHA"
else
echo "No PR found on branch ${BRANCH}, skipping review trigger."
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}