`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.
251 lines
9.3 KiB
Python
251 lines
9.3 KiB
Python
"""Canary tests for the plugin scaffolder's frontend generation.
|
|
|
|
`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
|
|
|
|
from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError
|
|
|
|
|
|
def _make_plugins_dir(tmp_path: Path) -> Path:
|
|
"""Create a tmp plugins dir."""
|
|
plugins_dir = tmp_path / 'plugins'
|
|
plugins_dir.mkdir()
|
|
return plugins_dir
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
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 substitutions land in the route file and the views."""
|
|
plugins_dir = _make_plugins_dir(tmp_path)
|
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
|
|
|
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 = (target / 'frontend' / 'views' / 'WidgetsList.vue').read_text()
|
|
assert 'widgetsApi' in list_view
|
|
assert "api.get('/widgets'" in list_view
|
|
# 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):
|
|
"""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 = (target / 'frontend' / 'routes.js').read_text()
|
|
# every route is gated on the plugin being enabled
|
|
assert route.count("plugin: 'widgets'") == 4
|
|
# create and edit are behind auth
|
|
assert route.count('requiresAuth: true') == 2
|
|
|
|
|
|
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 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 = target / 'frontend' / 'views'
|
|
list_view = (views / 'WidgetsList.vue').read_text()
|
|
form_view = (views / 'WidgetsForm.vue').read_text()
|
|
|
|
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)
|
|
for view in (list_view, form_view):
|
|
assert not re.search(r'(?m)^\s*\.form-control\s*\{', view)
|
|
|
|
|
|
def test_frontend_can_be_disabled(tmp_path):
|
|
"""frontend=False generates neither views, route, nor snippet."""
|
|
plugins_dir = _make_plugins_dir(tmp_path)
|
|
|
|
target = scaffold_plugin(
|
|
name='widgets',
|
|
description='Test',
|
|
plugins_dir=plugins_dir,
|
|
frontend=False,
|
|
)
|
|
|
|
assert not (target / 'frontend').exists()
|
|
assert not (target / 'frontend-api-snippet.js').exists()
|
|
# backend skeleton is still intact
|
|
assert (target / 'manifest.json').exists()
|
|
assert (target / 'plugin.py').exists()
|
|
|
|
|
|
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 = _make_plugins_dir(tmp_path)
|
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
|
|
|
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 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 = _make_plugins_dir(tmp_path)
|
|
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
|
|
|
generated = [
|
|
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:
|
|
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 = _make_plugins_dir(tmp_path)
|
|
scaffold_plugin('widgets', 'first', plugins_dir)
|
|
|
|
with pytest.raises(ScaffoldError, match='already exists'):
|
|
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
|