Skip to main content

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 }
]
}
}
}
OverrideEffect
"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.

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_PROFILE environment 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.

  1. In the web UI, open the project → Service TokensNew token.
  2. Name it (e.g. ci-e2e), pick an expiry (1 year is fine; you can rotate any time).
  3. Grant the minimum permissions the pipeline needs:
    • can_read (always on)
    • can_change_gws_configs — so gws fork / gws up can apply manifests
    • Leave can_change_secrets and can_delete_project off unless your pipeline actually needs them
  4. Click Create and copy the value immediately — it's shown once.
  5. 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