mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-24 20:18:06 +09:00
Wire the server half of runtime-frontend delivery: get_active_contributions()
now emits panel sdk_version and a 'frontends' descriptor map {slug: {entry,
hashes, sdk_version}} for every active extension whose frontend_entry is a .mjs
ESM bundle. The bundle sha256 is pinned at install time into the plugin config
under the reserved _frontend_hashes key (no migration — reuses config_json);
update_plugin_config() and the config API preserve/hide reserved keys. Add the
extensions.runtime_frontend kill switch (default on). The client loader already
consumed this shape, so an installed extension's UI now renders on a pre-built
panel with no Vite rebuild. Un-skips the restored plan-25 recovery suites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
6.9 KiB
Python
162 lines
6.9 KiB
Python
"""Runtime extension frontend loading — server-side contract (plan 25 Phase 2).
|
|
|
|
Covers the halves the backend owns:
|
|
- install-time bundle hashing (#6): sha256 of the runtime ESM bundle recorded
|
|
in the plugin config store,
|
|
- the /plugins/contributions `frontends` descriptor map exposing entry + hashes,
|
|
- the kill switch (Decision 4): `extensions.runtime_frontend` off -> no `frontends`,
|
|
- dual-path coexistence: a baked (.jsx) builtin + a runtime (.mjs) extension in
|
|
one session -> only the runtime one appears in `frontends`,
|
|
- config PUT preserving the panel-managed `_frontend_hashes` key.
|
|
|
|
The frontend halves (blob-import, hash-mismatch refusal, error-boundary render)
|
|
are exercised by the JS render smoke test / screenshots fixture; here we lock the
|
|
data contract they depend on.
|
|
|
|
RECOVERY NOTE (plan 42): this entire runtime-frontend backend contract did NOT
|
|
survive the data loss and is hollow in the current tree — the whole module is
|
|
skipped, with the reconstruction preserved so it can be un-skipped once the
|
|
feature is restored. Missing surface:
|
|
- contribution_service.get_active_contributions() emits no ``frontends`` map
|
|
(and no ``sdk_version``),
|
|
- no ``_frontend_hashes`` key / install-time ESM sha256 recording in the plugin
|
|
config store,
|
|
- no ``extensions.runtime_frontend`` kill switch is consulted.
|
|
"""
|
|
import json
|
|
import zipfile
|
|
from io import BytesIO
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app import db
|
|
from app.models.plugin import InstalledPlugin
|
|
from app.services import contribution_service, plugin_service, registry_service
|
|
from app.services.settings_service import SettingsService
|
|
|
|
_BUNDLE = 'export function Page(){return null;}'
|
|
_ICON = '<circle cx="12" cy="12" r="8"/>'
|
|
|
|
|
|
@pytest.fixture
|
|
def plugin_dirs(app, tmp_path, monkeypatch):
|
|
"""Redirect plugin install roots at throwaway tmp dirs so installs never
|
|
touch the real backend/frontend/builtin plugin trees.
|
|
|
|
Depends on ``app`` so the test body runs inside an application context (the
|
|
``app`` fixture yields while its context is active) — the assertions query
|
|
the DB and call services directly."""
|
|
backend = tmp_path / 'backend_plugins'
|
|
frontend = tmp_path / 'frontend_plugins'
|
|
builtin = tmp_path / 'builtin_extensions'
|
|
for d in (backend, frontend, builtin):
|
|
d.mkdir()
|
|
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend))
|
|
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend))
|
|
monkeypatch.setattr(plugin_service, 'BUILTIN_EXTENSIONS_DIR', str(builtin))
|
|
return SimpleNamespace(backend=backend, frontend=frontend, builtin=builtin)
|
|
|
|
|
|
def _runtime_zip(slug='serverkit-runtime', bundle=_BUNDLE, **kw):
|
|
"""Build an install zip for a runtime-frontend extension: a plugin.json whose
|
|
frontend is a prebuilt ESM bundle at frontend/dist/index.mjs."""
|
|
manifest = {
|
|
'name': slug,
|
|
'display_name': 'Runtime Demo',
|
|
'version': '1.0.0',
|
|
'description': ('A minimal extension whose frontend is a prebuilt ESM bundle at\n'
|
|
' frontend/dist/index.mjs (the runtime-load convention).'),
|
|
'category': 'utility',
|
|
'sdk_version': '^1.0.0',
|
|
'frontend_entry': 'dist/index.mjs',
|
|
'contributions': {
|
|
'nav': [{'id': 'runtime-demo', 'label': 'Runtime Demo',
|
|
'route': 'runtime-demo', 'category': 'system', 'icon': _ICON}],
|
|
'routes': [{'path': 'runtime-demo', 'component': 'Page'}],
|
|
},
|
|
}
|
|
manifest.update(kw)
|
|
|
|
buf = BytesIO()
|
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr('plugin.json', json.dumps(manifest))
|
|
zf.writestr('frontend/dist/index.mjs', bundle)
|
|
buf.seek(0)
|
|
return buf
|
|
|
|
|
|
def _install_runtime(**kw):
|
|
buf = _runtime_zip(**kw)
|
|
return plugin_service.install_from_zip(buf.getvalue(), source_name='runtime-extension')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# install-time bundle hashing (#6)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_install_records_bundle_sha256(plugin_dirs):
|
|
import hashlib
|
|
plugin = _install_runtime()
|
|
expected = hashlib.sha256(_BUNDLE.encode()).hexdigest()
|
|
row = InstalledPlugin.query.filter_by(slug='serverkit-runtime').first()
|
|
hashes = (row.config or {}).get('_frontend_hashes') or {}
|
|
assert hashes.get('dist/index.mjs') == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /plugins/contributions `frontends` descriptor map
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_contributions_exposes_frontends_descriptor(plugin_dirs):
|
|
_install_runtime()
|
|
contrib = contribution_service.get_active_contributions()
|
|
fe = contrib['frontends']['serverkit-runtime']
|
|
assert fe['entry'] == 'dist/index.mjs'
|
|
assert 'hashes' in fe
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# kill switch (Decision 4)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_kill_switch_removes_frontends(plugin_dirs):
|
|
_install_runtime()
|
|
SettingsService.set('extensions.runtime_frontend', False)
|
|
contrib = contribution_service.get_active_contributions()
|
|
assert not contrib.get('frontends')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dual-path coexistence: baked builtin (.jsx) + runtime (.mjs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_baked_and_runtime_coexist(plugin_dirs):
|
|
_install_runtime()
|
|
# A baked builtin contributes via a .jsx component, not a runtime bundle, so
|
|
# it must NOT appear in the runtime `frontends` map.
|
|
baked = InstalledPlugin(slug='serverkit-baked', name='Baked',
|
|
display_name='Baked', version='1.0.0',
|
|
status=InstalledPlugin.STATUS_ACTIVE,
|
|
manifest={'frontend_entry': 'components/Baked.jsx'})
|
|
db.session.add(baked)
|
|
db.session.commit()
|
|
contrib = contribution_service.get_active_contributions()
|
|
assert 'serverkit-runtime' in contrib['frontends']
|
|
assert 'serverkit-baked' not in contrib['frontends']
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# config PUT preserves the panel-managed `_frontend_hashes`
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_config_put_preserves_frontend_hashes(plugin_dirs):
|
|
_install_runtime()
|
|
row = InstalledPlugin.query.filter_by(slug='serverkit-runtime').first()
|
|
before = (row.config or {}).get('_frontend_hashes')
|
|
plugin_service.update_plugin_config('serverkit-runtime', {'user_key': 'value'})
|
|
row = InstalledPlugin.query.filter_by(slug='serverkit-runtime').first()
|
|
after = (row.config or {}).get('_frontend_hashes')
|
|
assert after == before
|
|
assert (row.config or {}).get('user_key') == 'value'
|