193 lines
7.6 KiB
Python
Executable File
193 lines
7.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
tests/check_bridge_subcommands.py
|
|
|
|
Cross-checks shared/bridge.js's exposed `bridge.<module>.<method>()` calls
|
|
against the actual COMMANDS dict declared in each bridge/<module>.py helper.
|
|
|
|
This guard exists because of the v0.0.27 bug where:
|
|
- bridge.js exposed `bridge.firmware.devices()` calling
|
|
`python3 firmware.py devices`, but firmware.py only implemented
|
|
`summary`. Every firmware plugin page crashed.
|
|
- bridge.js exposed `bridge.benchmark.runTest(name)` calling
|
|
`python3 benchmark.py run-test <name>`, but benchmark.py had no
|
|
`run-test` subcommand. Clicking "Run" on any test in the Available
|
|
Tests table errored out.
|
|
|
|
Both bugs shipped through `make check` because the existing guards
|
|
(manifest consistency, metainfo consistency, Makefile recipes, version
|
|
sync, broken-import patterns) never cross-referenced the JS bridge
|
|
surface against the Python helper surfaces. This script does that.
|
|
|
|
Strategy:
|
|
1. Parse shared/bridge.js with a regex that finds every
|
|
`bridgeCmd("<module>", ["<subcommand>", ...])` call.
|
|
2. For each <module>, parse bridge/<module>.py with the ast module
|
|
and extract the keys of its COMMANDS dict (or, for helpers without
|
|
a COMMANDS dict, fall back to scanning main()'s argv[0] checks).
|
|
3. Verify every subcommand the JS expects actually exists in the
|
|
Python helper's dispatch table.
|
|
|
|
Exits non-zero with a clear message naming the bad file + call on failure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
BRIDGE_JS = ROOT / "shared" / "bridge.js"
|
|
BRIDGE_DIR = ROOT / "bridge"
|
|
|
|
|
|
def extract_bridge_cmd_calls(js_source: str) -> list[tuple[str, str, int]]:
|
|
"""Return [(module, subcommand, line_no), ...] for every bridgeCmd() call.
|
|
|
|
The bridge.js bridgeCmd helper signature is:
|
|
|
|
bridgeCmd(module, args = [], options = {})
|
|
|
|
where args is an array literal whose first element is a string literal
|
|
naming the subcommand. Examples we want to match:
|
|
|
|
bridgeCmd("containers", ["list"])
|
|
bridgeCmd("firewall", ["ruleset"])
|
|
bridgeCmd("packages", ["search", q])
|
|
bridgeCmd("benchmark", ["run-test", name], { superuser: true })
|
|
|
|
We deliberately only match the first-array-element string literal — if
|
|
a future bridge.js author computes the subcommand dynamically, we can't
|
|
statically verify it, and we just skip that call (rather than failing).
|
|
"""
|
|
pattern = re.compile(
|
|
r'bridgeCmd\(\s*' # bridgeCmd(
|
|
r'["\']([^"\']+)["\']\s*,\s*' # "module"
|
|
r'\[\s*["\']([^"\']+)["\']' # ["subcommand"
|
|
, re.MULTILINE
|
|
)
|
|
calls: list[tuple[str, str, int]] = []
|
|
for match in pattern.finditer(js_source):
|
|
line_no = js_source.count('\n', 0, match.start()) + 1
|
|
calls.append((match.group(1), match.group(2), line_no))
|
|
return calls
|
|
|
|
|
|
def extract_python_commands(py_path: Path) -> tuple[set[str], str]:
|
|
"""Return (subcommand_set, source_kind) for a bridge/<module>.py file.
|
|
|
|
source_kind is one of:
|
|
'COMMANDS dict' — module has a COMMANDS = {...} dict at module level
|
|
'main argv' — module's main() does explicit argv[0] == "..." checks
|
|
'none' — neither; we can't statically determine subcommands
|
|
"""
|
|
source = py_path.read_text(encoding="utf-8")
|
|
try:
|
|
tree = ast.parse(source)
|
|
except SyntaxError as exc:
|
|
print(f"FAIL: {py_path.name}: Python syntax error: {exc}",
|
|
file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Look for a top-level `COMMANDS = { ... }` assignment.
|
|
for node in tree.body:
|
|
if isinstance(node, ast.Assign):
|
|
for target in node.targets:
|
|
if getattr(target, 'id', None) == 'COMMANDS':
|
|
if isinstance(node.value, ast.Dict):
|
|
keys: set[str] = set()
|
|
for k in node.value.keys:
|
|
if isinstance(k, ast.Constant) and isinstance(k.value, str):
|
|
keys.add(k.value)
|
|
return keys, 'COMMANDS dict'
|
|
if isinstance(node.value, ast.Call):
|
|
# Dynamic — can't statically extract.
|
|
return set(), 'none'
|
|
|
|
# Fall back: scan main()'s body for `if argv[0] == "X":` patterns.
|
|
subcommands: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Compare):
|
|
# Match: argv[0] == "summary"
|
|
if (isinstance(node.left, ast.Subscript)
|
|
and isinstance(node.left.value, ast.Name)
|
|
and node.left.value.id == 'argv'
|
|
and len(node.ops) == 1
|
|
and isinstance(node.ops[0], ast.Eq)
|
|
and len(node.comparators) == 1
|
|
and isinstance(node.comparators[0], ast.Constant)
|
|
and isinstance(node.comparators[0].value, str)):
|
|
subcommands.add(node.comparators[0].value)
|
|
if subcommands:
|
|
return subcommands, 'main argv'
|
|
return set(), 'none'
|
|
|
|
|
|
def main() -> int:
|
|
if not BRIDGE_JS.is_file():
|
|
print(f"FAIL: {BRIDGE_JS} not found", file=sys.stderr)
|
|
return 1
|
|
|
|
js_source = BRIDGE_JS.read_text(encoding="utf-8")
|
|
calls = extract_bridge_cmd_calls(js_source)
|
|
if not calls:
|
|
print("FAIL: no bridgeCmd() calls found in shared/bridge.js — "
|
|
"the bridge surface is empty or the regex is stale.",
|
|
file=sys.stderr)
|
|
return 1
|
|
|
|
# Build module → set(subcommands) map from the Python side.
|
|
py_subcommands: dict[str, tuple[set[str], str]] = {}
|
|
for py_file in sorted(BRIDGE_DIR.glob("*.py")):
|
|
if py_file.name == "__init__.py":
|
|
continue
|
|
subs, kind = extract_python_commands(py_file)
|
|
py_subcommands[py_file.stem] = (subs, kind)
|
|
|
|
failures: list[str] = []
|
|
checked = 0
|
|
for module, subcommand, line_no in sorted(set(calls)):
|
|
py_path = BRIDGE_DIR / f"{module}.py"
|
|
if not py_path.is_file():
|
|
failures.append(
|
|
f"shared/bridge.js:{line_no}: bridgeCmd(\"{module}\", "
|
|
f"[\"{subcommand}\", ...]) — no bridge/{module}.py file exists."
|
|
)
|
|
continue
|
|
subs, kind = py_subcommands.get(module, (set(), 'none'))
|
|
if kind == 'none':
|
|
# Helper has no static dispatch table (e.g. db.py, hwalert.py).
|
|
# We can't verify the call — skip rather than fail.
|
|
continue
|
|
if subcommand not in subs:
|
|
failures.append(
|
|
f"shared/bridge.js:{line_no}: bridgeCmd(\"{module}\", "
|
|
f"[\"{subcommand}\", ...]) — bridge/{module}.py does not "
|
|
f"expose a \"{subcommand}\" subcommand. Its COMMANDS dict "
|
|
f"has: {sorted(subs) or '(empty)'}."
|
|
)
|
|
checked += 1
|
|
|
|
if failures:
|
|
print("FAIL: bridge.js calls Python subcommands that don't exist:",
|
|
file=sys.stderr)
|
|
for f in failures:
|
|
print(f" {f}", file=sys.stderr)
|
|
print(file=sys.stderr)
|
|
print("To fix: either add the missing subcommand to the Python "
|
|
"helper's COMMANDS dict, or change the bridge.js call to use "
|
|
"a subcommand that exists.", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"OK: {checked} bridge.js calls verified against Python "
|
|
f"COMMANDS dicts across {len(py_subcommands)} bridge modules.")
|
|
print(" All bridgeCmd() subcommands resolve to a real Python "
|
|
"dispatch entry.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|