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>.<org>.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>.<org>.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 Tokens → New 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 }}.<org>.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, with a working e2e-tests/ suite built on 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>.<org>.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​