Search results open the record they found; plugin scaffolder emits a prunable frontend

This commit is contained in:
2026-08-24 14:06:14 -04:00
parent 0398c55fb5
commit ead9fd6b56
9 changed files with 312 additions and 203 deletions

View File

@@ -1,16 +1,21 @@
"""Canary tests for the plugin scaffolder's frontend generation.
`flask plugin new <name>` 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.
`flask plugin new <name>` generates the backend skeleton and a matching Vue
frontend starting point: list, detail, and form views, a route file, and a
paste-in api-client snippet. All of it lands INSIDE the plugin, under
`plugins/<name>/frontend/`, which is what lets stage-frontend.mjs stage it and
a lean build prune it (ADR-013).
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
touched.
"""
import re
from pathlib import Path
import pytest
@@ -18,178 +23,172 @@ 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).
"""
def _make_plugins_dir(tmp_path: Path) -> Path:
"""Create a tmp plugins 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
return plugins_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)
def test_frontend_files_generated_inside_the_plugin(tmp_path):
"""Views, route file, and api snippet appear with the expected names.
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(
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' / 'views' / 'WidgetsList.vue').exists()
assert (target / 'frontend' / 'views' / 'WidgetsDetail.vue').exists()
assert (target / 'frontend' / 'views' / 'WidgetsForm.vue').exists()
assert (target / 'frontend' / 'routes.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):
"""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)
"""Name and Name substitutions land in the route file and the views."""
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin('widgets', 'Test', plugins_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
route = (target / 'frontend' / 'routes.js').read_text()
assert "import('./views/WidgetsList.vue')" in route
assert "import('./views/WidgetsForm.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 "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
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)
"""ADR-009 gating on every route; requiresAuth on the form routes."""
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin('widgets', 'Test', plugins_dir)
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
# ADR-009 gating on every route
route = (target / 'frontend' / 'routes.js').read_text()
# every route is gated on the plugin being enabled
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 "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):
"""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 lean on the global form/filter styles, not per-page input CSS."""
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin('widgets', 'Test', plugins_dir)
views = frontend_dir / 'views' / 'widgets'
views = target / 'frontend' / 'views'
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
assert '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 <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):
"""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(
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').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()
# backend skeleton is still intact
assert (target / 'manifest.json').exists()
assert (target / 'plugin.py').exists()
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
view crashes the Vite dev server.
"""
plugins_dir, frontend_dir = _make_dirs(tmp_path)
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin('widgets', 'Test', plugins_dir)
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
import re
imported = re.findall(r"import\('(\.\./\.\./views/[^']+)'\)", route)
route_dir = target / 'frontend'
route = (route_dir / 'routes.js').read_text()
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}'
# rel is relative to the route file, which sits at frontend/routes.js
assert (route_dir / rel).resolve().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,
)
plugins_dir = _make_plugins_dir(tmp_path)
target = scaffold_plugin('widgets', 'Test', plugins_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' / 'views' / 'WidgetsList.vue',
target / 'frontend' / 'views' / 'WidgetsDetail.vue',
target / 'frontend' / 'views' / 'WidgetsForm.vue',
target / 'frontend' / 'routes.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 <tmp>/frontend/src (absent)
scaffold_plugin('widgets', 'Test', plugins_dir)
assert not (tmp_path / 'frontend').exists()
path.read_text().encode('ascii') # raises if any non-ASCII slipped in
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)
plugins_dir = _make_plugins_dir(tmp_path)
scaffold_plugin('widgets', 'first', plugins_dir)
with pytest.raises(ScaffoldError, match='already exists'):
scaffold_plugin(
'widgets', 'second', plugins_dir, frontend_dir=frontend_dir,
)
scaffold_plugin('widgets', 'second', plugins_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