Files
opensquilla--opensquilla/tests/test_gateway_static_skills_view.py
2026-07-13 13:12:33 +08:00

630 lines
22 KiB
Python

from __future__ import annotations
import subprocess
import tempfile
import textwrap
from pathlib import Path
SKILLS_VIEW = Path("src/opensquilla/gateway/static/js/views/skills.js")
def _run_skills_view_harness(assertion_script: str) -> str:
source = SKILLS_VIEW.read_text(encoding="utf-8")
instrumented = source.replace(
"return { render, destroy };",
"""return { render, destroy, __test: {
setState: (state) => {
_el = state.el || _el;
_rpc = state.rpc || _rpc;
_allSkills = state.allSkills || _allSkills;
},
openSkillDialog: _openSkillDialog,
dependencySummary: _dependencySummary
} };""",
)
script = textwrap.dedent(
f"""
const vm = require('vm');
function makeButton() {{
return {{
_listeners: {{}},
addEventListener(type, fn) {{ this._listeners[type] = fn; }},
click() {{
if (this._listeners.click) {{
return this._listeners.click({{ preventDefault() {{}}, stopPropagation() {{}} }});
}}
}},
}};
}}
const closeButton = makeButton();
const dialog = {{
open: false,
addEventListener() {{}},
close() {{ this.open = false; }},
showModal() {{ this.open = true; }},
setAttribute(name) {{ if (name === 'open') this.open = true; }},
}};
const body = {{
innerHTML: '',
querySelector(selector) {{
if (selector === '#skill-dialog-close') return closeButton;
return null;
}},
}};
const el = {{
querySelector(selector) {{
if (selector === '#skill-detail-dialog') return dialog;
if (selector === '#skill-detail-body') return body;
return null;
}},
}};
const icons = new Proxy({{}}, {{
get(_target, prop) {{
return () => `[icon:${{String(prop)}}]`;
}},
}});
const context = {{
window: {{}},
document: {{}},
icons,
App: {{ getRpc: () => null }},
UI: {{ toast() {{}}, confirm: async () => true }},
console,
setTimeout,
clearTimeout,
encodeURIComponent,
}};
vm.createContext(context);
vm.runInContext({instrumented!r}, context);
const SkillsView = context.window.SkillsView;
const env = {{ SkillsView, dialog, body, el, closeButton }};
async function main() {{
{textwrap.indent(assertion_script, " ")}
}}
main().catch((error) => {{
console.error(error && error.stack ? error.stack : String(error));
process.exit(1);
}});
"""
)
with tempfile.TemporaryDirectory() as tmp:
script_path = Path(tmp) / "skills-view-harness.js"
script_path.write_text(script, encoding="utf-8")
result = subprocess.run(
["node", str(script_path)],
check=False,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise AssertionError(result.stderr or result.stdout)
return result.stdout
def test_skills_view_exposes_direct_github_install_control() -> None:
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
assert 'id="skills-github-url"' in view
assert 'class="btn btn--primary" id="skills-github-install"' in view
assert "_installSkill(githubInput.value.trim(), 'github'," in view
def test_skills_view_search_stays_clawhub_only() -> None:
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
assert 'id="skills-registry-source"' not in view
assert "Searching ClawHub" in view
assert "skills.search', { query: query.trim(), limit: 20 }" in view
def test_skills_view_distinguishes_bundled_from_local_layers() -> None:
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
assert "Bundled skills ship with OpenSquilla." in view
assert "Managed skills are locally installed into OpenSquilla state." in view
assert "Personal skills are local user installs, not bundled." in view
def test_skills_view_renders_pending_proposals_section() -> None:
"""Path 3 of the auto-propose feature plugs into the Skills view.
Static asserts cover (a) the RPC calls that feed it,
(b) the visible HTML markers, and (c) the three action handlers."""
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
css = Path("src/opensquilla/gateway/static/css/views/skills.css").read_text(encoding="utf-8")
# RPC calls
assert "_rpc.call('exec.proposals.list')" in view
assert "_rpc.call('exec.proposals.show'" in view
assert "_rpc.call('exec.proposals.accept'" in view
assert "_rpc.call('exec.proposals.reject'" in view
assert "_rpc.call('exec.proposals.auto_enabled.list')" in view
assert "_rpc.call('exec.proposals.auto_enabled.disable'" in view
# HTML structure
assert "sk-group--proposals" in view
assert "Pending Proposals" in view
assert "_renderProposalRow" in view
assert "_renderAutoEnabledRow" in view
# Action handlers wired into the click delegate
assert "[data-proposal-show]" in view
assert "[data-proposal-accept]" in view
assert "[data-proposal-reject]" in view
assert "[data-auto-enabled-disable]" in view
# CSS for the new chips + dialog
assert ".sk-group--proposals" in css
assert ".sk-prop-chip--auto" in css
assert ".sk-proposal-row" in css
def test_skills_view_renders_missing_env_any_groups() -> None:
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
assert "missing_env_any" in view
assert "env var group" in view
def test_skills_view_keeps_dialog_open_for_missing_env_any_after_install() -> None:
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
assert "const still = res.missing_still || {};" in view
assert "(still.bins || []).length" in view
assert "(still.env || []).length" in view
assert "(still.env_any || []).length" in view
def test_skills_view_renders_auto_enable_audit_summary() -> None:
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
css = Path("src/opensquilla/gateway/static/css/views/skills.css").read_text(encoding="utf-8")
assert "_renderAutoEnableAudit" in view
assert "auto_enable_audit" in view
assert "validation_profile" in view
assert "static-safety" in view
assert "sk-audit-grid" in view
assert ".sk-audit-grid" in css
def test_skills_view_force_accepts_after_gate_failure_confirm() -> None:
"""When proposals.accept returns refused because of failed gates,
the UI prompts and retries with force=true. Static check that the
retry path passes force=true."""
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
assert "force: true" in view
def test_skills_view_auto_chip_recognises_auto_triggered_by() -> None:
"""Provenance chip: rows from cron/dream show [auto] alongside the
proposal_id so operators can spot bot-generated proposals at a glance."""
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
assert "p.triggered_by.startsWith('auto_')" in view
assert "sk-prop-chip--auto" in view
def test_skills_view_renders_auto_propose_settings_panel() -> None:
"""The settings toggle for unattended-synthesis must be in the Skills
view: RPC calls, two checkbox bindings, and the CSS class names."""
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
css = Path("src/opensquilla/gateway/static/css/views/skills.css").read_text(encoding="utf-8")
# RPC calls present
assert "_rpc.call('exec.proposals.settings.get')" in view
assert "_rpc.call('exec.proposals.settings.set'" in view
# Distinct toggles
assert 'data-ap-toggle="enabled"' in view
assert 'data-ap-toggle="on_dream_complete"' in view
assert 'data-ap-toggle="auto_enable"' in view
assert 'data-ap-risk-select' in view
# Section renderer
assert "_renderAutoProposeSettings" in view
assert "sk-group--ap-settings" in view
assert "Off by default. Enable cron or dream" in view
assert ".sk-group--ap-settings" in css
assert ".sk-ap-toggle" in css
# Bookkeeping state
assert "_proposalsSettings" in view
assert "_toggleAutoPropose" in view
assert "_setAutoEnableRisk" in view
def test_skills_view_renders_settings_panel_even_with_no_pending_proposals() -> None:
"""The toggle has to appear before any proposal exists, otherwise
the operator can't turn the feature on from a clean state."""
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
# The settings renderer is gated on _proposalsSettings.available
# rather than on _proposals.length.
assert "if (_proposalsSettings && _proposalsSettings.available)" in view
def test_skills_view_renders_dependency_badges_on_cards() -> None:
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
css = Path("src/opensquilla/gateway/static/css/views/skills.css").read_text(encoding="utf-8")
assert "dependency_summary" in view
assert "_renderDependencyBadges" in view
assert "sk-card__deps" in view
assert "sk-card__dep-badge" in view
assert "py " in view
assert "bin " in view
assert "env " in view
assert "missing " in view
assert "advisory " in view
assert ".sk-card__deps" in css
assert ".sk-card__dep-badge" in css
assert ".sk-card__dep-badge--missing" in css
assert ".sk-card__dep-badge--advisory" in css
def test_skills_view_renders_detailed_dependency_section_in_dialog() -> None:
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
css = Path("src/opensquilla/gateway/static/css/views/skills.css").read_text(encoding="utf-8")
assert "_rpc.call('skills.get'" in view
assert "_renderDependencySection" in view
assert "Dependencies" in view
assert "Declared Python Packages" in view
assert "Declared Binaries" in view
assert "Declared API Env" in view
assert "Missing Dependencies" in view
assert "Suggested next steps" in view
assert "Advisory only" in view
assert "sub-skill rollup" in view
assert "missing.binaries.any" in view
assert "missing.api_env.any" in view
assert "scan_errors" in view
assert view.index("Suggested next steps") < view.index("Declared Python Packages")
assert ".sk-dep-grid" in css
assert ".sk-dep-block" in css
assert ".sk-dep-block--suggestions" in css
assert "grid-column: 1 / -1" in css
assert ".sk-dep-block--suggestions .sk-dep-block__title" in css
assert "text-transform: none" in css
assert ".sk-dep-list" in css
assert ".sk-dep-note" in css
assert ".sk-dep-subskill-row" in css
def test_skills_view_detail_x_uses_dialog_close_delegate() -> None:
view = Path("src/opensquilla/gateway/static/js/views/skills.js").read_text(encoding="utf-8")
assert 'id="skill-dialog-close" data-dialog-close' in view
assert "_closeSkillDialog" in view
def test_skills_view_detail_x_closes_dialog_without_reference_error() -> None:
_run_skills_view_harness(
"""
const detail = {
name: 'demo-skill',
description: 'Needs setup',
layer: 'managed',
status: 'needs_setup',
dependency_summary: {
declared: {
binaries: { all: [], any: [] },
python_packages: [],
api_env: { all: [], any: [] },
},
missing: {
binaries: { all: [], any: [] },
api_env: { all: [], any: [] },
count: 0,
},
inferred: { python_imports: [], api_env: [], scan_errors: [] },
sub_skill_dependencies: {
skills: [],
missing_count: 0,
inferred_count: 0,
missing_references: [],
},
},
};
const rpc = {
async call(method, payload) {
if (method !== 'skills.get' || payload.name !== 'demo-skill') {
throw new Error(`unexpected rpc call ${method}`);
}
return detail;
},
};
SkillsView.__test.setState({ el, rpc, allSkills: [] });
await SkillsView.__test.openSkillDialog(detail);
if (!dialog.open) throw new Error('dialog should be open before close click');
closeButton.click();
if (dialog.open) {
throw new Error('dialog should close after clicking the X button');
}
"""
)
def test_skills_view_dialog_suggests_missing_dependency_next_steps() -> None:
_run_skills_view_harness(
"""
const detail = {
name: 'demo-skill',
description: 'Needs setup',
layer: 'managed',
status: 'needs_setup',
dependency_summary: {
declared: {
binaries: { all: ['demo-tool'], any: [] },
python_packages: [
{
install_id: 'py',
label: 'Demo Python package',
package: 'demo-pkg',
module: 'demo_pkg',
},
],
api_env: { all: ['DEMO_API_KEY'], any: [] },
},
missing: {
binaries: { all: ['demo-tool'], any: [] },
api_env: { all: ['DEMO_API_KEY'], any: [] },
count: 2,
},
inferred: { python_imports: [], api_env: [], scan_errors: [] },
sub_skill_dependencies: {
skills: [],
missing_count: 0,
inferred_count: 0,
missing_references: ['child-skill'],
},
},
};
const rpc = {
async call(method, payload) {
if (method !== 'skills.get' || payload.name !== 'demo-skill') {
throw new Error(`unexpected rpc call ${method}`);
}
return detail;
},
};
SkillsView.__test.setState({ el, rpc, allSkills: [] });
await SkillsView.__test.openSkillDialog(detail);
const html = body.innerHTML;
if (!html.includes('Suggested next steps')) {
throw new Error(`missing suggestion section: ${html}`);
}
if (!html.includes('uv pip install demo-pkg')) {
throw new Error(`missing python install command: ${html}`);
}
if (!html.includes('Install demo-tool')) {
throw new Error(`missing binary install advice: ${html}`);
}
if (!html.includes('Set DEMO_API_KEY')) {
throw new Error(`missing env setup advice: ${html}`);
}
if (!html.includes('Install or enable sub-skill child-skill')) {
throw new Error(`missing sub-skill setup advice: ${html}`);
}
if (!html.includes('opensquilla skills install child-skill')) {
throw new Error(`missing sub-skill install command: ${html}`);
}
"""
)
def test_skills_view_dialog_exposes_package_only_install_actions() -> None:
_run_skills_view_harness(
"""
const detail = {
name: 'package-only',
description: 'Needs a Python package setup action',
layer: 'managed',
status: 'not_declared',
dependency_summary: {
declared: {
binaries: { all: [], any: [] },
python_packages: [
{
install_id: 'py-dep',
label: 'Install package dependency',
package: 'package-dep',
module: 'package_dep',
},
],
api_env: { all: [], any: [] },
},
missing: {
binaries: { all: [], any: [] },
api_env: { all: [], any: [] },
count: 0,
},
inferred: { python_imports: [], api_env: [], scan_errors: [] },
sub_skill_dependencies: {
skills: [],
missing_count: 0,
inferred_count: 0,
missing_references: [],
},
},
install: [
{
id: 'py-dep',
kind: 'uv',
label: 'Install package dependency',
},
],
};
const rpc = {
async call(method, payload) {
if (method !== 'skills.get' || payload.name !== 'package-only') {
throw new Error(`unexpected rpc call ${method}`);
}
return detail;
},
};
SkillsView.__test.setState({ el, rpc, allSkills: [] });
await SkillsView.__test.openSkillDialog(detail);
const html = body.innerHTML;
if (!html.includes('data-install-deps-id="py-dep"')) {
throw new Error(`missing package-only install action: ${html}`);
}
if (!html.includes('uv pip install package-dep')) {
throw new Error(`missing package-only install command: ${html}`);
}
"""
)
def test_skills_view_dialog_ignores_stale_fetch_and_hides_optional_installs() -> None:
_run_skills_view_harness(
"""
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const first = deferred();
const second = deferred();
const calls = [];
const rpc = {
async call(method, payload) {
calls.push({ method, payload });
if (method !== 'skills.get') {
throw new Error(`unexpected rpc method: ${method}`);
}
if (payload.name === 'alpha') return first.promise;
if (payload.name === 'beta') return second.promise;
throw new Error(`unexpected skill name: ${payload.name}`);
},
};
SkillsView.__test.setState({ el, rpc, allSkills: [] });
const alpha = {
name: 'alpha',
description: 'Alpha list payload',
layer: 'managed',
status: 'needs_setup',
dependency_summary: {
declared: {
binaries: { all: ['alpha-bin'], any: [] },
python_packages: [],
api_env: { all: [], any: [] },
},
missing: {
binaries: { all: ['alpha-bin'], any: [] },
api_env: { all: [], any: [] },
count: 1,
},
inferred: { python_imports: [], api_env: [], scan_errors: [] },
sub_skill_dependencies: {
skills: [],
missing_count: 0,
inferred_count: 0,
missing_references: [],
},
},
install: [
{ id: 'alpha-installer', kind: 'uv', label: 'Install alpha', bins: ['alpha-bin'] },
],
};
const beta = {
name: 'beta',
description: 'Beta list payload',
layer: 'managed',
status: 'ready',
dependency_summary: {
declared: {
binaries: { all: ['beta-bin'], any: ['beta-alt-a', 'beta-alt-b'] },
python_packages: [
{
install_id: 'pkg',
label: 'Install pkg',
package: 'beta-pkg',
module: 'beta_pkg',
},
],
api_env: { all: [], any: ['BETA_API_KEY', 'ALT_BETA_API_KEY'] },
},
missing: {
binaries: { all: [], any: [] },
api_env: { all: [], any: [] },
count: 0,
},
inferred: { python_imports: [], api_env: [], scan_errors: [] },
sub_skill_dependencies: {
skills: [],
missing_count: 0,
inferred_count: 0,
missing_references: [],
},
},
install: [
{ id: 'beta-installer', kind: 'uv', label: 'Install beta', bins: ['beta-bin'] },
],
};
const alphaPromise = SkillsView.__test.openSkillDialog(alpha);
if (!dialog.open) throw new Error('dialog should open immediately');
if (!body.innerHTML.includes('Loading latest dependency details')) {
throw new Error(`loading state missing: ${body.innerHTML}`);
}
if (body.innerHTML.includes('data-install-deps-id="alpha-installer"')) {
throw new Error(`loading state should not show stale install action: ${body.innerHTML}`);
}
const betaPromise = SkillsView.__test.openSkillDialog(beta);
second.resolve({
...beta,
description: 'Beta detail payload',
});
await betaPromise;
if (!body.innerHTML.includes('Beta detail payload')) {
throw new Error(`latest skill detail not rendered: ${body.innerHTML}`);
}
if (body.innerHTML.includes('data-install-deps-id="beta-installer"')) {
throw new Error(`ready skill should not show install action: ${body.innerHTML}`);
}
first.resolve({
...alpha,
description: 'Alpha detail payload',
});
await alphaPromise;
if (body.innerHTML.includes('Alpha detail payload')) {
throw new Error(`stale skill response overwrote active dialog: ${body.innerHTML}`);
}
if (!body.innerHTML.includes('Beta detail payload')) {
throw new Error(`beta detail should remain after stale resolve: ${body.innerHTML}`);
}
if (calls.length !== 2) {
throw new Error(`expected 2 skills.get calls, saw ${calls.length}`);
}
"""
)