Scaffold a plugin's frontend into the plugin, so it can be pruned

`flask plugin new` wrote views to frontend/src/views/<name>/ and a route file
to frontend/src/router/routes/<name>.js. Every one of the 16 bundled plugins
keeps its frontend in plugins/<name>/frontend/ instead, staged into
.plugins-staged by stage-frontend.mjs and aggregated into routes.gen.js.

It was not visibly broken, which is why it lasted: the router still globs
./routes/*.js during the Phase 4 dual-location transition, so a scaffolded
plugin's pages loaded. What a new plugin author silently lost was ADR-013 lean
builds. Pruning works off the staged tree, so a frontend in the core tree
cannot be excluded from a site build, and deleting the plugin strands its
views behind.

The templates for the correct layout already existed; only the output paths
were wrong. Also fixed the imports they carry: core imports now use the @
alias (@/api, @/components/...), which resolves both before and after staging,
where the previous ../../api was correct only in the old location. Sibling
views stay relative and travel with the route file.

frontend_dir is gone from scaffold_plugin. There is no second output tree to
point at, and a plugin developed in its own repo now gets the same frontend as
a bundled one rather than being skipped.

Proven end to end: scaffolded a throwaway plugin, confirmed it stages, builds
real chunks, and that SITE_PLUGINS=network prunes it, which is what the old
layout could not do.
This commit is contained in:
cproudlock
2026-08-24 14:05:15 -04:00
parent eecb0595dd
commit 7b79a444b8
7 changed files with 232 additions and 207 deletions

View File

@@ -17,7 +17,9 @@ For the architectural decisions behind the contract, see [docs/adr/](../docs/adr
flask plugin new cameras --description "Tracks shop-floor surveillance cameras" flask plugin new cameras --description "Tracks shop-floor surveillance cameras"
``` ```
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.) Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, a README, and a paste-in `frontend-api-snippet.js`. It also writes the frontend starting points, and they live **inside the plugin**: `plugins/cameras/frontend/routes.js` and `plugins/cameras/frontend/views/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`.
Nothing is written into `frontend/src/`. The plugin owns its frontend because that is what makes it prunable: `scripts/stage-frontend.mjs` copies each plugin's `frontend/` into `frontend/src/.plugins-staged/<name>/` and aggregates the route modules into `routes.gen.js`, so a plugin left out of `SITE_PLUGINS` simply is not copied and its frontend leaves the build with it (ADR-013). A plugin developed in its own repo gets exactly this same layout, and its frontend starts working the moment the directory is dropped into a host repo's `plugins/`.
The generated plugin already passes the framework's contract tests. Verify before editing: The generated plugin already passes the framework's contract tests. Verify before editing:
@@ -142,28 +144,35 @@ Each hook has a default that does nothing. Override only what your plugin needs.
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: 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** - 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. 1. **View files** - the scaffold created `plugins/cameras/frontend/views/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** - 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`: **Imports have one rule, and staging is the reason.** Anything from the CORE tree uses the `@` alias (`@/api`, `@/components/PaginationBar.vue`, `@/stores/auth`); anything inside your own plugin is relative (`./views/...`). A relative path into the core tree such as `../../api` is correct where the file sits in your plugin and wrong once staged into `.plugins-staged/cameras/`, and the failure is a build error rather than anything subtle.
2. **Route file** - the scaffold created `plugins/cameras/frontend/routes.js` exporting a route array plus a `toplevel` array. `stage-frontend.mjs` aggregates both into `routes.gen.js`, which the router spreads in, 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 ```js
export default [ export default [
{ {
path: 'cameras', path: 'cameras',
name: 'cameras', name: 'cameras',
component: () => import('../../views/cameras/CamerasList.vue'), component: () => import('./views/CamerasList.vue'),
meta: { plugin: 'cameras' } meta: { plugin: 'cameras' }
}, },
{ {
path: 'cameras/:id/edit', path: 'cameras/:id/edit',
name: 'cameras-edit', name: 'cameras-edit',
component: () => import('../../views/cameras/CamerasForm.vue'), component: () => import('./views/CamerasForm.vue'),
meta: { requiresAuth: true, plugin: 'cameras' } meta: { requiresAuth: true, plugin: 'cameras' }
} }
] ]
// Full-screen routes that render OUTSIDE AppLayout (kiosk, print pages).
// Export it even when empty: routes.gen.js reads `.toplevel` off every staged
// module, and a missing export is a Rollup warning under strict ESM.
export const toplevel = []
``` ```
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)`). 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`. 4. **Sidebar entry** - implement `get_navigation_items` on the plugin class. No frontend edit: the sidebar builds itself from `/api/dashboard/navigation`.

View File

@@ -81,18 +81,28 @@ def _scaffold_frontend(
name: str, name: str,
substitutions: dict, substitutions: dict,
plugin_target: Path, plugin_target: Path,
frontend_dir: Path,
template_root: Path, template_root: Path,
overwrite: bool, overwrite: bool,
) -> None: ) -> None:
"""Render the frontend starting points for a scaffolded plugin. """Render the frontend starting points for a scaffolded plugin.
Writes the paste-in api-client snippet into the plugin directory, then the Everything lands INSIDE the plugin, under `plugins/<name>/frontend/`:
Vue views and the router route file into the real frontend tree. The `routes.js` plus `views/<Name>{List,Detail,Form}.vue`, and the paste-in
snippet is emitted regardless of whether the frontend tree exists, because api-client snippet at the plugin root.
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 plugin owns its frontend because that is what makes it prunable.
the normal case for a plugin developed in its own repository. scripts/stage-frontend.mjs copies each plugin's `frontend/` into
`frontend/src/.plugins-staged/<name>/` and aggregates the route modules
into `routes.gen.js`; a plugin left out of SITE_PLUGINS is simply not
copied, so its frontend leaves the build with it (ADR-013). A route file
written into the CORE tree instead is still picked up - the router globs
`./routes/*.js` during the Phase 4 dual-location transition - but it is
invisible to that pruning, and deleting the plugin strands its views.
This is also why nothing here depends on the core frontend tree existing.
A plugin developed in its own repo gets exactly the same layout, and its
frontend starts working the moment the plugin directory is dropped into
`plugins/`.
Ordering matters: every view is written before the route file. A route file 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, that lazy-imports a view that is not on disk crashes the Vite dev server,
@@ -104,7 +114,7 @@ def _scaffold_frontend(
plugin_name = substitutions['Name'] plugin_name = substitutions['Name']
# snippet lands in the plugin dir; author pastes it into api/index.js # snippet lands at the plugin root; author pastes it into api/index.js
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl' snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
if snippet_template.exists(): if snippet_template.exists():
_render_template( _render_template(
@@ -114,11 +124,8 @@ def _scaffold_frontend(
overwrite, overwrite,
) )
# views and route need the real frontend tree; external repos skip these frontend_dir = plugin_target / 'frontend'
if not frontend_dir.exists(): views_dir = frontend_dir / 'views'
return
views_dir = frontend_dir / 'views' / name
# views first: route file lazy-imports them, missing views 500 vite # views first: route file lazy-imports them, missing views 500 vite
view_templates = { view_templates = {
@@ -141,7 +148,7 @@ def _scaffold_frontend(
if route_template.exists(): if route_template.exists():
_render_template( _render_template(
route_template, route_template,
frontend_dir / 'router' / 'routes' / f'{name}.js', frontend_dir / 'routes.js',
substitutions, substitutions,
overwrite, overwrite,
) )
@@ -154,7 +161,6 @@ def scaffold_plugin(
template_root: Optional[Path] = None, template_root: Optional[Path] = None,
overwrite: bool = False, overwrite: bool = False,
frontend: bool = True, frontend: bool = True,
frontend_dir: Optional[Path] = None,
) -> Path: ) -> Path:
"""Generate a new plugin from templates. """Generate a new plugin from templates.
@@ -166,11 +172,10 @@ def scaffold_plugin(
overwrite: If True, overwrite an existing plugin directory and any overwrite: If True, overwrite an existing plugin directory and any
existing generated frontend files existing generated frontend files
frontend: If True, also render the Vue frontend starting points (list, frontend: If True, also render the Vue frontend starting points (list,
detail, form views, a route file, and a paste-in api-client snippet) detail, form views, a route file, and a paste-in api-client
frontend_dir: Frontend src directory to render views/routes into snippet) into `plugins/<name>/frontend/`. There is no separate
(default: <plugins_dir>/../frontend/src). Views and the route file output directory: the plugin owns its frontend so that
are skipped when this directory does not exist, which is the normal stage-frontend.mjs can stage it and a lean build can prune it.
case for a plugin developed in its own repository.
Returns: Returns:
Path to the generated plugin directory. Path to the generated plugin directory.
@@ -205,7 +210,9 @@ def scaffold_plugin(
for template_path in template_root.rglob('*.tmpl'): for template_path in template_root.rglob('*.tmpl'):
rel = template_path.relative_to(template_root) rel = template_path.relative_to(template_root)
# frontend templates render into the frontend tree, not the plugin dir # frontend templates are handled by _scaffold_frontend: they are
# renamed on the way out (List.vue.tmpl -> <Name>List.vue) and are
# skipped entirely when frontend=False
if rel.parts and rel.parts[0] == 'frontend': if rel.parts and rel.parts[0] == 'frontend':
continue continue
@@ -222,13 +229,10 @@ def scaffold_plugin(
out_path.write_text(rendered) out_path.write_text(rendered)
if frontend: if frontend:
if frontend_dir is None:
frontend_dir = plugins_dir.parent / 'frontend' / 'src'
_scaffold_frontend( _scaffold_frontend(
name=name, name=name,
substitutions=substitutions, substitutions=substitutions,
plugin_target=target, plugin_target=target,
frontend_dir=Path(frontend_dir),
template_root=template_root, template_root=template_root,
overwrite=overwrite, overwrite=overwrite,
) )

View File

@@ -1,35 +1,49 @@
/** /**
* ${Name} plugin routes. * ${Name} plugin routes.
* *
* Auto-discovered by the router via import.meta.glob, so no registration * This file lives in the plugin, not in the core frontend tree.
* edit is needed. Every route carries meta.plugin '${name}' so the ADR-009 * scripts/stage-frontend.mjs copies plugins/${name}/frontend/ into
* guard redirects to the dashboard when the ${name} backend plugin is * frontend/src/.plugins-staged/${name}/ and aggregates this default export
* disabled. Form routes add requiresAuth so anonymous users cannot reach * into routes.gen.js, which the router spreads in - so no registration edit
* create or edit. * is needed, and pruning the plugin for a lean build removes its frontend
* with it (ADR-013).
*
* 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.
*
* View imports are relative (./views/...) because they travel with this file.
* Anything from the CORE tree uses the @ alias (@/api, @/components/...),
* which resolves the same before and after staging.
*/ */
export default [ export default [
{ {
path: '${name}', path: '${name}',
name: '${name}', name: '${name}',
component: () => import('../../views/${name}/${Name}List.vue'), component: () => import('./views/${Name}List.vue'),
meta: { plugin: '${name}' } meta: { plugin: '${name}' }
}, },
{ {
path: '${name}/new', path: '${name}/new',
name: '${name}-new', name: '${name}-new',
component: () => import('../../views/${name}/${Name}Form.vue'), component: () => import('./views/${Name}Form.vue'),
meta: { requiresAuth: true, plugin: '${name}' } meta: { requiresAuth: true, plugin: '${name}' }
}, },
{ {
path: '${name}/:id', path: '${name}/:id',
name: '${name}-detail', name: '${name}-detail',
component: () => import('../../views/${name}/${Name}Detail.vue'), component: () => import('./views/${Name}Detail.vue'),
meta: { plugin: '${name}' } meta: { plugin: '${name}' }
}, },
{ {
path: '${name}/:id/edit', path: '${name}/:id/edit',
name: '${name}-edit', name: '${name}-edit',
component: () => import('../../views/${name}/${Name}Form.vue'), component: () => import('./views/${Name}Form.vue'),
meta: { requiresAuth: true, plugin: '${name}' } meta: { requiresAuth: true, plugin: '${name}' }
} }
] ]
// Full-screen routes that render OUTSIDE AppLayout (kiosk, print pages).
// Declared even when empty so routes.gen.js can read `.toplevel` off the
// module without Rollup warning about a missing export.
export const toplevel = []

View File

@@ -78,12 +78,12 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import api from '../../api' import api from '@/api'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '@/stores/auth'
// local api client. move into src/api/index.js (see // local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for: // plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api' // import { ${name}Api } from '@/api'
const ${name}Api = { const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) }, list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) }, get(itemId) { return api.get(`/${name}/${itemId}`) },

View File

@@ -77,11 +77,11 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import api from '../../api' import api from '@/api'
// local api client. move into src/api/index.js (see // local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for: // plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api' // import { ${name}Api } from '@/api'
const ${name}Api = { const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) }, list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) }, get(itemId) { return api.get(`/${name}/${itemId}`) },

View File

@@ -66,12 +66,12 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import api from '../../api' import api from '@/api'
import PaginationBar from '../../components/PaginationBar.vue' import PaginationBar from '@/components/PaginationBar.vue'
// local api client. move into src/api/index.js (see // local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for: // plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api' // import { ${name}Api } from '@/api'
const ${name}Api = { const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) }, list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) }, get(itemId) { return api.get(`/${name}/${itemId}`) },

View File

@@ -1,16 +1,21 @@
"""Canary tests for the plugin scaffolder's frontend generation. """Canary tests for the plugin scaffolder's frontend generation.
`flask plugin new <name>` generates the backend skeleton and, when a frontend `flask plugin new <name>` generates the backend skeleton and a matching Vue
tree is present, a matching Vue frontend starting point: list, detail, and form frontend starting point: list, detail, and form views, a route file, and a
views, a router route file, and a paste-in api-client snippet. These tests paste-in api-client snippet. All of it lands INSIDE the plugin, under
guard the file names, the substitutions, the ADR-009 plugin gating on the `plugins/<name>/frontend/`, which is what lets stage-frontend.mjs stage it and
routes, the use of global CSS classes over per-page input styling, graceful a lean build prune it (ADR-013).
skipping when there is no frontend tree, and the no-clobber behaviour.
These tests guard the file names and their location, the substitutions, the
ADR-009 plugin gating on the routes, the alias-vs-relative import split that
has to survive staging, the use of global CSS classes over per-page input
styling, and the no-clobber behaviour.
All generation runs against tmp_path. The real frontend/src tree is never All generation runs against tmp_path. The real frontend/src tree is never
touched. touched.
""" """
import re
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -18,178 +23,172 @@ import pytest
from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError
def _make_dirs(tmp_path: Path): def _make_plugins_dir(tmp_path: Path) -> Path:
"""Create a tmp plugins dir and a tmp frontend/src tree. """Create a tmp plugins dir."""
Returns (plugins_dir, frontend_dir).
"""
plugins_dir = tmp_path / 'plugins' plugins_dir = tmp_path / 'plugins'
plugins_dir.mkdir() plugins_dir.mkdir()
frontend_dir = tmp_path / 'frontend' / 'src' return plugins_dir
frontend_dir.mkdir(parents=True)
return plugins_dir, frontend_dir
def test_frontend_files_generated_with_correct_names(tmp_path): def test_frontend_files_generated_inside_the_plugin(tmp_path):
"""Views, route file, and api snippet appear with the expected names.""" """Views, route file, and api snippet appear with the expected names.
plugins_dir, frontend_dir = _make_dirs(tmp_path)
Location is the point of the test: the frontend belongs to the plugin, not
to the core tree.
"""
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin( target = scaffold_plugin(
name='widgets', name='widgets',
description='Test widgets plugin', description='Test widgets plugin',
plugins_dir=plugins_dir, plugins_dir=plugins_dir,
frontend_dir=frontend_dir,
) )
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists() assert (target / 'frontend' / 'views' / 'WidgetsList.vue').exists()
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue').exists() assert (target / 'frontend' / 'views' / 'WidgetsDetail.vue').exists()
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue').exists() assert (target / 'frontend' / 'views' / 'WidgetsForm.vue').exists()
assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists() assert (target / 'frontend' / 'routes.js').exists()
# paste-in snippet lands in the plugin dir, not the frontend tree
assert (target / 'frontend-api-snippet.js').exists() assert (target / 'frontend-api-snippet.js').exists()
def test_nothing_is_written_into_the_core_frontend_tree(tmp_path):
"""The scaffolder never touches frontend/src.
This is the regression that prompted the move. Writing views to
`frontend/src/views/<name>/` and a route to
`frontend/src/router/routes/<name>.js` still *renders* - the router globs
that directory - but the result is invisible to stage-frontend.mjs, so the
plugin cannot be pruned from a lean build and deleting it strands its
views in the core tree.
"""
plugins_dir = _make_plugins_dir(tmp_path)
core_frontend = tmp_path / 'frontend' / 'src'
core_frontend.mkdir(parents=True)
scaffold_plugin('widgets', 'Test', plugins_dir)
assert not (core_frontend / 'views' / 'widgets').exists()
assert not (core_frontend / 'router' / 'routes' / 'widgets.js').exists()
assert list(core_frontend.iterdir()) == []
def test_frontend_generated_with_no_core_tree_at_all(tmp_path):
"""A plugin developed in its own repo gets the same frontend.
There is no core frontend tree to depend on, and none is fabricated. The
plugin's frontend starts working the moment the directory is dropped into
a host repo's `plugins/`.
"""
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin('widgets', 'Test', plugins_dir)
assert (target / 'frontend' / 'routes.js').exists()
assert (target / 'frontend' / 'views' / 'WidgetsList.vue').exists()
assert not (tmp_path / 'frontend').exists()
def test_frontend_substitutions_applied(tmp_path): def test_frontend_substitutions_applied(tmp_path):
"""Name and Name placeholders are substituted, none left raw.""" """Name and Name substitutions land in the route file and the views."""
plugins_dir, frontend_dir = _make_dirs(tmp_path) plugins_dir = _make_plugins_dir(tmp_path)
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) target = scaffold_plugin('widgets', 'Test', plugins_dir)
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() route = (target / 'frontend' / 'routes.js').read_text()
assert "import('../../views/widgets/WidgetsList.vue')" in route assert "import('./views/WidgetsList.vue')" in route
assert "import('../../views/widgets/WidgetsForm.vue')" in route assert "import('./views/WidgetsForm.vue')" in route
assert "import('../../views/widgets/WidgetsDetail.vue')" in route assert "import('./views/WidgetsDetail.vue')" in route
list_view = (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').read_text() list_view = (target / 'frontend' / 'views' / 'WidgetsList.vue').read_text()
assert 'widgetsApi' in list_view assert 'widgetsApi' in list_view
assert "api.get('/widgets'" in list_view assert "api.get('/widgets'" in list_view
# no unresolved template placeholders for our known substitution keys # No unresolved placeholders for our known substitution keys. Checked by
# key, not for a bare '${': the views carry real JS template literals
# (`/widgets/${item.assetid}`) that must survive.
assert '${name}' not in list_view assert '${name}' not in list_view
assert '${Name}' not in list_view assert '${Name}' not in list_view
def test_route_file_has_plugin_gating_and_requiresauth(tmp_path): def test_route_file_has_plugin_gating_and_requiresauth(tmp_path):
"""Every route carries meta.plugin; form routes add requiresAuth.""" """ADR-009 gating on every route; requiresAuth on the form routes."""
plugins_dir, frontend_dir = _make_dirs(tmp_path) plugins_dir = _make_plugins_dir(tmp_path)
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) target = scaffold_plugin('widgets', 'Test', plugins_dir)
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() route = (target / 'frontend' / 'routes.js').read_text()
# every route is gated on the plugin being enabled
# ADR-009 gating on every route
assert route.count("plugin: 'widgets'") == 4 assert route.count("plugin: 'widgets'") == 4
# form routes (new + edit) require auth; list + detail do not # create and edit are behind auth
assert route.count('requiresAuth: true') == 2 assert route.count('requiresAuth: true') == 2
assert "path: 'widgets/new'" in route
assert "path: 'widgets/:id/edit'" in route
def test_route_file_exports_toplevel(tmp_path):
"""`toplevel` is exported even when empty.
routes.gen.js reads `.toplevel` off every staged route module; a module
without the export is a Rollup warning under strict ESM.
"""
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin('widgets', 'Test', plugins_dir)
route = (target / 'frontend' / 'routes.js').read_text()
assert 'export const toplevel' in route
def test_view_imports_survive_staging(tmp_path):
"""Core imports use the @ alias; sibling views are relative.
stage-frontend.mjs copies `plugins/<name>/frontend/` to
`frontend/src/.plugins-staged/<name>/`, so a `../../api` written for the
old location resolves somewhere else entirely once staged. `@/api` is
correct in both places, and `./views/...` travels with the route file.
"""
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin('widgets', 'Test', plugins_dir)
views = target / 'frontend' / 'views'
for view in views.iterdir():
text = view.read_text()
assert '../../api' not in text
assert '../../components' not in text
assert '../../stores' not in text
route = (target / 'frontend' / 'routes.js').read_text()
assert './views/' in route
assert '../../views' not in route
def test_views_use_global_classes_not_custom_input_css(tmp_path): def test_views_use_global_classes_not_custom_input_css(tmp_path):
"""Views lean on global .filters / .form-control / .card classes and never """Views lean on the global form/filter styles, not per-page input CSS."""
redefine input styling in a scoped block.""" plugins_dir = _make_plugins_dir(tmp_path)
plugins_dir, frontend_dir = _make_dirs(tmp_path) target = scaffold_plugin('widgets', 'Test', plugins_dir)
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
views = frontend_dir / 'views' / 'widgets' views = target / 'frontend' / 'views'
list_view = (views / 'WidgetsList.vue').read_text() list_view = (views / 'WidgetsList.vue').read_text()
form_view = (views / 'WidgetsForm.vue').read_text() form_view = (views / 'WidgetsForm.vue').read_text()
assert 'class="filters"' in list_view assert 'form-control' in form_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 # 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) # on its own (a descendant layout helper like `.filters .form-control` is ok)
import re
for view in (list_view, form_view): for view in (list_view, form_view):
assert not re.search(r'(?m)^\s*\.form-control\s*\{', 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 <plugins_dir>/../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): def test_frontend_can_be_disabled(tmp_path):
"""frontend=False generates neither views, route, nor snippet.""" """frontend=False generates neither views, route, nor snippet."""
plugins_dir, frontend_dir = _make_dirs(tmp_path) plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin( target = scaffold_plugin(
name='widgets', name='widgets',
description='Test', description='Test',
plugins_dir=plugins_dir, plugins_dir=plugins_dir,
frontend_dir=frontend_dir,
frontend=False, frontend=False,
) )
assert not (frontend_dir / 'views' / 'widgets').exists() assert not (target / 'frontend').exists()
assert not (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
assert not (target / 'frontend-api-snippet.js').exists() assert not (target / 'frontend-api-snippet.js').exists()
# backend skeleton is still intact
assert (target / 'manifest.json').exists()
def test_no_clobber_without_overwrite(tmp_path): assert (target / 'plugin.py').exists()
"""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): def test_route_references_only_existing_views(tmp_path):
@@ -198,55 +197,54 @@ def test_route_references_only_existing_views(tmp_path):
Guards the write-views-before-route ordering: a route pointing at a missing Guards the write-views-before-route ordering: a route pointing at a missing
view crashes the Vite dev server. view crashes the Vite dev server.
""" """
plugins_dir, frontend_dir = _make_dirs(tmp_path) plugins_dir = _make_plugins_dir(tmp_path)
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir) target = scaffold_plugin('widgets', 'Test', plugins_dir)
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text() route_dir = target / 'frontend'
import re route = (route_dir / 'routes.js').read_text()
imported = re.findall(r"import\('(\.\./\.\./views/[^']+)'\)", route) imported = re.findall(r"import\('(\./views/[^']+)'\)", route)
assert imported assert imported
for rel in imported: for rel in imported:
# rel is relative to router/routes/; resolve against that dir # rel is relative to the route file, which sits at frontend/routes.js
resolved = (frontend_dir / 'router' / 'routes' / rel).resolve() assert (route_dir / rel).resolve().exists(), \
assert resolved.exists(), f'route imports missing view: {rel}' f'route imports missing view: {rel}'
def test_generated_frontend_is_ascii(tmp_path): def test_generated_frontend_is_ascii(tmp_path):
"""Generated frontend files contain plain ASCII only.""" """Generated frontend files contain plain ASCII only."""
plugins_dir, frontend_dir = _make_dirs(tmp_path) plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin( target = scaffold_plugin('widgets', 'Test', plugins_dir)
'widgets', 'Test', plugins_dir, frontend_dir=frontend_dir,
)
generated = [ generated = [
frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue', target / 'frontend' / 'views' / 'WidgetsList.vue',
frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue', target / 'frontend' / 'views' / 'WidgetsDetail.vue',
frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue', target / 'frontend' / 'views' / 'WidgetsForm.vue',
frontend_dir / 'router' / 'routes' / 'widgets.js', target / 'frontend' / 'routes.js',
target / 'frontend-api-snippet.js', target / 'frontend-api-snippet.js',
] ]
for path in generated: for path in generated:
text = path.read_text() path.read_text().encode('ascii') # raises if any non-ASCII slipped in
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 <tmp>/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): def test_scaffold_still_raises_on_existing_plugin(tmp_path):
"""Frontend generation does not weaken the plugin-dir overwrite guard.""" """Frontend generation does not weaken the plugin-dir overwrite guard."""
plugins_dir, frontend_dir = _make_dirs(tmp_path) plugins_dir = _make_plugins_dir(tmp_path)
scaffold_plugin('widgets', 'first', plugins_dir, frontend_dir=frontend_dir) scaffold_plugin('widgets', 'first', plugins_dir)
with pytest.raises(ScaffoldError, match='already exists'): with pytest.raises(ScaffoldError, match='already exists'):
scaffold_plugin( scaffold_plugin('widgets', 'second', plugins_dir)
'widgets', 'second', plugins_dir, frontend_dir=frontend_dir,
)
def test_overwrite_replaces_existing_frontend(tmp_path):
"""--overwrite regenerates the plugin's frontend files."""
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin('widgets', 'Test', plugins_dir)
list_view = target / 'frontend' / 'views' / 'WidgetsList.vue'
list_view.write_text('CLOBBER ME')
scaffold_plugin('widgets', 'Test', plugins_dir, overwrite=True)
regenerated = list_view.read_text()
assert regenerated != 'CLOBBER ME'
assert 'widgetsApi' in regenerated