diff --git a/docs/wiki-drafts/ADR-019-Plugin-Dynamic-Settings-Routes.md b/docs/wiki-drafts/ADR-019-Plugin-Dynamic-Settings-Routes.md index 2da71604..daf2c74a 100644 --- a/docs/wiki-drafts/ADR-019-Plugin-Dynamic-Settings-Routes.md +++ b/docs/wiki-drafts/ADR-019-Plugin-Dynamic-Settings-Routes.md @@ -84,7 +84,7 @@ def get_plugin_settings_routes(): return success_response({'routes': routes}) ``` -**Auth:** Deliberately anonymous (`jwt_required(optional=True)`) so unauthenticated pages (kiosks, TV displays) can load plugin routes. +**Auth:** the endpoint is gated on `settings.view`. It was written anonymous, on the reasoning that unauthenticated pages (kiosks, TV displays) register plugin tabs before login. Nothing does: its only caller is `ModelCatalogHub.vue`, which sits behind `requiresAuth` and `requiresAdmin`. Open, it handed an anonymous caller the enabled plugin list, the settings hub layout and the internal Vue component paths, so it was closed. ### Frontend: Dynamic Route Registration @@ -163,6 +163,80 @@ onMounted(async () => { - Components lazy-load when the tab is clicked (code-splitting preserved) - Tabs only appear if the plugin is enabled and staged +## Constraints as implemented + +What the sections above describe is the mechanism as designed. What ships is +narrower, and a plugin author reading only the hook signature would not find +this out until a tab failed to appear in front of a user. The three constraints +below are the ones the contract does not express. + +**One hub draws plugin tabs: Model Catalog.** `router.addRoute` appears exactly +once in the frontend, in `frontend/src/views/settings/ModelCatalogHub.vue`. +`AssetConfigHub`, `SiteConfigHub`, `NetworkHub`, `SecurityHub`, `LocationsHub`, +`IntegrationsHub` and `AssetTypeHub` render their own tabs and nothing else, so +a route declaring any of their ids is accepted by the hook, returned by the API +and drawn by nobody. Adding a hub to the supported set means writing the +fetch-and-register code into that hub's `.vue` file, not editing a list. The +supported set is named once, in `SETTINGS_HUBS_WITH_PLUGIN_TABS` +(`shopdb/plugins/base.py`); `/api/plugins/settings-routes` drops a route outside +it and logs an error naming the plugin and the hub, the plugin manager logs the +same finding at startup, and `tests/test_core/test_plugin_settings_hubs.py` +fails if a bundled plugin declares an unsupported hub or if the list and the +frontend drift apart. + +**A plugin tab is not deep-linkable.** The hub registers its plugin routes in +`onMounted`. Before the hub has mounted, the router has no such route, so +navigating straight to a plugin tab URL - from a link, a bookmark, or a refresh +while sitting on one - resolves against a router that does not know the path. +Only arriving through the parent hub works. This is a real limit on what a +plugin settings page can be: it cannot be somewhere you send a person. + +**The component does not load in a production build at all.** The `component` +field is a server-supplied string interpolated into a dynamic import, +``() => import(`../../${tab.component}`)``. Vite refuses that template - it has +no static file extension - and says so during every build: + + [plugin vite:dynamic-import-vars] src/views/settings/ModelCatalogHub.vue: + invalid import "../../${tab.component}". A file extension must be included + in the static part of the import. + +The build still succeeds, and the emitted chunk carries the expression verbatim +(`import(`../../${e.component}`)`), rewritten to nothing. At runtime the browser +resolves it against the chunk's own URL under `/assets/` and asks the server for +a `.vue` file; `dist/` contains no `.vue` files, so the request 404s and the tab +throws when it is clicked. Under `npm run dev` the same import works, because +the dev server serves sources by path. So the mechanism as it stands is a +development-only path: a tab that appears in production and fails on the click. + +Nothing visibly broke because the one plugin using it does not depend on it. +`plugins/computers/frontend/routes.js` also registers the same page statically +at `/settings/operatingsystems`, and that route - a normal static import, a real +URL - is what production actually uses. The dynamic tab is a duplicate way in. + +Two ways out, and this ADR does not pick one: give the template a static +extension (declare `component` without `.vue` and import ``${component}.vue``), +which makes Vite glob every `.vue` under `frontend/src` at build time and makes +the tab real for staged plugins while staying build-time bound; or accept that +plugin settings pages are ordinary plugin routes and drop the hub-tab mechanism. +Deciding that is the open question the architectural review raised. + +**What a plugin author must therefore do:** + +1. Declare `hub: 'model-catalog'` or declare nothing. For a settings page that + belongs anywhere else, own a full page under the plugin's own routes + (`plugins//frontend/routes.js`), which is statically registered, deep- + linkable and not subject to any of this. +2. Register the page in `plugins//frontend/routes.js` even when you do + declare a tab, and treat that route as the real one. A tab whose component is + reachable no other way is a page nobody can open in production. + `tests/test_core/test_plugin_settings_hubs.py` enforces this. +3. Point `component` at the staged path - `.plugins-staged//views/X.vue`, + relative to `frontend/src` - and keep the source at + `plugins//frontend/views/X.vue` so staging finds it. +4. Expect the frontend to be rebuilt when the plugin is added to a site. + Enabling the plugin alone does not put its component in the bundle. +5. Do not publish, link to or bookmark the tab's URL. Send people to the hub. + ## Consequences ### Benefits @@ -171,7 +245,7 @@ onMounted(async () => { **Plugins self-contain their UI:** Adding a settings page to a plugin no longer requires touching core code. The plugin declares the route; the framework discovers it. -**Runtime flexibility:** Enabling/disabling a plugin (via `/api/plugins/{name}` PUT) changes what routes are returned. After an app restart, the tabs appear or disappear automatically. +**Runtime flexibility:** Enabling/disabling a plugin (via `/api/plugins/{name}` PUT) changes what routes are returned. After an app restart, the tabs appear or disappear automatically - for a plugin whose frontend is in the running bundle. Enabling one that was never staged adds a tab whose component cannot be resolved (see "Constraints as implemented"). **Composability:** Multiple plugins can contribute to the same hub. Model Catalog could have tabs from `computers` (Operating Systems), `printers` (Printer Models), `machines` (Machine Types), etc., with no conflicts. @@ -179,7 +253,11 @@ onMounted(async () => { **Route registration is async:** The tabs appear after mount, not at router initialization. In practice this is <100ms and visually seamless, but the router doesn't know about plugin routes at build time. -**Component paths are strings:** The `component` field is a string path that gets interpolated into `import()`. This prevents static analysis tools from tracing the dependency, though Vite's glob imports during the plugin staging step ensure the files are bundled. +**Component paths are strings:** The `component` field is a string path that gets interpolated into `import()`. This prevents static analysis tools from tracing the dependency, and as written Vite will not rewrite the import at all, so it resolves in the dev server and 404s in a build (see "Constraints as implemented"). + +**Composability is one hub wide:** "Multiple plugins contribute to the same hub" is true of Model Catalog and of no other hub, because no other hub page registers plugin routes. See "Constraints as implemented". + +**A plugin tab has no URL worth sharing:** routes register at hub mount, so the tab is not reachable by link, bookmark or refresh. **No SSR for plugin tabs:** Server-side rendering can't evaluate `router.addRoute()` at build time. ShopDB doesn't use SSR, so this is not a concern, but it would block SSR adoption without refactoring. diff --git a/docs/wiki-drafts/README.md b/docs/wiki-drafts/README.md index 9cbbdd7a..98dfba5e 100644 --- a/docs/wiki-drafts/README.md +++ b/docs/wiki-drafts/README.md @@ -14,6 +14,8 @@ This directory contains drafted wiki pages based on work completed Q3 2026. Copy - Decision: Backend hook + frontend dynamic route registration - Implementation: BasePlugin hook, /api/plugins/settings-routes endpoint, ModelCatalogHub integration - Benefits: Lean builds work, plugins self-contain UI, runtime flexibility +- Constraints as implemented: Model Catalog is the only hub that draws plugin + tabs, the tabs are not deep-linkable, and the component is bound at build time **Related commits:** fe09d2745e, d59d0c3194 @@ -109,12 +111,36 @@ class ComputersPlugin(BasePlugin): The frontend fetches routes from `/api/plugins/settings-routes` and registers them dynamically via `router.addRoute()`. +### Rules for declaring one + +1. **Declare `hub: 'model-catalog'` or declare nothing.** Model Catalog is the + only hub whose page registers plugin tabs. A route naming any other hub + (`asset-config`, `security`, `network`, ...) is dropped by the API with an + error in the log, because nothing would draw it. For a settings page that + belongs elsewhere, own a full page in `plugins//frontend/routes.js` + instead: it is statically registered and behaves like any other route. +2. **Your frontend must be staged into the build.** `component` is a path + relative to `frontend/src`, so it must be the staged copy - + `.plugins-staged//views/X.vue`, made by `scripts/stage-frontend.mjs` + from `plugins//frontend/views/X.vue`. Enabling the plugin at runtime + does not put the file in a bundle that was built without it; that needs a + frontend rebuild. +3. **Register the page in your `routes.js` too, and treat that as the real + route.** The hub's import of your component is not rewritten by Vite (no + static file extension in the template), so it resolves under `npm run dev` + and 404s in a production build. The tab is a convenience on top of a normal + plugin route, not a way to ship a page. A gate test enforces this. +4. **Do not link directly to your tab's URL.** The hub registers the route when + it mounts, so the URL only resolves for someone who reached it through the + hub. A bookmark, a link in an email, or a refresh on the tab lands nowhere. + **Benefits:** - Lean builds work (no hardcoded plugin imports) - Plugins self-contain their UI extensions -- Runtime flexibility (enable/disable plugin affects tabs) +- Enable/disable changes the tabs, for a plugin already in the build -**Reference:** ADR-019 Plugin Dynamic Settings Routes +**Reference:** ADR-019 Plugin Dynamic Settings Routes, section "Constraints as +implemented" ``` --- @@ -125,9 +151,9 @@ The frontend fetches routes from `/api/plugins/settings-routes` and registers th ```markdown ## GET /api/plugins/settings-routes -Returns settings routes contributed by all enabled plugins via `get_settings_routes()` hook. +Returns settings routes contributed by all enabled plugins via `get_settings_routes()` hook. A route naming a hub that does not draw plugin tabs is dropped, with an error in the server log naming the plugin and the hub. -**Auth:** None (jwt-optional, deliberately anonymous for kiosk/TV displays) +**Auth:** `settings.view` (it names the enabled plugins, the hub layout and the internal component paths; no pre-login page calls it) **Response:** ```json @@ -147,7 +173,7 @@ Returns settings routes contributed by all enabled plugins via `get_settings_rou } ``` -**Used by:** Settings hub pages (ModelCatalogHub, AssetConfigHub, etc.) to dynamically register plugin tabs. +**Used by:** ModelCatalogHub, to register plugin tabs. It is the only hub page that does; the others draw their own tabs only. **Reference:** ADR-019 Plugin Dynamic Settings Routes ``` diff --git a/frontend/src/router/routes/core.js b/frontend/src/router/routes/core.js index 303a5b3e..e1fead62 100644 --- a/frontend/src/router/routes/core.js +++ b/frontend/src/router/routes/core.js @@ -59,6 +59,12 @@ export default [ // Plugin settings routes (e.g., Operating Systems from computers plugin) are added // dynamically by ModelCatalogHub via router.addRoute() from /api/plugins/settings-routes. // This allows plugins to extend settings hubs without breaking lean builds. + // This hub is the ONLY one that does it: no other hub page below fetches + // or registers plugin routes, so a plugin declaring their hub id gets a + // tab nobody draws. The server knows the same list + // (SETTINGS_HUBS_WITH_PLUGIN_TABS in shopdb/plugins/base.py) and drops + // anything outside it; adding a hub means writing the code into that + // hub's .vue file AND adding it there. See ADR-019. ] }, { diff --git a/frontend/src/views/settings/ModelCatalogHub.vue b/frontend/src/views/settings/ModelCatalogHub.vue index 6b0debde..78208523 100644 --- a/frontend/src/views/settings/ModelCatalogHub.vue +++ b/frontend/src/views/settings/ModelCatalogHub.vue @@ -56,6 +56,17 @@ const route = useRoute() const router = useRouter() const pluginTabs = ref([]) +// Plugin tabs (ADR-019). Two things this does not do, so nobody plans around +// them. The routes exist only after this hub has mounted, so a plugin tab is +// not deep-linkable and does not survive a refresh. And the import below has no +// static file extension, so vite:dynamic-import-vars refuses to rewrite it (it +// says so on every build) and the built chunk asks for a .vue file that dist +// does not ship: the tab loads under `npm run dev` and 404s in production. The +// pages behind these tabs are therefore expected to carry a static route of +// their own in the plugin's routes.js, which is what a site actually uses. +// Copying this block into another hub is what makes that hub support plugin +// tabs - and it must then be added to SETTINGS_HUBS_WITH_PLUGIN_TABS in +// shopdb/plugins/base.py, which is what the server filters on. onMounted(async () => { // Fetch plugin-contributed settings routes try { diff --git a/shopdb/core/api/plugins.py b/shopdb/core/api/plugins.py index 65f60891..eaaa2e72 100644 --- a/shopdb/core/api/plugins.py +++ b/shopdb/core/api/plugins.py @@ -3,6 +3,7 @@ from flask import Blueprint, current_app, request from flask_jwt_extended import jwt_required +from shopdb.plugins.base import renderable_settings_routes from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.authz import require_role @@ -60,6 +61,13 @@ def get_plugin_settings_routes(): component paths - free reconnaissance about which modules a site runs. The login page never calls this, so gating it costs nothing pre-login. + A route naming a hub that has no code to draw plugin tabs is DROPPED here, + loudly. Returning it would put an entry in a response that nothing reads: + the plugin author sees a correct declaration and an API that accepts it, and + the tab never appears anywhere. Dropping it costs the same missing tab and + leaves a line in the log that says which plugin, which hub, and what the + supported set is. See SETTINGS_HUBS_WITH_PLUGIN_TABS in shopdb/plugins/base. + Returns: { routes: [{hub, path, name, tab, component}, ...] } """ pm = current_app.extensions.get('plugin_manager') @@ -81,8 +89,8 @@ def get_plugin_settings_routes(): 'get_settings_routes failed for plugin %s; skipping it', plugin_name) continue - if plugin_routes: - routes.extend(plugin_routes) + routes.extend(renderable_settings_routes(plugin_name, plugin_routes, + current_app.logger)) return success_response({'routes': routes}) diff --git a/shopdb/plugins/__init__.py b/shopdb/plugins/__init__.py index 8dd7c4d1..efe27bd5 100644 --- a/shopdb/plugins/__init__.py +++ b/shopdb/plugins/__init__.py @@ -6,7 +6,7 @@ from flask import Flask import logging import re -from .base import BasePlugin, PluginMeta +from .base import BasePlugin, PluginMeta, renderable_settings_routes from .registry import PluginRegistry, PluginState from .loader import PluginLoader from .migrations import PluginMigrationManager @@ -103,6 +103,22 @@ class PluginManager: for name, plugin in plugins.items(): self._register_plugin_components(plugin) + self._check_settings_routes(name, plugin) + + def _check_settings_routes(self, name: str, plugin: BasePlugin) -> None: + """Say at boot, once per plugin, if a declared settings tab is dead. + + Only some settings hubs draw plugin tabs (see + SETTINGS_HUBS_WITH_PLUGIN_TABS). A route for any other hub is accepted + by the hook and returned by the API and then rendered by nobody, which + looks from every side like the plugin is at fault. The API drops such a + route; this makes the same finding at startup, so it is in the log of a + site that never opens the settings page.""" + try: + renderable_settings_routes(name, plugin.get_settings_routes(), + logger) + except Exception: + logger.exception("get_settings_routes failed for %s", name) def upgrade_all_plugins(self) -> Dict[str, str]: """Run pending Alembic migrations for every discovered plugin. diff --git a/shopdb/plugins/base.py b/shopdb/plugins/base.py index 67d98a7d..341b12e2 100644 --- a/shopdb/plugins/base.py +++ b/shopdb/plugins/base.py @@ -19,6 +19,41 @@ from dataclasses import dataclass, field from flask import Flask, Blueprint +# Settings hubs whose Vue page actually draws plugin-contributed tabs: it asks +# /api/plugins/settings-routes, keeps the routes whose `hub` matches its own id, +# and registers them with router.addRoute(). Every other hub renders its own +# tabs and nothing else, so a route naming one would be returned by the API and +# never drawn - a tab the author declared correctly that simply does not exist +# for the user. Hubs are added here only when their .vue file gains that code; +# tests/test_core/test_plugin_settings_hubs.py fails if the two drift apart. +SETTINGS_HUBS_WITH_PLUGIN_TABS = ('model-catalog',) + + +def renderable_settings_routes(plugin_name: str, routes: Optional[List[Dict]], + logger) -> List[Dict]: + """The subset of a plugin's declared settings routes that a hub can draw. + + Anything naming a hub outside SETTINGS_HUBS_WITH_PLUGIN_TABS is dropped and + logged as an error naming the plugin, the hub and the supported set. The + declaration is not a crash - the plugin's API, models and pages are all + fine - so this refuses the dead tab rather than the plugin. The hard stop + lives in the test suite, where the author still has a chance to fix it. + """ + kept = [] + for route in routes or []: + hub = route.get('hub') + if hub in SETTINGS_HUBS_WITH_PLUGIN_TABS: + kept.append(route) + continue + logger.error( + "Plugin %s declares settings route '%s' for hub '%s'. No settings " + "hub page registers plugin tabs for that hub, so the tab would " + "never render; dropping it. Hubs that support plugin tabs: %s", + plugin_name, route.get('name'), hub, + ', '.join(SETTINGS_HUBS_WITH_PLUGIN_TABS)) + return kept + + @dataclass class PluginMeta: """Plugin metadata container.""" @@ -147,11 +182,17 @@ class BasePlugin(ABC): Returns a list of route configurations for the plugin's settings pages. Each route is a dict with: - hub - settings hub to attach to ('model-catalog', 'asset-config', etc.) + hub - settings hub to attach to; ONLY a hub named in + SETTINGS_HUBS_WITH_PLUGIN_TABS (top of this module) draws + plugin tabs. A route for any other hub is dropped by + /api/plugins/settings-routes with an error in the log. path - route path relative to hub (e.g. 'operatingsystems') name - unique route name (e.g. 'model-catalog-os') tab - tab label shown in hub UI (e.g. 'Operating Systems') - component- path to Vue component (e.g. 'plugins//frontend/views/.vue') + component- path to the Vue component AS IT EXISTS IN THE BUILD, i.e. + relative to frontend/src, which for a plugin means + '.plugins-staged//views/.vue' - the copy + scripts/stage-frontend.mjs makes of plugins//frontend Example: return [{ @@ -159,9 +200,29 @@ class BasePlugin(ABC): 'path': 'operatingsystems', 'name': 'model-catalog-os', 'tab': 'Operating Systems', - 'component': 'plugins/computers/frontend/views/OperatingSystemsList.vue' + 'component': '.plugins-staged/computers/views/OperatingSystemsList.vue' }] + Three constraints the shape of this hook does not show, so read them + before you rely on it (ADR-019, "Constraints as implemented"): + + 1. Declare a hub only if it is in SETTINGS_HUBS_WITH_PLUGIN_TABS. Today + that is 'model-catalog' alone. The other hubs have no code to draw + your tab, so the route is accepted and dropped. + 2. Route the page yourself as well, in plugins//frontend/ + routes.js, and treat THAT as the real route. The hub interpolates + `component` into a dynamic import with no static file extension, + which Vite will not rewrite, so the tab loads under `npm run dev` + and 404s in a production build. A page reachable only through the + tab is a page nobody can open at a site. Your frontend also has to + be staged into the build (SITE_PLUGINS decides that), so enabling a + plugin whose UI was never staged needs a frontend rebuild. + 3. The tab is not a link you can hand out. The hub registers the route + when it mounts, so the URL resolves only for someone who has already + been through the parent hub in that page load. Do not link to it + from an email, a doc, or another page, and do not expect a refresh + on it to land anywhere. + Return [] (default) if the plugin adds no settings pages. """ return [] diff --git a/tests/test_core/test_plugin_settings_hubs.py b/tests/test_core/test_plugin_settings_hubs.py new file mode 100644 index 00000000..0d0b7346 --- /dev/null +++ b/tests/test_core/test_plugin_settings_hubs.py @@ -0,0 +1,160 @@ +"""A plugin settings tab is only real if a hub is written to draw it. + +`get_settings_routes()` (ADR-019) lets a plugin declare a settings tab and name +the hub it belongs to. The server accepts any hub string, but only a hub whose +.vue page asks /api/plugins/settings-routes and calls router.addRoute() ever +draws one. Today that is Model Catalog and nothing else. + +The failure that gets is the quiet kind: the plugin is right, the API is right, +the response carries the tab, and the person never sees it. So the supported set +is named once in shopdb/plugins/base.py, the API drops anything outside it with +an error in the log, and these tests hold the two ends together - no bundled +plugin may declare a hub that cannot draw it, and the list in the server may not +drift away from the hubs that actually register routes in the frontend. +""" +import re +from pathlib import Path + +import pytest + +from shopdb.plugins import plugin_manager +from shopdb.plugins.base import SETTINGS_HUBS_WITH_PLUGIN_TABS + +REPO = Path(__file__).resolve().parents[2] +HUB_SOURCES = sorted((REPO / 'frontend' / 'src' / 'views' / 'settings') + .glob('*.vue')) + +# router.addRoute('model-catalog', { ... }) - the parent whose children the hub +# adds to, which is also the id a route's `hub` field has to carry. +ADDROUTE = re.compile(r"router\.addRoute\(\s*['\"]([^'\"]+)['\"]") +# ...filter(r => r.hub === 'model-catalog') +HUB_FILTER = re.compile(r"\.hub\s*===\s*['\"]([^'\"]+)['\"]") + +BUNDLED = sorted(path.parent.name + for path in (REPO / 'plugins').glob('*/manifest.json')) + + +@pytest.fixture +def declared_routes(app): + """Every bundled plugin's declared settings routes, enabled or not. + + Read from the classes rather than the loaded plugins: a plugin disabled on + this machine still ships, and a dead tab in it is still a defect. + """ + routes = {} + with app.app_context(): + for name in BUNDLED: + cls = plugin_manager.loader.load_plugin_class(name) + assert cls is not None, f'Plugin class for {name} could not be loaded' + routes[name] = cls().get_settings_routes() or [] + return routes + + +def test_the_sweep_actually_reads_something(): + """A gate that matches nothing passes forever.""" + assert len(HUB_SOURCES) > 5, [path.name for path in HUB_SOURCES] + assert BUNDLED, 'no bundled plugins found - the glob is probably wrong' + assert any(ADDROUTE.search(path.read_text()) for path in HUB_SOURCES), \ + 'no hub registers plugin routes at all - the pattern is probably wrong' + + +def test_every_bundled_plugin_declares_a_hub_that_can_draw_it(declared_routes): + offenders = [ + "%s: route '%s' names hub '%s'" % (name, route.get('name'), + route.get('hub')) + for name, routes in declared_routes.items() + for route in routes + if route.get('hub') not in SETTINGS_HUBS_WITH_PLUGIN_TABS + ] + assert not offenders, ( + 'These settings tabs would be returned by the API and drawn by no one. ' + 'Move them to a hub that supports plugin tabs (%s), or write the ' + 'fetch-and-addRoute code into the hub page and add it to ' + 'SETTINGS_HUBS_WITH_PLUGIN_TABS:\n %s' + % (', '.join(SETTINGS_HUBS_WITH_PLUGIN_TABS), '\n '.join(offenders))) + + +def test_the_supported_hubs_are_the_ones_the_frontend_registers(): + """The server's list and the frontend's code cannot be allowed to drift. + + A hub page supports plugin tabs when it does both halves: keeps the routes + whose `hub` is its own id, and registers them with addRoute. A page doing + only one of the two is worse than neither, so it counts as neither. + """ + registering = set() + for path in HUB_SOURCES: + source = path.read_text() + if not ADDROUTE.search(source): + continue + registering.update(HUB_FILTER.findall(source)) + + assert registering == set(SETTINGS_HUBS_WITH_PLUGIN_TABS), ( + 'SETTINGS_HUBS_WITH_PLUGIN_TABS says %s; the settings hubs that fetch ' + 'and register plugin routes are %s. Whichever one moved, the other has ' + 'to move with it.' + % (sorted(SETTINGS_HUBS_WITH_PLUGIN_TABS), sorted(registering))) + + +def test_a_declared_tab_is_also_reachable_by_a_static_route(declared_routes): + """A settings tab cannot be the only way to a plugin's page. + + The hub loads the tab with `import(`../../${tab.component}`)`, a template + with no static file extension. Vite's dynamic-import-vars plugin refuses to + rewrite that ("A file extension must be included in the static part of the + import"), so a production build emits the expression verbatim and the + browser asks for a .vue path that no build ships. The tab therefore works + under `npm run dev`, where the dev server serves sources by path, and not in + a built bundle. + + Until that is decided one way or the other (ADR-019), a plugin that declares + a tab must also register the same page in its own routes.js, the way + computers does with /settings/operatingsystems - a static import, a real + URL, and the thing production actually uses. + """ + routesjs = re.compile(r"import\(\s*['\"]([^'\"]+\.vue)['\"]") + offenders = [] + for name, routes in declared_routes.items(): + source = REPO / 'plugins' / name / 'frontend' / 'routes.js' + statics = {Path(match).name + for match in routesjs.findall(source.read_text())} \ + if source.exists() else set() + for route in routes: + component = Path(route.get('component') or '').name + if component not in statics: + offenders.append("%s: '%s' (%s) has no static route" + % (name, route.get('name'), component)) + + assert not offenders, ( + 'These settings pages are reachable only through a hub tab, whose ' + 'component import does not survive a production build. Give each one a ' + 'route in the plugin routes.js as well:\n ' + '\n '.join(offenders)) + + +def test_a_route_for_an_unsupported_hub_is_dropped_and_logged( + app, client, db, monkeypatch, caplog, auth_headers): + manager = app.extensions.get('plugin_manager') + plugins = manager.get_all_plugins() + enabled = [name for name in plugins if manager.registry.is_enabled(name)] + assert enabled + + def contribute(): + return [ + {'hub': 'model-catalog', 'path': 'drawn', 'name': 'drawn-route', + 'tab': 'Drawn', 'component': 'drawn.vue'}, + {'hub': 'asset-config', 'path': 'dead', 'name': 'dead-route', + 'tab': 'Dead', 'component': 'dead.vue'}, + ] + + monkeypatch.setattr(plugins[enabled[0]], 'get_settings_routes', contribute, + raising=False) + + with caplog.at_level('ERROR'): + response = client.get('/api/plugins/settings-routes', + headers=auth_headers) + + assert response.status_code == 200, response.get_json() + names = [route['name'] for route in response.get_json()['data']['routes']] + assert 'drawn-route' in names + assert 'dead-route' not in names + assert any('asset-config' in record.getMessage() for record in caplog.records), \ + 'the drop has to be loud, or it is the same silence in a new place'