Setup Guide
This guide walks you through installing PowerTesting, configuring its SQLite database, seeding test cases from JSON, and adding new products / validators. It is the operator-facing companion to the Concepts and User Guides — if you are bringing up a fresh environment or onboarding a new product, start here.
Architecture
Components
PowerTesting ships as two cooperating services and a JSON seed corpus:
| Component | Tech | Role |
|---|---|---|
testing-api | Python 3.11 / FastAPI / SQLAlchemy / SQLite | Owns the database. Exposes REST endpoints for products, test cases, runs, results, coverage, and user preferences. |
testing-ui | Static HTML / ES modules / Bootstrap 5 | Browser SPA. Talks to testing-api over HTTPS. Open access — no sign-in. |
testing-api/seed/ | JSON files | Source-of-truth for products, validator types, categories, and test cases. Loaded into SQLite on first start. |
Repository Layout
PowerTesting/
├── testing-api/
│ ├── powertesting_api/ # FastAPI app (config, db, models, routers, seed)
│ ├── seed/
│ │ ├── products.json
│ │ ├── validator_types.json
│ │ ├── product_categories.json
│ │ └── test_cases/
│ │ ├── powersolver/*.json
│ │ ├── powerforecast/*.json
│ │ ├── powerdecision/*.json
│ │ ├── powerai/*.json
│ │ └── powerorchestrator/*.json
│ ├── scripts/
│ │ ├── seed_db.py # CLI re-seed
│ │ ├── run_dev.sh # Local dev runner
│ │ └── smoke_test.sh # Post-deploy smoke
│ ├── data/
│ │ └── powertesting.db # SQLite file (created on first start)
│ ├── docker-compose.yml
│ ├── Dockerfile
│ └── pyproject.toml
└── testing-ui/ # Static SPA (deployed independently)
Quickstart (Docker)
The supported way to bring up the API is Docker Compose. From the repo root:
cd testing-api
docker compose up -d --build
# Verify
curl http://localhost:8090/health
# → {"status":"ok"}
# Confirm the seed loaded
curl http://localhost:8090/products | jq '.[].name'
# → "PowerSolver" "PowerForecast" "PowerDecision" "PowerAI" "PowerOrchestrator"
On first start the container creates /data/powertesting.db in the
powertesting-data named volume, runs the SQLAlchemy schema
migrations, and loads every JSON file under seed/.
Quickstart (Local Dev, no Docker)
For iterating on the API itself:
cd testing-api
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
pip install -e .
# Run the dev server (port 8090, hot-reload)
./scripts/run_dev.sh # Windows: .\scripts\run_dev.bat
# Or directly
uvicorn powertesting_api.main:app --reload --port 8090
Configuration
Environment Variables
Every setting is read by powertesting_api/config.py with the
POWERTESTING_ prefix. Defaults work for local dev; override in
docker-compose.yml or a .env file for everything else.
| Variable | Default | Purpose |
|---|---|---|
POWERTESTING_API_PORT | 8090 | HTTP port the FastAPI app binds to. |
POWERTESTING_DB_PATH | ./data/powertesting.db | Absolute path to the SQLite file. In Docker we point this at the mounted volume (/data/powertesting.db). |
POWERTESTING_ENVIRONMENT | local | Free-form label echoed by /health and stored on every run. Set to container or prod in compose. |
POWERTESTING_SEED_ON_STARTUP | true | If true, the app upserts every JSON in seed/ on each boot. Set false in prod after the initial load if you manage cases via API. |
POWERTESTING_CORS_ORIGINS | * | Comma-separated list of allowed origins. Plain string, not JSON. See the CORS section. |
POWERTESTING_BOOTSTRAP_ADMIN_USERNAME | admin | Username seeded at first start if no users exist. |
POWERTESTING_BOOTSTRAP_ADMIN_PASSWORD | empty | If set, creates the admin row on first start. Empty = "do not seed an admin." See Auth Bootstrap. |
CORS
POWERTESTING_CORS_ORIGINS is a comma-separated string — never a JSON
array. pydantic-settings would try to json.loads() a JSON
default and crash the container.
# dev: allow everything
POWERTESTING_CORS_ORIGINS=*
# prod: lock to the SPA origin
POWERTESTING_CORS_ORIGINS=https://testing.planningpowertools.com
Database
Where the DB Lives
- Local dev:
testing-api/data/powertesting.db(created on first start). - Docker:
/data/powertesting.dbinside the container, mounted from the named volumepowertesting-data.
Move the volume between hosts to migrate data; copy the .db file out for
backup. SQLite is single-file — no pg_dump or extra tooling needed.
Schema Overview
Every table is declared in powertesting_api/models.py:
| Table | Purpose |
|---|---|
products | One row per PDP product (PowerSolver, PowerForecast, …) with base URLs, supported levels, default timeout. |
validator_types | Catalogue of validators, their JSON-Schema arg shapes, and which JS module implements them. |
product_categories | Per-product category buckets used by the UI to group cases. |
test_cases | The full test corpus — one row per case, JSON columns for payload / validators / tags. |
test_runs | One row per Run (a batch of cases executed against an environment). |
test_results | One row per (run, case): status, durations, captured envelope, validation details. |
user_preferences | Sticky per-browser prefs (currently only the env switcher). |
users / auth_tokens | Operator auth for the prod-write gate (UI is open access, but a real user is needed to seed prod write actions). |
Seed-on-Startup
With POWERTESTING_SEED_ON_STARTUP=true (the default), main.py
calls load_all_seed() on app startup. This walks seed/ and
upserts every JSON file. The startup log prints a summary:
Seeded: {'products': 5, 'validator_types': 28,
'product_categories': 14, 'test_cases': 47}
Upserts are keyed: products by name, validator_types
by name, test_cases by uuid. Test-case
files without a uuid field are silently skipped — this is
deliberate, so cases created at runtime via POST /test-cases aren't
overwritten by an unrelated file.
Manual Re-seed
To re-seed without restarting (e.g. after editing a JSON file):
# From inside the container
docker exec -it powertesting-api python -m scripts.seed_db
# Or, in local dev
cd testing-api
python scripts/seed_db.py
Backup & Reset
# Backup (Docker)
docker run --rm -v powertesting-data:/data -v "$PWD":/backup \
alpine sh -c 'cp /data/powertesting.db /backup/powertesting-$(date +%F).db'
# Wipe and re-seed from scratch
docker compose down -v # removes the volume
docker compose up -d --build # fresh DB, fresh seed
down -v drops every test run and result you
have ever recorded. If you care about run history, back up first.
Seed Files
Four kinds of file live under testing-api/seed/:
products.json
One object per product. Determines what shows up in the sidebar and where runs send their HTTP calls.
[
{
"name": "PowerSolver",
"display_name": "PowerSolver",
"color_hex": "#4E79A7",
"icon": "cpu",
"local_base_url": "http://localhost:8001",
"prod_base_url": "https://api.planningpowertools.com",
"default_timeout_ms": 60000,
"supports_levels": ["L1", "L2"],
"display_order": 1,
"enabled": true
}
]
validator_types.json
Every validator the UI may reference must have a row here. product_scope
is null for generic validators (StatusValidator, FieldMatchValidator,
SchemaValidator, ResponseTimeValidator) and a product name otherwise.
{
"name": "ScoreValidator",
"description": "Validates Timefold solver score, e.g. '0hard/-5soft'.",
"product_scope": "PowerSolver",
"arg_schema": {
"type": "object",
"properties": {
"pattern": { "type": "string" },
"feasible": { "type": "boolean" }
},
"required": ["pattern"]
},
"implementation_module": "products/powersolver.js"
}
product_categories.json
Per-product category buckets used to group cases in the UI (e.g. feasibility, regression, performance). Optional — cases also work without a declared category, though the UI groups them under "Uncategorised".
test_cases/<product>/*.json
One JSON file per test case, one folder per product. The seed loader walks the tree
and upserts each file by its uuid.
Test Case Schema
Field Reference
| Field | Type | Required | Notes |
|---|---|---|---|
uuid | string (UUID) | yes (for seeding) | Stable id. Re-using the same uuid upserts; missing uuid means the file is skipped by the seed loader. |
name | string | yes | Human-readable id (shown in the UI list). |
product | string | yes | Must match a row in products. |
level | L1 / L2 / AI_OWN / ORCH_OWN | yes | Must be in the product's supports_levels. |
category | string | yes | Free-form; should match a row in product_categories if you want UI grouping. |
endpoint_template | string | yes | Use {base} as a placeholder for the env-resolved base URL: {base}/api/solve. |
http_method | string | no (default POST) | Any HTTP verb. |
request_payload | object | yes | Body sent to the endpoint. Echoed unchanged. |
request_headers | object | no | Extra headers; auth is added by the run-controller. |
validator_refs | array | yes | List of { "name": "<ValidatorName>", "args": { … } }. Names must exist in validator_types. |
tags | array<string> | no | Used by UI filters (smoke, regression, …). |
environments | array<string> | no | If empty / omitted, the case is environment-agnostic. Otherwise the run-controller only includes it for matching envs. |
timeout_ms | integer | no (default 60000) | Per-case override of the product's default_timeout_ms. |
priority | integer | no (default 1) | Higher = listed first. |
enabled | boolean | no (default true) | Soft-disable without deleting. |
Worked Example
A real PowerSolver smoke case — this is what
seed/test_cases/powersolver/mock-feasible.json looks like:
{
"uuid": "11111111-1111-1111-1111-111111111111",
"name": "mock-feasible",
"product": "PowerSolver",
"level": "L1",
"category": "feasibility",
"endpoint_template": "{base}/api/solve",
"http_method": "POST",
"request_payload": { "problem": "mock" },
"validator_refs": [
{ "name": "StatusValidator", "args": { "expected": "success" } },
{ "name": "ScoreValidator", "args": { "maxSoftPenalty": 100 } }
],
"tags": ["smoke"],
"environments": ["local"]
}
Add a Test Case
You have two options:
1. File-based (recommended for cases you want under version control).
# Create the file
seed/test_cases/powersolver/my-new-case.json
# Generate a UUID and paste it into the "uuid" field
python -c "import uuid; print(uuid.uuid4())"
# Re-seed
docker exec -it powertesting-api python -m scripts.seed_db
# or restart the container — POWERTESTING_SEED_ON_STARTUP=true picks it up
2. Runtime (recommended for ad-hoc experimentation).
curl -X POST https://testing-api.planningpowertools.com/test-cases \
-H "Content-Type: application/json" \
-d @my-new-case.json
Runtime-created cases live only in the DB — they are not written back to disk. Promote them to the seed corpus when you want them version-controlled.
Add a Product
- Add a row to
seed/products.jsonwith name, base URLs, supported levels, and display order. - (Optional) Add product-scoped validators to
seed/validator_types.json. - (Optional) Add the categories you'll use to
seed/product_categories.json. - Re-seed the DB (restart or run
scripts/seed_db.py). - On the UI side, create
js/products/<new-product>.jsexporting a module with the product's adapter and any product-specific validators, then register it injs/main.js. - Add at least one test case in
seed/test_cases/<new-product>/so the product tab isn't empty.
Add a Validator Type
A validator has two halves: the metadata row and the JS implementation.
Metadata — add to seed/validator_types.json:
{
"name": "MyCustomValidator",
"description": "Asserts foo equals bar in the response payload.",
"product_scope": "PowerSolver",
"arg_schema": {
"type": "object",
"properties": { "expected": { "type": "string" } },
"required": ["expected"]
},
"implementation_module": "products/powersolver.js"
}
Implementation — in the referenced JS module:
export const myCustomValidator = {
name: 'MyCustomValidator',
scope: 'PowerSolver',
validate(envelope, args) {
const actual = envelope.result?.foo;
const ok = actual === args.expected;
return {
ok,
message: ok
? `OK (foo == ${args.expected})`
: `Expected foo=${args.expected}, got ${actual}`,
};
},
};
Then re-seed the DB (so the metadata row is registered) and reload the UI (so the JS implementation is picked up). Coverage view will now show the validator under its product scope.
Auth Bootstrap
The UI itself is open access, but operator-level write actions (run prod, set prod prefs) require a token. To create the first admin:
# In docker-compose.yml or a .env file
POWERTESTING_BOOTSTRAP_ADMIN_USERNAME=admin
POWERTESTING_BOOTSTRAP_ADMIN_PASSWORD=<a strong password>
# Then start the container — main.py calls bootstrap_admin_if_empty()
docker compose up -d
The password is hashed (stdlib scrypt) before being written. The
bootstrap is a no-op if any user already exists or if the password env var is empty
— this is deliberate so a default like change-me never ships.
Verifying Setup
The repository ships a smoke script that exercises the major endpoints:
cd testing-api
./scripts/smoke_test.sh http://localhost:8090
# Expected:
# ✓ /health
# ✓ /products (5 rows)
# ✓ /validator-types (28 rows)
# ✓ /test-cases (≥1 row)
# ✓ /test-runs (POST, then GET)
If any line fails, jump to Troubleshooting.
Production Deploy
The cloud setup running at testing-api.planningpowertools.com is:
- Hetzner host running Docker.
docker-compose.ymlfromtesting-api/, with prod env overrides.- Caddy / nginx in front terminating TLS and forwarding to
:8090. powertesting-datanamed volume mounted at/data— this is the only stateful dependency.
# prod env overrides (in deploy .env)
POWERTESTING_ENVIRONMENT=prod
POWERTESTING_SEED_ON_STARTUP=true # safe: upserts only
POWERTESTING_CORS_ORIGINS=https://testing.planningpowertools.com
POWERTESTING_BOOTSTRAP_ADMIN_PASSWORD=<set once, then unset>
The static testing-ui is published separately to
testing.planningpowertools.com. It reads the API base from
window.POWERTESTING_API_URL in index.html — update that
line if the API hostname ever changes.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Container restart-loops, log shows json.decoder.JSONDecodeError | POWERTESTING_CORS_ORIGINS set as a JSON array. | Use a comma-separated string, e.g. https://a.com,https://b.com. |
/products returns [] | Seeding is off, or seed/ wasn't copied into the image. | Set POWERTESTING_SEED_ON_STARTUP=true; rebuild with --no-cache. |
| Test case file ignored by re-seed | Missing uuid field. | Add a UUID; the loader skips uuid-less files on purpose. |
| Run errors with "Validator not registered" | validator_refs[].name doesn't match any row in validator_types. | Add the row to validator_types.json or fix the name in the case. |
| Run errors with "Product 'X' does not support level 'L2'" | Case's level isn't in the product's supports_levels. | Either change the case's level or add the level to the product row in products.json. |
| UI shows every product as UNKNOWN | CORS blocked, or the product API isn't running. | Confirm the product container is up; widen POWERTESTING_CORS_ORIGINS; check the product's own CORS for the SPA origin. |
| DB file missing on host | Volume not mounted, or path mismatch with POWERTESTING_DB_PATH. | Confirm volumes: entry in compose; POWERTESTING_DB_PATH=/data/powertesting.db. |