"""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, )