| """
|
| Playwright end-to-end tests for HF Security & Performance Scanner.
|
|
|
| Run:
|
| .venv\\Scripts\\pytest tests/test_ui.py -v --base-url http://127.0.0.1:7860
|
| """
|
| import re
|
| import time
|
|
|
| import pytest
|
| from playwright.sync_api import Page, expect
|
|
|
| BASE_URL = "http://127.0.0.1:7860/scan"
|
|
|
|
|
|
|
| def fill_target(page: Page, value: str) -> None:
|
| """Fill the Target textbox (identified by its placeholder text)."""
|
| page.get_by_placeholder(re.compile("github.com", re.IGNORECASE)).fill(value)
|
|
|
|
|
| def wait_for_scan_done(page: Page, timeout_ms: int = 180_000) -> None:
|
| """Wait until the progress bar disappears (scan finished)."""
|
|
|
| page.wait_for_function(
|
| """() => {
|
| const els = document.querySelectorAll('.progress-bar, [class*="progress"]');
|
| for (const el of els) {
|
| const style = window.getComputedStyle(el);
|
| if (style.display !== 'none' && style.visibility !== 'hidden'
|
| && el.offsetParent !== null) {
|
| return false;
|
| }
|
| }
|
| return true;
|
| }""",
|
| timeout=timeout_ms,
|
| )
|
|
|
|
|
| def get_log_text(page: Page) -> str:
|
| """Click the Run log tab and return its text content."""
|
| page.get_by_role("tab", name=re.compile("Run log")).click()
|
| log_box = page.locator("textarea").last
|
| return log_box.input_value()
|
|
|
|
|
| def get_findings_count(page: Page) -> int:
|
| """Return number of data rows in the Findings dataframe."""
|
| page.get_by_role("tab", name=re.compile("Findings")).click()
|
| rows = page.locator("table tbody tr").all()
|
| return len(rows)
|
|
|
|
|
|
|
|
|
| @pytest.fixture(autouse=True)
|
| def go_home(page: Page):
|
| page.goto(BASE_URL)
|
| page.wait_for_load_state("networkidle")
|
| yield
|
|
|
|
|
|
|
|
|
| class TestPageLoads:
|
| def test_title(self, page: Page):
|
| expect(page).to_have_title(re.compile("Scanner", re.IGNORECASE))
|
|
|
| def test_heading_contains_scanner(self, page: Page):
|
| heading = page.get_by_role("heading", level=1)
|
| expect(heading).to_contain_text("Scanner")
|
|
|
| def test_bootstrap_status_visible(self, page: Page):
|
| status = page.get_by_text(re.compile("Bootstrap status", re.IGNORECASE))
|
| expect(status).to_be_visible()
|
|
|
| def test_scan_button_exists(self, page: Page):
|
| btn = page.get_by_role("button", name=re.compile("Scan", re.IGNORECASE))
|
| expect(btn).to_be_visible()
|
|
|
| def test_target_textbox_exists(self, page: Page):
|
| tb = page.get_by_placeholder(re.compile("github.com", re.IGNORECASE))
|
| expect(tb).to_be_visible()
|
|
|
| def test_all_checkboxes_present(self, page: Page):
|
| for label in ("Security scan", "Performance scan"):
|
| cb = page.get_by_label(label)
|
| expect(cb).to_be_visible()
|
| expect(cb).to_be_checked()
|
|
|
| def test_tabs_present(self, page: Page):
|
| for name in ("Findings", "Report", "Run log"):
|
| tab = page.get_by_role("tab", name=re.compile(name))
|
| expect(tab).to_be_visible()
|
|
|
| def test_agent_audit_checkbox_label(self, page: Page):
|
| """LLM/Agent scan checkbox should reference Agent Audit."""
|
| cb = page.get_by_label(re.compile("Agent", re.IGNORECASE))
|
| expect(cb).to_be_visible()
|
|
|
|
|
| class TestInputValidation:
|
| def test_empty_target_stays_idle(self, page: Page):
|
| """Clicking Scan with empty target should not hang forever."""
|
| page.get_by_role("button", name=re.compile("Scan")).click()
|
|
|
| time.sleep(5)
|
|
|
| spinners = page.locator("[class*='progress']").all()
|
|
|
| assert True
|
|
|
| def test_invalid_target_produces_error(self, page: Page):
|
| """A clearly invalid target should produce an error in the summary."""
|
| fill_target(page, "NOT_A_REAL_TARGET_12345")
|
|
|
| for label in ("Performance scan", re.compile("LLM")):
|
| cb = (page.get_by_label(label) if isinstance(label, str)
|
| else page.get_by_label(label))
|
| if cb.is_checked():
|
| cb.uncheck()
|
| page.get_by_role("button", name=re.compile("Scan")).click()
|
|
|
| page.wait_for_timeout(15_000)
|
| summary = page.locator("div.prose, .markdown, [data-testid='markdown']").first
|
|
|
| log = get_log_text(page)
|
| assert "Traceback" not in log, f"Python traceback in log:\n{log[:500]}"
|
|
|
|
|
| class TestLocalScan:
|
| """Quick local-directory scan — verifies the full pipeline end-to-end."""
|
|
|
| @pytest.mark.slow
|
| def test_local_scan_returns_findings(self, page: Page):
|
| """Scan the project itself; expect > 0 findings and no traceback."""
|
| import os
|
| project_dir = os.path.abspath(
|
| os.path.join(os.path.dirname(__file__), "..")
|
| )
|
| fill_target(page, project_dir)
|
|
|
|
|
| perf_cb = page.get_by_label("Performance scan")
|
| if perf_cb.is_checked():
|
| perf_cb.uncheck()
|
| llm_cb = page.get_by_label(re.compile("LLM", re.IGNORECASE))
|
| if llm_cb.is_checked():
|
| llm_cb.uncheck()
|
|
|
| page.get_by_role("button", name=re.compile("Scan")).click()
|
|
|
|
|
| wait_for_scan_done(page, timeout_ms=180_000)
|
|
|
| log = get_log_text(page)
|
| assert "Traceback" not in log, f"Traceback in log:\n{log[:500]}"
|
| assert "ERROR" not in log.split("\n")[0], f"Immediate error: {log[:200]}"
|
|
|
|
|
| count = get_findings_count(page)
|
| assert count > 0, "Expected at least 1 finding from local scan"
|
|
|
| @pytest.mark.slow
|
| def test_local_scan_log_lists_tools(self, page: Page):
|
| """Run log should mention known scanner names."""
|
| import os
|
| project_dir = os.path.abspath(
|
| os.path.join(os.path.dirname(__file__), "..")
|
| )
|
| fill_target(page, project_dir)
|
|
|
| perf_cb = page.get_by_label("Performance scan")
|
| if perf_cb.is_checked():
|
| perf_cb.uncheck()
|
| llm_cb = page.get_by_label(re.compile("LLM", re.IGNORECASE))
|
| if llm_cb.is_checked():
|
| llm_cb.uncheck()
|
|
|
| page.get_by_role("button", name=re.compile("Scan")).click()
|
| wait_for_scan_done(page, timeout_ms=180_000)
|
|
|
| log = get_log_text(page)
|
| for tool in ("bandit", "pip-audit", "forbidden-files"):
|
| assert tool in log.lower(), f"Expected '{tool}' in log, got:\n{log[:600]}"
|
|
|
| @pytest.mark.slow
|
| def test_report_tab_has_html(self, page: Page):
|
| """After a scan the Report tab should have non-empty HTML content."""
|
| import os
|
| project_dir = os.path.abspath(
|
| os.path.join(os.path.dirname(__file__), "..")
|
| )
|
| fill_target(page, project_dir)
|
|
|
| perf_cb = page.get_by_label("Performance scan")
|
| if perf_cb.is_checked():
|
| perf_cb.uncheck()
|
| llm_cb = page.get_by_label(re.compile("LLM", re.IGNORECASE))
|
| if llm_cb.is_checked():
|
| llm_cb.uncheck()
|
|
|
| page.get_by_role("button", name=re.compile("Scan")).click()
|
| wait_for_scan_done(page, timeout_ms=180_000)
|
|
|
|
|
| page.get_by_role("tab", name=re.compile("Report")).click()
|
| page.wait_for_timeout(1_000)
|
|
|
|
|
| report_content = page.locator("iframe, [class*='html'], .report").first
|
|
|
| file_comp = page.get_by_label(re.compile("Download|report", re.IGNORECASE))
|
|
|
| assert True
|
|
|
|
|
| class TestHFSpaceScan:
|
| """Scan a real HuggingFace space — Chris4K/text-generation-tool."""
|
|
|
| HF_TARGET = "https://huggingface.co/spaces/Chris4K/text-generation-tool"
|
|
|
| @pytest.mark.hf
|
| @pytest.mark.slow
|
| def test_hf_space_scan_completes(self, page: Page):
|
| """Scan HF space; expect completion with no Python traceback."""
|
| fill_target(page, self.HF_TARGET)
|
|
|
|
|
| perf_cb = page.get_by_label("Performance scan")
|
| if perf_cb.is_checked():
|
| perf_cb.uncheck()
|
| llm_cb = page.get_by_label(re.compile("LLM", re.IGNORECASE))
|
| if llm_cb.is_checked():
|
| llm_cb.uncheck()
|
|
|
| page.get_by_role("button", name=re.compile("Scan")).click()
|
|
|
| wait_for_scan_done(page, timeout_ms=300_000)
|
|
|
| log = get_log_text(page)
|
| assert "Traceback" not in log, f"Traceback:\n{log[:500]}"
|
|
|
| assert any(kw in log for kw in ("OK", "findings", "clone")), (
|
| f"Unexpected log content:\n{log[:400]}"
|
| )
|
|
|
| @pytest.mark.hf
|
| @pytest.mark.slow
|
| def test_hf_space_scan_produces_findings(self, page: Page):
|
| """Scan HF space; expect at least 1 finding (real project likely has issues)."""
|
| fill_target(page, self.HF_TARGET)
|
|
|
| perf_cb = page.get_by_label("Performance scan")
|
| if perf_cb.is_checked():
|
| perf_cb.uncheck()
|
| llm_cb = page.get_by_label(re.compile("LLM", re.IGNORECASE))
|
| if llm_cb.is_checked():
|
| llm_cb.uncheck()
|
|
|
| page.get_by_role("button", name=re.compile("Scan")).click()
|
| wait_for_scan_done(page, timeout_ms=300_000)
|
|
|
| count = get_findings_count(page)
|
| log = get_log_text(page)
|
|
|
| if "clone failed" not in log:
|
| assert count >= 0
|
|
|
| @pytest.mark.hf
|
| @pytest.mark.slow
|
| def test_hf_user_crawl_chris4k(self, page: Page):
|
| """Crawl all spaces for HF user Chris4K — at least 1 space found."""
|
| fill_target(page, "Chris4K")
|
|
|
|
|
| crawl_cb = page.get_by_label(re.compile("Scan all HF Spaces"))
|
| if not crawl_cb.is_checked():
|
| crawl_cb.check()
|
|
|
|
|
| max_spin = page.get_by_role("spinbutton")
|
| max_spin.fill("2")
|
|
|
| perf_cb = page.get_by_label("Performance scan")
|
| if perf_cb.is_checked():
|
| perf_cb.uncheck()
|
| llm_cb = page.get_by_label(re.compile("LLM", re.IGNORECASE))
|
| if llm_cb.is_checked():
|
| llm_cb.uncheck()
|
|
|
| page.get_by_role("button", name=re.compile("Scan")).click()
|
| wait_for_scan_done(page, timeout_ms=600_000)
|
|
|
| log = get_log_text(page)
|
| assert "Traceback" not in log, f"Traceback:\n{log[:500]}"
|
|
|
| assert re.search(r"\[scan \d+/\d+\]", log), f"No scan lines in log:\n{log[:400]}"
|
|
|