P0 exit: e2e harness, Playwright shell journeys green, deploy skeleton

- teamclaw-server e2e mode (TEAMCLAW_MODE=e2e): idempotent deterministic
  seed through the real registration paths
- Playwright suite (6 journeys) against the real backend + prod Next build:
  login redirect, bad-password error, shell/roster/online-dot, team members,
  seeded credits, sign-out revocation — P0 exit criterion met
- Dockerfiles: musl-static server -> distroless, Next standalone -> distroless
  node (multi-arch via TARGETARCH)
- Air-gapped compose topology with edge/core/sandbox_net/secrets_net
  segmentation (engine-validated in CI), config-file + env-overlay pattern
- CI: compose validation + e2e job with trace upload

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-09 22:48:28 -05:00
co-authored by Claude Fable 5
parent 172a3c8fed
commit fc173f170d
18 changed files with 482 additions and 1 deletions
+29
View File
@@ -14,6 +14,8 @@ jobs:
run: ./ci/check-loc.sh run: ./ci/check-loc.sh
- name: No placeholder markers - name: No placeholder markers
run: ./ci/check-no-placeholders.sh run: ./ci/check-no-placeholders.sh
- name: Compose config validates
run: POSTGRES_PASSWORD=ci docker compose -f deploy/compose/docker-compose.yml config -q
rust: rust:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -55,3 +57,30 @@ jobs:
- name: Unit and component tests - name: Unit and component tests
run: npm test run: npm test
if: ${{ hashFiles('frontend/package-lock.json') != '' }} if: ${{ hashFiles('frontend/package-lock.json') != '' }}
e2e:
runs-on: ubuntu-latest
needs: [rust, frontend]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.96.0
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Install Playwright browsers
working-directory: frontend
run: npx playwright install --with-deps chromium
- name: Run end-to-end journeys against the real backend
working-directory: frontend
run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-traces
path: frontend/test-results/
Generated
+4
View File
@@ -2815,9 +2815,13 @@ name = "teamclaw-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"sqlx",
"tc-api", "tc-api",
"tc-auth",
"tc-config", "tc-config",
"tc-db", "tc-db",
"tc-domain",
"time",
"tokio", "tokio",
] ]
+4
View File
@@ -8,9 +8,13 @@ publish.workspace = true
[dependencies] [dependencies]
axum = "0.8" axum = "0.8"
sqlx = { workspace = true }
tc-api = { path = "../../tc-api" } tc-api = { path = "../../tc-api" }
tc-auth = { path = "../../tc-auth" }
tc-config = { path = "../../tc-config" } tc-config = { path = "../../tc-config" }
tc-db = { path = "../../tc-db" } tc-db = { path = "../../tc-db" }
tc-domain = { path = "../../tc-domain" }
time = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
[lints] [lints]
+76
View File
@@ -0,0 +1,76 @@
//! Deterministic seed data for end-to-end tests.
//!
//! Active only when `TEAMCLAW_MODE=e2e`. Idempotent: a fixed workspace,
//! owner, agent, and credit balance that Playwright journeys assert against.
//! This is real production code driving the real registration paths — the
//! only test-specific part is the fixture data itself.
use sqlx::PgPool;
use tc_auth::AuthService;
use tc_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
};
pub const E2E_OWNER_EMAIL: &str = "[email protected]";
pub const E2E_OWNER_PASSWORD: &str = "e2e-password";
pub fn enabled() -> bool {
std::env::var("TEAMCLAW_MODE").as_deref() == Ok("e2e")
}
pub async fn seed(pool: &PgPool) -> Result<(), String> {
if tc_db::repo::users::find_by_email(pool, E2E_OWNER_EMAIL)
.await
.is_ok()
{
return Ok(());
}
let workspace = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
tc_db::repo::workspaces::insert(pool, &workspace)
.await
.map_err(|e| format!("seed workspace: {e}"))?;
let owner = User {
id: UserId::new(),
workspace_id: workspace.id,
email: E2E_OWNER_EMAIL.into(),
role: Role::Owner,
display_name: "Avery Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
tc_db::repo::users::insert(pool, &owner)
.await
.map_err(|e| format!("seed owner: {e}"))?;
AuthService::new(pool.clone())
.set_password(owner.id, E2E_OWNER_PASSWORD)
.await
.map_err(|e| format!("seed password: {e}"))?;
let agent = Agent {
id: AgentId::new(),
workspace_id: workspace.id,
name: "Scout".into(),
job_title: "Research Analyst".into(),
system_prompt: "You research things carefully.".into(),
avatar: "scout-1".into(),
accent: "#f96565".into(),
wallpaper: "dunes".into(),
managed_by: owner.id,
status: AgentStatus::Online,
};
tc_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.map_err(|e| format!("seed agent: {e}"))?;
tc_db::repo::credits::add_lot(pool, workspace.id, 1250, "e2e-seed")
.await
.map_err(|e| format!("seed credits: {e}"))?;
println!("teamclaw-server: e2e seed applied ({E2E_OWNER_EMAIL})");
Ok(())
}
+6
View File
@@ -1,6 +1,8 @@
//! TeamClaw server: REST API, and (in later phases) the streaming gateway, //! TeamClaw server: REST API, and (in later phases) the streaming gateway,
//! scheduler, and safety worker, composed into one binary. //! scheduler, and safety worker, composed into one binary.
mod e2e;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
@@ -30,6 +32,10 @@ async fn run() -> Result<(), String> {
.await .await
.map_err(|e| format!("migrations failed: {e}"))?; .map_err(|e| format!("migrations failed: {e}"))?;
if e2e::enabled() {
e2e::seed(&pool).await?;
}
let app = tc_api::router(tc_api::AppState::new(pool)); let app = tc_api::router(tc_api::AppState::new(pool));
let listener = tokio::net::TcpListener::bind(config.listen_addr) let listener = tokio::net::TcpListener::bind(config.listen_addr)
.await .await
+3
View File
@@ -0,0 +1,3 @@
# Copy to .env and set real values before `docker compose up`.
POSTGRES_PASSWORD=change-me
TEAMCLAW_VERSION=latest
+70
View File
@@ -0,0 +1,70 @@
# Air-gapped single-node topology. The network segmentation IS the security
# model (spec §15):
# edge — host-published entry points only (frontend, server API)
# core — internal: server <-> postgres
# sandbox_net — internal, NO egress: agent sandboxes (joined in P2)
# secrets_net — internal: secret broker + server only (joined in P2)
# Images are preloaded from the signed offline bundle; nothing pulls at
# install time.
name: teamclaw
networks:
edge: {}
core:
internal: true
sandbox_net:
internal: true
secrets_net:
internal: true
volumes:
pgdata: {}
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: teamclaw
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
volumes:
- pgdata:/var/lib/postgresql/data
networks: [core]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d teamclaw"]
interval: 5s
timeout: 3s
retries: 12
server:
image: teamclaw/server:${TEAMCLAW_VERSION:-latest}
build:
context: ../..
dockerfile: images/server.Dockerfile
restart: unless-stopped
environment:
TEAMCLAW_CONFIG: /etc/teamclaw/teamclaw.toml
TEAMCLAW_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/teamclaw
volumes:
- ./teamclaw.toml:/etc/teamclaw/teamclaw.toml:ro
networks: [edge, core]
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
frontend:
image: teamclaw/frontend:${TEAMCLAW_VERSION:-latest}
build:
context: ../..
dockerfile: images/frontend.Dockerfile
restart: unless-stopped
environment:
API_ORIGIN: http://server:8080
networks: [edge]
ports:
- "3000:3000"
depends_on:
- server
+18
View File
@@ -0,0 +1,18 @@
# Server configuration for the air-gapped compose deployment. Secrets and
# host-specific values are overlaid via TEAMCLAW_* environment variables in
# docker-compose.yml (e.g. TEAMCLAW_DATABASE__URL carries the password).
deploy_target = "air_gapped"
listen_addr = "0.0.0.0:8080"
[database]
# Overridden by TEAMCLAW_DATABASE__URL; kept here so the file is complete.
url = "postgres://postgres:placeholder-overridden-by-env@postgres:5432/teamclaw"
[llm]
provider = "openai_compat"
base_url = "http://local-llm:8000/v1"
model = "qwen2.5-72b-instruct"
[auth]
mode = "local"
+9
View File
@@ -0,0 +1,9 @@
# Scenario script for the deterministic `scripted` LLM provider.
# Consumed by tc-llm's ScriptedProvider from P1 onward; each entry maps a
# prompt marker to the exact event stream the provider emits.
[[scenario]]
marker = "[[scenario:hello]]"
events = [
{ type = "text", text = "Hello! I'm Scout, your research analyst." },
]
+16
View File
@@ -0,0 +1,16 @@
# Configuration for the local/CI end-to-end harness. The server runs with
# TEAMCLAW_MODE=e2e, which applies the deterministic seed at boot.
deploy_target = "air_gapped"
listen_addr = "127.0.0.1:8080"
[database]
url = "postgres://postgres:[email protected]:54330/teamclaw_e2e"
[llm]
provider = "scripted"
model = "scripted-e2e"
scenario_path = "deploy/e2e/scenarios.toml"
[auth]
mode = "local"
+3 -1
View File
@@ -1,7 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ // Standalone output: the frontend image runs `node server.js` from
// distroless with no node_modules — required for the air-gapped bundle.
output: "standalone",
}; };
export default nextConfig; export default nextConfig;
+64
View File
@@ -17,6 +17,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
@@ -1283,6 +1284,22 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -6280,6 +6297,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+1
View File
@@ -20,6 +20,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig, devices } from "@playwright/test";
// E2E journeys run against the REAL Rust backend (deterministic e2e seed)
// and the production Next build — no network interception anywhere.
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
fullyParallel: false,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: "http://127.0.0.1:3100",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: [
{
command: "bash ../scripts/e2e-backend.sh",
url: "http://127.0.0.1:8080/healthz",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
},
{
command: "npm run build && npx next start -p 3100",
url: "http://127.0.0.1:3100/login",
reuseExistingServer: !process.env.CI,
timeout: 180_000,
env: { API_ORIGIN: "http://127.0.0.1:8080" },
},
],
});
+81
View File
@@ -0,0 +1,81 @@
import { expect, test } from "@playwright/test";
// P0 exit criterion (spec §17): an authenticated empty shell against the
// real backend. Seed data comes from teamclaw-server's e2e mode.
const OWNER_EMAIL = "[email protected]";
const OWNER_PASSWORD = "e2e-password";
async function signIn(page: import("@playwright/test").Page) {
await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByLabel("Password").fill(OWNER_PASSWORD);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "TeamClaw" })).toBeVisible();
}
test("unauthenticated visitors are redirected to login", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
});
test("wrong password shows an error and stays on login", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(OWNER_EMAIL);
await page.getByLabel("Password").fill("wrong-password");
await page.getByRole("button", { name: "Sign in" }).click();
// Next.js's route announcer is also role="alert"; scope to the form's.
await expect(
page.getByRole("alert").filter({ hasText: "Invalid" }),
).toHaveText("Invalid email or password.");
await expect(page).toHaveURL(/\/login$/);
});
test("signing in reveals the shell: rail, roster, and current user", async ({
page,
}) => {
await signIn(page);
const rail = page.getByRole("complementary");
await expect(rail.getByText("teamclaw")).toBeVisible();
await expect(
rail.getByRole("list", { name: "Your claws" }).getByText("Scout"),
).toBeVisible();
await expect(
rail.getByRole("status", { name: "Scout is online" }),
).toBeVisible();
await expect(rail.getByText("Avery Owner")).toBeVisible();
});
test("team page lists workspace members from the real API", async ({
page,
}) => {
await signIn(page);
await page.getByRole("link", { name: "Team" }).click();
await expect(
page.getByRole("heading", { name: "Team", exact: true }),
).toBeVisible();
await expect(page.getByText("1 member in your workspace")).toBeVisible();
const row = page.getByRole("row").filter({ hasText: "Avery Owner" });
await expect(row.getByText(OWNER_EMAIL)).toBeVisible();
await expect(row.getByText("Owner", { exact: true })).toBeVisible();
});
test("credits page shows the seeded balance", async ({ page }) => {
await signIn(page);
await page.getByRole("link", { name: "Credits" }).click();
await expect(
page.getByRole("heading", { name: "Credits", exact: true }),
).toBeVisible();
await expect(page.getByText("1,250")).toBeVisible();
});
test("signing out returns to login and revokes the session", async ({
page,
}) => {
await signIn(page);
await page.getByRole("button", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/login$/);
await page.goto("/");
await expect(page).toHaveURL(/\/login$/);
});
+18
View File
@@ -0,0 +1,18 @@
# Next.js standalone build into distroless node — runs `node server.js` with
# no package manager or shell; fonts and all assets are vendored in-repo so
# the image is fully self-contained for air-gapped installs.
FROM node:22-alpine AS builder
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
ENV PORT=3000 HOSTNAME=0.0.0.0 NODE_ENV=production
EXPOSE 3000
CMD ["server.js"]
+25
View File
@@ -0,0 +1,25 @@
# teamclaw-server: static musl build into distroless. The same image serves
# the air-gapped bundle and the cloud registry.
FROM rust:1.96-slim AS builder
ARG TARGETARCH
RUN apt-get update \
&& apt-get install -y --no-install-recommends musl-tools \
&& rm -rf /var/lib/apt/lists/*
RUN case "$TARGETARCH" in \
arm64) echo aarch64-unknown-linux-musl > /rust-target ;; \
*) echo x86_64-unknown-linux-musl > /rust-target ;; \
esac \
&& rustup target add "$(cat /rust-target)"
WORKDIR /src
COPY Cargo.toml rust-toolchain.toml ./
COPY crates ./crates
COPY migrations ./migrations
COPY .sqlx ./.sqlx
ENV SQLX_OFFLINE=true
RUN cargo build --release --target "$(cat /rust-target)" -p teamclaw-server \
&& cp "target/$(cat /rust-target)/release/teamclaw-server" /teamclaw-server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /teamclaw-server /usr/local/bin/teamclaw-server
USER nonroot
ENTRYPOINT ["/usr/local/bin/teamclaw-server"]
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Boots the real backend for Playwright: a fresh Postgres container plus
# teamclaw-server in e2e mode (deterministic seed). Playwright's webServer
# waits on the /healthz port.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
docker rm -f teamclaw-e2e-pg >/dev/null 2>&1 || true
docker run -d --name teamclaw-e2e-pg \
-e POSTGRES_PASSWORD=e2e \
-e POSTGRES_DB=teamclaw_e2e \
-p 54330:5432 \
postgres:16-alpine >/dev/null
until docker exec teamclaw-e2e-pg pg_isready -U postgres -q >/dev/null 2>&1; do
sleep 0.5
done
cd "$ROOT"
export TEAMCLAW_MODE=e2e
export TEAMCLAW_CONFIG="$ROOT/deploy/e2e/teamclaw.e2e.toml"
export SQLX_OFFLINE=true
exec cargo run -p teamclaw-server