From f3e70bd13be2bee88437426bb66ff31c9654c81b Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Tue, 9 Jun 2026 10:44:55 +0530 Subject: [PATCH 1/5] feat(e2e): add Playwright coverage for workflow create/run/delete lifecycle - Add workflow.spec.ts with mocked API routes for GET/POST/DELETE /workflows - Cover creation via CreateSheet, manual run with queued task display, and delete with grid removal confirmation - Include full lifecycle scenario combining create, run, and delete - Set up API key in localStorage before navigation to bypass auth gate Fixes #563 --- frontend/testing/e2e/workflow.spec.ts | 151 ++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 frontend/testing/e2e/workflow.spec.ts diff --git a/frontend/testing/e2e/workflow.spec.ts b/frontend/testing/e2e/workflow.spec.ts new file mode 100644 index 000000000..f5002b1e0 --- /dev/null +++ b/frontend/testing/e2e/workflow.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test'; + +const BASE = 'http://127.0.0.1:5173'; +const API_KEY = '6abeafd82cdb0eebea98dc3817b0bb5f9f8773f60402bccad9eaad7870ae8f58'; + +const MOCK_WORKFLOWS = [ + { + id: 'wf-001', + name: 'Daily DNS Scan', + schedule_seconds: 86400, + enabled: true, + steps: [{ plugin_id: 'dns_recon', inputs: { target: 'example.com' } }], + last_run_at: new Date(Date.now() - 3600000).toISOString(), + queued_task_ids: [], + created_at: new Date().toISOString(), + }, +]; + +const MOCK_CREATED_WORKFLOW = { + id: 'wf-002', + name: 'Nightly Port Scan', + schedule_seconds: 43200, + enabled: true, + steps: [{ plugin_id: 'port_scanner', inputs: { target: '10.0.0.1' } }], + last_run_at: null, + queued_task_ids: [], + created_at: new Date().toISOString(), +}; + +const MOCK_RUN_RESPONSE = { + queued_task_ids: ['task-001', 'task-002'], +}; + +async function setupMocks(page: import('@playwright/test').Page) { + await page.route(`${BASE}/api/v1/workflows`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ workflows: MOCK_WORKFLOWS, total: 1 }), + }); + } else if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_CREATED_WORKFLOW), + }); + } else { + await route.fulfill({ status: 404 }); + } + }); + + await page.route(`${BASE}/api/v1/workflows/*/run`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_RUN_RESPONSE), + }); + await page.route(`${BASE}/api/v1/workflows`, async (innerRoute) => { + const updated = MOCK_WORKFLOWS.map(w => ({ + ...w, + queued_task_ids: ['task-001', 'task-002'], + last_run_at: new Date().toISOString(), + })); + await innerRoute.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ workflows: updated, total: 1 }), + }); + }); + }); + + await page.route(`${BASE}/api/v1/workflows/*`, async (route) => { + if (route.request().method() === 'DELETE') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ deleted: true }), + }); + await page.route(`${BASE}/api/v1/workflows`, async (innerRoute) => { + await innerRoute.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ workflows: [], total: 0 }), + }); + }); + } else { + await route.fulfill({ status: 404 }); + } + }); +} + +test.describe('Workflow lifecycle', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate((key) => { + localStorage.setItem('secuscan_api_key', key); + }, API_KEY); + }); + + test('displays existing workflows', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await expect(page.getByRole('heading', { name: 'Workflows' })).toBeVisible(); + await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Enabled')).toBeVisible(); + }); + + test('creates a new workflow via the create sheet', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await page.getByRole('button', { name: /new workflow/i }).click(); + await expect(page.getByRole('heading', { name: /new workflow/i })).toBeVisible(); + await page.getByPlaceholder('My Workflow').fill('Nightly Port Scan'); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByText('Nightly Port Scan')).toBeVisible({ timeout: 10000 }); + }); + + test('runs a workflow and shows queued tasks', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 }); + await page.getByTitle('Run now').click(); + await expect(page.getByText('task-001')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('task-002')).toBeVisible(); + }); + + test('deletes a workflow and removes it from the grid', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 }); + await page.getByTitle('Delete').click(); + await expect(page.getByRole('heading', { name: /delete workflow/i })).toBeVisible(); + await page.getByRole('button', { name: 'Delete' }).click(); + await expect(page.getByText('No Workflows')).toBeVisible({ timeout: 10000 }); + }); + + test('full lifecycle: create, run, and delete a workflow', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 }); + await page.getByRole('button', { name: /new workflow/i }).click(); + await page.getByPlaceholder('My Workflow').fill('Nightly Port Scan'); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByText('Nightly Port Scan')).toBeVisible({ timeout: 10000 }); + await page.getByTitle('Run now').first().click(); + await page.getByTitle('Delete').first().click(); + await page.getByRole('button', { name: 'Delete' }).click(); + await expect(page.getByText('No Workflows')).toBeVisible({ timeout: 10000 }); + }); +}); From 1e35fbfd444c627305ae19067acd8784a5a2686f Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Tue, 9 Jun 2026 10:53:17 +0530 Subject: [PATCH 2/5] perf: replace AppShell 100ms localStorage polling with CustomEvent-based sidebar sync - Remove 100ms polling interval from AppShell that was polling localStorage to detect sidebar state changes - Sidebar now dispatches a 'sidebar-state-changed' CustomEvent on window whenever isExpanded changes - AppShell listens for the CustomEvent instead of polling - Preserves localStorage persistence for cross-tab scenarios - No external dependencies or context providers needed --- frontend/src/components/AppShell.tsx | 15 +++++---------- frontend/src/components/Sidebar.tsx | 1 + 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1c73e91bb..09ab29ec9 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -20,18 +20,13 @@ export default function AppShell({ children }: AppShellProps) { return saved !== null ? JSON.parse(saved) : true }) - // Brief hack to sync sidebar state without a full context provider useEffect(() => { - const handleStorage = () => { - const saved = localStorage.getItem('sidebar-expanded') - if (saved !== null) setSidebarExpanded(JSON.parse(saved)) - } - window.addEventListener('storage', handleStorage) - const interval = setInterval(handleStorage, 100) - return () => { - window.removeEventListener('storage', handleStorage) - clearInterval(interval) + const handleSidebarChange = (e: Event) => { + const detail = (e as CustomEvent).detail + if (typeof detail === 'boolean') setSidebarExpanded(detail) } + window.addEventListener('sidebar-state-changed', handleSidebarChange) + return () => window.removeEventListener('sidebar-state-changed', handleSidebarChange) }, []) useEffect(() => { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b8..fb7b00546 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -108,6 +108,7 @@ export default function Sidebar() { useEffect(() => { localStorage.setItem('sidebar-expanded', JSON.stringify(isExpanded)) + window.dispatchEvent(new CustomEvent('sidebar-state-changed', { detail: isExpanded })) }, [isExpanded]) return ( From 900bc540dde59b70bcf3c0843bd54bbad2f60ff3 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Tue, 9 Jun 2026 11:02:44 +0530 Subject: [PATCH 3/5] feat(plugins): add metadata lint rules for missing help text and ambiguous categories - Add VALID_CATEGORIES constant with all 12 recognized plugin categories - Add _check_category() validator that rejects unknown categories - Add help text lint check in _check_fields() to flag fields without user-facing description - Update valid test fixture to include help text on fields - Add comprehensive tests for both new lint rules - Document the new lint rules in PLUGINS.md under Plugin Validation Fixes #551 --- PLUGINS.md | 26 +++++++ backend/secuscan/plugin_validator.py | 23 +++++++ .../plugins/valid_plugin/metadata.json | 6 +- testing/backend/unit/test_plugin_validator.py | 68 ++++++++++++++++++- 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/PLUGINS.md b/PLUGINS.md index 8e8505732..65066a8bb 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -306,3 +306,29 @@ python scripts/validate_plugin.py --plugin plugins/nmap The validation checks metadata JSON, required fields, checksums, and custom parser imports when applicable. + +### Metadata quality lint rules + +Two additional lint checks help maintain high-quality plugin metadata: + +1. **Missing field help text** — Each field in the `fields` array should include + a `help` string that provides a brief user-facing description of the input. + Fields without `help` text are flagged with a lint warning. + + ```json + // Good — has help text + { "id": "target", "label": "Target", "type": "text", "help": "IP address or hostname to scan" } + + // Bad — missing help text (lint warning) + { "id": "target", "label": "Target", "type": "text" } + ``` + +2. **Ambiguous category** — Each plugin's `category` must be one of the + recognized categories: `recon`, `vulnerability`, `web`, `exploit`, `network`, + `expert`, `code`, `forensics`, `utils`, `execution`, `security`, `robots`. + Unknown or misspelled categories are rejected. + + ```bash + # Run the linter + python scripts/validate_plugins.py + ``` diff --git a/backend/secuscan/plugin_validator.py b/backend/secuscan/plugin_validator.py index 7c3ac455f..52c2a0c21 100644 --- a/backend/secuscan/plugin_validator.py +++ b/backend/secuscan/plugin_validator.py @@ -22,6 +22,11 @@ VALID_SAFETY_LEVELS = {"safe", "intrusive", "exploit"} VALID_FIELD_TYPES = {"string","integer","text", "number", "boolean", "select", "multiselect", "textarea"} VALID_PARSER_TYPES = {"json", "text", "custom", "none"} +VALID_CATEGORIES = { + "recon", "vulnerability", "web", "exploit", "network", + "expert", "code", "forensics", "utils", "execution", + "security", "robots", +} REQUIRED_TOP_LEVEL_FIELDS = [ "id", @@ -109,6 +114,7 @@ def validate(self) -> ValidationResult: result = ValidationResult(plugin_id=plugin_id, plugin_dir=self.plugin_dir) self._check_required_fields(data, result) + self._check_category(data, result) self._check_engine(data, result) self._check_command_template(data, result) self._check_fields(data, result) @@ -126,6 +132,17 @@ def _check_required_fields(self, data: dict, result: ValidationResult) -> None: if key not in data or data[key] in (None, "", [], {}): result.add(key, f"Required field '{key}' is missing or empty") + def _check_category(self, data: dict, result: ValidationResult) -> None: + cat = data.get("category") + if not cat: + return + if cat not in VALID_CATEGORIES: + result.add( + "category", + f"'{cat}' is not a recognized category — " + f"must be one of: {sorted(VALID_CATEGORIES)}", + ) + def _check_engine(self, data: dict, result: ValidationResult) -> None: engine = data.get("engine") if not isinstance(engine, dict): @@ -214,6 +231,12 @@ def _check_fields(self, data: dict, result: ValidationResult) -> None: f"Field '{fid}' is type '{ftype}' and must have a non-empty 'options' list", ) + if not f.get("help"): + result.add( + f"{prefix}.help", + f"Field '{fid}' is missing 'help' text — add a brief user-facing description", + ) + def _check_output(self, data: dict, result: ValidationResult) -> None: output = data.get("output") if not isinstance(output, dict): diff --git a/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json b/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json index be121e37f..5264d21a9 100644 --- a/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json +++ b/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json @@ -21,14 +21,16 @@ "label": "Target Host", "type": "text", "required": true, - "placeholder": "192.168.1.1" + "placeholder": "192.168.1.1", + "help": "IP address or hostname to ping" }, { "id": "count", "label": "Packet Count", "type": "number", "required": false, - "default": 4 + "default": 4, + "help": "Number of ICMP packets to send" } ], "output": { diff --git a/testing/backend/unit/test_plugin_validator.py b/testing/backend/unit/test_plugin_validator.py index f344cd76c..4acc01c75 100644 --- a/testing/backend/unit/test_plugin_validator.py +++ b/testing/backend/unit/test_plugin_validator.py @@ -26,6 +26,7 @@ VALID_SAFETY_LEVELS, VALID_FIELD_TYPES, VALID_PARSER_TYPES, + VALID_CATEGORIES, ) FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "plugins" @@ -65,8 +66,8 @@ def _minimal_valid() -> dict: "engine": {"type": "cli", "binary": "ping"}, "command_template": ["ping", "-c", "{count}", "{target}"], "fields": [ - {"id": "target", "label": "Target Host", "type": "text"}, - {"id": "count", "label": "Count", "type": "number"}, + {"id": "target", "label": "Target Host", "type": "text", "help": "IP address or hostname"}, + {"id": "count", "label": "Count", "type": "number", "help": "Number of packets"}, ], "output": {"parser": "text"}, "safety": {"level": "safe", "requires_consent": False}, @@ -135,6 +136,11 @@ def test_invalid_fixture_catches_unknown_placeholder(self): placeholder_errors = [e for e in result.errors if "Placeholder" in e.message] assert placeholder_errors, "Expected placeholder-mismatch error" + def test_invalid_fixture_catches_missing_help_text(self): + result = validate_one_plugin(INVALID_FIXTURE) + help_errors = [e for e in result.errors if e.path.endswith(".help")] + assert len(help_errors) >= 2, "Expected help text errors for both fields" + # =========================================================================== # Required fields @@ -536,4 +542,60 @@ def test_error_display_format(self, tmp_path): result = validate_one_plugin(plugin_dir) err = next(e for e in result.errors if e.path == "safety.level") display = err.display() - assert "[" in display and "safety.level" in display and "→" in display \ No newline at end of file + assert "[" in display and "safety.level" in display and "→" in display + + +# =========================================================================== +# Metadata quality lint checks +# =========================================================================== + + +class TestMetadataQualityLint: + def test_missing_field_help_text_reported(self, tmp_path): + data = _minimal_valid() + data["fields"] = [ + {"id": "target", "label": "Target", "type": "text"}, + ] + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + help_errors = [e for e in result.errors if e.path == "fields[0].help"] + assert len(help_errors) == 1 + assert "help" in help_errors[0].message + + def test_field_help_text_present_passes(self, tmp_path): + data = _minimal_valid() + data["fields"] = [ + {"id": "target", "label": "Target", "type": "text", "help": "The target IP or hostname"}, + ] + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + help_errors = [e for e in result.errors if e.path.startswith("fields[0].help")] + assert help_errors == [] + + def test_invalid_category_reported(self, tmp_path): + data = _minimal_valid() + data["category"] = "unknown_category" + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + assert "category" in _error_paths(result) + cat_errors = [e for e in result.errors if e.path == "category"] + assert len(cat_errors) == 1 + assert "not a recognized category" in cat_errors[0].message + + def test_valid_categories_accepted(self, tmp_path): + for cat in sorted(VALID_CATEGORIES): + data = _minimal_valid() + data["category"] = cat + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + cat_errors = [e for e in result.errors if e.path == "category"] + assert cat_errors == [], f"Category '{cat}' should be valid" + + def test_missing_category_is_not_flagged(self, tmp_path): + data = _minimal_valid() + del data["category"] + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + cat_errors = [e for e in result.errors if e.path == "category"] + assert len(cat_errors) == 1 + assert "Required" in cat_errors[0].message \ No newline at end of file From b609521296a24be9b8d299fe90a873b0ca1a9ea0 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Wed, 10 Jun 2026 14:19:19 +0530 Subject: [PATCH 4/5] fix: remove trailing whitespace in PLUGINS.md (formatting-hygiene) --- PLUGINS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLUGINS.md b/PLUGINS.md index 65066a8bb..7f327a2c4 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -318,7 +318,7 @@ Two additional lint checks help maintain high-quality plugin metadata: ```json // Good — has help text { "id": "target", "label": "Target", "type": "text", "help": "IP address or hostname to scan" } - + // Bad — missing help text (lint warning) { "id": "target", "label": "Target", "type": "text" } ``` From 25e2b7bba8bd480b326e8c90260db3810a3305f0 Mon Sep 17 00:00:00 2001 From: OpenCode Agent Date: Wed, 10 Jun 2026 14:26:23 +0530 Subject: [PATCH 5/5] fix: add exist_ok=True to _write_metadata mkdir() call test_valid_categories_accepted creates multiple plugin directories under the same tmp_path in a loop, causing FileExistsError without exist_ok=True. --- testing/backend/unit/test_plugin_validator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/backend/unit/test_plugin_validator.py b/testing/backend/unit/test_plugin_validator.py index 4acc01c75..af8508169 100644 --- a/testing/backend/unit/test_plugin_validator.py +++ b/testing/backend/unit/test_plugin_validator.py @@ -49,7 +49,7 @@ def _error_messages(result: ValidationResult) -> list[str]: def _write_metadata(tmp_path: Path, data: dict) -> Path: plugin_dir = tmp_path / "my_plugin" - plugin_dir.mkdir() + plugin_dir.mkdir(exist_ok=True) (plugin_dir / "metadata.json").write_text(json.dumps(data), encoding="utf-8") return plugin_dir