diff --git a/CLAUDE.md b/CLAUDE.md index 11f3e9e..e16f076 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,9 +36,9 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr ### Active state -- 206 tests passing, naming/style check green -- `__contract_version__` at 0.5.0 -- 10 bundled plugins all satisfy contract: computers, employees, equipment, knowledgebase, network, notifications, printers, slides, usb, warranty +- 340 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build) +- `__contract_version__` at 0.6.0 (product `__version__` 0.5.0 - distinct series, ADR-007) +- 11 bundled plugins all satisfy contract: computers, employees, equipment, knowledgebase, measuringtools, network, notifications, printers, slides, usb, warranty - Single core Alembic chain: baseline `68b3947ae14f` -> head `7d16_directoryemployees` (23 migrations). A fresh site runs `flask db upgrade` from empty; it is reproducible and idempotent. - Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0 @@ -46,7 +46,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr - Equipment data migration (one-shot script for legacy ASP shopdb -> assets). Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates. Skill `migrating-asset-schema` documents the pattern; the actual one-shot script lives in `scripts/migration/` when run. - Printers retirement: legacy `PrinterData` model + frontend changes. Coordinated with the equipment data migration. -- `measuringtools` plugin (ADR-005). First plugin to be built using the scaffold. +- (DONE 2026-07-11) `measuringtools` plugin (ADR-005) is built and bundled; docs/PLUGIN-GUIDE.md narrates its construction as the plugin tutorial. - Frontend hook contract for asset-detail, map markers, search results - Alembic per-plugin migration chains (the framework supports them; bundled plugins haven't moved off `db.create_all()` yet) diff --git a/docs/CONTRACT-STABILITY.md b/docs/CONTRACT-STABILITY.md new file mode 100644 index 0000000..d624d98 --- /dev/null +++ b/docs/CONTRACT-STABILITY.md @@ -0,0 +1,112 @@ +# Path to contract 1.0 + +This page is an honest read of how stable the plugin contract is today, for a +sister site deciding how much to build on it. It is derived from the ADRs and +the live code, not aspiration. The authoritative hook reference is +[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md); the versioning rules are in +[ADR-002](adr/ADR-002-plugin-versioning.md). + +## Current version + +The plugin contract is at **0.6.0**, declared in `shopdb/__init__.py` as +`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor +bump is allowed to break the contract, and this project has used that latitude. + +The product release version (`__version__`, currently 0.5.0) is a separate +series with its own bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md). +Do not pin against it for compatibility - pin against `__contract_version__`. + +### 0.x history + +Recorded in the comment block in `shopdb/__init__.py`: + +| Version | Change | Kind | +|---------|--------|------| +| 0.3.0 | `shopdb.api` expanded to the full plugin import surface (db, cache, model bases, core models, response + pagination helpers, `employee_connection`) so plugins stop importing internal core paths | additive (minor) | +| 0.4.0 | Removed the never-implemented `get_searchable_fields` hook (search is a core concern over the asset model) and wired `get_dashboard_widgets` to a real consumer (`/api/dashboard/widgets`) | pre-1.0 contract reduction | +| 0.6.0 | Added the `get_reports` hook, consumed by `GET /api/reports` to merge plugin report cards into the Reports hub | additive optional hook (minor) | + +The source comment block documents 0.3.0, 0.4.0, and 0.6.0. Earlier points +(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback +`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the +0.2 baseline. + +## Settled surface + +These are unlikely to break before 1.0. A plugin can depend on them with +reasonable confidence; a breaking change to any would be a major bump and would +land with a new or amended ADR. + +| Surface | What it is | +|---------|-----------| +| `meta` / manifest schema | `PluginMeta` fields and `manifest.json` (ADR-002 single source of truth) | +| `get_blueprint` | Flask Blueprint registration at the manifest `api_prefix` | +| `get_models` | SQLAlchemy model classes the plugin owns | +| `init_app` | Custom init after blueprint + models are known | +| `get_cli_commands` | Click commands added to the Flask CLI | +| `get_services` | Named service classes, consumed by `plugin_manager.get_service` | +| Lifecycle hooks | `on_install`, `on_uninstall`, `on_enable`, `on_disable` | +| `get_navigation_items` | Sidebar menu entries | +| `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` | +| `get_reports` | Report cards, consumed by `/api/reports` (added 0.6.0) | +| Collector pair | `get_collector_schema` + `apply_collector_payload` per [ADR-006](adr/ADR-006-collector-contract.md) | +| Settings helpers | `get_setting` / `set_setting`, namespaced to the plugin | +| `get_provisioning_note` | Setup-wizard transparency note for extra tables | +| `get_config_schema` | Setup-wizard config field declarations | +| `shopdb.api` import surface | The only core module plugins may import (plus `shopdb.plugins.base`); adding a name is minor, removing one is major | + +The asset model itself - `Asset`, `AssetType`, `AssetStatus`, +`AssetRelationship`, and the shared reference models - is the platform contract +locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md). + +## Expected churn before 1.0 + +Known-unstable areas. Building on these means expecting rework. + +| Area | Status | Reference | +|------|--------|-----------| +| Frontend hook contract | Not defined yet. There is no server-side hook for asset-detail panels, map markers, or search-result rendering. A plugin that needs custom UI still hand-edits the Vue frontend. This is the single biggest gap. | project charter / this doc | +| Per-plugin migrations | Brand new. The per-plugin Alembic engine exists and every bundled plugin now carries a chain, but the pattern has one release of production mileage, not years. | [ADR-008](adr/ADR-008-plugin-migration-ownership.md) (2026-07-10) | +| Pip distribution | Deferred to v2. External plugins install by clone / submodule / symlink; there is no entry-point discovery and no automatic update path yet. | [ADR-003](adr/ADR-003-plugin-distribution.md) | + +## Bump rules + +From [ADR-002](adr/ADR-002-plugin-versioning.md), applied to `__contract_version__`: + +| Bump | Trigger | +|------|---------| +| major | Breaking change to the `BasePlugin` ABC, the `PluginMeta` schema, or any model in the platform contract (`Asset`, `AssetType`, `AssetStatus`, `AssetRelationship`, `Vendor`, `Location`, `BusinessUnit`, `Model`, `OperatingSystem`). Removing a name from `shopdb.api` is major. | +| minor | Additive change: a new optional hook, a new field on a contract model with a default, a new name added to `shopdb.api`. | +| patch | Bug fix with no change to the contract surface. | + +### Deprecation policy + +ADR-002 defines the bump classification above but is **silent** on any +deprecation window or notice period for pre-1.0 removals. In practice a removal +is simply a major (or, pre-1.0, a breaking minor) bump: the hook or name is +gone, and the loader fails loud in dev or excludes the mismatched plugin in prod +(see the load-time table in [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md)). +Because the ADR says nothing about a grace period, this document proposes none; +the honest guidance for sister sites is the mitigation that already exists - pin +a tight `core_version` range and re-test before widening it. + +## Criteria for declaring 1.0 + +Provisional. This is the maintainer's working list, not a committed checklist, +and it will move. ADR-002's own open question ("When does the framework declare +1.0.0?") ties 1.0 to the `Machine` retirement from ADR-001 and the framework +being "ready for sister sites"; the items below expand that intent. + +- Frontend hook contract defined via its own ADR (asset-detail panels, map + markers, search results), closing the biggest churn item above. +- `Machine` / legacy-model retirement complete per ADR-001, so the asset model + is the only contract. +- At least two external plugins running in production at a second site - the + bar ADR-003 already sets before pip distribution and sister-site readiness + are considered justified. +- The per-plugin migration pattern (ADR-008) proven across real upgrades, not + just fresh installs. +- No contract bump needed for N consecutive product releases (N to be fixed + when the list is firmed up), showing the surface has actually settled. + +Until then: pre-1.0, pin tight, re-test on every framework bump. diff --git a/docs/PLUGIN-EXTERNAL-REPO.md b/docs/PLUGIN-EXTERNAL-REPO.md new file mode 100644 index 0000000..72470e2 --- /dev/null +++ b/docs/PLUGIN-EXTERNAL-REPO.md @@ -0,0 +1,340 @@ +# Developing a plugin in its own repo + +This guide is for a sister GE Aerospace site (or any third party) building a +shopdb-flask plugin in a git repo it owns, outside the framework tree. It is +the "external plugin" path in [ADR-003](adr/ADR-003-plugin-distribution.md): +the framework ships a bundled set, and you drop your own plugin into +`/plugins//` by clone, submodule, or symlink. No pip packaging +is required for v1 (pip distribution is deferred to v2 per ADR-003). + +If you have not written a plugin before, start with +[PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) and the hook reference in +[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md). This document only covers the parts that are +different when the plugin lives in its own repo. + +## Recommended repo layout + +Your repo root holds the plugin directory contents directly, so the whole repo +can be dropped in at `/plugins//`. A plugin named `shipping` +in a repo named `wjsf-shipping` looks like this: + +``` +wjsf-shipping/ # your git repo root == the plugin directory + manifest.json # required: name, version, description, core_version + plugin.py # required: your BasePlugin subclass + __init__.py + models/ + __init__.py # exports every model + shipping.py + api/ + __init__.py + routes.py # Flask Blueprint returned by get_blueprint() + migrations/ # per-plugin Alembic chain (ADR-008), if you own tables + env.py + script.py.mako + versions/ + 0001_shipping_baseline.py + tests/ # your own tests (the CI harness runs these) + test_shipping.py + README.md # what it tracks, who maintains it, where to file issues +``` + +Notes: + +- `manifest.json` is the single source of truth for metadata (ADR-002). The + `name` field must match the directory name the site installs it under and + follows the framework naming convention (lowercase concatenated, no + underscores or dashes). Prefix site-specific names with the site code when a + collision across sites is possible, e.g. `wjsf-shipping` + (see [PLUGINS.md](PLUGINS.md) naming policy and ADR-003). +- `migrations/` is only needed if your plugin owns tables. A plugin built + outside the tree never had its tables created by the framework's core chain, + so its `0001` is a REAL baseline that creates them, not a stamp-only anchor. + This is the same "baseline vs anchor" distinction ADR-008 draws for + post-cutover plugins; see [ADR-008](adr/ADR-008-plugin-migration-ownership.md). +- Import core code ONLY through `shopdb.api` (plus `shopdb.plugins.base` for the + ABC). Deep imports of `shopdb.core.*`, `shopdb.extensions`, or `shopdb.utils.*` + are contract violations. See PLUGIN-HOOKS.md for the exposed surface. + +## Local dev workflow + +Symlinking lets you edit in your own repo while the framework loads the plugin +live. The loader discovers a symlinked directory the same as a real one. + +```bash +# 1. Clone the framework and your plugin repo side by side. +git clone https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git +git clone https://gitea.proudtech.net/wjsf/wjsf-shipping.git + +# 2. Symlink your repo into the framework's plugins/ directory. +# The link name is the plugin name from your manifest.json. +cd shopdb-flask +ln -s ../../wjsf-shipping plugins/shipping +# (use an absolute path if you prefer: ln -s "$(pwd)/../wjsf-shipping" plugins/shipping) + +# 3. Set up the framework as usual. +python3 -m venv venv +venv/bin/pip install -r requirements.txt + +# 4. Install (enable) your plugin. +venv/bin/flask plugin install shipping + +# 5. If your plugin owns tables, apply its migrations. +venv/bin/flask db upgrade # core chain +venv/bin/flask plugin upgrade-all # plugin chains, including yours + +# 6. Run tests. +venv/bin/python -m pytest tests/test_plugin_contract.py +venv/bin/python -m pytest ../wjsf-shipping/tests +``` + +Edits in `../wjsf-shipping` are picked up on the next framework restart, because +the symlink points back at your working tree. + +## core_version pinning for sister sites + +The framework is pre-1.0. Under semver, any 0.x minor bump is allowed to break +the contract, and this project uses that latitude (see the history in +[CONTRACT-STABILITY.md](CONTRACT-STABILITY.md)). So pin a TIGHT range that +admits only the contract minor you tested against, not the whole 0.x line. + +The current contract version is declared in `shopdb/__init__.py`: + +```python +__contract_version__ = '0.6.0' +``` + +Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges): + +```json +{ + "name": "shipping", + "version": "1.0.0", + "description": "Tracks shipping-station scanners and label printers", + "core_version": ">=0.6.0,<0.7.0", + "dependencies": [] +} +``` + +Do NOT pin the loose `>=0.2.0,<1.0.0` default that `PluginMeta` falls back to. +That default exists so bundled plugins keep loading across minor bumps; an +external plugin should be deliberate and re-test before widening its range. + +What happens at load time on a mismatch (ADR-002): + +| Environment | Behavior on core_version mismatch | +|-------------|-----------------------------------| +| dev / test | The loader re-raises. Startup fails loud so you notice immediately. | +| production | The loader logs an error, marks the plugin incompatible, and excludes it from registration. The rest of the app still starts. | + +When you move a site to a newer framework, bump your `core_version` upper bound +only after the harness below passes against the new ref. + +## CI recipe + +`scripts/test-external-plugin.sh` (in the framework repo) stands up a throwaway +framework at a pinned ref, drops your plugin in as a symlink, and runs the +framework contract tests plus your own `tests/`. It has two modes: + +- **CI / remote** (default): clones the framework at `FRAMEWORK_REF` and builds + a fresh venv. Needs network access to git and PyPI. +- **Local / offline**: set `LOCAL_FRAMEWORK` to a framework checkout on disk. + The script exports that checkout at HEAD with `git archive` and reuses its + existing venv, so it runs with no internet. Useful for air-gapped verification. + +```bash +# CI: test the plugin in the current repo against a pinned tag +PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh + +# Offline: test against a framework checkout already on disk +LOCAL_FRAMEWORK=/opt/shopdb-flask PLUGIN_DIR=. scripts/test-external-plugin.sh +``` + +The full script: + +```bash +#!/usr/bin/env bash +# +# test-external-plugin.sh +# +# Verify an out-of-tree shopdb-flask plugin against a pinned framework build. +# Stand up a throwaway copy of the framework, drop the plugin into +# plugins// as a symlink (the mechanism ADR-003 documents for sister +# sites), and run the framework contract tests plus the plugin's own tests. +# Nonzero exit means the plugin is not compatible with that framework ref. +# +# Two modes: +# +# CI / remote (default): clone the framework at FRAMEWORK_REF from +# FRAMEWORK_URL, build a fresh venv, pip install requirements. Needs +# network access to git and PyPI. +# +# Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk. +# The script exports that checkout at HEAD with `git archive` (no network) +# and reuses the checkout's existing venv, so it runs with no internet. +# +# Inputs (env var, or positional): +# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required. +# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main. +# FRAMEWORK_URL framework git URL for CI mode. +# Default: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git +# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline. + +set -eu + +PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}" +FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}" +FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}" +LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}" + +if [ -z "$PLUGIN_DIR" ]; then + echo "ERROR: PLUGIN_DIR is required (env var or first argument)." >&2 + exit 2 +fi +if [ ! -f "$PLUGIN_DIR/manifest.json" ]; then + echo "ERROR: $PLUGIN_DIR has no manifest.json - not a plugin directory." >&2 + exit 2 +fi + +PLUGIN_ABS="$(cd "$PLUGIN_DIR" && pwd)" +PLUGIN_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ABS/manifest.json" | head -n1)" +if [ -z "$PLUGIN_NAME" ]; then + PLUGIN_NAME="$(basename "$PLUGIN_ABS")" +fi + +WORKDIR="$(mktemp -d)" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +FRAMEWORK="$WORKDIR/framework" +mkdir -p "$FRAMEWORK" + +if [ -n "$LOCAL_FRAMEWORK" ]; then + echo "==> Local mode: exporting framework from $LOCAL_FRAMEWORK (HEAD)" + LOCAL_ABS="$(cd "$LOCAL_FRAMEWORK" && pwd)" + git -C "$LOCAL_ABS" archive HEAD | tar -x -C "$FRAMEWORK" + if [ -x "$LOCAL_ABS/venv/bin/python" ]; then + PYTHON="$LOCAL_ABS/venv/bin/python" + elif [ -x "$LOCAL_ABS/.venv/bin/python" ]; then + PYTHON="$LOCAL_ABS/.venv/bin/python" + else + echo "ERROR: no venv found under $LOCAL_ABS (looked for venv/ and .venv/)." >&2 + exit 2 + fi +else + echo "==> CI mode: cloning $FRAMEWORK_URL @ $FRAMEWORK_REF" + git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" "$FRAMEWORK" + echo "==> Creating venv and installing requirements" + python3 -m venv "$WORKDIR/venv" + PYTHON="$WORKDIR/venv/bin/python" + "$PYTHON" -m pip install --upgrade pip >/dev/null + "$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt" +fi + +echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/" +rm -rf "$FRAMEWORK/plugins/$PLUGIN_NAME" +ln -s "$PLUGIN_ABS" "$FRAMEWORK/plugins/$PLUGIN_NAME" + +rm -f "$FRAMEWORK/instance/plugins.json" + +RC=0 + +echo "==> Running framework contract tests" +( cd "$FRAMEWORK" && "$PYTHON" -m pytest tests/test_plugin_contract.py -q ) || RC=1 + +if [ -d "$PLUGIN_ABS/tests" ]; then + echo "==> Running plugin's own tests" + ( cd "$FRAMEWORK" && "$PYTHON" -m pytest "$PLUGIN_ABS/tests" -q ) || RC=1 +else + echo "==> Plugin has no tests/ directory - skipping plugin test step" +fi + +if [ "$RC" -eq 0 ]; then + echo "==> PASS: plugin '$PLUGIN_NAME' is compatible with framework ref '$FRAMEWORK_REF'" +else + echo "==> FAIL: plugin '$PLUGIN_NAME' - see output above" >&2 +fi +exit "$RC" +``` + +### What the harness does and does not check + +The symlinked plugin is discovered and loaded by the plugin loader when the app +starts under test. That validates the things that break a real install: the +manifest parses, the `core_version` range admits the framework's +`__contract_version__` (an out-of-range plugin makes startup fail loud, so the +contract test run errors and the script exits nonzero), models expose +`__tablename__`, and hooks return the right shapes. + +One gap to know about: the framework's `test_plugins_only_import_contract_surface` +scans `plugins/` with `Path.rglob`, which does not descend symlinked directories +on CPython 3.12. So that particular sub-test does not see a symlinked external +plugin's source. Keep an equivalent import-surface assertion in your own +`tests/` so your CI still enforces "core imports only via `shopdb.api`". A +minimal version: + +```python +# tests/test_import_surface.py +import re +from pathlib import Path + +ALLOWED = ('shopdb.api', 'shopdb.plugins.base') +IMPORT_RE = re.compile(r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE) + +def test_only_contract_surface_imports(): + root = Path(__file__).resolve().parent.parent + bad = [] + for path in root.rglob('*.py'): + if '__pycache__' in path.parts or 'migrations' in path.parts: + continue + for m in IMPORT_RE.finditer(path.read_text()): + mod = m.group(1) or m.group(2) + if mod and not any(mod == a or mod.startswith(a + '.') for a in ALLOWED): + bad.append(f'{path.name}: {mod}') + assert not bad, 'core imports outside shopdb.api / shopdb.plugins.base: ' + '; '.join(bad) +``` + +## GitHub Actions example + +For a plugin repo hosted on GitHub, a workflow calling the harness against a +pinned framework tag (config only, adjust URLs and ref to your setup): + +```yaml +name: plugin-contract + +on: + push: + pull_request: + +jobs: + contract: + runs-on: ubuntu-latest + env: + FRAMEWORK_REF: v0.5.0 + FRAMEWORK_URL: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git + steps: + - name: Check out the plugin + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Fetch the harness from the framework + run: | + git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" /tmp/framework + - name: Run the contract harness against this plugin + run: | + PLUGIN_DIR="$GITHUB_WORKSPACE" \ + FRAMEWORK_REF="$FRAMEWORK_REF" \ + FRAMEWORK_URL="$FRAMEWORK_URL" \ + bash /tmp/framework/scripts/test-external-plugin.sh +``` + +## See also + +- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate and install a plugin fast +- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) - the full hook and import-surface reference +- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - what is settled vs still churning before 1.0 +- [ADR-002](adr/ADR-002-plugin-versioning.md) - contract versioning and core_version ranges +- [ADR-003](adr/ADR-003-plugin-distribution.md) - the bundled vs external distribution model +- [ADR-008](adr/ADR-008-plugin-migration-ownership.md) - per-plugin migration ownership diff --git a/docs/PLUGIN-QUICKSTART.md b/docs/PLUGIN-QUICKSTART.md index 722a462..f5d76ed 100644 --- a/docs/PLUGIN-QUICKSTART.md +++ b/docs/PLUGIN-QUICKSTART.md @@ -11,7 +11,7 @@ For the architectural decisions behind the contract, see [docs/adr/](../docs/adr flask plugin new cameras --description "Tracks shop-floor surveillance cameras" ``` -Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, and a README. +Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, a README, and a paste-in `frontend-api-snippet.js`. When a `frontend/src/` tree is present, it also writes the frontend starting points: `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`, and the auto-discovered route file `frontend/src/router/routes/cameras.js`. (A plugin developed in its own repo, with no frontend tree, gets the backend skeleton plus the snippet only.) The generated plugin already passes the framework's contract tests. Verify before editing: @@ -126,13 +126,13 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS Each hook has a default that does nothing. Override only what your plugin needs. -## Step 7: Frontend (manual checklist) +## Step 7: Frontend (finish the generated starting points) -Backend scaffolding is automated. The frontend is a manual checklist until a frontend scaffold ships. Copy from the closest bundled plugin (`network` is the cleanest) and work through these in order: +The scaffold generates the frontend starting points too: three views, a route file, and a paste-in api-client snippet (see Step 1). They build and run out of the box against the example model, so `npm run build` is green immediately after scaffolding. The list below is what you finish by hand once the views exist. Copy patterns from the closest bundled plugin (`network` is the cleanest) as you flesh them out, and work through these in order: -1. **View files** - create `frontend/src/views/cameras/CamerasList.vue`, `CameraDetail.vue`, `CameraForm.vue`. Copy from `frontend/src/views/network/` and rename. Use the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling. +1. **View files** - the scaffold created `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue` from the example model. Replace the `examplefield` columns and inputs with your domain fields. Keep the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling. -2. **Route file** - create `frontend/src/router/routes/cameras.js` exporting a route array. The router auto-discovers every file in `routes/` via `import.meta.glob`, so no registration edit is needed. Tag EVERY route with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled. Add `requiresAuth: true` on form routes: +2. **Route file** - the scaffold created `frontend/src/router/routes/cameras.js` exporting a route array. The router auto-discovers every file in `routes/` via `import.meta.glob`, so no registration edit is needed. Every route is already tagged with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled - and the form routes already carry `requiresAuth: true`: ```js export default [ @@ -144,14 +144,14 @@ export default [ }, { path: 'cameras/:id/edit', - name: 'camera-edit', - component: () => import('../../views/cameras/CameraForm.vue'), + name: 'cameras-edit', + component: () => import('../../views/cameras/CamerasForm.vue'), meta: { requiresAuth: true, plugin: 'cameras' } } ] ``` -3. **API client** - add a `camerasApi` block to `frontend/src/api/index.js` wrapping your endpoints. Match an existing block's shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`). +3. **API client** - the scaffolded views ship with an inline `camerasApi` client so they run standalone. To graduate to the shared module, paste the generated `plugins/cameras/frontend-api-snippet.js` block into `frontend/src/api/index.js`, then delete the inline const in each view and `import { camerasApi } from '../../api'` instead. The snippet already matches the existing blocks' shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`, `remove(id)`). 4. **Sidebar entry** - implement `get_navigation_items` on the plugin class. No frontend edit: the sidebar builds itself from `/api/dashboard/navigation`. @@ -182,3 +182,5 @@ export default [ ## Distribution If you are building a plugin for a specific GE Aerospace site (sister-site adoption), ship it as its own git repo. The site running shopdb-flask clones or symlinks your plugin into `/plugins//`. See [ADR-003](../docs/adr/ADR-003-plugin-distribution.md). + +For the full own-repo workflow (layout, symlink dev loop, CI recipe with `scripts/test-external-plugin.sh`, version pinning), see [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md). For what you can rely on staying stable before contract 1.0, see [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md). diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 5ad845c..6508aaa 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -18,10 +18,12 @@ These plugins are in `plugins/` in this repo. Enable per site with `flask plugin ## Building your own -Two guides: +Guides: - [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate, customize, install, and test a plugin in 30 minutes using `flask plugin new`. - [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the full narrative walkthrough of building the `measuringtools` plugin, the exemplar that exercises every current framework feature (models, per-plugin migrations, authz, hooks, frontend integration, tests). +- [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md) - developing a plugin in its own repo per ADR-003: repo layout, symlink dev workflow, `core_version` pinning, and a runnable CI harness (`scripts/test-external-plugin.sh`) that tests the plugin against a pinned framework ref. +- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - path to contract 1.0: what is settled vs still churning, the bump rules, and how much a sister site can safely build on today. The contract is locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](adr/ADR-002-plugin-versioning.md). diff --git a/scripts/test-external-plugin.sh b/scripts/test-external-plugin.sh new file mode 100755 index 0000000..7764135 --- /dev/null +++ b/scripts/test-external-plugin.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# test-external-plugin.sh +# +# Verify an out-of-tree shopdb-flask plugin against a pinned framework build. +# Stand up a throwaway copy of the framework, drop the plugin into +# plugins// as a symlink (the mechanism ADR-003 documents for sister +# sites), and run the framework contract tests plus the plugin's own tests. +# Nonzero exit means the plugin is not compatible with that framework ref. +# +# Two modes: +# +# CI / remote (default): clone the framework at FRAMEWORK_REF from +# FRAMEWORK_URL, build a fresh venv, pip install requirements. Needs +# network access to git and PyPI. +# +# Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk. +# The script exports that checkout at HEAD with `git archive` (no network) +# and reuses the checkout's existing venv, so it runs with no internet. +# +# Inputs (env var, or positional): +# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required. +# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main. +# FRAMEWORK_URL framework git URL for CI mode. +# Default: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git +# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline. +# +# Examples: +# # CI: test the plugin in the current repo against a pinned tag +# PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh +# +# # Offline: test a bundled plugin against this checkout, no network +# LOCAL_FRAMEWORK=. PLUGIN_DIR=plugins/warranty scripts/test-external-plugin.sh +# +# Note on coverage: a symlinked plugin is discovered and loaded by the plugin +# loader (so loadability, manifest validity, and the core_version range are all +# checked), but the import-surface scan in test_plugin_contract.py uses +# Path.rglob over plugins/, which does not descend symlinked directories on +# CPython 3.12. To have that scan cover your plugin too, keep an equivalent +# import-surface assertion in your own tests/ (see docs/PLUGIN-EXTERNAL-REPO.md). + +set -eu + +PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}" +FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}" +FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}" +LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}" + +if [ -z "$PLUGIN_DIR" ]; then + echo "ERROR: PLUGIN_DIR is required (env var or first argument)." >&2 + exit 2 +fi +if [ ! -f "$PLUGIN_DIR/manifest.json" ]; then + echo "ERROR: $PLUGIN_DIR has no manifest.json - not a plugin directory." >&2 + exit 2 +fi + +# Absolute path so the symlink still resolves after we cd into the temp tree. +PLUGIN_ABS="$(cd "$PLUGIN_DIR" && pwd)" +# Plugin name: prefer the manifest name, fall back to the directory basename. +PLUGIN_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ABS/manifest.json" | head -n1)" +if [ -z "$PLUGIN_NAME" ]; then + PLUGIN_NAME="$(basename "$PLUGIN_ABS")" +fi + +WORKDIR="$(mktemp -d)" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +FRAMEWORK="$WORKDIR/framework" +mkdir -p "$FRAMEWORK" + +if [ -n "$LOCAL_FRAMEWORK" ]; then + echo "==> Local mode: exporting framework from $LOCAL_FRAMEWORK (HEAD)" + LOCAL_ABS="$(cd "$LOCAL_FRAMEWORK" && pwd)" + git -C "$LOCAL_ABS" archive HEAD | tar -x -C "$FRAMEWORK" + # Reuse the checkout's venv so no pip and no network are needed. + if [ -x "$LOCAL_ABS/venv/bin/python" ]; then + PYTHON="$LOCAL_ABS/venv/bin/python" + elif [ -x "$LOCAL_ABS/.venv/bin/python" ]; then + PYTHON="$LOCAL_ABS/.venv/bin/python" + else + echo "ERROR: no venv found under $LOCAL_ABS (looked for venv/ and .venv/)." >&2 + exit 2 + fi +else + echo "==> CI mode: cloning $FRAMEWORK_URL @ $FRAMEWORK_REF" + git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" "$FRAMEWORK" + echo "==> Creating venv and installing requirements" + python3 -m venv "$WORKDIR/venv" + PYTHON="$WORKDIR/venv/bin/python" + "$PYTHON" -m pip install --upgrade pip >/dev/null + "$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt" +fi + +echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/" +rm -rf "$FRAMEWORK/plugins/$PLUGIN_NAME" +ln -s "$PLUGIN_ABS" "$FRAMEWORK/plugins/$PLUGIN_NAME" + +# Drop any exported registry so the loader re-discovers and enables the plugin +# (the test conftest seeds a fresh registry from plugins/*/manifest.json). +rm -f "$FRAMEWORK/instance/plugins.json" + +RC=0 + +echo "==> Running framework contract tests" +( cd "$FRAMEWORK" && "$PYTHON" -m pytest tests/test_plugin_contract.py -q ) || RC=1 + +if [ -d "$PLUGIN_ABS/tests" ]; then + echo "==> Running plugin's own tests" + ( cd "$FRAMEWORK" && "$PYTHON" -m pytest "$PLUGIN_ABS/tests" -q ) || RC=1 +else + echo "==> Plugin has no tests/ directory - skipping plugin test step" +fi + +if [ "$RC" -eq 0 ]; then + echo "==> PASS: plugin '$PLUGIN_NAME' is compatible with framework ref '$FRAMEWORK_REF'" +else + echo "==> FAIL: plugin '$PLUGIN_NAME' - see output above" >&2 +fi +exit "$RC" diff --git a/shopdb/plugins/scaffolder.py b/shopdb/plugins/scaffolder.py index 72a1b6a..ea6eebe 100644 --- a/shopdb/plugins/scaffolder.py +++ b/shopdb/plugins/scaffolder.py @@ -57,12 +57,104 @@ def pascal_case(name: str) -> str: return name[:1].upper() + name[1:] +def _render_template( + template_path: Path, + out_path: Path, + substitutions: dict, + overwrite: bool, +) -> bool: + """Render one template to out_path. + + Skips silently when out_path already exists and overwrite is False, so a + scaffold never clobbers a file the author has already edited. Returns True + when the file was written, False when it was skipped. + """ + if out_path.exists() and not overwrite: + return False + out_path.parent.mkdir(parents=True, exist_ok=True) + body = template_path.read_text() + out_path.write_text(Template(body).safe_substitute(substitutions)) + return True + + +def _scaffold_frontend( + name: str, + substitutions: dict, + plugin_target: Path, + frontend_dir: Path, + template_root: Path, + overwrite: bool, +) -> None: + """Render the frontend starting points for a scaffolded plugin. + + Writes the paste-in api-client snippet into the plugin directory, then the + Vue views and the router route file into the real frontend tree. The + snippet is emitted regardless of whether the frontend tree exists, because + it is a plugin-directory artifact useful even for external-repo plugins. + Views and the route file are skipped when frontend_dir is missing, which is + the normal case for a plugin developed in its own repository. + + Ordering matters: every view is written before the route file. A route file + that lazy-imports a view that is not on disk crashes the Vite dev server, + so the views must land first. + """ + fe_templates = template_root / 'frontend' + if not fe_templates.exists(): + return + + plugin_name = substitutions['Name'] + + # snippet lands in the plugin dir; author pastes it into api/index.js + snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl' + if snippet_template.exists(): + _render_template( + snippet_template, + plugin_target / 'frontend-api-snippet.js', + substitutions, + overwrite, + ) + + # views and route need the real frontend tree; external repos skip these + if not frontend_dir.exists(): + return + + views_dir = frontend_dir / 'views' / name + + # views first: route file lazy-imports them, missing views 500 vite + view_templates = { + 'List.vue.tmpl': f'{plugin_name}List.vue', + 'Detail.vue.tmpl': f'{plugin_name}Detail.vue', + 'Form.vue.tmpl': f'{plugin_name}Form.vue', + } + for template_name, out_name in view_templates.items(): + template_path = fe_templates / 'views' / template_name + if template_path.exists(): + _render_template( + template_path, + views_dir / out_name, + substitutions, + overwrite, + ) + + # route file last: all referenced views now exist on disk + route_template = fe_templates / 'routes.js.tmpl' + if route_template.exists(): + _render_template( + route_template, + frontend_dir / 'router' / 'routes' / f'{name}.js', + substitutions, + overwrite, + ) + + def scaffold_plugin( name: str, description: str, plugins_dir: Path, template_root: Optional[Path] = None, overwrite: bool = False, + frontend: bool = True, + frontend_dir: Optional[Path] = None, ) -> Path: """Generate a new plugin from templates. @@ -71,7 +163,14 @@ def scaffold_plugin( description: One-sentence description for manifest.json + README plugins_dir: Target plugins directory (e.g., /plugins) template_root: Override template source dir (default: bundled templates) - overwrite: If True, overwrite an existing plugin directory + overwrite: If True, overwrite an existing plugin directory and any + existing generated frontend files + frontend: If True, also render the Vue frontend starting points (list, + detail, form views, a route file, and a paste-in api-client snippet) + frontend_dir: Frontend src directory to render views/routes into + (default: /../frontend/src). Views and the route file + are skipped when this directory does not exist, which is the normal + case for a plugin developed in its own repository. Returns: Path to the generated plugin directory. @@ -106,6 +205,10 @@ def scaffold_plugin( for template_path in template_root.rglob('*.tmpl'): rel = template_path.relative_to(template_root) + # frontend templates render into the frontend tree, not the plugin dir + if rel.parts and rel.parts[0] == 'frontend': + continue + out_rel_str = str(rel.with_suffix('')) if 'model.py' in out_rel_str: out_rel_str = out_rel_str.replace('model.py', f'{name}.py') @@ -118,4 +221,16 @@ def scaffold_plugin( out_path.write_text(rendered) + if frontend: + if frontend_dir is None: + frontend_dir = plugins_dir.parent / 'frontend' / 'src' + _scaffold_frontend( + name=name, + substitutions=substitutions, + plugin_target=target, + frontend_dir=Path(frontend_dir), + template_root=template_root, + overwrite=overwrite, + ) + return target diff --git a/shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl b/shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl new file mode 100644 index 0000000..5877649 --- /dev/null +++ b/shopdb/plugins/templates/frontend/frontend-api-snippet.js.tmpl @@ -0,0 +1,35 @@ +/* + * ${Name} API client snippet. + * + * Paste this ${name}Api block into frontend/src/api/index.js (next to the + * other per-resource blocks). Then, in the generated ${Name}List, ${Name}Detail, + * and ${Name}Form views, delete the local ${name}Api const and import the shared + * one instead: + * + * import { ${name}Api } from '../../api' + * + * The scaffolded views ship with an identical inline client so they build and + * run before you touch the shared api module. This file is NOT auto-merged into + * api/index.js on purpose; that module is hand-maintained and shared. + * + * The create/update/delete calls assume matching POST/PUT/DELETE routes exist + * on the backend. The scaffolded api/routes.py only ships list and get; add the + * write endpoints when you wire up the form. + */ +export const ${name}Api = { + list(params = {}) { + return api.get('/${name}', { params }) + }, + get(itemId) { + return api.get(`/${name}/${itemId}`) + }, + create(data) { + return api.post('/${name}', data) + }, + update(itemId, data) { + return api.put(`/${name}/${itemId}`, data) + }, + remove(itemId) { + return api.delete(`/${name}/${itemId}`) + } +} diff --git a/shopdb/plugins/templates/frontend/routes.js.tmpl b/shopdb/plugins/templates/frontend/routes.js.tmpl new file mode 100644 index 0000000..758b6f7 --- /dev/null +++ b/shopdb/plugins/templates/frontend/routes.js.tmpl @@ -0,0 +1,35 @@ +/** + * ${Name} plugin routes. + * + * Auto-discovered by the router via import.meta.glob, so no registration + * edit is needed. Every route carries meta.plugin '${name}' so the ADR-009 + * guard redirects to the dashboard when the ${name} backend plugin is + * disabled. Form routes add requiresAuth so anonymous users cannot reach + * create or edit. + */ +export default [ + { + path: '${name}', + name: '${name}', + component: () => import('../../views/${name}/${Name}List.vue'), + meta: { plugin: '${name}' } + }, + { + path: '${name}/new', + name: '${name}-new', + component: () => import('../../views/${name}/${Name}Form.vue'), + meta: { requiresAuth: true, plugin: '${name}' } + }, + { + path: '${name}/:id', + name: '${name}-detail', + component: () => import('../../views/${name}/${Name}Detail.vue'), + meta: { plugin: '${name}' } + }, + { + path: '${name}/:id/edit', + name: '${name}-edit', + component: () => import('../../views/${name}/${Name}Form.vue'), + meta: { requiresAuth: true, plugin: '${name}' } + } +] diff --git a/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl b/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl new file mode 100644 index 0000000..9777a79 --- /dev/null +++ b/shopdb/plugins/templates/frontend/views/Detail.vue.tmpl @@ -0,0 +1,149 @@ + + + + + diff --git a/shopdb/plugins/templates/frontend/views/Form.vue.tmpl b/shopdb/plugins/templates/frontend/views/Form.vue.tmpl new file mode 100644 index 0000000..a1a4031 --- /dev/null +++ b/shopdb/plugins/templates/frontend/views/Form.vue.tmpl @@ -0,0 +1,228 @@ + + + + + diff --git a/shopdb/plugins/templates/frontend/views/List.vue.tmpl b/shopdb/plugins/templates/frontend/views/List.vue.tmpl new file mode 100644 index 0000000..bf324aa --- /dev/null +++ b/shopdb/plugins/templates/frontend/views/List.vue.tmpl @@ -0,0 +1,142 @@ + + + + + diff --git a/tests/test_plugin_scaffold_frontend.py b/tests/test_plugin_scaffold_frontend.py new file mode 100644 index 0000000..ec6e8db --- /dev/null +++ b/tests/test_plugin_scaffold_frontend.py @@ -0,0 +1,252 @@ +"""Canary tests for the plugin scaffolder's frontend generation. + +`flask plugin new ` generates the backend skeleton and, when a frontend +tree is present, a matching Vue frontend starting point: list, detail, and form +views, a router route file, and a paste-in api-client snippet. These tests +guard the file names, the substitutions, the ADR-009 plugin gating on the +routes, the use of global CSS classes over per-page input styling, graceful +skipping when there is no frontend tree, and the no-clobber behaviour. + +All generation runs against tmp_path. The real frontend/src tree is never +touched. +""" + +from pathlib import Path + +import pytest + +from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError + + +def _make_dirs(tmp_path: Path): + """Create a tmp plugins dir and a tmp frontend/src tree. + + Returns (plugins_dir, frontend_dir). + """ + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + frontend_dir = tmp_path / 'frontend' / 'src' + frontend_dir.mkdir(parents=True) + return plugins_dir, frontend_dir + + +def test_frontend_files_generated_with_correct_names(tmp_path): + """Views, route file, and api snippet appear with the expected names.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + target = scaffold_plugin( + name='widgets', + description='Test widgets plugin', + plugins_dir=plugins_dir, + frontend_dir=frontend_dir, + ) + + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists() + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue').exists() + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue').exists() + assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() + # paste-in snippet lands in the plugin dir, not the frontend tree + assert (target / 'frontend-api-snippet.js').exists() + + +def test_frontend_substitutions_applied(tmp_path): + """Name and Name placeholders are substituted, none left raw.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() + assert "import('../../views/widgets/WidgetsList.vue')" in route + assert "import('../../views/widgets/WidgetsForm.vue')" in route + assert "import('../../views/widgets/WidgetsDetail.vue')" in route + + list_view = (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').read_text() + assert 'widgetsApi' in list_view + assert "api.get('/widgets'" in list_view + # no unresolved template placeholders for our known substitution keys + assert '${name}' not in list_view + assert '${Name}' not in list_view + + +def test_route_file_has_plugin_gating_and_requiresauth(tmp_path): + """Every route carries meta.plugin; form routes add requiresAuth.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() + + # ADR-009 gating on every route + assert route.count("plugin: 'widgets'") == 4 + # form routes (new + edit) require auth; list + detail do not + assert route.count('requiresAuth: true') == 2 + assert "path: 'widgets/new'" in route + assert "path: 'widgets/:id/edit'" in route + + +def test_views_use_global_classes_not_custom_input_css(tmp_path): + """Views lean on global .filters / .form-control / .card classes and never + redefine input styling in a scoped block.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + views = frontend_dir / 'views' / 'widgets' + list_view = (views / 'WidgetsList.vue').read_text() + form_view = (views / 'WidgetsForm.vue').read_text() + + assert 'class="filters"' in list_view + assert 'class="form-control"' in list_view + assert 'class="card"' in list_view + assert 'class="form-control"' in form_view + + # per-page input styling is forbidden: no scoped rule targets .form-control + # on its own (a descendant layout helper like `.filters .form-control` is ok) + import re + for view in (list_view, form_view): + assert not re.search(r'(?m)^\s*\.form-control\s*\{', view) + + +def test_default_frontend_dir_resolves_relative_to_plugins(tmp_path): + """With no frontend_dir arg, views land in /../frontend/src.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + scaffold_plugin('widgets', 'Test', plugins_dir) + + assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists() + assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() + + +def test_skip_when_no_frontend_dir(tmp_path): + """No frontend tree: views and route are skipped, backend still generated. + + The api snippet is still emitted because it is a plugin-directory artifact. + """ + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + missing_frontend = tmp_path / 'nowhere' / 'src' + + target = scaffold_plugin( + name='widgets', + description='Test', + plugins_dir=plugins_dir, + frontend_dir=missing_frontend, + ) + + # backend skeleton is intact + assert (target / 'manifest.json').exists() + assert (target / 'plugin.py').exists() + # frontend views and route were skipped + assert not missing_frontend.exists() + # snippet still lands in the plugin dir + assert (target / 'frontend-api-snippet.js').exists() + + +def test_frontend_can_be_disabled(tmp_path): + """frontend=False generates neither views, route, nor snippet.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + target = scaffold_plugin( + name='widgets', + description='Test', + plugins_dir=plugins_dir, + frontend_dir=frontend_dir, + frontend=False, + ) + + assert not (frontend_dir / 'views' / 'widgets').exists() + assert not (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() + assert not (target / 'frontend-api-snippet.js').exists() + + +def test_no_clobber_without_overwrite(tmp_path): + """An existing frontend view is left untouched unless overwrite is set.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + # author already hand-edited a view before re-scaffolding + views_dir = frontend_dir / 'views' / 'widgets' + views_dir.mkdir(parents=True) + existing = views_dir / 'WidgetsList.vue' + existing.write_text('SENTINEL do not clobber') + + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + # untouched without overwrite + assert existing.read_text() == 'SENTINEL do not clobber' + + +def test_overwrite_replaces_existing_frontend(tmp_path): + """overwrite=True regenerates an existing frontend view.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + + views_dir = frontend_dir / 'views' / 'widgets' + views_dir.mkdir(parents=True) + existing = views_dir / 'WidgetsList.vue' + existing.write_text('SENTINEL do not clobber') + + # plugin dir must also exist to reach overwrite path + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + scaffold_plugin( + 'widgets', 'Test', plugins_dir, + frontend_dir=frontend_dir, overwrite=True, + ) + + assert existing.read_text() != 'SENTINEL do not clobber' + assert 'widgetsApi' in existing.read_text() + + +def test_route_references_only_existing_views(tmp_path): + """Every view lazy-imported by the route file exists on disk. + + Guards the write-views-before-route ordering: a route pointing at a missing + view crashes the Vite dev server. + """ + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) + + route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() + import re + imported = re.findall(r"import\('(\.\./\.\./views/[^']+)'\)", route) + assert imported + for rel in imported: + # rel is relative to router/routes/; resolve against that dir + resolved = (frontend_dir / 'router' / 'routes' / rel).resolve() + assert resolved.exists(), f'route imports missing view: {rel}' + + +def test_generated_frontend_is_ascii(tmp_path): + """Generated frontend files contain plain ASCII only.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + target = scaffold_plugin( + 'widgets', 'Test', plugins_dir, frontend_dir=frontend_dir, + ) + + generated = [ + frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue', + frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue', + frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue', + frontend_dir / 'router' / 'routes' / 'widgets.js', + target / 'frontend-api-snippet.js', + ] + for path in generated: + text = path.read_text() + text.encode('ascii') # raises if any non-ASCII slipped in + + +def test_frontend_dir_not_created_when_absent(tmp_path): + """Scaffolder does not fabricate a frontend tree that was not there.""" + plugins_dir = tmp_path / 'plugins' + plugins_dir.mkdir() + + # no frontend dir exists; default resolves to /frontend/src (absent) + scaffold_plugin('widgets', 'Test', plugins_dir) + + assert not (tmp_path / 'frontend').exists() + + +def test_scaffold_still_raises_on_existing_plugin(tmp_path): + """Frontend generation does not weaken the plugin-dir overwrite guard.""" + plugins_dir, frontend_dir = _make_dirs(tmp_path) + scaffold_plugin('widgets', 'first', plugins_dir, frontend_dir=frontend_dir) + + with pytest.raises(ScaffoldError, match='already exists'): + scaffold_plugin( + 'widgets', 'second', plugins_dir, frontend_dir=frontend_dir, + )