- flask plugin new now scaffolds the frontend too: List/Detail/Form views on the global styles, a gated route module (ADR-009), and an api-client snippet emitted into the plugin dir. Views are written before the route file so a partially generated plugin cannot 500 the dev server. - docs/PLUGIN-EXTERNAL-REPO.md + scripts/test-external-plugin.sh: how a sister site develops a plugin in its own repo and runs the framework contract tests in CI against a pinned framework ref (script verified to fail on a broken core_version pin). - docs/CONTRACT-STABILITY.md: settled vs churning contract surface and the provisional 1.0 criteria. - CLAUDE.md active-state refresh (contract 0.6.0, 11 plugins, 340 tests, measuringtools done). Known limitation documented: Path.rglob does not descend symlinks, so the import-surface contract test skips symlinked external plugins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
253 lines
9.5 KiB
Python
253 lines
9.5 KiB
Python
"""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.
|
|
|
|
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 <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)
|
|
|
|
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 <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):
|
|
"""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,
|
|
)
|