diff --git a/.gitignore b/.gitignore index 6140377..465bd67 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,13 @@ __pycache__/ .venv/ venv/ +# Node +node_modules/ + +# Built library bundles (regenerate with `pnpm build` in the package). +# The dist is what a container boots; it is an artifact, not source. +dist/ + # Generated runnable instance (entirely gitignored - regenerate with build.py) gen/ diff --git a/CLAUDE.md b/CLAUDE.md index 0198df0..9c4b6cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,20 +51,19 @@ spr/ │ └── amar/ # Amar room config │ ├── config.json │ ├── data/ -│ ├── artery/ # Amar-specific (merged into output) -│ │ └── shunts/amar/ -│ ├── atlas/ # Amar-specific books -│ │ └── books/ -│ ├── station/ # Amar-specific tools config -│ │ └── tools/datagen/ +│ ├── soleprint/ # Room overlay — merged over soleprint/ on build +│ │ ├── artery/ # room shunts, pulses +│ │ │ └── shunts/amar/ +│ │ ├── atlas/ # room books +│ │ │ └── books/ +│ │ ├── station/ # room tool configs +│ │ │ └── tools/datagen/ +│ │ └── nginx/ +│ ├── ctrl/ # Room lifecycle scripts (copied into gen//) │ ├── link/ # Bridge to managed app -│ ├── soleprint/ # Soleprint docker config -│ ├── databrowse/ -│ ├── tester/ -│ ├── monitors/ -│ └── models/ +│ └── amar/ # The managed app itself │ -├── ctrl/ # Build/run scripts +├── ctrl/ # Build/run scripts (see Build & Run) │ └── gen/ # Built instances (gitignored) ├── standalone/ @@ -100,26 +99,38 @@ Each room in `cfg/` has: - `config.json` - Framework branding/terminology - `data/` - Data files (veins.json, shunts.json, etc.) -Room-specific system configs (merged into output): -- `artery/` - Room-specific shunts, pulses -- `atlas/` - Room-specific books -- `station/` - Room-specific tool configs (datagen, tester tests, etc.) +Room-specific system configs live under `cfg//soleprint/` and are merged over +the core `soleprint/` tree at build time: +- `soleprint/artery/` - Room-specific shunts, pulses +- `soleprint/atlas/` - Room-specific books +- `soleprint/station/` - Room-specific tool configs (datagen generators, tester tests) + +A room's own lifecycle scripts live in `cfg//ctrl/` and land in +`gen//ctrl/` — that is what `make start ` dispatches to. ## Build & Run +`make` is the front door — one target per `ctrl/` script, with the subcommand as an +argument (`make cluster up`, not `make cluster-up`). The logic lives in the scripts, +never in the Makefile. `make help` lists every target. + ```bash -# Build -python build.py # -> gen/standalone/ -python build.py --cfg amar # -> gen/amar/ -python build.py --all # -> all rooms +make # = make help +make build [room|all|models] # -> gen// (default: standalone) +make start [room] [-d] # dispatches to gen//ctrl/start.sh +make stop [room] +make cluster [up|down|status] # the shared `spr` kind cluster +make component [list|sync|watch|publish|diff] +make deploy [--build|--sync-only] +``` -# Run bare-metal -cd gen/standalone && python run.py +Every script stays runnable on its own — the standalone rule holds: -# Using ctrl scripts -./ctrl/build.sh [room] -./ctrl/start.sh [room] [-d] -./ctrl/stop.sh [room] +```bash +python build.py --cfg amar # -> gen/amar/ +cd gen/standalone && python run.py # bare-metal +./ctrl/kind-up.sh # still works directly +cd gen/ && ./ctrl/start.sh # each room owns its lifecycle scripts ``` ## Adding a New Room @@ -129,12 +140,12 @@ mkdir -p cfg/newroom/data cp cfg/standalone/config.json cfg/newroom/ cp -r cfg/standalone/data/* cfg/newroom/data/ -# Add room-specific configs as needed: -# cfg/newroom/artery/shunts/... -# cfg/newroom/atlas/books/... -# cfg/newroom/station/tools/... +# Add room-specific configs as needed (note the soleprint/ overlay level): +# cfg/newroom/soleprint/artery/shunts/... +# cfg/newroom/soleprint/atlas/books/... +# cfg/newroom/soleprint/station/tools/datagen/.py -python build.py --cfg newroom +make build newroom ``` ## Ports diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a15f344 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +# Thin control Makefile — one target per ctrl/ script, and the subcommand is an +# argument rather than a second target: `make cluster down`, not `make cluster-down`. +# +# The logic lives in the scripts, never here. Each target maps to exactly one +# file, and that file holds the variants: +# +# make cluster up -> ctrl/cluster.sh up +# make build amar -> ctrl/build.sh amar +# +# Bare words pass straight through. Anything starting with a dash would be +# swallowed by make itself, so pass those via ARGS instead: +# +# make component ARGS="publish soleprint-ui /tmp/out --dist" +# make deploy ARGS="--build" +# +# Every script stays runnable on its own (./ctrl/kind-up.sh still works, and each +# built room keeps its own gen//ctrl/*.sh) — the standalone rule holds, and +# this only saves typing. +# +# Start with: make build && make start + +# The room to act on when none is named. Rooms live in cfg/ and build into gen/. +ROOM ?= standalone +PYTHON ?= python3 +export PYTHON + +# Words after the target become the script's subcommand. Make would otherwise +# treat them as goals of their own, so each gets a no-op rule. +ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifneq ($(ARGS),) +$(eval $(ARGS):;@:) +endif + +.DEFAULT_GOAL := help +.PHONY: help build start stop cluster deploy component + +help: ## list targets + @grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 + +# ── rooms ────────────────────────────────────────────────────────────────── + +build: ## build a room into gen/ [|all|models] + bash ctrl/build.sh $(or $(ARGS),$(ROOM)) + +start: ## run a built room [] [-d] [--build] + bash ctrl/start.sh $(or $(ARGS),$(ROOM)) + +stop: ## stop a running room [] + bash ctrl/stop.sh $(or $(ARGS),$(ROOM)) + +# ── cluster ──────────────────────────────────────────────────────────────── + +cluster: ## shared kind cluster [up|down|status] (default status) + bash ctrl/cluster.sh $(or $(ARGS),status) + +# ── distribution ─────────────────────────────────────────────────────────── + +component: ## publish components [list|sync|watch|publish|diff] + $(PYTHON) ctrl/spr.py $(or $(ARGS),list) + +deploy: ## push standalone to the server [--build|--sync-only] + bash ctrl/deploy.sh $(ARGS) diff --git a/build.py b/build.py index 031c334..d73dd8e 100644 --- a/build.py +++ b/build.py @@ -419,7 +419,11 @@ def main(): elif args.all: build(SPR_ROOT / "gen" / "standalone", None) for room in (SPR_ROOT / "cfg").iterdir(): - if room.is_dir() and room.name not in ("__pycache__", "standalone"): + # cfg/ is itself a git repo and rooms may carry dot-dirs — skip them, + # or --all tries to build ".git" as a room. + if room.name.startswith(".") or room.name == "__pycache__": + continue + if room.is_dir() and room.name != "standalone": build(SPR_ROOT / "gen" / room.name, room.name) else: if args.output: diff --git a/ctrl/build.sh b/ctrl/build.sh new file mode 100755 index 0000000..88603c7 --- /dev/null +++ b/ctrl/build.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Build a room into gen/ — thin wrapper over build.py. +# +# Usage: +# ./ctrl/build.sh # standalone -> gen/standalone/ +# ./ctrl/build.sh amar # amar -> gen/amar/ +# ./ctrl/build.sh all # every room under cfg/ +# ./ctrl/build.sh models # only regenerate models +# +# build.py holds the logic; this exists so `make build` has exactly one +# script to call, and so the room name is a plain argument. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +cd "$ROOT_DIR" + +PYTHON="${PYTHON:-python3}" +ROOM="${1:-standalone}" + +case "$ROOM" in + all) exec "$PYTHON" build.py --all ;; + models) exec "$PYTHON" build.py --models ;; +esac + +if [[ ! -d "cfg/$ROOM" ]]; then + echo "No such room: cfg/$ROOM" >&2 + echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2 + exit 1 +fi + +# standalone is build.py's default and takes no --cfg +if [[ "$ROOM" == "standalone" ]]; then + exec "$PYTHON" build.py +fi + +exec "$PYTHON" build.py --cfg "$ROOM" diff --git a/ctrl/cluster.sh b/ctrl/cluster.sh new file mode 100755 index 0000000..1511efc --- /dev/null +++ b/ctrl/cluster.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# The shared `spr` kind cluster [up|down|status] (default status). +# +# Usage: +# ./ctrl/cluster.sh up # create the cluster (no-op if it exists) +# ./ctrl/cluster.sh down # delete it (drops every room's namespace) +# ./ctrl/cluster.sh status # what's running on it +# +# One target, one script — the variants live here. The kind-*.sh files stay +# exactly as they are and remain runnable on their own; this only dispatches. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +case "${1:-status}" in + up) exec "$SCRIPT_DIR/kind-up.sh" ;; + down) exec "$SCRIPT_DIR/kind-down.sh" ;; + status) exec "$SCRIPT_DIR/kind-status.sh" ;; + *) + echo "Unknown subcommand: $1" >&2 + echo "Usage: cluster.sh [up|down|status]" >&2 + exit 1 + ;; +esac diff --git a/ctrl/spr.py b/ctrl/spr.py index a91a4f8..37e3e46 100755 --- a/ctrl/spr.py +++ b/ctrl/spr.py @@ -11,6 +11,7 @@ Usage: python ctrl/spr.py sync soleprint-ui ~/wdir/unt/ui/framework python ctrl/spr.py watch soleprint-ui ~/wdir/unt/ui/framework # ctrl+c to stop python ctrl/spr.py publish soleprint-ui ~/wdir/mpr/ui/framework + python ctrl/spr.py publish soleprint-ui /tmp/out --dist # built bundle only python ctrl/spr.py diff soleprint-ui ~/wdir/mpr/ui/framework """ @@ -194,6 +195,37 @@ def cmd_list(args): log.info(" %s %s v%-10s %s", f"{name:<25}", f"{comp_type:<5}", version, entry["path"]) +def publish_dist(source, dest): + """Copy only the built bundle — dist/ plus the manifest and any docs. + + The artifact-only form: a consumer gets something that runs, not the + sources. Deliberately NOT a variant of copy_tree, which walks the whole + tree and strips dist; here dist is the entire point. + """ + dist = source / "dist" + if not dist.is_dir() or not any(dist.iterdir()): + log.error("no build at %s", dist) + log.info("build it first: cd %s && pnpm build", source) + sys.exit(1) + + count = 0 + dest.mkdir(parents=True, exist_ok=True) + for item in dist.rglob("*"): + if item.is_file(): + target = dest / "dist" / item.relative_to(dist) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + count += 1 + + # The manifest travels too, or the bundle's entry points aren't resolvable. + for name in ("package.json", "README.md", "LICENSE"): + if (source / name).is_file(): + shutil.copy2(source / name, dest / name) + count += 1 + + return count + + def cmd_publish(args): registry = load_registry() comp_type, source = resolve_component(registry, args.component) @@ -202,12 +234,17 @@ def cmd_publish(args): if dest.exists(): shutil.rmtree(dest) - count = copy_tree(source, dest) - write_stamp(dest, args.component, comp_type, source, "published") + if args.dist: + count = publish_dist(source, dest) + mode = "published-dist" + else: + count = copy_tree(source, dest) + mode = "published" + write_stamp(dest, args.component, comp_type, source, mode) version = get_version(comp_type, source) sha = get_sha() - log.info("%s v%s (%s) -> %s (%d files)", args.component, version, sha, dest, count) + log.info("%s v%s (%s) -> %s (%d files, %s)", args.component, version, sha, dest, count, mode) def cmd_sync(args): @@ -306,6 +343,12 @@ def main(): p = sub.add_parser(cmd) p.add_argument("component", help="component name") p.add_argument("dest", help="target folder path") + if cmd == "publish": + p.add_argument( + "--dist", + action="store_true", + help="ship only the built bundle (dist/ + manifest), not the sources", + ) p = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)") p.add_argument("component", help="component name") diff --git a/ctrl/start.sh b/ctrl/start.sh new file mode 100755 index 0000000..03f9002 --- /dev/null +++ b/ctrl/start.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Start a built room by dispatching to its own ctrl/start.sh in gen/. +# +# Usage: +# ./ctrl/start.sh # standalone, foreground +# ./ctrl/start.sh amar -d # amar, detached +# ./ctrl/start.sh sample --build # flags pass straight through +# +# Every room ships its own start script (gen//ctrl/start.sh) and stays +# runnable on its own — this only saves cd'ing there and picks the default room. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +ROOM="standalone" +if [[ $# -gt 0 && "$1" != -* ]]; then + ROOM="$1" + shift +fi + +ROOM_DIR="$ROOT_DIR/gen/$ROOM" + +if [[ ! -d "$ROOM_DIR" ]]; then + echo "Room '$ROOM' is not built — run: ./ctrl/build.sh $ROOM" >&2 + exit 1 +fi + +if [[ ! -x "$ROOM_DIR/ctrl/start.sh" ]]; then + echo "No start script at gen/$ROOM/ctrl/start.sh" >&2 + echo "(rebuild the room, or start it by hand from $ROOM_DIR)" >&2 + exit 1 +fi + +exec "$ROOM_DIR/ctrl/start.sh" "$@" diff --git a/ctrl/stop.sh b/ctrl/stop.sh new file mode 100755 index 0000000..78a4230 --- /dev/null +++ b/ctrl/stop.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Stop a running room by dispatching to its own ctrl/stop.sh in gen/. +# +# Usage: +# ./ctrl/stop.sh # standalone +# ./ctrl/stop.sh amar # a named room +# +# Mirror of ctrl/start.sh — the room's own script does the work. +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +ROOM="standalone" +if [[ $# -gt 0 && "$1" != -* ]]; then + ROOM="$1" + shift +fi + +ROOM_DIR="$ROOT_DIR/gen/$ROOM" + +if [[ ! -x "$ROOM_DIR/ctrl/stop.sh" ]]; then + echo "No stop script at gen/$ROOM/ctrl/stop.sh — nothing to stop." >&2 + exit 0 +fi + +exec "$ROOM_DIR/ctrl/stop.sh" "$@" diff --git a/soleprint/common/ui/package.json b/soleprint/common/ui/package.json index 242e0e4..a40486e 100644 --- a/soleprint/common/ui/package.json +++ b/soleprint/common/ui/package.json @@ -4,7 +4,22 @@ "private": true, "type": "module", "main": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./style.css": "./dist/style.css", + "./theme.css": "./src/theme.css", + "./tokens.css": "./src/tokens.css", + "./base.css": "./src/base.css", + "./dist/*": "./dist/*", + "./src/*": "./src/*" + }, + "files": [ + "dist", + "src" + ], "scripts": { + "build": "vite build", + "build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types", "test": "vitest run", "test:watch": "vitest", "typecheck": "vue-tsc --noEmit" diff --git a/soleprint/common/ui/pnpm-workspace.yaml b/soleprint/common/ui/pnpm-workspace.yaml new file mode 100644 index 0000000..dfc584e --- /dev/null +++ b/soleprint/common/ui/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +# pnpm 10+ moved settings here out of package.json. +# +# esbuild (vite's bundler) and vue-demi (pinia) need their postinstall to link +# platform binaries. Without this pnpm refuses to run them and exits non-zero, +# which makes every `pnpm typecheck` / `test` / `build` fail before it starts. +allowBuilds: + esbuild: true + vue-demi: true diff --git a/soleprint/common/ui/src/base.css b/soleprint/common/ui/src/base.css new file mode 100644 index 0000000..2b59822 --- /dev/null +++ b/soleprint/common/ui/src/base.css @@ -0,0 +1,69 @@ +/* Framework base layer — element defaults written against tokens.css. + * + * This was duplicated byte-for-byte in every app's own styles.css (doocus-app, + * meetus-app). It is theme, not app, so it ships with the framework: an app that + * imports the framework gets a consistent shell without restating it. + * + * Retheme by replacing tokens.css — every value here resolves through it. */ + +* { + box-sizing: border-box; +} + +html, +body, +#app { + margin: 0; + height: 100%; + width: 100%; +} + +body { + background: var(--surface-0); + color: var(--text-primary); + font-family: var(--font-ui); + font-size: var(--font-size-base); +} + +button { + font-family: var(--font-ui); + font-size: var(--font-size-base); + color: var(--text-primary); + background: var(--surface-2); + border: var(--panel-border); + border-radius: var(--panel-radius); + padding: var(--space-1) var(--space-3); + cursor: pointer; +} + +button:hover:not(:disabled) { + background: var(--surface-3); +} + +button:disabled { + opacity: 0.5; + cursor: default; +} + +input, +select, +textarea { + font-family: var(--font-ui); + font-size: var(--font-size-base); + color: var(--text-primary); + background: var(--surface-0); + border: var(--panel-border); + border-radius: 4px; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + background: var(--surface-3); + border-radius: 5px; +} +::-webkit-scrollbar-track { + background: transparent; +} diff --git a/soleprint/common/ui/src/index.ts b/soleprint/common/ui/src/index.ts index 32e2edb..1304817 100644 --- a/soleprint/common/ui/src/index.ts +++ b/soleprint/common/ui/src/index.ts @@ -1,4 +1,10 @@ // Framework public API + +// Theme (tokens + base layer). Imported here so the visual identity is part of +// the bundle rather than something each consumer must remember to wire up — +// every component below styles itself with the variables it defines. +import './theme.css' + export { DataSource, type DataSourceStatus } from './datasources/DataSource' export { SSEDataSource } from './datasources/SSEDataSource' export { StaticDataSource } from './datasources/StaticDataSource' diff --git a/soleprint/common/ui/src/theme.css b/soleprint/common/ui/src/theme.css new file mode 100644 index 0000000..3e7b60d --- /dev/null +++ b/soleprint/common/ui/src/theme.css @@ -0,0 +1,11 @@ +/* The whole visual identity in one import: design tokens + element defaults. + * + * index.ts imports this, so the theme travels with the bundle and cannot be + * forgotten — a dist that renders unthemed is a broken dist, not an unbranded + * one, because every component styles itself with var(--surface-0) and friends. + * + * Consumers of the built package import `soleprint-ui/style.css`. + * To retheme, override the variables from tokens.css after this import. */ + +@import './tokens.css'; +@import './base.css'; diff --git a/soleprint/common/ui/vite.config.ts b/soleprint/common/ui/vite.config.ts new file mode 100644 index 0000000..040d5d0 --- /dev/null +++ b/soleprint/common/ui/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +/** + * Library build — emits dist/soleprint-ui.js plus a single dist/style.css + * containing the theme (tokens + base) and every component's scoped styles. + * + * The CSS is the point as much as the JS: components style themselves with + * var(--surface-0) and friends, so a bundle shipped without it renders broken. + * + * Peer packages are external so a consuming app resolves ONE copy of vue — + * two Vue instances break reactivity and provide/inject in ways that are + * miserable to debug. + */ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + lib: { + entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + formats: ['es'], + fileName: () => 'soleprint-ui.js', + cssFileName: 'style', + }, + rollupOptions: { + external: ['vue', 'pinia', '@vue-flow/core', 'uplot'], + output: { + globals: { vue: 'Vue' }, + }, + }, + }, +}) diff --git a/soleprint/station/tools/datagen/README.md b/soleprint/station/tools/datagen/README.md index f074f13..25ad504 100644 --- a/soleprint/station/tools/datagen/README.md +++ b/soleprint/station/tools/datagen/README.md @@ -1,164 +1,87 @@ -# Datagen - Test Data Generator +# Datagen — Test Data Generator -Pluggable test data generators for various domain models and external APIs. +Room-specific test data generators, discovered and served by the hub. -## Purpose - -- Generate realistic test data for Amar domain models -- Generate mock API responses for external services (MercadoPago, etc.) -- Can be plugged into any nest (test suites, mock veins, seeders) -- Domain-agnostic and reusable +The core ships the base class and the API only. **Generators themselves belong to a +room** (`cfg//soleprint/station/tools/datagen/`) and are merged into the built +instance — so no client's domain vocabulary lives here. ## Structure ``` datagen/ ├── __init__.py -├── amar.py # Amar domain models (petowner, pet, cart, etc.) -├── mercadopago.py # MercadoPago API responses -└── README.md # This file +├── base.py # BaseDataGenerator — the contract +├── api.py # FastAPI router, mounted at /tools/datagen +├── templates/ +│ └── index.html # browser UI +└── README.md # this file ``` -## Usage +## Writing a generator -### In Tests +Subclass `BaseDataGenerator` and name each method after the model it generates. The +method name *is* the model name — there is no registry to update. ```python -from ward.tools.datagen.amar import AmarDataGenerator +from faker import Faker -def test_petowner_creation(): - owner_data = AmarDataGenerator.petowner(address="Av. Corrientes 1234") - assert owner_data["address"] == "Av. Corrientes 1234" -``` +# Guarded so the file also runs on its own, outside a built instance — +# see cfg/sample/soleprint/station/tools/datagen/fixture.py. +try: + from station.tools.datagen.base import BaseDataGenerator +except ImportError: + class BaseDataGenerator: + pass -### In Mock Veins +fake = Faker() -```python -from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator - -@router.post("/v1/preferences") -async def create_preference(request: dict): - # Generate mock response - return MercadoPagoDataGenerator.preference( - description=request["items"][0]["title"], - total=request["items"][0]["unit_price"], - ) -``` -### In Seeders +class MyRoomGenerator(BaseDataGenerator): + def user(self, **kwargs): + return {"id": fake.uuid4(), "name": fake.name(), **kwargs} -```python -from ward.tools.datagen.amar import AmarDataGenerator - -# Create 10 test pet owners -for i in range(10): - owner = AmarDataGenerator.petowner(is_guest=False) - # Save to database... + def product(self, category=None, **kwargs): + return {"id": fake.uuid4(), "name": fake.word(), "category": category, **kwargs} ``` -## Design Principles - -1. **Pluggable**: Can be used anywhere, not tied to specific frameworks -2. **Realistic**: Generated data matches real-world patterns -3. **Flexible**: Override any field via `**overrides` parameter -4. **Domain-focused**: Each generator focuses on a specific domain -5. **Stateless**: Pure functions, no global state +Drop it in `cfg//soleprint/station/tools/datagen/.py` and rebuild the room. -## Generators +**Discovery rules** (`api.py:_load_generators`): every `*.py` in the tool directory is +scanned except `base.py`, `api.py`, and anything starting with `_`. The first class whose +name ends in `Generator` (and isn't `BaseDataGenerator`) is instantiated. -### AmarDataGenerator (amar.py) +## What the base class gives you -Generates data for Amar platform: +| Method | Purpose | +|---|---| +| `generate(model, count=1, **kwargs)` | Call the matching method `count` times; raises `ValueError` listing available models if there's no match | +| `available_models()` | Method names, minus the reserved ones — i.e. the models you support | +| `schema()` | Optional override returning a graphgen-compatible schema; `None` by default | -- `petowner()` - Pet owners (guest and registered) -- `pet()` - Pets with species, age, etc. -- `cart()` - Shopping carts -- `service_request()` - Service requests -- `filter_services()` - Service filtering by species/neighborhood -- `filter_categories()` - Category filtering -- `calculate_cart_summary()` - Cart totals with discounts +## HTTP API -### MercadoPagoDataGenerator (mercadopago.py) +Mounted at `/tools/datagen`: -Generates MercadoPago API responses: +| Route | Purpose | +|---|---| +| `GET /api/generators` | loaded generator files and their models | +| `GET /api/models` | models for one generator (`?generator=`) | +| `POST /api/generate` | `{model, count, generator?, kwargs}` → generated items | +| `GET /api/schema` | graphgen-compatible schema, when the generator exposes one | -- `preference()` - Checkout Pro preference -- `payment()` - Payment (Checkout API/Bricks) -- `merchant_order()` - Merchant order -- `oauth_token()` - OAuth token exchange -- `webhook_notification()` - Webhook payloads +With one generator loaded, `generator` can be omitted everywhere — the only one is used. -## Examples - -### Generate a complete turnero flow - -```python -from ward.tools.datagen.amar import AmarDataGenerator - -# Step 1: Guest pet owner -owner = AmarDataGenerator.petowner( - address="Av. Santa Fe 1234, Palermo", - is_guest=True -) - -# Step 2: Pet -pet = AmarDataGenerator.pet( - owner_id=owner["id"], - name="Luna", - species="DOG", - age_value=3, - age_unit="years" -) - -# Step 3: Cart -cart = AmarDataGenerator.cart(owner_id=owner["id"]) - -# Step 4: Add services to cart -services = AmarDataGenerator.filter_services( - species="DOG", - neighborhood_id=owner["neighborhood"]["id"] -) - -cart_with_items = AmarDataGenerator.calculate_cart_summary( - cart, - items=[ - {"service_id": services[0]["id"], "price": services[0]["price"], "quantity": 1, "pet_id": pet["id"]}, - ] -) - -# Step 5: Service request -request = AmarDataGenerator.service_request(cart_id=cart["id"]) -``` - -### Generate a payment flow - -```python -from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator - -# Create preference -pref = MercadoPagoDataGenerator.preference( - description="Visita a domicilio", - total=95000, - external_reference="SR-12345" -) - -# Simulate payment -payment = MercadoPagoDataGenerator.payment( - transaction_amount=95000, - description="Visita a domicilio", - status="approved", - application_fee=45000 # Platform fee (split payment) -) - -# Webhook notification -webhook = MercadoPagoDataGenerator.webhook_notification( - topic="payment", - resource_id=str(payment["id"]) -) +```bash +curl -X POST localhost:12000/tools/datagen/api/generate \ + -H 'content-type: application/json' \ + -d '{"model": "user", "count": 3}' ``` -## Future Generators +## Design principles -- `google.py` - Google API responses (Calendar, Sheets) -- `whatsapp.py` - WhatsApp API responses -- `slack.py` - Slack API responses +1. **Room-owned** — domain vocabulary lives in `cfg//`, never in core. +2. **Convention over registration** — a method name is a model name. +3. **Flexible** — any field is overridable through `**kwargs`. +4. **Stateless** — no global state between calls. +5. **Standalone** — usable directly as a Python class, with or without the hub. diff --git a/soleprint/station/tools/tester/README.md b/soleprint/station/tools/tester/README.md index 1ac555e..1936a19 100644 --- a/soleprint/station/tools/tester/README.md +++ b/soleprint/station/tools/tester/README.md @@ -1,178 +1,127 @@ -# Tester - HTTP Contract Test Runner +# Tester — HTTP Contract Test Runner -Web UI for discovering and running contract tests. +Discovers and runs contract tests against any environment, with a web UI for +visibility. + +**Test Definitions** → **Tester (Runner + UI)** → **Target API** ## Quick Start ```bash -# Sync tests from production repo (local dev) -/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh - -# Run locally -cd /home/mariano/wdir/ama/pawprint/ward -python -m tools.tester +CONTRACT_TEST_URL=http://localhost:8000 python -m tester run +python -m tester discover # list what was found -# Open in browser -http://localhost:12003/tester +# In a built instance, the UI is mounted by the hub: +# http://localhost:12000/tools/tester ``` -## Architecture +## Where tests live -**Test Definitions** → **Tester (Runner + UI)** → **Target API** +**No test bodies are committed to core.** The tool ships the base class, the +runner and the UI. Tests belong to a room: ``` -amar_django_back_contracts/ -└── tests/contracts/ ← Test definitions (source of truth) - ├── mascotas/ - ├── productos/ - └── workflows/ - -ward/tools/tester/ -├── tests/ ← Synced from contracts (deployment) -│ ├── mascotas/ -│ ├── productos/ -│ └── workflows/ -├── base.py ← HTTP test base class -├── core.py ← Test discovery & execution -├── api.py ← FastAPI endpoints -└── templates/ ← Web UI - +cfg//soleprint/station/tools/tester/tests/ ``` -## Strategy: Separation of Concerns +They are merged into the built instance and discovered from `tests/` there. + +See [`tests/test_template.py`](tests/test_template.py) — an intentionally empty +test file whose docstring covers the execution modes, environment targeting, the +`ContractTestCase` surface, and a worked example. -1. **Tests live in production repo** (`amar_django_back_contracts`) - - Developers write tests alongside code - - Tests are versioned with the API - - PR reviews include test changes +Keeping tests in the room rather than the runner means they version alongside the +API they describe, and the runner stays reusable across projects. -2. **Tester consumes tests** (`ward/tools/tester`) - - Provides web UI for visibility - - Runs tests against any target (dev, stage, prod) - - Shows test coverage to product team +## Layout -3. **Deployment syncs tests** - - `sync-tests.sh` copies tests from contracts to tester - - Deployment script includes test sync - - Server always has latest tests +``` +tester/ +├── base.py # ContractTestCase — httpx + stdlib unittest +├── core.py # discovery & execution +├── cli.py # python -m tester [discover|run] +├── config.py # .env + environment overrides +├── api.py # FastAPI routes +├── environments.json # named targets +├── templates/ # web UI +├── gherkin/ # optional feature/scenario metadata mapping +├── playwright/ # browser adapter (scaffolded; see the template) +└── tests/ + ├── base.py + ├── test_template.py # start here + └── example/ # fallback health check, runs with no room config +``` ## Configuration -### Single Environment (.env) +### Single environment (.env) ```env -CONTRACT_TEST_URL=https://demo.amarmascotas.ar +CONTRACT_TEST_URL=https://api.example.com CONTRACT_TEST_API_KEY=your-api-key-here ``` -### Multiple Environments (environments.json) +### Multiple environments (environments.json) -Configure multiple target environments with individual tokens: +Same suite, many targets — this is the point of the tool. ```json [ { - "id": "demo", - "name": "Demo", - "url": "https://demo.amarmascotas.ar", + "id": "local", + "name": "Local", + "url": "http://localhost:8000", "api_key": "", - "description": "Demo environment for testing", + "description": "Local development server", "default": true }, { - "id": "dev", - "name": "Development", - "url": "https://dev.amarmascotas.ar", - "api_key": "dev-token-here", - "description": "Development environment" - }, - { - "id": "prod", - "name": "Production", - "url": "https://amarmascotas.ar", - "api_key": "prod-token-here", - "description": "Production (use with caution!)" + "id": "stage", + "name": "Staging", + "url": "https://stage.example.com", + "api_key": "stage-token-here", + "description": "Staging environment" } ] ``` -**Environment Selector**: Available in UI header on both Runner and Filters pages. Selection persists via localStorage. - -## Web UI Features +Selection is available in the UI header and persists via localStorage. Tokens are +per-environment; keep real ones in a room's gitignored config, never here. -- **Filters**: Advanced filtering by domain, module, status, and search -- **Runner**: Execute tests with real-time progress tracking -- **Multi-Environment**: Switch between dev/stage/prod with per-environment tokens -- **URL State**: Filter state persists via URL when running tests -- **Real-time Status**: See test results as they run +See the template for every `CONTRACT_TEST_*` variable. -## API Endpoints +## API ``` GET /tools/tester/ # Runner UI GET /tools/tester/filters # Filters UI GET /tools/tester/api/tests # List all tests +GET /tools/tester/api/tests/tree # Tests grouped as a tree GET /tools/tester/api/environments # List environments POST /tools/tester/api/environment/select # Switch environment POST /tools/tester/api/run # Start test run -GET /tools/tester/api/run/{run_id} # Get run status (polling) +GET /tools/tester/api/run/{run_id} # Run status (polling) GET /tools/tester/api/runs # List all runs +GET /tools/tester/api/features # Gherkin features +POST /tools/tester/api/features/sync # Sync feature files ``` -## Usage Flow - -### From Filters to Runner +### URL parameters -1. Go to `/tools/tester/filters` -2. Filter tests (domain, module, search) -3. Select tests to run -4. Click "Run Selected" -5. → Redirects to Runner with filters applied and auto-starts execution +The runner accepts deep links: -### URL Parameters - -Runner accepts URL params for deep linking: - -``` -/tools/tester/?run=abc123&domains=mascotas&search=owner ``` - -- `run` - Auto-load results for this run ID -- `domains` - Filter by domains (comma-separated) -- `modules` - Filter by modules (comma-separated) -- `search` - Search term for test names -- `status` - Filter by status (passed,failed,skipped) - -## Deployment - -Tests are synced during deployment: - -```bash -# Full deployment (includes test sync) -cd /home/mariano/wdir/ama/pawprint/deploy -./deploy.sh - -# Or sync tests only -/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh +/tools/tester/?run=abc123&modules=customers&search=invoice ``` -## Why This Design? - -**Problem**: Tests scattered, no visibility, hard to demonstrate value - -**Solution**: -- Tests in production repo (developer workflow) -- Tester provides visibility (product team, demos) -- Separation allows independent evolution - -**Benefits**: -- Product team sees test coverage -- Demos show "quality dashboard" -- Tests protect marketplace automation work -- Non-devs can run tests via UI +- `run` — auto-load results for this run ID +- `domains` / `modules` — comma-separated filters +- `search` — search term for test names +- `status` — `passed,failed,skipped` -## Related +## Why this design -- Production tests: `/home/mariano/wdir/ama/amar_django_back_contracts/tests/contracts/` -- Sync script: `/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh` -- Ward system: `/home/mariano/wdir/ama/pawprint/ward/` +Tests scattered across repos give no visibility and are hard to demonstrate. +Keeping definitions with the API while the runner stays generic means the suite +evolves with the code, non-developers can run it from the UI, and the same tests +prove the contract in every environment you can point them at. diff --git a/soleprint/station/tools/tester/__main__.py b/soleprint/station/tools/tester/__main__.py index 9e73571..c4d631a 100644 --- a/soleprint/station/tools/tester/__main__.py +++ b/soleprint/station/tools/tester/__main__.py @@ -1,10 +1,10 @@ """ -CLI entry point for contracts_http tool. +CLI entry point for the tester tool. Usage: - python -m contracts_http discover - python -m contracts_http run - python -m contracts_http run mascotas + python -m tester discover + python -m tester run + python -m tester run customers # only tests matching a pattern """ from .cli import main diff --git a/soleprint/station/tools/tester/cli.py b/soleprint/station/tools/tester/cli.py index 3d6f285..0df1435 100644 --- a/soleprint/station/tools/tester/cli.py +++ b/soleprint/station/tools/tester/cli.py @@ -113,7 +113,7 @@ def main(args=None): # run command run_parser = subparsers.add_parser("run", help="Run tests") - run_parser.add_argument("pattern", nargs="?", help="Filter tests by pattern (e.g., 'mascotas', 'pet_owners')") + run_parser.add_argument("pattern", nargs="?", help="Filter tests by pattern (e.g. 'customers', 'invoices')") args = parser.parse_args(args) diff --git a/soleprint/station/tools/tester/gherkin/mapper.py b/soleprint/station/tools/tester/gherkin/mapper.py index 0bd903f..1ac0ee1 100644 --- a/soleprint/station/tools/tester/gherkin/mapper.py +++ b/soleprint/station/tools/tester/gherkin/mapper.py @@ -4,22 +4,22 @@ Map tests to Gherkin scenarios based on metadata. Tests can declare their Gherkin metadata via docstrings: ```python -def test_coverage_check(self): +def test_create_then_read_back(self): ''' - Feature: Reservar turno veterinario - Scenario: Verificar cobertura en zona disponible - Tags: @smoke @coverage + Feature: Customer records + Scenario: A created customer can be read back + Tags: @smoke @customers ''' ``` Or via class docstrings: ```python -class TestCoverageFlow(ContractHTTPTestCase): - """ - Feature: Reservar turno veterinario - Tags: @coverage - """ +class TestCustomers(ContractTestCase): + ''' + Feature: Customer records + Tags: @customers + ''' ``` """ diff --git a/soleprint/station/tools/tester/gherkin/sync.py b/soleprint/station/tools/tester/gherkin/sync.py index 784b21a..6522b16 100644 --- a/soleprint/station/tools/tester/gherkin/sync.py +++ b/soleprint/station/tools/tester/gherkin/sync.py @@ -12,11 +12,16 @@ def sync_features_from_album( tester_path: Optional[Path] = None ) -> dict: """ - Sync .feature files from album/book/gherkin-samples/ to ward/tools/tester/features/. + Sync .feature files from an atlas book into the tester's features/ directory. + + Feature files are room-owned and live in a book: + cfg//soleprint/atlas/books/gherkin-samples/ + which lands at atlas/books/gherkin-samples/ in a built instance. They are + synced, not committed here (see features/.gitignore). Args: - album_path: Path to album/book/gherkin-samples/ (auto-detected if None) - tester_path: Path to ward/tools/tester/features/ (auto-detected if None) + album_path: Path to the gherkin-samples book (auto-detected if None) + tester_path: Path to tester/features/ (auto-detected if None) Returns: Dict with sync stats: {synced: int, skipped: int, errors: int} @@ -26,9 +31,9 @@ def sync_features_from_album( tester_path = Path(__file__).parent.parent / "features" if album_path is None: - # Attempt to find album in pawprint - pawprint_root = Path(__file__).parent.parent.parent.parent - album_path = pawprint_root / "album" / "book" / "gherkin-samples" + # parents[4] is the instance root — station/tools/tester/gherkin/sync.py + instance_root = Path(__file__).resolve().parents[4] + album_path = instance_root / "atlas" / "books" / "gherkin-samples" # Ensure paths exist if not album_path.exists(): diff --git a/soleprint/station/tools/tester/tests/README.md b/soleprint/station/tools/tester/tests/README.md index 19a6871..7e64726 100644 --- a/soleprint/station/tools/tester/tests/README.md +++ b/soleprint/station/tools/tester/tests/README.md @@ -1,73 +1,42 @@ # Contract Tests -API contract tests organized by Django app, with optional workflow tests. +Black-box HTTP tests that validate an API contract. Framework-agnostic by +construction: they talk to a URL, so they run against any implementation and any +environment. -## Testing Modes +**No tests are committed here.** This directory holds the base class and a +template; test bodies belong to a room: -Two modes via `CONTRACT_TEST_MODE` environment variable: - -| Mode | Command | Description | -|------|---------|-------------| -| **api** (default) | `pytest tests/contracts/` | Fast, Django test client, test DB | -| **live** | `CONTRACT_TEST_MODE=live pytest tests/contracts/` | Real HTTP, LiveServerTestCase, test DB | +``` +cfg//soleprint/station/tools/tester/tests/ +``` -### Mode Comparison +They are merged into the built instance and discovered from there. -| | `api` (default) | `live` | -|---|---|---| -| **Base class** | `APITestCase` | `LiveServerTestCase` | -| **HTTP** | In-process (Django test client) | Real HTTP via `requests` | -| **Auth** | `force_authenticate()` | JWT tokens via API | -| **Database** | Django test DB (isolated) | Django test DB (isolated) | -| **Speed** | ~3-5 sec | ~15-30 sec | -| **Server** | None (in-process) | Auto-started by Django | +## Start here -### Key Point: Both Modes Use Test Database +[`test_template.py`](test_template.py) — an intentionally empty test file whose +docstring carries the whole story: the execution modes, environment targeting, the +`ContractTestCase` surface, and a worked example. -Neither mode touches your real database. Django automatically: -1. Creates a test database (prefixed with `test_`) -2. Runs migrations -3. Destroys it after tests complete +## The two rules -## File Structure +1. **One suite, any environment.** A test states what the API promises, never who + implements it or where it runs. Point it at a laptop container, a cluster + namespace, or a deployed host — the result should only change if the contract + broke. -``` -tests/contracts/ -├── base.py # Mode switcher (imports from base_api or base_live) -├── base_api.py # APITestCase implementation -├── base_live.py # LiveServerTestCase implementation -├── conftest.py # pytest-django configuration -├── endpoints.py # API paths (single source of truth) -├── helpers.py # Shared test data helpers -│ -├── mascotas/ # Django app: mascotas -│ ├── test_pet_owners.py -│ ├── test_pets.py -│ └── test_coverage.py -│ -├── productos/ # Django app: productos -│ ├── test_services.py -│ └── test_cart.py -│ -├── solicitudes/ # Django app: solicitudes -│ └── test_service_requests.py -│ -└── workflows/ # Multi-step API sequences (e.g., turnero booking flow) - └── test_turnero_general.py -``` +2. **No helper framework tools.** stdlib `unittest` and `httpx`. No pytest + fixtures or plugins, no framework test client, no factories or DSL, no ORM or + database access. A test that needs framework helpers to express itself has + stopped testing the contract, and stopped being portable. -## Running Tests +## Running ```bash -# All contract tests -pytest tests/contracts/ - -# Single app -pytest tests/contracts/mascotas/ - -# Single file -pytest tests/contracts/mascotas/test_pet_owners.py - -# Live mode (real HTTP) -CONTRACT_TEST_MODE=live pytest tests/contracts/ +CONTRACT_TEST_URL=http://localhost:8000 python -m tester run +python -m tester discover # list what was found ``` + +Targets are configured in [`../environments.json`](../environments.json) and +selectable from the web UI. See the template for every environment variable. diff --git a/soleprint/station/tools/tester/tests/test_template.py b/soleprint/station/tools/tester/tests/test_template.py new file mode 100644 index 0000000..e74d7dc --- /dev/null +++ b/soleprint/station/tools/tester/tests/test_template.py @@ -0,0 +1,143 @@ +""" +TEMPLATE — how to write a contract test. Intentionally empty: no test runs from +this file, and no tests are committed to the core repo at all. + +Tests belong to a room: + + cfg//soleprint/station/tools/tester/tests/ + +They get merged into the built instance and discovered from there. Core ships the +base class, the runner and the UI — never anyone's test bodies. + + +THE POINT +───────── +One suite, any environment. A test says what the API promises; it never says who +implements it or where it runs. Point the same file at a container on your laptop, +a namespace in the cluster, or a deployed host, and the answer should only differ +if the contract actually broke. + +That constrains how tests are written, which is the second idea: + + +NO HELPER FRAMEWORK TOOLS +───────────────────────── +Deliberately plain. A test makes an HTTP call and asserts on the response. + + - stdlib `unittest`, not pytest fixtures/plugins/parametrize machinery + - `httpx` against a URL, not a framework test client + - no factories, no DSL, no ORM access, no database setup + - no in-process shortcuts — if the test can reach it, so can a client + +The moment a test needs framework helpers to express itself, it has stopped +testing the contract and started testing the implementation. It also stops being +portable: helper-bound tests only run where that framework runs. + +(Django, where it appears at all, is an optional private DB editor via its admin — +never the framework, and never something a test reaches into.) + + +MODES OF EXECUTION +────────────────── +A mode is how a test reaches the system under test. Only the first is built. + + http direct BUILT, and the one that matters. `ContractTestCase` below: pure + httpx over the wire. Discovered by + `unittest.TestLoader().discover(pattern="test_*.py")`, so a test + MUST subclass `ContractTestCase` to appear in the runner. + + browser SCAFFOLDED, not wired end to end, and rarely the right tool. + `playwright/runner.py` shells out (`npx playwright test + --reporter=json`) and parses the report. + + Browser tests earn their cost on complex UIs. Dashboards, monitors + and log views are not that — they render what an endpoint already + returned, so testing the endpoint tests the dashboard. Reach for + this only when behaviour lives in the browser and nowhere else; + data visualisation may eventually qualify. Not now. + + any language INTENDED, not built. The browser adapter is the shape it would take: + a runner owes only a command to invoke and a machine-readable report + to parse. Nothing about that requires the test to be Python — R, Go, + k6 or a shell script satisfy the same contract. + +Environment targeting is orthogonal to mode. `environments.json` holds the targets: + + [{"id": "local", "name": "Local", "url": "http://localhost:8000", + "api_key": "", "description": "...", "default": true}] + +Select one via `POST /tools/tester/api/environment/select`, or set the env directly: + + CONTRACT_TEST_URL required — base URL of the target + CONTRACT_TEST_AUTH_TYPE bearer (default) | api-key | none + CONTRACT_TEST_TOKEN bearer token; fetched from the token endpoint if unset + CONTRACT_TEST_API_KEY required when auth type is api-key + CONTRACT_TEST_TOKEN_ENDPOINT default /api/token/ + CONTRACT_TEST_USER / CONTRACT_TEST_PASSWORD used to fetch a token + + CONTRACT_TEST_URL=http://localhost:8000 python -m tester run + + +WRITING ONE +─────────── +The domain below is the in-repo invoicing fixture (cfg/sample) — deliberately +generic. Substitute your own; the shape is the part that transfers. + + from ..base import ContractTestCase + + + class TestCustomers(ContractTestCase): + '''Customer endpoints.''' + + def test_list_returns_customers(self): + '''A list endpoint returns a list.''' + response = self.get("/api/customers/") + self.assert_status(response, 200) + self.assert_is_list(response.data) + + def test_create_then_read_back(self): + '''What was written is what comes back.''' + created = self.post("/api/customers/", {"name": "Acme Ltd"}) + self.assert_status(created, 201) + self.assert_has_fields(created.data, "id", "name") + + fetched = self.get(f"/api/customers/{created.data['id']}/") + self.assert_status(fetched, 200) + self.assertEqual(fetched.data["name"], "Acme Ltd") + + def test_unknown_customer_is_404(self): + '''Absence is reported, not guessed at.''' + self.assert_status(self.get("/api/customers/00000000/"), 404) + +Inherited from `ContractTestCase` (see ../base.py) — this is the whole surface: + + self.get / post / put / patch / delete auth headers applied, .data parsed + self.assert_status(response, code) + self.assert_has_fields(data, *names) + self.assert_is_list(data, min_length=0) + self.base_url / self.token / self.api_key + +Conventions that keep a suite portable: + + - Group by area in subfolders; put multi-step sequences under `workflows/`. + - Keep paths in one `endpoints.py` per room so a URL change is a one-line diff. + - Create what you need through the API and assert on what comes back. A test that + depends on data already being there only passes in the environment it was + written against, which defeats the point. + - Skip, don't fail, when the target is simply unreachable — an unreachable + environment is not a broken contract. + + +GHERKIN (OPTIONAL) +────────────────── +A test can declare feature/scenario metadata in its docstring; the mapper reads it +to tie runs back to `.feature` files. Feature files are synced, not committed +(see features/.gitignore). + + def test_create_then_read_back(self): + ''' + Feature: Customer records + Scenario: A created customer can be read back + Tags: @smoke @customers + ''' +"""