End-to-end testing
A practical recipe for running end-to-end tests against the full stack — your API, frontend, workers, database, queues — in disposable, isolated environments. One namespace per test run, no shared state, no flake from cross-PR collisions.
Prerequisites
- A working dev environment — see Dev environment
- A test runner (Playwright, Cypress, Jest, pytest, k6 — anything that can hit a URL)
- The project already runs cleanly with
gws up
1. Create the e2e profile
A profile is an overlay inside gws.json that adjusts services for a specific scenario without forking the file. The e2e profile should turn off live file sync, turn off source watchers, swap in production-like Dockerfiles, and skip services the test suite never touches.
Choose one path:
Path A — let an AI agent generate it (recommended):
In Claude Code, GitHub Copilot CLI, OpenAI Codex CLI, Cursor, or Amp, run:
/gws-setup add an e2e profile to gws.json: disable fileSync and watchers
for every service, point each service at a prod-like Dockerfile.e2e and an
e2e manifest folder, and disable any service the test suite doesn't hit
The agent reads your gws.json, drafts a profiles.e2e block, generates the matching Dockerfile.e2e for each service, validates against the live schema, and runs gws config import. Review the diff, then deploy with --profile e2e (see step 3).
Path B — author the profile by hand:
Open gws.json and add a profiles.e2e block, then validate and import:
gws config validate
gws config import
Show a worked example of a hand-written e2e profile and matching Dockerfile pair
{
"name": "my-project",
"services": [
{
"name": "api",
"path": "./api",
"fileSync": true,
"dockerfile": "./api/Dockerfile.dev",
"manifests": "./api/k8s/dev"
},
{
"name": "web",
"path": "./frontend",
"fileSync": true,
"dockerfile": "./frontend/Dockerfile.dev",
"manifests": "./frontend/k8s/dev"
},
{ "name": "docs", "path": "./docs", "fileSync": true }
],
"profiles": {
"e2e": {
"description": "End-to-end testing — no sync, no watchers, prod-like images",
"services": [
// Wildcard applies first
{ "name": "*", "fileSync": false, "watch": null },
// Specific overrides win
{ "name": "api", "dockerfile": "./api/Dockerfile.e2e", "manifests": "./api/k8s/e2e" },
{ "name": "web", "dockerfile": "./frontend/Dockerfile.e2e", "manifests": "./frontend/k8s/e2e" },
// Skip services the suite doesn't need
{ "name": "docs", "enabled": false }
]
}
}
}
| Override | Effect |
|---|---|
"fileSync": false (wildcard) | Pods run exactly what the image baked in — no editor activity poisons a test. |
"watch": null (wildcard) | No background rebuilds during a suite. |
"dockerfile": "./api/Dockerfile.e2e" | Prod-like image per service. |
"manifests": "./api/k8s/e2e" | E2E-tuned probes, resources, and any test-only services. |
"docs": { "enabled": false } | Don't deploy what isn't tested. |
A typical Dockerfile pair:
# api/Dockerfile.dev — fast iteration, used by base config
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]
# api/Dockerfile.e2e — prod-like, used by the e2e profile
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
ENV NODE_ENV=production
CMD ["dist/server.js"]
See the Profiles page for the full override schema.
2. Run the suite locally against the default deployment
The simplest setup: keep your dev environment up, point your test runner at the live URL.
gws up
gws status # confirm everything is healthy first
E2E_BASE_URL="https://web.<project>.local.getwebstack.dev" \
npx playwright test
This works for quick local verification but shares state with your dev work. Use a fork for anything you don't want to clobber your local DB.
3. Use a fork-with-e2e-profile per test run (recommended)
Every test run gets its own namespace, deployed with the e2e profile.
# Create the fork
gws fork e2e-run-$RUN_ID
# Bring it up with the e2e profile (no sync, prod-like images)
gws up -w e2e-run-$RUN_ID --profile e2e
# Wait for it to be healthy
gws status -w e2e-run-$RUN_ID --json | jq -e '.services | all(.healthy)'
# Run the suite against the fork (cookie selects the namespace)
NS=$(gws status -w e2e-run-$RUN_ID --json | jq -r .deploymentId)
E2E_BASE_URL="https://web.<project>.local.getwebstack.dev" \
E2E_COOKIE="gws-namespace=$NS" \
npx playwright test
# Tear it down (always, even on failure)
gws down -w e2e-run-$RUN_ID
gws delete e2e-run-$RUN_ID
Wrap the teardown in trap 'gws down -w e2e-run-$RUN_ID && gws delete e2e-run-$RUN_ID' EXIT so a failing test still cleans up. (gws delete refuses to remove a worktree whose deployment is still up; the down is required first.)
You can also pin the profile via the
GWS_PROFILEenvironment variable (GWS_PROFILE=e2e gws up …) — handy in CI shell scripts where every command should use the same overlay.
4. Mint a service token for CI
CI can't sit through an OAuth flow, so authenticate it with a service token — a project-scoped credential not tied to any user.
- In the web UI, open the project → Service Tokens → New token.
- Name it (e.g.
ci-e2e), pick an expiry (1 year is fine; you can rotate any time). - Grant the minimum permissions the pipeline needs:
can_read(always on)can_change_gws_configs— sogws fork/gws upcan apply manifests- Leave
can_change_secretsandcan_delete_projectoff unless your pipeline actually needs them
- Click Create and copy the value immediately — it's shown once.
- Store it in your CI secret manager as
GWS_TOKEN(GitHub Actions: repo Settings → Secrets and variables → Actions → New repository secret).
Authenticate the CLI with the token at the start of the job:
gws login --token "$GWS_TOKEN" # writes ~/.getwebstackrc
In ephemeral CI runners you can point GWS_AUTH_FILE at a path inside the job's temp directory so the credentials file goes away with the runner.
To rotate: mint a new token, update the CI secret, re-run the pipeline once, then revoke the old one. See Service Tokens for the full reference.
5. Wire it into CI
The same commands run in GitHub Actions, GitLab CI, Argo Workflows, or any CI that can talk to your cluster (local or remote). Sketch:
# .github/workflows/e2e.yml
env:
GWS_PROFILE: e2e # every gws command in this job uses the e2e profile
steps:
- name: Authenticate
run: gws login --token "$GWS_TOKEN"
- name: Spin up per-PR environment
run: |
gws fork pr-${{ github.event.pull_request.number }}
gws up -w pr-${{ github.event.pull_request.number }}
- name: Wait for healthy
run: |
timeout 180 bash -c '
until gws status -w pr-${{ github.event.pull_request.number }} --json \
| jq -e ".services | all(.healthy)"; do sleep 5; done
'
- name: Run E2E
env:
E2E_BASE_URL: https://web.${{ vars.GWS_PROJECT }}.local.getwebstack.dev
run: |
NS=$(gws status -w pr-${{ github.event.pull_request.number }} --json | jq -r .deploymentId)
E2E_COOKIE="gws-namespace=$NS" npx playwright test
- name: Tear down
if: always()
run: |
gws down -w pr-${{ github.event.pull_request.number }}
gws delete pr-${{ github.event.pull_request.number }}
The GetWebstack monorepo itself uses this pattern — see e2e-tests/ for a working example with Jest + Playwright.
6. Run shards in parallel
Each fork is a network- and storage-isolated namespace, so you can fan out:
for SHARD in 1 2 3 4; do
(
gws fork e2e-shard-$SHARD
gws up -w e2e-shard-$SHARD --profile e2e
NS=$(gws status -w e2e-shard-$SHARD --json | jq -r .deploymentId)
E2E_BASE_URL="https://web.<project>.local.getwebstack.dev" \
E2E_COOKIE="gws-namespace=$NS" \
npx playwright test --shard=$SHARD/4
gws down -w e2e-shard-$SHARD
gws delete e2e-shard-$SHARD
) &
done
wait
No port collisions, no shared DB, no flaky cross-talk.
7. Seed test data
Two common patterns:
Per-fork seed step — fastest to write, slowest to run:
gws up -w pr-1234 --profile e2e
gws exec -w pr-1234 api -- npm run seed:e2e
npx playwright test
Pre-seeded DB image — bake a fixture DB into a Dockerfile referenced by gws.json so every fork starts pre-loaded. Use a watch rule so the seed image rebuilds when fixtures change.
8. Debug a failing test
When a test fails, the fork is still up. Inspect it directly:
gws status -w pr-1234
gws logs -w pr-1234 -f api
gws exec -w pr-1234 api # interactive shell
gws logs -w pr-1234 -f web
Open the live URL in a real browser to repro by hand. Or, if you're using an AI agent, hand it the fork name and run /gws-debug.
Once you've identified the bug, you can either:
- Keep the fork to verify the fix interactively (re-deploy with
gws rebuild <svc> -w pr-1234) - Delete and re-fork to confirm the failure was reproducible from scratch
9. Reproduce a CI failure locally
Same fork name, same commit, same profile, same outcome:
git checkout <failing-commit>
gws fork pr-1234-debug
gws up -w pr-1234-debug --profile e2e
# run the same test command CI ran
The environment is bit-for-bit the same as CI (same manifests, same images), so the only variable left is your test code.
See also
- Dev environment — prerequisite
- Configuration profiles — full overlay schema and override rules
gws fork,gws up,gws delete,gws status,gws execgws profile— list, validate, and inspect profiles/gws-debug— let an AI agent diagnose a failing fork