Ship plugin framework shore-up: frontend scaffold, sister-site adoption kit
- 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>
This commit is contained in:
121
scripts/test-external-plugin.sh
Executable file
121
scripts/test-external-plugin.sh
Executable file
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# test-external-plugin.sh
|
||||
#
|
||||
# Verify an out-of-tree shopdb-flask plugin against a pinned framework build.
|
||||
# Stand up a throwaway copy of the framework, drop the plugin into
|
||||
# plugins/<name>/ as a symlink (the mechanism ADR-003 documents for sister
|
||||
# sites), and run the framework contract tests plus the plugin's own tests.
|
||||
# Nonzero exit means the plugin is not compatible with that framework ref.
|
||||
#
|
||||
# Two modes:
|
||||
#
|
||||
# CI / remote (default): clone the framework at FRAMEWORK_REF from
|
||||
# FRAMEWORK_URL, build a fresh venv, pip install requirements. Needs
|
||||
# network access to git and PyPI.
|
||||
#
|
||||
# Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk.
|
||||
# The script exports that checkout at HEAD with `git archive` (no network)
|
||||
# and reuses the checkout's existing venv, so it runs with no internet.
|
||||
#
|
||||
# Inputs (env var, or positional):
|
||||
# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required.
|
||||
# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main.
|
||||
# FRAMEWORK_URL framework git URL for CI mode.
|
||||
# Default: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||
# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline.
|
||||
#
|
||||
# Examples:
|
||||
# # CI: test the plugin in the current repo against a pinned tag
|
||||
# PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh
|
||||
#
|
||||
# # Offline: test a bundled plugin against this checkout, no network
|
||||
# LOCAL_FRAMEWORK=. PLUGIN_DIR=plugins/warranty scripts/test-external-plugin.sh
|
||||
#
|
||||
# Note on coverage: a symlinked plugin is discovered and loaded by the plugin
|
||||
# loader (so loadability, manifest validity, and the core_version range are all
|
||||
# checked), but the import-surface scan in test_plugin_contract.py uses
|
||||
# Path.rglob over plugins/, which does not descend symlinked directories on
|
||||
# CPython 3.12. To have that scan cover your plugin too, keep an equivalent
|
||||
# import-surface assertion in your own tests/ (see docs/PLUGIN-EXTERNAL-REPO.md).
|
||||
|
||||
set -eu
|
||||
|
||||
PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
|
||||
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
|
||||
FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}"
|
||||
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"
|
||||
|
||||
if [ -z "$PLUGIN_DIR" ]; then
|
||||
echo "ERROR: PLUGIN_DIR is required (env var or first argument)." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -f "$PLUGIN_DIR/manifest.json" ]; then
|
||||
echo "ERROR: $PLUGIN_DIR has no manifest.json - not a plugin directory." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Absolute path so the symlink still resolves after we cd into the temp tree.
|
||||
PLUGIN_ABS="$(cd "$PLUGIN_DIR" && pwd)"
|
||||
# Plugin name: prefer the manifest name, fall back to the directory basename.
|
||||
PLUGIN_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ABS/manifest.json" | head -n1)"
|
||||
if [ -z "$PLUGIN_NAME" ]; then
|
||||
PLUGIN_NAME="$(basename "$PLUGIN_ABS")"
|
||||
fi
|
||||
|
||||
WORKDIR="$(mktemp -d)"
|
||||
cleanup() { rm -rf "$WORKDIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
FRAMEWORK="$WORKDIR/framework"
|
||||
mkdir -p "$FRAMEWORK"
|
||||
|
||||
if [ -n "$LOCAL_FRAMEWORK" ]; then
|
||||
echo "==> Local mode: exporting framework from $LOCAL_FRAMEWORK (HEAD)"
|
||||
LOCAL_ABS="$(cd "$LOCAL_FRAMEWORK" && pwd)"
|
||||
git -C "$LOCAL_ABS" archive HEAD | tar -x -C "$FRAMEWORK"
|
||||
# Reuse the checkout's venv so no pip and no network are needed.
|
||||
if [ -x "$LOCAL_ABS/venv/bin/python" ]; then
|
||||
PYTHON="$LOCAL_ABS/venv/bin/python"
|
||||
elif [ -x "$LOCAL_ABS/.venv/bin/python" ]; then
|
||||
PYTHON="$LOCAL_ABS/.venv/bin/python"
|
||||
else
|
||||
echo "ERROR: no venv found under $LOCAL_ABS (looked for venv/ and .venv/)." >&2
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
echo "==> CI mode: cloning $FRAMEWORK_URL @ $FRAMEWORK_REF"
|
||||
git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" "$FRAMEWORK"
|
||||
echo "==> Creating venv and installing requirements"
|
||||
python3 -m venv "$WORKDIR/venv"
|
||||
PYTHON="$WORKDIR/venv/bin/python"
|
||||
"$PYTHON" -m pip install --upgrade pip >/dev/null
|
||||
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt"
|
||||
fi
|
||||
|
||||
echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/"
|
||||
rm -rf "$FRAMEWORK/plugins/$PLUGIN_NAME"
|
||||
ln -s "$PLUGIN_ABS" "$FRAMEWORK/plugins/$PLUGIN_NAME"
|
||||
|
||||
# Drop any exported registry so the loader re-discovers and enables the plugin
|
||||
# (the test conftest seeds a fresh registry from plugins/*/manifest.json).
|
||||
rm -f "$FRAMEWORK/instance/plugins.json"
|
||||
|
||||
RC=0
|
||||
|
||||
echo "==> Running framework contract tests"
|
||||
( cd "$FRAMEWORK" && "$PYTHON" -m pytest tests/test_plugin_contract.py -q ) || RC=1
|
||||
|
||||
if [ -d "$PLUGIN_ABS/tests" ]; then
|
||||
echo "==> Running plugin's own tests"
|
||||
( cd "$FRAMEWORK" && "$PYTHON" -m pytest "$PLUGIN_ABS/tests" -q ) || RC=1
|
||||
else
|
||||
echo "==> Plugin has no tests/ directory - skipping plugin test step"
|
||||
fi
|
||||
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "==> PASS: plugin '$PLUGIN_NAME' is compatible with framework ref '$FRAMEWORK_REF'"
|
||||
else
|
||||
echo "==> FAIL: plugin '$PLUGIN_NAME' - see output above" >&2
|
||||
fi
|
||||
exit "$RC"
|
||||
Reference in New Issue
Block a user