project-slug.test.ts next to project-slug.ts); E2E tests live under orchestrator/tests/e2e/.
First-time test setup
E2E tests load env fromorchestrator/.env.test (not .env). That file is not committed. Copy the example and fill in values as needed:
Running tests
From the repo root:orchestrator/:
Test layers
- Unit (
*.test.ts): Fast, no external services. Mocks fs, logger, etc. Run by default withtest:unit. Excluded from build output. - Integration (
*.integration.test.ts): Real file I/O; NginxManager tests use a temp dir and no-op reload (no nginx binary). DockerManager integration tests run only whenDOCKER_TEST_REPO_URLis set (see below). - E2E (
tests/e2e/*.e2e.test.ts):- API tier (
webhook-api.e2e.test.ts): Full HTTP stack with mocked GitHub and Docker. No extra env; always runs withtest:e2e. - Full tier (
webhook-full.e2e.test.ts): Real GitHub client, real Docker, real clone and deploy. Skipped unlessE2E_FULL=1orCI_FULL_E2E=1. RequiresGITHUB_TOKEN,GITHUB_WEBHOOK_SECRET,ALLOWED_REPOS,PREVIEW_BASE_URL, and a test repo with a minimal Dockerfile and health endpoint.
- API tier (
test:unit.
Guidelines for writing tests
Use these so tests stay consistent and other agents can follow the same patterns.Layout and naming
- Unit / integration: Co-locate with source. Suffixes:
*.test.ts(unit),*.integration.test.ts(integration). E2E:orchestrator/tests/e2e/*.e2e.test.ts. - Descriptive names: Test names should describe behavior or outcome, not implementation (e.g. “should return 401 when signature is invalid”, not “should test validation”).
Unit tests
- Mock external deps: Mock
fs/fs/promises, logger, and other I/O so tests are fast and deterministic. - Mocking
fs/promises: Use a Jest factory so you can set implementations:jest.mock('fs/promises', () => ({ access: jest.fn(), readFile: jest.fn() })). In tests usefsMock.access.mockResolvedValue(...)(do not reassignfsMock.access = jest.fn()— the module may have getter-only properties). - Tracked state: Use temp files/dirs for code that reads or writes the filesystem (e.g. deployment tracker). Create a unique path per run (e.g.
path.join(os.tmpdir(), 'prefix-' + Date.now() + '-' + Math.random().toString(36).slice(2))) and clean up inafterEach/afterAll.
Integration tests
- Temp dirs: Use temp dirs for config, deployments, and DB paths; create and clean up in
beforeEach/afterEachorbeforeAll/afterAll. - NginxManager: Pass
reloadCommand: async () => {}so tests don’t need nginx or sudo. Assert only file contents and presence/absence of config files. - DockerManager: Run the suite only when
DOCKER_TEST_REPO_URLis set (e.g.const describeIfRepo = process.env.DOCKER_TEST_REPO_URL ? describe : describe.skip). Save the deployment to the tracker afterdeployPreviewsocleanupPreviewcan find the work dir. Clean up inafterAll.
E2E tests
- Env: E2E loads
orchestrator/.env.testviatests/setup-env.ts(Jest e2esetupFiles). Do not rely on.envfor E2E. - Stopping the app: If the test uses
createApp, callstopScheduledCleanup()inafterEachso the cleanup interval doesn’t keep the process alive and Jest can exit. Store the return value and call it inafterEach. - Webhook signature: Sign the exact string the server will verify. The server uses
JSON.stringify(req.body). In the test: build the payload object, thenbodyString = JSON.stringify(payload), signbodyString, and send the same object with.send(payload)so the server’s stringify matches. - Invalid-signature tests:
crypto.timingSafeEqualrequires same-length buffers. Use a same-length invalid value (e.g.'sha256=' + '0'.repeat(64)) so the handler can return 401 instead of throwing. - Full E2E: Skip unless
E2E_FULL=1orCI_FULL_E2E=1(e.g.const describeFull = runFullE2E ? describe : describe.skip). Require env vars inbeforeAlland fail fast with a clear message.
General
- Arrange–Act–Assert: Structure tests as setup, action, then assertions.
- One assertion per test when it keeps tests clear; group related assertions in a single test when they describe one behavior.
- Edge cases: Cover boundaries (e.g. first allocation, duplicate allocation, invalid input) and error paths.
- Production code: Prefer dependency injection and small, pure functions so units can be tested without heavy mocking.
Integration test details
- NginxManager: Writes config to a temp dir; reload is a no-op. No nginx binary or sudo required.
- DockerManager: Runs only when
DOCKER_TEST_REPO_URLis set to a minimal public repo (e.g. one with a Dockerfile and/health). Clone, deploy, then cleanup. Example:DOCKER_TEST_REPO_URL=https://github.com/owner/minimal-preview-app.git.
E2E full tier
E2E tests load env fromorchestrator/.env.test (via tests/setup-env.ts), not .env. Copy orchestrator/.env.test.example to orchestrator/.env.test and set the required vars.
To run full E2E (real deploy and cleanup):
.env.test set GITHUB_TOKEN, GITHUB_WEBHOOK_SECRET, ALLOWED_REPOS (e.g. owner/repo), and PREVIEW_BASE_URL. The test repo should have branch main, a Dockerfile, and a health endpoint (e.g. /health).
Current coverage
Unit tests cover:- project-slug (project-slug-util):
toProjectSlug,toDeploymentId - framework-detection: NestJS/Go/Laravel detection,
resolveFramework(mockedfs/promises) - deployment-tracker:
allocatePorts,releasePorts, get/save/delete deployment,getAllDeployments,getDeploymentAge(temp file store, mock logger)
- NginxManager:
addPreviewwrites path-based config andproxy_pass;removePreviewdeletes the file. - DockerManager (when
DOCKER_TEST_REPO_URLset): Deploy and cleanup with a real repo.
- Health, webhook (signed), list previews, delete preview, list empty; invalid signature returns 401.
After code changes, run
pnpm build and orchestrator unit tests before considering work done.