mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 08:07:37 +09:00
The `app` fixture called create_app('testing') for every one of 3173 tests,
registering 90+ blueprints each time. Measured on a fixed 199-test sample
(pytest --splits 16 --group 5 --durations=0):
setup 144.9s 89.5%
call 8.8s 5.4%
teardown 8.2s 5.0%
95% of the suite's time was preparing to test; 5% was testing. The app is now
built once per process and shared.
Same 794-test slice, before and after — identical results, 2.66x faster:
before 4 failed, 773 passed, 17 skipped 593.30s
after 4 failed, 773 passed, 17 skipped 223.11s
(the 4 are pre-existing locally: serverkit-wordpress isn't checked out here.)
Sharing one app surfaces three things a per-test app hid, all handled in the
fixture rather than patched test by test:
1. create_app() is not a pure constructor — it SEEDS the bundled flagship
extension rows as a boot side effect (test_cloudflare_extraction asserts
they are "seeded on boot"). The per-test drop_all wipes them, and a shared
app never re-boots, so every test after the first saw no flagship and the
extension routes answered 503. seed_flagship_extensions() is now re-run
after each create_all. The session fixture also boots twice on purpose:
once to create the schema, once to seed into a schema that exists.
2. Config mutation now leaks where it used to die with the app (e.g.
test_demo_deploys sets DEMO_DEPLOYS_ENABLED=False and never restores it),
so config is snapshotted and restored around every test.
3. Structure mutation cannot be undone at all — Flask has no
unregister_blueprint, and refuses setup methods once an app has served a
request. Those tests opt out via `pytestmark = pytest.mark.fresh_app` and
get a private app. Three groups needed it: 13 modules calling
register_blueprint/add_url_rule directly, 11 that install plugins (
plugin_service hot-loads their blueprints onto the live app), and
test_audit_log which adds routes with @app.route.
Tests using the `wp_extension` fixture are detected from the fixture graph
instead of being marked, since that fixture mounts blueprints on whatever app
it is handed — miss one marker and the failure lands in an unrelated test much
later in the run.
Plan 64 Phase 1. Phase 2 (savepoint rollback instead of create_all/drop_all)
is NOT included: it currently takes the slice from 4 failures to 100 failures
and 201 errors, and needs its own round.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
7.6 KiB
Python
179 lines
7.6 KiB
Python
"""Prove the one-shot auto-install of converted builtin extensions (D3).
|
|
|
|
An *upgraded* panel (has users) auto-installs a converted builtin once; a *fresh*
|
|
panel (no users yet) does not — it sees the extension in the Marketplace. Either
|
|
way the pass is idempotent and recorded so it never repeats.
|
|
"""
|
|
import json
|
|
|
|
import pytest
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
from app import db
|
|
from app.models.plugin import InstalledPlugin
|
|
from app.models.user import User
|
|
from app.services import plugin_service, extension_migration
|
|
|
|
# Installs plugins, and plugin_service hot-loads their blueprints onto the
|
|
# live app. Flask refuses register_blueprint once an app has served a
|
|
# request, so these need a private app (plan 64 Phase 1).
|
|
pytestmark = pytest.mark.fresh_app
|
|
|
|
|
|
@pytest.fixture
|
|
def demo_builtin(tmp_path, monkeypatch):
|
|
"""Redirect plugin dirs to temp, ship one converted builtin ('serverkit-demo')."""
|
|
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))
|
|
monkeypatch.setattr(extension_migration, 'CONVERTED_BUILTIN_SLUGS', ['serverkit-demo'])
|
|
|
|
folder = builtin / 'serverkit-demo'
|
|
(folder / 'frontend').mkdir(parents=True)
|
|
manifest = {
|
|
'name': 'serverkit-demo', 'display_name': 'Demo', 'version': '1.0.0',
|
|
'category': 'utility',
|
|
'contributions': {'nav': [{'id': 'demo', 'label': 'Demo', 'route': '/demo'}]},
|
|
}
|
|
(folder / 'plugin.json').write_text(json.dumps(manifest), encoding='utf-8')
|
|
(folder / 'frontend' / 'index.jsx').write_text('export function P(){return null;}\n')
|
|
return builtin
|
|
|
|
|
|
def _make_user():
|
|
u = User(email='u@test.local', username='someuser',
|
|
password_hash=generate_password_hash('x'),
|
|
role=User.ROLE_ADMIN, is_active=True)
|
|
db.session.add(u)
|
|
db.session.commit()
|
|
|
|
|
|
def test_upgrade_auto_installs_converted_builtin(app, demo_builtin):
|
|
_make_user() # existing install
|
|
extension_migration.run_auto_install()
|
|
|
|
p = InstalledPlugin.query.filter_by(slug='serverkit-demo').first()
|
|
assert p is not None
|
|
assert p.status == InstalledPlugin.STATUS_ACTIVE
|
|
|
|
# Marker recorded → second run is a no-op (no error, still one row).
|
|
extension_migration.run_auto_install()
|
|
assert InstalledPlugin.query.filter_by(slug='serverkit-demo').count() == 1
|
|
|
|
|
|
def test_fresh_install_does_not_auto_install(app, demo_builtin):
|
|
# No users → brand-new panel.
|
|
extension_migration.run_auto_install()
|
|
assert InstalledPlugin.query.filter_by(slug='serverkit-demo').first() is None
|
|
# But it was marked processed, so a later boot (even once users exist) won't
|
|
# retroactively install it.
|
|
assert 'serverkit-demo' in extension_migration._processed_slugs()
|
|
|
|
|
|
def test_user_uninstall_is_not_undone(app, demo_builtin):
|
|
_make_user()
|
|
extension_migration.run_auto_install()
|
|
p = InstalledPlugin.query.filter_by(slug='serverkit-demo').first()
|
|
plugin_service.uninstall_plugin(p.id)
|
|
assert InstalledPlugin.query.filter_by(slug='serverkit-demo').first() is None
|
|
|
|
# Re-running must NOT reinstall — the one-shot already happened.
|
|
extension_migration.run_auto_install()
|
|
assert InstalledPlugin.query.filter_by(slug='serverkit-demo').first() is None
|
|
|
|
|
|
@pytest.fixture
|
|
def gated_gpu_builtin(tmp_path, monkeypatch):
|
|
"""Ship 'serverkit-gpu' as a gated builtin (no ungated converted slugs)."""
|
|
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))
|
|
monkeypatch.setattr(extension_migration, 'CONVERTED_BUILTIN_SLUGS', [])
|
|
|
|
folder = builtin / 'serverkit-gpu'
|
|
(folder / 'frontend').mkdir(parents=True)
|
|
manifest = {
|
|
'name': 'serverkit-gpu', 'display_name': 'GPU Monitor', 'version': '1.0.0',
|
|
'category': 'monitoring',
|
|
'contributions': {'nav': [{'id': 'gpu', 'label': 'GPU', 'route': '/gpu'}]},
|
|
}
|
|
(folder / 'plugin.json').write_text(json.dumps(manifest), encoding='utf-8')
|
|
(folder / 'frontend' / 'index.jsx').write_text('export function P(){return null;}\n')
|
|
return builtin
|
|
|
|
|
|
def test_upgrade_without_gpu_does_not_install_but_marks(app, gated_gpu_builtin, monkeypatch):
|
|
_make_user() # existing install (upgrade path)
|
|
# App boot already ran a fresh-install pass that marked converted/gated slugs;
|
|
# clear the marker so this test genuinely exercises the gate.
|
|
extension_migration._save_processed(set())
|
|
monkeypatch.setitem(extension_migration.GATED_BUILTIN_SLUGS, 'serverkit-gpu',
|
|
lambda: False)
|
|
extension_migration.run_auto_install()
|
|
|
|
# Gate false → not installed, but recorded so it never retries.
|
|
assert InstalledPlugin.query.filter_by(slug='serverkit-gpu').first() is None
|
|
assert 'serverkit-gpu' in extension_migration._processed_slugs()
|
|
|
|
|
|
def test_upgrade_with_gpu_installs(app, gated_gpu_builtin, monkeypatch):
|
|
_make_user() # existing install (upgrade path)
|
|
# Clear the boot-time processed marker (see note above) so the gate decides.
|
|
extension_migration._save_processed(set())
|
|
monkeypatch.setitem(extension_migration.GATED_BUILTIN_SLUGS, 'serverkit-gpu',
|
|
lambda: True)
|
|
extension_migration.run_auto_install()
|
|
|
|
p = InstalledPlugin.query.filter_by(slug='serverkit-gpu').first()
|
|
assert p is not None
|
|
assert p.status == InstalledPlugin.STATUS_ACTIVE
|
|
|
|
|
|
@pytest.fixture
|
|
def retired_leftovers(tmp_path, monkeypatch):
|
|
"""Plant leftover files for a retired extension slug in both plugin dirs."""
|
|
backend = tmp_path / 'backend_plugins'
|
|
frontend = tmp_path / 'frontend_plugins'
|
|
for d in (backend, frontend):
|
|
(d / 'serverkit-oldthing').mkdir(parents=True)
|
|
(backend / 'serverkit-oldthing' / '__init__.py').write_text('')
|
|
(frontend / 'serverkit-oldthing' / 'index.jsx').write_text("import 'gone';\n")
|
|
monkeypatch.setattr(plugin_service, 'BACKEND_PLUGINS_DIR', str(backend))
|
|
monkeypatch.setattr(plugin_service, 'FRONTEND_PLUGINS_DIR', str(frontend))
|
|
monkeypatch.setattr(extension_migration, 'RETIRED_EXTENSION_SLUGS',
|
|
['serverkit-oldthing'])
|
|
return backend, frontend
|
|
|
|
|
|
def test_retired_extension_sweep(app, retired_leftovers):
|
|
"""Retired leftovers (files + row) are swept at boot; the sweep is idempotent.
|
|
|
|
This is the serverkit-workflows scenario: a stale dir in the live tree was
|
|
carried into every update by the plugin preservation step, where its dead
|
|
imports broke the frontend build.
|
|
"""
|
|
backend, frontend = retired_leftovers
|
|
db.session.add(InstalledPlugin(
|
|
name='serverkit-oldthing', display_name='Old Thing',
|
|
slug='serverkit-oldthing', version='1.0.0'))
|
|
db.session.commit()
|
|
|
|
extension_migration.remove_retired_extensions()
|
|
|
|
assert not (backend / 'serverkit-oldthing').exists()
|
|
assert not (frontend / 'serverkit-oldthing').exists()
|
|
assert InstalledPlugin.query.filter_by(slug='serverkit-oldthing').first() is None
|
|
|
|
# Second sweep on an already-clean tree is a quiet no-op.
|
|
extension_migration.remove_retired_extensions()
|