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:
co-authored by
Claude Fable 5
parent
172a3c8fed
commit
fc173f170d
@@ -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
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Copy to .env and set real values before `docker compose up`.
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
TEAMCLAW_VERSION=latest
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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." },
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
@@ -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;
|
||||||
|
|||||||
Generated
+64
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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$/);
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
Executable
+24
@@ -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
|
||||||
Reference in New Issue
Block a user