patchi 0.7.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patchi/__init__.py +1 -0
- patchi/__main__.py +13 -0
- patchi/cli/__init__.py +0 -0
- patchi/cli/command_families.py +235 -0
- patchi/cli/commands/__init__.py +0 -0
- patchi/cli/commands/access_cmd.py +65 -0
- patchi/cli/commands/agent_stats_cmd.py +273 -0
- patchi/cli/commands/agents_cmd.py +187 -0
- patchi/cli/commands/ai_cmd.py +308 -0
- patchi/cli/commands/assure_cmd.py +538 -0
- patchi/cli/commands/audit_cmd.py +286 -0
- patchi/cli/commands/auto_cmd.py +110 -0
- patchi/cli/commands/blame_cmd.py +53 -0
- patchi/cli/commands/chains_cmd.py +327 -0
- patchi/cli/commands/charter_cmd.py +173 -0
- patchi/cli/commands/chat_cmd.py +597 -0
- patchi/cli/commands/check_cmd.py +232 -0
- patchi/cli/commands/cleanup_cmd.py +328 -0
- patchi/cli/commands/cockpit_cmd.py +208 -0
- patchi/cli/commands/commands_cmd.py +160 -0
- patchi/cli/commands/cross_repo_cmd.py +113 -0
- patchi/cli/commands/deps_cmd.py +110 -0
- patchi/cli/commands/dev_check_cmd.py +342 -0
- patchi/cli/commands/dev_cmd.py +445 -0
- patchi/cli/commands/doctor_cmd.py +457 -0
- patchi/cli/commands/findings_cmd.py +187 -0
- patchi/cli/commands/fix_cmd.py +648 -0
- patchi/cli/commands/fix_review_cmd.py +273 -0
- patchi/cli/commands/heatmap_cmd.py +62 -0
- patchi/cli/commands/help_cmd.py +159 -0
- patchi/cli/commands/hosted_cmd.py +980 -0
- patchi/cli/commands/init.py +456 -0
- patchi/cli/commands/key_cmd.py +571 -0
- patchi/cli/commands/learn_cmd.py +140 -0
- patchi/cli/commands/link_cmd.py +104 -0
- patchi/cli/commands/log_cmd.py +57 -0
- patchi/cli/commands/memory_cmd.py +215 -0
- patchi/cli/commands/model_cmd.py +207 -0
- patchi/cli/commands/notify_cmd.py +213 -0
- patchi/cli/commands/notify_prompts.py +29 -0
- patchi/cli/commands/patch_cmd.py +231 -0
- patchi/cli/commands/plan_cmd.py +128 -0
- patchi/cli/commands/plugins_cmd.py +307 -0
- patchi/cli/commands/queue_cmd.py +152 -0
- patchi/cli/commands/quick_cmd.py +159 -0
- patchi/cli/commands/ready_cmd.py +292 -0
- patchi/cli/commands/report_cmd.py +416 -0
- patchi/cli/commands/restrict_cmd.py +127 -0
- patchi/cli/commands/review_cmd.py +225 -0
- patchi/cli/commands/rules_cmd.py +233 -0
- patchi/cli/commands/scan_cmd.py +1908 -0
- patchi/cli/commands/settings_cmd.py +45 -0
- patchi/cli/commands/status_cmd.py +679 -0
- patchi/cli/commands/test_cmd.py +1052 -0
- patchi/cli/commands/trend_cmd.py +296 -0
- patchi/cli/commands/undo_cmd.py +235 -0
- patchi/cli/commands/update_cmd.py +345 -0
- patchi/cli/commands/verify_cmd.py +56 -0
- patchi/cli/commands/vr_cmd.py +440 -0
- patchi/cli/commands/watch_cmd.py +304 -0
- patchi/cli/commands/web_cmd.py +114 -0
- patchi/cli/console.py +101 -0
- patchi/cli/display/__init__.py +1 -0
- patchi/cli/display/live_audit.py +269 -0
- patchi/cli/display/live_progress.py +169 -0
- patchi/cli/framework.py +241 -0
- patchi/cli/logo.py +128 -0
- patchi/cli/main.py +222 -0
- patchi/cli/registry.py +1171 -0
- patchi/cli/style.py +68 -0
- patchi/cli/themes.py +187 -0
- patchi/cli/ux.py +444 -0
- patchi/core/__init__.py +0 -0
- patchi/core/agents/__init__.py +0 -0
- patchi/core/agents/api_fuzzer_agent.py +141 -0
- patchi/core/agents/attack_agent.py +242 -0
- patchi/core/agents/base.py +1012 -0
- patchi/core/agents/bug_predictor_agent.py +58 -0
- patchi/core/agents/build_tool_validator.py +150 -0
- patchi/core/agents/cache.py +192 -0
- patchi/core/agents/chaos_agent.py +133 -0
- patchi/core/agents/cicd_generator.py +240 -0
- patchi/core/agents/comment_scanner.py +280 -0
- patchi/core/agents/console_logging_agent.py +156 -0
- patchi/core/agents/coordinator.py +858 -0
- patchi/core/agents/core_scanner.py +269 -0
- patchi/core/agents/coverage_prioritizer.py +181 -0
- patchi/core/agents/dead_code_hygiene.py +296 -0
- patchi/core/agents/dead_code_scanner.py +692 -0
- patchi/core/agents/dependency_scanner.py +725 -0
- patchi/core/agents/doc_claim_agent.py +573 -0
- patchi/core/agents/duplicate_scanner.py +568 -0
- patchi/core/agents/env_scanner.py +410 -0
- patchi/core/agents/feature_flag_agent.py +89 -0
- patchi/core/agents/frontend_framework_agent.py +133 -0
- patchi/core/agents/goroutine_agent.py +82 -0
- patchi/core/agents/governor.py +1972 -0
- patchi/core/agents/i18n_agent.py +95 -0
- patchi/core/agents/license_compliance.py +247 -0
- patchi/core/agents/linking_agent.py +251 -0
- patchi/core/agents/memory_profiler_agent.py +102 -0
- patchi/core/agents/modernization_agent.py +84 -0
- patchi/core/agents/mutation_agent.py +174 -0
- patchi/core/agents/nl_query_agent.py +74 -0
- patchi/core/agents/promise_rejection_agent.py +100 -0
- patchi/core/agents/refactoring_agent.py +286 -0
- patchi/core/agents/resource_leak_agent.py +91 -0
- patchi/core/agents/route_graph_scanner.py +951 -0
- patchi/core/agents/run_agent.py +106 -0
- patchi/core/agents/rust_unwrap_agent.py +100 -0
- patchi/core/agents/sbom_generator.py +284 -0
- patchi/core/agents/scanners.py +67 -0
- patchi/core/agents/side/__init__.py +1 -0
- patchi/core/agents/side/build_agent.py +65 -0
- patchi/core/agents/side/format_agent.py +50 -0
- patchi/core/agents/side/install_agent.py +63 -0
- patchi/core/agents/side_file_scanner.py +549 -0
- patchi/core/agents/snapshot_drift_detector.py +146 -0
- patchi/core/agents/spa_route_inventory.py +203 -0
- patchi/core/agents/test_scanner.py +442 -0
- patchi/core/agents/tool_health.py +109 -0
- patchi/core/agents/tool_runner.py +162 -0
- patchi/core/agents/type_scanner.py +168 -0
- patchi/core/agents/ui_scanner.py +558 -0
- patchi/core/ai/__init__.py +20 -0
- patchi/core/ai/agent_audit.py +153 -0
- patchi/core/ai/agent_profiler.py +277 -0
- patchi/core/ai/client.py +499 -0
- patchi/core/ai/cost_tracker.py +107 -0
- patchi/core/ai/model_router.py +318 -0
- patchi/core/ai/orchestrator.py +625 -0
- patchi/core/ai/prompts.py +681 -0
- patchi/core/ai/smart.py +302 -0
- patchi/core/ai/tool_executor.py +24 -0
- patchi/core/ai/tools/__init__.py +10 -0
- patchi/core/ai/tools/executor.py +621 -0
- patchi/core/ai/tools/realize.py +1160 -0
- patchi/core/ai/tools/registry.py +1577 -0
- patchi/core/assurance/__init__.py +33 -0
- patchi/core/assurance/builder.py +211 -0
- patchi/core/assurance/graph.py +342 -0
- patchi/core/assurance/invariants.py +168 -0
- patchi/core/atomic.py +61 -0
- patchi/core/attackers/__init__.py +17 -0
- patchi/core/attackers/api_abuse.py +89 -0
- patchi/core/attackers/auth_bypass.py +73 -0
- patchi/core/attackers/base.py +91 -0
- patchi/core/attackers/business_logic.py +88 -0
- patchi/core/attackers/planner.py +108 -0
- patchi/core/attackers/priv_escalation.py +88 -0
- patchi/core/attackers/recon.py +87 -0
- patchi/core/brain/__init__.py +0 -0
- patchi/core/brain/ast_utils/__init__.py +81 -0
- patchi/core/brain/ast_utils/assignments.py +219 -0
- patchi/core/brain/ast_utils/calls.py +139 -0
- patchi/core/brain/ast_utils/config.py +132 -0
- patchi/core/brain/ast_utils/control_flow.py +74 -0
- patchi/core/brain/ast_utils/dead_symbols.py +205 -0
- patchi/core/brain/ast_utils/decorators.py +108 -0
- patchi/core/brain/ast_utils/helpers.py +144 -0
- patchi/core/brain/ast_utils/imports.py +169 -0
- patchi/core/brain/ast_utils/scan.py +143 -0
- patchi/core/brain/ast_utils/taint.py +310 -0
- patchi/core/brain/audit.py +379 -0
- patchi/core/brain/baseline.py +195 -0
- patchi/core/brain/blast_radius.py +312 -0
- patchi/core/brain/body_tags.py +144 -0
- patchi/core/brain/brain.py +1161 -0
- patchi/core/brain/brain_context.py +393 -0
- patchi/core/brain/brain_watcher.py +340 -0
- patchi/core/brain/charter.py +473 -0
- patchi/core/brain/classifier.py +214 -0
- patchi/core/brain/cockpit.py +350 -0
- patchi/core/brain/code_query.py +710 -0
- patchi/core/brain/contract.py +889 -0
- patchi/core/brain/contract_diff.py +155 -0
- patchi/core/brain/council.py +800 -0
- patchi/core/brain/cross_repo.py +104 -0
- patchi/core/brain/dead_code_tools.py +157 -0
- patchi/core/brain/doc_validator.py +197 -0
- patchi/core/brain/domain_activator.py +2183 -0
- patchi/core/brain/enriched_context.py +220 -0
- patchi/core/brain/file_corpus.py +220 -0
- patchi/core/brain/framework.py +953 -0
- patchi/core/brain/freshness.py +273 -0
- patchi/core/brain/git_aware.py +206 -0
- patchi/core/brain/heatmap.py +104 -0
- patchi/core/brain/ignore_parser.py +153 -0
- patchi/core/brain/import_graph.py +486 -0
- patchi/core/brain/languages.py +397 -0
- patchi/core/brain/layered_brain.py +419 -0
- patchi/core/brain/learning.py +229 -0
- patchi/core/brain/orphaned_endpoints.py +313 -0
- patchi/core/brain/personas/__init__.py +648 -0
- patchi/core/brain/personas/bad_user.py +165 -0
- patchi/core/brain/personas/base.py +349 -0
- patchi/core/brain/proactive.py +807 -0
- patchi/core/brain/project_context.py +121 -0
- patchi/core/brain/project_reader.py +521 -0
- patchi/core/brain/reasoning.py +269 -0
- patchi/core/brain/route_detector/__init__.py +15 -0
- patchi/core/brain/route_detector/base.py +31 -0
- patchi/core/brain/route_detector/csharp.py +209 -0
- patchi/core/brain/route_detector/file_based.py +81 -0
- patchi/core/brain/route_detector/go.py +152 -0
- patchi/core/brain/route_detector/java.py +195 -0
- patchi/core/brain/route_detector/javascript.py +197 -0
- patchi/core/brain/route_detector/php.py +158 -0
- patchi/core/brain/route_detector/python.py +193 -0
- patchi/core/brain/route_detector/registry.py +25 -0
- patchi/core/brain/route_detector/ruby.py +134 -0
- patchi/core/brain/route_detector/rust.py +216 -0
- patchi/core/brain/route_detector/swift.py +118 -0
- patchi/core/brain/route_mapper.py +265 -0
- patchi/core/brain/scanner.py +2136 -0
- patchi/core/brain/secrets.py +280 -0
- patchi/core/brain/symbol_graph.py +1955 -0
- patchi/core/brain/team.py +28 -0
- patchi/core/brain/trace_log.py +132 -0
- patchi/core/brain/type_checker/__init__.py +65 -0
- patchi/core/brain/type_checker/base.py +60 -0
- patchi/core/brain/type_checker/csharp.py +59 -0
- patchi/core/brain/type_checker/dart.py +59 -0
- patchi/core/brain/type_checker/go.py +81 -0
- patchi/core/brain/type_checker/java.py +75 -0
- patchi/core/brain/type_checker/kotlin.py +75 -0
- patchi/core/brain/type_checker/php.py +88 -0
- patchi/core/brain/type_checker/python.py +87 -0
- patchi/core/brain/type_checker/rust.py +81 -0
- patchi/core/brain/type_checker/swift.py +59 -0
- patchi/core/brain/type_checker/typescript.py +298 -0
- patchi/core/brain/understander.py +130 -0
- patchi/core/brain/verify.py +379 -0
- patchi/core/campaigns/__init__.py +14 -0
- patchi/core/campaigns/base.py +62 -0
- patchi/core/campaigns/data_flow_abuse.py +153 -0
- patchi/core/campaigns/orchestrator.py +140 -0
- patchi/core/campaigns/state_transitions.py +178 -0
- patchi/core/ci_bundle.py +104 -0
- patchi/core/config.py +311 -0
- patchi/core/constants.py +245 -0
- patchi/core/debug/__init__.py +25 -0
- patchi/core/debug/adapters/__init__.py +11 -0
- patchi/core/debug/adapters/codelldb.py +379 -0
- patchi/core/debug/adapters/node.py +222 -0
- patchi/core/debug/adapters/powershell.py +210 -0
- patchi/core/debug/adapters/python.py +244 -0
- patchi/core/debug/capture.py +210 -0
- patchi/core/debug/dap_client.py +268 -0
- patchi/core/detector/__init__.py +0 -0
- patchi/core/detector/audit.py +194 -0
- patchi/core/detector/bus.py +226 -0
- patchi/core/detector/dispatcher.py +275 -0
- patchi/core/detector/event.py +254 -0
- patchi/core/detector/sigma_engine.py +378 -0
- patchi/core/detector/triage.py +289 -0
- patchi/core/export/sarif.py +113 -0
- patchi/core/fix/__init__.py +17 -0
- patchi/core/fix/applier.py +574 -0
- patchi/core/fix/base.py +510 -0
- patchi/core/fix/code_fixer.py +169 -0
- patchi/core/fix/dead_code_remover.py +131 -0
- patchi/core/fix/fix_agents.py +775 -0
- patchi/core/fix/patch.py +444 -0
- patchi/core/fix/risk_gate.py +383 -0
- patchi/core/fix/security_fixer.py +93 -0
- patchi/core/fix/verify_loop.py +204 -0
- patchi/core/fuzz/__init__.py +8 -0
- patchi/core/fuzz/corpus.py +137 -0
- patchi/core/fuzz/input_fuzzer.py +256 -0
- patchi/core/fuzz/sequence_fuzzer.py +162 -0
- patchi/core/fuzz/state_fuzzer.py +236 -0
- patchi/core/health.py +407 -0
- patchi/core/hosted/__init__.py +1 -0
- patchi/core/hosted/anomaly.py +298 -0
- patchi/core/hosted/audit_log.py +102 -0
- patchi/core/hosted/compliance_report.py +148 -0
- patchi/core/hosted/ip_reputation.py +169 -0
- patchi/core/hosted/log_parsers.py +251 -0
- patchi/core/hosted/tokens.py +127 -0
- patchi/core/hosted/watchlist.py +164 -0
- patchi/core/hosted/webhooks.py +156 -0
- patchi/core/memory.py +437 -0
- patchi/core/notifications/__init__.py +18 -0
- patchi/core/notifications/channels.py +192 -0
- patchi/core/notifications/digest.py +141 -0
- patchi/core/notifications/escalation.py +158 -0
- patchi/core/notifications/notifier.py +182 -0
- patchi/core/notifications/quiet_hours.py +69 -0
- patchi/core/plugins/__init__.py +48 -0
- patchi/core/plugins/analyzer.py +326 -0
- patchi/core/plugins/analyzers/__init__.py +6 -0
- patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
- patchi/core/plugins/analyzers/secret_scanner.py +158 -0
- patchi/core/plugins/analyzers/todo_scanner.py +115 -0
- patchi/core/plugins/registry.py +335 -0
- patchi/core/queue.py +375 -0
- patchi/core/reliability/__init__.py +13 -0
- patchi/core/reliability/chaos.py +163 -0
- patchi/core/reliability/fault_injector.py +172 -0
- patchi/core/reliability/idempotency.py +128 -0
- patchi/core/reliability/recovery.py +147 -0
- patchi/core/runtime/__init__.py +5 -0
- patchi/core/runtime/tracer.py +230 -0
- patchi/core/scan_bus.py +156 -0
- patchi/core/security/__init__.py +1 -0
- patchi/core/security/ai_validator.py +462 -0
- patchi/core/security/app_mapper.py +216 -0
- patchi/core/security/app_profile.py +464 -0
- patchi/core/security/attack_feedback.py +141 -0
- patchi/core/security/attack_scenarios/__init__.py +1 -0
- patchi/core/security/attack_tree.py +176 -0
- patchi/core/security/auth_audit_agent.py +344 -0
- patchi/core/security/authz_agent.py +602 -0
- patchi/core/security/auto_fix_proactive.py +620 -0
- patchi/core/security/auto_fixer.py +660 -0
- patchi/core/security/auto_ticket.py +53 -0
- patchi/core/security/bandit_agent.py +152 -0
- patchi/core/security/blast_radius.py +226 -0
- patchi/core/security/browser_tester.py +333 -0
- patchi/core/security/business_logic_agent.py +263 -0
- patchi/core/security/catch_block_auditor.py +395 -0
- patchi/core/security/cdn_cache_agent.py +357 -0
- patchi/core/security/chain_analyzer.py +321 -0
- patchi/core/security/chain_to_assurance.py +168 -0
- patchi/core/security/charter.py +510 -0
- patchi/core/security/cloud_waf_detector.py +295 -0
- patchi/core/security/codeql_agent.py +233 -0
- patchi/core/security/compliance_agent.py +348 -0
- patchi/core/security/confidence_gate.py +423 -0
- patchi/core/security/container_scanner.py +282 -0
- patchi/core/security/crypto_agent.py +378 -0
- patchi/core/security/cve_monitor.py +262 -0
- patchi/core/security/dast_agent.py +865 -0
- patchi/core/security/defectdojo.py +238 -0
- patchi/core/security/defenders/__init__.py +75 -0
- patchi/core/security/defenders/auth_middleware.py +115 -0
- patchi/core/security/defenders/base.py +97 -0
- patchi/core/security/defenders/block_ip.py +71 -0
- patchi/core/security/defenders/block_ws_origin.py +36 -0
- patchi/core/security/defenders/crypto_fix.py +118 -0
- patchi/core/security/defenders/enforce_rate_limit.py +108 -0
- patchi/core/security/defenders/fix_code.py +93 -0
- patchi/core/security/defenders/invalidate_session.py +40 -0
- patchi/core/security/defenders/patch_config.py +70 -0
- patchi/core/security/defenders/rotate_secret.py +54 -0
- patchi/core/security/defenders/suspend_account.py +41 -0
- patchi/core/security/defenders/update_dependency.py +160 -0
- patchi/core/security/defense_layer.py +217 -0
- patchi/core/security/dependency_vulnerability_agent.py +616 -0
- patchi/core/security/detection_pipeline.py +289 -0
- patchi/core/security/dns_security_agent.py +497 -0
- patchi/core/security/domain_activator_v2.py +913 -0
- patchi/core/security/domain_loader.py +1004 -0
- patchi/core/security/domains/__init__.py +0 -0
- patchi/core/security/email_authentication_agent.py +417 -0
- patchi/core/security/env_var_validator.py +304 -0
- patchi/core/security/evidence.py +100 -0
- patchi/core/security/falco_runtime_agent.py +387 -0
- patchi/core/security/fix-playbooks/__init__.py +0 -0
- patchi/core/security/gated_finding.py +85 -0
- patchi/core/security/git_diff_activator.py +367 -0
- patchi/core/security/governance.py +208 -0
- patchi/core/security/gradual_gate.py +34 -0
- patchi/core/security/history.py +315 -0
- patchi/core/security/iac_scanner.py +250 -0
- patchi/core/security/ignore_expiry.py +111 -0
- patchi/core/security/ignore_learner.py +416 -0
- patchi/core/security/injection_agent.py +319 -0
- patchi/core/security/insecure_randomness_agent.py +288 -0
- patchi/core/security/intent_analyzer.py +326 -0
- patchi/core/security/jwt_agent.py +453 -0
- patchi/core/security/kubernetes_agent.py +638 -0
- patchi/core/security/layer2_orchestrator.py +341 -0
- patchi/core/security/llm_security_agent.py +252 -0
- patchi/core/security/misconfig_agent.py +600 -0
- patchi/core/security/mobile_security_agent.py +474 -0
- patchi/core/security/network_agent.py +376 -0
- patchi/core/security/noise_filter.py +274 -0
- patchi/core/security/orchestrator.py +313 -0
- patchi/core/security/pattern_context.py +125 -0
- patchi/core/security/pattern_loader.py +101 -0
- patchi/core/security/pentest/__init__.py +4 -0
- patchi/core/security/pentest/base.py +58 -0
- patchi/core/security/pentest/dalfox_adapter.py +40 -0
- patchi/core/security/pentest/ffuf_adapter.py +48 -0
- patchi/core/security/pentest/nuclei_adapter.py +50 -0
- patchi/core/security/pentest/registry.py +80 -0
- patchi/core/security/pentest/shannon_adapter.py +107 -0
- patchi/core/security/pentest/sqlmap_adapter.py +56 -0
- patchi/core/security/pentest/zap_adapter.py +52 -0
- patchi/core/security/plan_auditor.py +276 -0
- patchi/core/security/policy_engine.py +272 -0
- patchi/core/security/prechecks.py +172 -0
- patchi/core/security/precommit_hook.py +247 -0
- patchi/core/security/privacy_agent.py +361 -0
- patchi/core/security/push_notification_agent.py +417 -0
- patchi/core/security/pysa_agent.py +143 -0
- patchi/core/security/reasoning.py +442 -0
- patchi/core/security/red_team_agent.py +184 -0
- patchi/core/security/red_team_engine.py +1142 -0
- patchi/core/security/remediation.py +360 -0
- patchi/core/security/request_interceptor.py +294 -0
- patchi/core/security/runtime_validator.py +245 -0
- patchi/core/security/saml_sso_agent.py +401 -0
- patchi/core/security/sast_agent.py +213 -0
- patchi/core/security/sast_gate.py +95 -0
- patchi/core/security/scheduler.py +287 -0
- patchi/core/security/secrets_runtime_agent.py +476 -0
- patchi/core/security/security_agents.py +175 -0
- patchi/core/security/security_config.py +551 -0
- patchi/core/security/security_probe.py +762 -0
- patchi/core/security/security_taint.py +1005 -0
- patchi/core/security/sensitive_data_agent.py +271 -0
- patchi/core/security/service_mesh_agent.py +412 -0
- patchi/core/security/session_management_agent.py +245 -0
- patchi/core/security/ssrf_agent.py +422 -0
- patchi/core/security/supply_chain.py +449 -0
- patchi/core/security/threat_model_generator.py +378 -0
- patchi/core/security/threat_model_updater.py +196 -0
- patchi/core/security/tool_adapters.py +135 -0
- patchi/core/security/tool_verify.py +173 -0
- patchi/core/security/websocket_security_agent.py +188 -0
- patchi/core/snapshot.py +242 -0
- patchi/core/tenant.py +386 -0
- patchi/core/testing/__init__.py +17 -0
- patchi/core/testing/_browser.py +147 -0
- patchi/core/testing/ads_agent.py +141 -0
- patchi/core/testing/api_contract_agent.py +450 -0
- patchi/core/testing/app_discovery_agent.py +641 -0
- patchi/core/testing/app_launcher.py +184 -0
- patchi/core/testing/browser_test_agent.py +516 -0
- patchi/core/testing/e2e_flow_agent.py +354 -0
- patchi/core/testing/flake_detector_agent.py +292 -0
- patchi/core/testing/gate.py +31 -0
- patchi/core/testing/live_test_runner.py +304 -0
- patchi/core/testing/live_v2/__init__.py +1 -0
- patchi/core/testing/live_v2/browser_pool.py +349 -0
- patchi/core/testing/live_v2/browser_test_runner.py +313 -0
- patchi/core/testing/live_v2/runner.py +626 -0
- patchi/core/testing/live_v2/screenshot_manager.py +474 -0
- patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
- patchi/core/testing/live_v2/video_recorder.py +303 -0
- patchi/core/testing/regression_agent.py +302 -0
- patchi/core/testing/security_test_agent.py +253 -0
- patchi/core/testing/stress_test_agent.py +203 -0
- patchi/core/testing/test_agents.py +50 -0
- patchi/core/testing/test_config.py +194 -0
- patchi/core/testing/ui_accessibility_agent.py +324 -0
- patchi/core/testing/ui_button_agent.py +294 -0
- patchi/core/testing/ui_layout_agent.py +330 -0
- patchi/core/testing/unit_test_agent.py +1149 -0
- patchi/core/testing/visual_regression_agent.py +260 -0
- patchi/web/__init__.py +1 -0
- patchi/web/api/__init__.py +1 -0
- patchi/web/api/brain_map.py +114 -0
- patchi/web/api/charts.py +47 -0
- patchi/web/api/chat.py +224 -0
- patchi/web/api/cicd.py +576 -0
- patchi/web/api/dast_evidence.py +49 -0
- patchi/web/api/dev_check.py +320 -0
- patchi/web/api/fix.py +134 -0
- patchi/web/api/guard.py +65 -0
- patchi/web/api/hosted.py +128 -0
- patchi/web/api/hosted_v2.py +234 -0
- patchi/web/api/live_testing.py +699 -0
- patchi/web/api/scan.py +582 -0
- patchi/web/api/smart.py +111 -0
- patchi/web/api/tenant.py +117 -0
- patchi/web/api/tools.py +85 -0
- patchi/web/api_legacy.py +2401 -0
- patchi/web/app.py +310 -0
- patchi/web/events.py +483 -0
- patchi/web/routes/__init__.py +1 -0
- patchi/web/routes/assurance.py +541 -0
- patchi/web/routes/brain.py +36 -0
- patchi/web/routes/charter.py +227 -0
- patchi/web/routes/chat.py +23 -0
- patchi/web/routes/dashboard.py +558 -0
- patchi/web/routes/dashboard_v2.py +402 -0
- patchi/web/routes/findings.py +330 -0
- patchi/web/routes/guard.py +35 -0
- patchi/web/routes/history.py +126 -0
- patchi/web/routes/landing.py +52 -0
- patchi/web/routes/live_testing.py +24 -0
- patchi/web/routes/review.py +57 -0
- patchi/web/routes/self_improvement.py +110 -0
- patchi/web/routes/settings.py +49 -0
- patchi/web/routes/tokens.py +29 -0
- patchi/web/spawn.py +139 -0
- patchi/web/ws.py +141 -0
- patchi-0.7.5.dist-info/METADATA +315 -0
- patchi-0.7.5.dist-info/RECORD +496 -0
- patchi-0.7.5.dist-info/WHEEL +5 -0
- patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
- patchi-0.7.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
"""Live Testing API — browser tests, screenshots, stress tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Request
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
_log = logging.getLogger("patchi.web.api.live_testing")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/api/live-testing")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StressTestRequest(BaseModel):
|
|
22
|
+
url: str = "http://127.0.0.1:1612"
|
|
23
|
+
users: int = 10
|
|
24
|
+
duration_seconds: int = 30
|
|
25
|
+
ramp_up_seconds: int = 5
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SmokeTestRequest(BaseModel):
|
|
29
|
+
url: str = "http://127.0.0.1:1612"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── State ────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
_stress_state = {"running": False, "last_result": None, "task": None, "cancelled": False}
|
|
35
|
+
_audit_state = {
|
|
36
|
+
"running": False,
|
|
37
|
+
"progress": {"current": 0, "total": 0, "route": ""},
|
|
38
|
+
"last_result": None,
|
|
39
|
+
}
|
|
40
|
+
_dast_state = {"running": False, "task": None, "cancelled": False}
|
|
41
|
+
_smoke_state = {"running": False, "task": None, "cancelled": False}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run_audit_sync(base_url: str, root: Path, routes: list[str] | None = None) -> dict:
|
|
45
|
+
"""Run full-page browser audit synchronously (called via to_thread)."""
|
|
46
|
+
from playwright.sync_api import sync_playwright
|
|
47
|
+
|
|
48
|
+
from patchi.core.testing._browser import (
|
|
49
|
+
discover_routes,
|
|
50
|
+
open_page,
|
|
51
|
+
save_screenshot,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if routes is None:
|
|
55
|
+
routes = discover_routes({}, {})
|
|
56
|
+
|
|
57
|
+
evidence_dir = root / ".patchi" / "evidence" / "browser_audit"
|
|
58
|
+
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
results = []
|
|
61
|
+
total = len(routes)
|
|
62
|
+
_audit_state["progress"] = {"current": 0, "total": total, "route": ""}
|
|
63
|
+
|
|
64
|
+
with sync_playwright() as pw:
|
|
65
|
+
browser = pw.chromium.launch(headless=True)
|
|
66
|
+
try:
|
|
67
|
+
for i, route in enumerate(routes):
|
|
68
|
+
url = f"{base_url}{route}"
|
|
69
|
+
_audit_state["progress"] = {
|
|
70
|
+
"current": i + 1,
|
|
71
|
+
"total": total,
|
|
72
|
+
"route": route,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
page_session = open_page(browser, url)
|
|
76
|
+
page = page_session.page
|
|
77
|
+
load_time_ms = 0
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
slug = route.strip("/").replace("/", "_") or "root"
|
|
81
|
+
|
|
82
|
+
# Re-measure load time with a fresh goto
|
|
83
|
+
perf_start = page.evaluate("() => performance.now()")
|
|
84
|
+
try:
|
|
85
|
+
page.reload(wait_until="load", timeout=15000)
|
|
86
|
+
page.wait_for_timeout(500)
|
|
87
|
+
except Exception as _exc:
|
|
88
|
+
_log.warning('_run_audit_sync failed: %s', _exc)
|
|
89
|
+
perf_end = page.evaluate("() => performance.now()")
|
|
90
|
+
load_time_ms = round(perf_end - perf_start, 1)
|
|
91
|
+
|
|
92
|
+
# Screenshot
|
|
93
|
+
shot_name = f"audit_{slug}"
|
|
94
|
+
shot_path = save_screenshot(page, evidence_dir, shot_name)
|
|
95
|
+
screenshot_file = shot_path.name if shot_path else None
|
|
96
|
+
|
|
97
|
+
# Collect page metrics
|
|
98
|
+
metrics = page.evaluate(
|
|
99
|
+
"() => ({dom_nodes: document.querySelector('*').length, "
|
|
100
|
+
"images: document.querySelector('img').length, "
|
|
101
|
+
"links: document.querySelector('a').length, "
|
|
102
|
+
"scripts: document.querySelector('script').length, "
|
|
103
|
+
"stylesheets: document.querySelector('link[rel=stylesheet]').length, "
|
|
104
|
+
"title: document.title || '', "
|
|
105
|
+
"has_viewport_meta: !!document.querySelector('meta[name=viewport]'), "
|
|
106
|
+
"has_h1: !!document.querySelector('h1')})"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Check for broken images
|
|
110
|
+
broken_images = page.evaluate(
|
|
111
|
+
"() => Array.from(document.querySelectorAll('img'))"
|
|
112
|
+
".filter(img => !img.complete || img.naturalWidth === 0)"
|
|
113
|
+
".map(img => img.src)"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Check for empty links
|
|
117
|
+
empty_links = page.evaluate(
|
|
118
|
+
"() => Array.from(document.querySelectorAll('a[href]'))"
|
|
119
|
+
".filter(a => !a.textContent.trim() && !a.querySelector('img'))"
|
|
120
|
+
".length"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
results.append({
|
|
124
|
+
"route": route,
|
|
125
|
+
"status": page_session.status,
|
|
126
|
+
"status_label": _status_label(page_session.status),
|
|
127
|
+
"load_time_ms": load_time_ms,
|
|
128
|
+
"console_errors": page_session.console_errors,
|
|
129
|
+
"console_error_count": len(page_session.console_errors),
|
|
130
|
+
"page_errors": page_session.page_errors,
|
|
131
|
+
"page_error_count": len(page_session.page_errors),
|
|
132
|
+
"screenshot": screenshot_file,
|
|
133
|
+
"metrics": metrics,
|
|
134
|
+
"broken_images": broken_images,
|
|
135
|
+
"broken_image_count": len(broken_images),
|
|
136
|
+
"empty_links": empty_links,
|
|
137
|
+
"severity": _severity_for_page(page_session, broken_images, empty_links),
|
|
138
|
+
})
|
|
139
|
+
except Exception as e:
|
|
140
|
+
results.append({
|
|
141
|
+
"route": route,
|
|
142
|
+
"status": -1,
|
|
143
|
+
"status_label": "ERROR",
|
|
144
|
+
"load_time_ms": 0,
|
|
145
|
+
"console_errors": [str(e)],
|
|
146
|
+
"console_error_count": 1,
|
|
147
|
+
"page_errors": [],
|
|
148
|
+
"page_error_count": 0,
|
|
149
|
+
"screenshot": None,
|
|
150
|
+
"metrics": {},
|
|
151
|
+
"broken_images": [],
|
|
152
|
+
"broken_image_count": 0,
|
|
153
|
+
"empty_links": 0,
|
|
154
|
+
"severity": "critical",
|
|
155
|
+
})
|
|
156
|
+
finally:
|
|
157
|
+
try:
|
|
158
|
+
page.close()
|
|
159
|
+
except Exception as _exc:
|
|
160
|
+
_log.warning('_run_audit_sync failed: %s', _exc)
|
|
161
|
+
finally:
|
|
162
|
+
browser.close()
|
|
163
|
+
|
|
164
|
+
# Summary
|
|
165
|
+
statuses = [r["status"] for r in results]
|
|
166
|
+
errors_4xx = sum(1 for s in statuses if 400 <= s < 500)
|
|
167
|
+
errors_5xx = sum(1 for s in statuses if s >= 500)
|
|
168
|
+
ok_count = sum(1 for s in statuses if 200 <= s < 400)
|
|
169
|
+
total_console = sum(r["console_error_count"] for r in results)
|
|
170
|
+
total_page_err = sum(r["page_error_count"] for r in results)
|
|
171
|
+
total_broken = sum(r["broken_image_count"] for r in results)
|
|
172
|
+
avg_load = round(sum(r["load_time_ms"] for r in results) / max(len(results), 1), 1)
|
|
173
|
+
critical = sum(1 for r in results if r["severity"] == "critical")
|
|
174
|
+
warnings = sum(1 for r in results if r["severity"] == "warning")
|
|
175
|
+
passed = sum(1 for r in results if r["severity"] == "pass")
|
|
176
|
+
|
|
177
|
+
# Save results JSON
|
|
178
|
+
result_data = {
|
|
179
|
+
"ok": True,
|
|
180
|
+
"base_url": base_url,
|
|
181
|
+
"total_routes": len(results),
|
|
182
|
+
"summary": {
|
|
183
|
+
"passed": passed,
|
|
184
|
+
"warnings": warnings,
|
|
185
|
+
"critical": critical,
|
|
186
|
+
"ok_2xx": ok_count,
|
|
187
|
+
"errors_4xx": errors_4xx,
|
|
188
|
+
"errors_5xx": errors_5xx,
|
|
189
|
+
"total_console_errors": total_console,
|
|
190
|
+
"total_page_errors": total_page_err,
|
|
191
|
+
"total_broken_images": total_broken,
|
|
192
|
+
"avg_load_time_ms": avg_load,
|
|
193
|
+
},
|
|
194
|
+
"pages": results,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# Persist to disk
|
|
198
|
+
audit_file = evidence_dir / "audit_results.json"
|
|
199
|
+
audit_file.write_text(json.dumps(result_data, indent=2, default=str), encoding="utf-8")
|
|
200
|
+
|
|
201
|
+
_audit_state["last_result"] = result_data
|
|
202
|
+
return result_data
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _status_label(status: int) -> str:
|
|
206
|
+
if status == 200:
|
|
207
|
+
return "OK"
|
|
208
|
+
if status == 301:
|
|
209
|
+
return "Redirect"
|
|
210
|
+
if status == 304:
|
|
211
|
+
return "Cached"
|
|
212
|
+
if status == 404:
|
|
213
|
+
return "Not Found"
|
|
214
|
+
if status == 500:
|
|
215
|
+
return "Server Error"
|
|
216
|
+
if status < 0:
|
|
217
|
+
return "Failed"
|
|
218
|
+
return str(status)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _severity_for_page(session, broken_images: list, empty_links: int) -> str:
|
|
222
|
+
if session.status >= 500 or session.status < 0:
|
|
223
|
+
return "critical"
|
|
224
|
+
if session.status >= 400:
|
|
225
|
+
return "warning"
|
|
226
|
+
if session.page_errors:
|
|
227
|
+
return "critical"
|
|
228
|
+
if len(session.console_errors) > 3 or broken_images or empty_links > 2:
|
|
229
|
+
return "warning"
|
|
230
|
+
if session.console_errors:
|
|
231
|
+
return "info"
|
|
232
|
+
return "pass"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ── Endpoints ────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@router.get("/status")
|
|
239
|
+
async def status(request: Request):
|
|
240
|
+
"""Get live testing status."""
|
|
241
|
+
root: Path = request.app.state.root
|
|
242
|
+
return {
|
|
243
|
+
"stress_running": _stress_state["running"],
|
|
244
|
+
"evidence_dir": str(root / ".patchi" / "evidence"),
|
|
245
|
+
"has_browser_pool": True,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@router.post("/smoke-test")
|
|
250
|
+
async def smoke_test(req: SmokeTestRequest, request: Request):
|
|
251
|
+
"""Run a quick smoke test against the app."""
|
|
252
|
+
root: Path = request.app.state.root
|
|
253
|
+
try:
|
|
254
|
+
from patchi.core.testing.live_v2.browser_test_runner import BrowserTestRunner
|
|
255
|
+
|
|
256
|
+
runner = BrowserTestRunner(root, base_url=req.url)
|
|
257
|
+
result = await runner.run_smoke_test(req.url)
|
|
258
|
+
return result.to_dict()
|
|
259
|
+
except Exception as e:
|
|
260
|
+
return JSONResponse(
|
|
261
|
+
status_code=500,
|
|
262
|
+
content={"error": str(e)},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@router.post("/stress-test")
|
|
267
|
+
async def stress_test(req: StressTestRequest, request: Request):
|
|
268
|
+
"""Start a stress test against the app."""
|
|
269
|
+
if _stress_state["running"]:
|
|
270
|
+
return JSONResponse(
|
|
271
|
+
status_code=409,
|
|
272
|
+
content={"error": "Stress test already running"},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
_stress_state["running"] = True
|
|
276
|
+
_stress_state["cancelled"] = False
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
from patchi.core.testing.live_v2.stress_orchestrator import (
|
|
280
|
+
StressConfig,
|
|
281
|
+
StressOrchestrator,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
config = StressConfig(
|
|
285
|
+
base_url=req.url,
|
|
286
|
+
users=req.users,
|
|
287
|
+
duration_seconds=req.duration_seconds,
|
|
288
|
+
ramp_up_seconds=req.ramp_up_seconds,
|
|
289
|
+
)
|
|
290
|
+
orchestrator = StressOrchestrator(config)
|
|
291
|
+
report = await orchestrator.run()
|
|
292
|
+
if _stress_state["cancelled"]:
|
|
293
|
+
return JSONResponse({"ok": False, "message": "Stress test was cancelled"})
|
|
294
|
+
_stress_state["last_result"] = report.to_dict()
|
|
295
|
+
return report.to_dict()
|
|
296
|
+
except asyncio.CancelledError:
|
|
297
|
+
return JSONResponse({"ok": False, "message": "Stress test was cancelled"})
|
|
298
|
+
except Exception as e:
|
|
299
|
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
|
300
|
+
finally:
|
|
301
|
+
_stress_state["running"] = False
|
|
302
|
+
_stress_state["task"] = None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@router.get("/stress-result")
|
|
306
|
+
async def stress_result():
|
|
307
|
+
"""Get the last stress test result."""
|
|
308
|
+
return _stress_state["last_result"] or {"message": "No stress test results yet"}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@router.post("/stress-cancel")
|
|
312
|
+
async def cancel_stress() -> JSONResponse:
|
|
313
|
+
"""Cancel a running stress test."""
|
|
314
|
+
if not _stress_state["running"]:
|
|
315
|
+
return JSONResponse({"ok": True, "message": "No stress test running"})
|
|
316
|
+
_stress_state["cancelled"] = True
|
|
317
|
+
task = _stress_state.get("task")
|
|
318
|
+
if task and not task.done():
|
|
319
|
+
task.cancel()
|
|
320
|
+
return JSONResponse({"ok": True, "message": "Stress test cancellation requested"})
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@router.post("/dast-cancel")
|
|
324
|
+
async def cancel_dast() -> JSONResponse:
|
|
325
|
+
"""Cancel a running DAST scan."""
|
|
326
|
+
if not _dast_state["running"]:
|
|
327
|
+
return JSONResponse({"ok": True, "message": "No DAST scan running"})
|
|
328
|
+
_dast_state["cancelled"] = True
|
|
329
|
+
task = _dast_state.get("task")
|
|
330
|
+
if task and not task.done():
|
|
331
|
+
task.cancel()
|
|
332
|
+
return JSONResponse({"ok": True, "message": "DAST scan cancellation requested"})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@router.post("/smoke-cancel")
|
|
336
|
+
async def cancel_smoke() -> JSONResponse:
|
|
337
|
+
"""Cancel a running smoke test."""
|
|
338
|
+
if not _smoke_state["running"]:
|
|
339
|
+
return JSONResponse({"ok": True, "message": "No smoke test running"})
|
|
340
|
+
_smoke_state["cancelled"] = True
|
|
341
|
+
task = _smoke_state.get("task")
|
|
342
|
+
if task and not task.done():
|
|
343
|
+
task.cancel()
|
|
344
|
+
return JSONResponse({"ok": True, "message": "Smoke test cancellation requested"})
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@router.get("/operations")
|
|
348
|
+
async def list_operations():
|
|
349
|
+
"""List all currently running operations across the system."""
|
|
350
|
+
ops = []
|
|
351
|
+
# Check scan state
|
|
352
|
+
try:
|
|
353
|
+
from patchi.web.api.scan import _scan_state
|
|
354
|
+
if _scan_state.get("running"):
|
|
355
|
+
ops.append({"type": "scan", "label": "Security Scan", "cancel_url": "/api/scan/cancel"})
|
|
356
|
+
except Exception as _exc:
|
|
357
|
+
_log.warning('list_operations failed: %s', _exc)
|
|
358
|
+
if _stress_state["running"]:
|
|
359
|
+
ops.append({"type": "stress", "label": "Stress Test", "cancel_url": "/api/live-testing/stress-cancel"})
|
|
360
|
+
if _dast_state["running"]:
|
|
361
|
+
ops.append({"type": "dast", "label": "DAST Scan", "cancel_url": "/api/live-testing/dast-cancel"})
|
|
362
|
+
if _smoke_state["running"]:
|
|
363
|
+
ops.append({"type": "smoke", "label": "Smoke Test", "cancel_url": "/api/live-testing/smoke-cancel"})
|
|
364
|
+
if _audit_state["running"]:
|
|
365
|
+
ops.append({"type": "audit", "label": "Browser Audit", "cancel_url": ""})
|
|
366
|
+
try:
|
|
367
|
+
from patchi.web.api.smart import _current_task
|
|
368
|
+
if _current_task and not _current_task.done():
|
|
369
|
+
ops.append({"type": "smart", "label": "Smart Agent", "cancel_url": "/api/smart/cancel"})
|
|
370
|
+
except Exception as _exc:
|
|
371
|
+
_log.warning('list_operations failed: %s', _exc)
|
|
372
|
+
return JSONResponse({"ok": True, "operations": ops, "count": len(ops)})
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@router.post("/screenshot")
|
|
376
|
+
async def capture_screenshot(req: SmokeTestRequest, request: Request):
|
|
377
|
+
"""Capture a screenshot of the app."""
|
|
378
|
+
root: Path = request.app.state.root
|
|
379
|
+
try:
|
|
380
|
+
from patchi.core.testing.live_v2.browser_pool import get_browser_pool
|
|
381
|
+
from patchi.core.testing.live_v2.screenshot_manager import ScreenshotManager
|
|
382
|
+
|
|
383
|
+
evidence_dir = root / ".patchi" / "evidence" / "screenshots"
|
|
384
|
+
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
screenshot_mgr = ScreenshotManager(evidence_dir)
|
|
386
|
+
pool = await get_browser_pool()
|
|
387
|
+
|
|
388
|
+
page = await pool.get_page()
|
|
389
|
+
try:
|
|
390
|
+
await page.goto(req.url, wait_until="domcontentloaded")
|
|
391
|
+
ss_result = await screenshot_mgr.capture(page, req.url, name="manual_screenshot")
|
|
392
|
+
return {"success": True, "path": ss_result.image_path, "size": ss_result.file_size}
|
|
393
|
+
finally:
|
|
394
|
+
await pool.release_page(page)
|
|
395
|
+
except Exception as e:
|
|
396
|
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@router.get("/browser-pool-stats")
|
|
400
|
+
async def browser_pool_stats():
|
|
401
|
+
"""Get browser pool statistics."""
|
|
402
|
+
try:
|
|
403
|
+
from patchi.core.testing.live_v2.browser_pool import get_browser_pool
|
|
404
|
+
|
|
405
|
+
pool = await get_browser_pool()
|
|
406
|
+
return pool.get_stats()
|
|
407
|
+
except Exception as e:
|
|
408
|
+
return {"error": str(e)}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _ads_dirs(root: Path) -> list[Path]:
|
|
412
|
+
"""Ads gallery viewport subdirs (evidence/ads/<WxH>/), if present."""
|
|
413
|
+
base = root / ".patchi" / "evidence" / "ads"
|
|
414
|
+
if not base.is_dir():
|
|
415
|
+
return []
|
|
416
|
+
try:
|
|
417
|
+
return [d for d in sorted(base.iterdir()) if d.is_dir()]
|
|
418
|
+
except OSError:
|
|
419
|
+
return []
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@router.get("/videos")
|
|
423
|
+
async def list_videos(request: Request):
|
|
424
|
+
"""List all recorded video evidence files."""
|
|
425
|
+
root: Path = request.app.state.root
|
|
426
|
+
video_dirs = [
|
|
427
|
+
root / ".patchi" / "evidence" / "video",
|
|
428
|
+
root / ".patchi" / "evidence" / "browser_tests" / "video",
|
|
429
|
+
*_ads_dirs(root),
|
|
430
|
+
]
|
|
431
|
+
|
|
432
|
+
videos = []
|
|
433
|
+
seen_names: set[str] = set()
|
|
434
|
+
for video_dir in video_dirs:
|
|
435
|
+
if not video_dir.is_dir():
|
|
436
|
+
continue
|
|
437
|
+
for f in sorted(video_dir.glob("*.webm"), key=lambda x: x.stat().st_mtime, reverse=True):
|
|
438
|
+
try:
|
|
439
|
+
stat = f.stat()
|
|
440
|
+
# Deduplicate by filename across directories
|
|
441
|
+
if f.name in seen_names:
|
|
442
|
+
continue
|
|
443
|
+
seen_names.add(f.name)
|
|
444
|
+
videos.append({
|
|
445
|
+
"name": f.stem,
|
|
446
|
+
"filename": f.name,
|
|
447
|
+
"path": str(f.relative_to(root)),
|
|
448
|
+
"size_bytes": stat.st_size,
|
|
449
|
+
"size_kb": round(stat.st_size / 1024, 1),
|
|
450
|
+
"timestamp": datetime.fromtimestamp(stat.st_mtime, UTC).isoformat(),
|
|
451
|
+
})
|
|
452
|
+
except Exception as _exc:
|
|
453
|
+
_log.warning('list_videos failed: %s', _exc)
|
|
454
|
+
# Sort all videos by mtime descending
|
|
455
|
+
videos.sort(key=lambda v: v["timestamp"], reverse=True)
|
|
456
|
+
|
|
457
|
+
return JSONResponse({
|
|
458
|
+
"ok": True,
|
|
459
|
+
"videos": videos[:50],
|
|
460
|
+
"total": len(videos),
|
|
461
|
+
"total_size_kb": sum(v["size_kb"] for v in videos),
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@router.get("/video/{filename}")
|
|
466
|
+
async def serve_video(filename: str, request: Request):
|
|
467
|
+
"""Serve a video recording file for playback."""
|
|
468
|
+
root: Path = request.app.state.root
|
|
469
|
+
# Check multiple video directories
|
|
470
|
+
video_dirs = [
|
|
471
|
+
root / ".patchi" / "evidence" / "video",
|
|
472
|
+
root / ".patchi" / "evidence" / "browser_tests" / "video",
|
|
473
|
+
*_ads_dirs(root),
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
# Security: only allow .webm files
|
|
477
|
+
import re as _re
|
|
478
|
+
if not _re.match(r'^[a-zA-Z0-9_@.\-]+\.webm$', filename):
|
|
479
|
+
return JSONResponse({"error": "Invalid filename"}, status_code=403)
|
|
480
|
+
|
|
481
|
+
for video_dir in video_dirs:
|
|
482
|
+
file_path = video_dir / filename
|
|
483
|
+
if file_path.exists() and file_path.is_file():
|
|
484
|
+
try:
|
|
485
|
+
file_path.resolve().relative_to(root.resolve())
|
|
486
|
+
except ValueError:
|
|
487
|
+
return JSONResponse({"error": "Access denied"}, status_code=403)
|
|
488
|
+
from starlette.responses import FileResponse
|
|
489
|
+
return FileResponse(file_path, media_type="video/webm")
|
|
490
|
+
|
|
491
|
+
return JSONResponse({"error": "Video not found"}, status_code=404)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
@router.post("/visual-regression")
|
|
495
|
+
async def run_visual_regression(request: Request):
|
|
496
|
+
"""Run visual regression agent — captures screenshots of all pages,
|
|
497
|
+
compares against baselines, and generates pixel-diff overlays."""
|
|
498
|
+
root: Path = request.app.state.root
|
|
499
|
+
try:
|
|
500
|
+
import asyncio
|
|
501
|
+
|
|
502
|
+
from patchi.core import config as cfg
|
|
503
|
+
from patchi.core import memory as mem
|
|
504
|
+
from patchi.core.agents.base import AgentInput, AgentResult
|
|
505
|
+
from patchi.core.testing.visual_regression_agent import VisualRegressionAgent
|
|
506
|
+
|
|
507
|
+
config = cfg.load(root) if root.exists() else {}
|
|
508
|
+
brain = mem.get_brain(root)
|
|
509
|
+
inp = AgentInput(root=root, scope=[], brain=brain, config=config)
|
|
510
|
+
result = AgentResult(agent_name="VisualRegressionAgent")
|
|
511
|
+
|
|
512
|
+
agent = VisualRegressionAgent()
|
|
513
|
+
# Run sync agent in thread pool so we don't block the event loop
|
|
514
|
+
await asyncio.to_thread(agent._run, inp, result)
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
"ok": True,
|
|
518
|
+
"screenshots": result.data.get("suite", {}).get("screenshots", 0),
|
|
519
|
+
"regressions": result.data.get("suite", {}).get("regressions", 0),
|
|
520
|
+
"new_baselines": result.data.get("suite", {}).get("new_baselines", 0),
|
|
521
|
+
"error_pages": result.data.get("suite", {}).get("error_pages", 0),
|
|
522
|
+
"findings": [f.to_dict() for f in result.findings],
|
|
523
|
+
}
|
|
524
|
+
except Exception as e:
|
|
525
|
+
return JSONResponse(status_code=500, content={"ok": False, "error": str(e)})
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@router.get("/screenshots")
|
|
529
|
+
async def list_screenshots(request: Request):
|
|
530
|
+
"""List visual regression screenshots and diff images."""
|
|
531
|
+
root: Path = request.app.state.root
|
|
532
|
+
evidence_dir = root / ".patchi" / "evidence" / "screenshots" / "visual_regression"
|
|
533
|
+
baseline_dir = root / ".patchi" / "visual_baselines"
|
|
534
|
+
|
|
535
|
+
screenshots = []
|
|
536
|
+
if evidence_dir.is_dir():
|
|
537
|
+
for f in sorted(evidence_dir.glob("*.png"), key=lambda x: x.stat().st_mtime, reverse=True):
|
|
538
|
+
try:
|
|
539
|
+
stat = f.stat()
|
|
540
|
+
is_diff = "_diff" in f.stem
|
|
541
|
+
screenshots.append({
|
|
542
|
+
"name": f.stem,
|
|
543
|
+
"filename": f.name,
|
|
544
|
+
"path": str(f.relative_to(root)),
|
|
545
|
+
"size_kb": round(stat.st_size / 1024, 1),
|
|
546
|
+
"timestamp": datetime.fromtimestamp(stat.st_mtime, UTC).isoformat(),
|
|
547
|
+
"is_diff": is_diff,
|
|
548
|
+
})
|
|
549
|
+
except Exception as _exc:
|
|
550
|
+
_log.warning('list_screenshots failed: %s', _exc)
|
|
551
|
+
|
|
552
|
+
baseline_count = 0
|
|
553
|
+
if baseline_dir.is_dir():
|
|
554
|
+
baseline_count = len(list(baseline_dir.rglob("*.png")))
|
|
555
|
+
|
|
556
|
+
return JSONResponse({
|
|
557
|
+
"ok": True,
|
|
558
|
+
"screenshots": screenshots[:100],
|
|
559
|
+
"total": len(screenshots),
|
|
560
|
+
"baselines": baseline_count,
|
|
561
|
+
"total_size_kb": round(sum(s["size_kb"] for s in screenshots), 1),
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@router.get("/screenshot/{filename}")
|
|
566
|
+
async def serve_screenshot(filename: str, request: Request):
|
|
567
|
+
"""Serve a screenshot file."""
|
|
568
|
+
root: Path = request.app.state.root
|
|
569
|
+
evidence_dir = root / ".patchi" / "evidence" / "screenshots"
|
|
570
|
+
file_path = evidence_dir / filename
|
|
571
|
+
|
|
572
|
+
if not file_path.suffix == ".png":
|
|
573
|
+
return JSONResponse({"error": "Only .png files allowed"}, status_code=403)
|
|
574
|
+
try:
|
|
575
|
+
file_path.resolve().relative_to(evidence_dir.resolve())
|
|
576
|
+
except ValueError:
|
|
577
|
+
return JSONResponse({"error": "Access denied"}, status_code=403)
|
|
578
|
+
|
|
579
|
+
if not file_path.exists():
|
|
580
|
+
# Fall back to ads gallery viewport subdirs
|
|
581
|
+
for ads_dir in _ads_dirs(root):
|
|
582
|
+
cand = ads_dir / filename
|
|
583
|
+
try:
|
|
584
|
+
cand.resolve().relative_to(ads_dir.resolve())
|
|
585
|
+
except ValueError:
|
|
586
|
+
return JSONResponse({"error": "Access denied"}, status_code=403)
|
|
587
|
+
if cand.is_file():
|
|
588
|
+
file_path = cand
|
|
589
|
+
break
|
|
590
|
+
else:
|
|
591
|
+
return JSONResponse({"error": "Screenshot not found"}, status_code=404)
|
|
592
|
+
|
|
593
|
+
from starlette.responses import FileResponse
|
|
594
|
+
return FileResponse(file_path, media_type="image/png")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@router.get("/ads-gallery")
|
|
598
|
+
async def ads_gallery(request: Request):
|
|
599
|
+
"""Marketing gallery groups (viewport → screenshots + videos)."""
|
|
600
|
+
root: Path = request.app.state.root
|
|
601
|
+
groups: list[dict] = []
|
|
602
|
+
for vdir in _ads_dirs(root):
|
|
603
|
+
try:
|
|
604
|
+
shots = sorted(
|
|
605
|
+
(p.name for p in vdir.glob("*.png") if p.is_file()),
|
|
606
|
+
key=lambda n: (vdir / n).stat().st_mtime,
|
|
607
|
+
reverse=True,
|
|
608
|
+
)
|
|
609
|
+
vids = sorted(
|
|
610
|
+
(p.name for p in vdir.glob("*.webm") if p.is_file()),
|
|
611
|
+
key=lambda n: (vdir / n).stat().st_mtime,
|
|
612
|
+
reverse=True,
|
|
613
|
+
)
|
|
614
|
+
except OSError:
|
|
615
|
+
shots, vids = [], []
|
|
616
|
+
if shots or vids:
|
|
617
|
+
groups.append({"viewport": vdir.name, "screenshots": shots, "videos": vids})
|
|
618
|
+
return JSONResponse({"ok": True, "groups": groups})
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# ── Full-Page Browser Audit ───────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
class FullAuditRequest(BaseModel):
|
|
625
|
+
url: str = "http://127.0.0.1:1612"
|
|
626
|
+
routes: list[str] | None = None
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@router.post("/full-audit")
|
|
630
|
+
async def run_full_audit(req: FullAuditRequest, request: Request):
|
|
631
|
+
"""Run full-page browser audit — navigates every page, captures
|
|
632
|
+
HTTP status, console errors, page errors, load time, screenshots,
|
|
633
|
+
broken images, empty links, and page metrics."""
|
|
634
|
+
root: Path = request.app.state.root
|
|
635
|
+
|
|
636
|
+
if _audit_state["running"]:
|
|
637
|
+
return JSONResponse(status_code=409, content={"error": "Audit already running"})
|
|
638
|
+
|
|
639
|
+
_audit_state["running"] = True
|
|
640
|
+
try:
|
|
641
|
+
import asyncio
|
|
642
|
+
result = await asyncio.to_thread(
|
|
643
|
+
_run_audit_sync, req.url, root, req.routes,
|
|
644
|
+
)
|
|
645
|
+
return result
|
|
646
|
+
except Exception as e:
|
|
647
|
+
return JSONResponse(status_code=500, content={"ok": False, "error": str(e)})
|
|
648
|
+
finally:
|
|
649
|
+
_audit_state["running"] = False
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@router.get("/full-audit")
|
|
653
|
+
async def get_full_audit(request: Request):
|
|
654
|
+
"""Get the last full-page audit results, or current progress if running."""
|
|
655
|
+
if _audit_state["running"]:
|
|
656
|
+
return {"running": True, "progress": _audit_state["progress"]}
|
|
657
|
+
|
|
658
|
+
# Try to load persisted results
|
|
659
|
+
if _audit_state["last_result"]:
|
|
660
|
+
return _audit_state["last_result"]
|
|
661
|
+
|
|
662
|
+
root: Path = request.app.state.root
|
|
663
|
+
audit_file = root / ".patchi" / "evidence" / "browser_audit" / "audit_results.json"
|
|
664
|
+
if audit_file.exists():
|
|
665
|
+
try:
|
|
666
|
+
return json.loads(audit_file.read_text(encoding="utf-8"))
|
|
667
|
+
except Exception as _exc:
|
|
668
|
+
_log.warning('get_full_audit failed: %s', _exc)
|
|
669
|
+
|
|
670
|
+
return {"ok": False, "message": "No audit results yet. Run a full-page audit first."}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
@router.get("/full-audit/progress")
|
|
674
|
+
async def full_audit_progress():
|
|
675
|
+
"""Poll audit progress while running."""
|
|
676
|
+
if not _audit_state["running"]:
|
|
677
|
+
return {"running": False}
|
|
678
|
+
return {"running": True, "progress": _audit_state["progress"]}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@router.get("/full-audit/screenshot/{filename}")
|
|
682
|
+
async def serve_audit_screenshot(filename: str, request: Request):
|
|
683
|
+
"""Serve a full-page audit screenshot."""
|
|
684
|
+
root: Path = request.app.state.root
|
|
685
|
+
evidence_dir = root / ".patchi" / "evidence" / "browser_audit"
|
|
686
|
+
file_path = evidence_dir / filename
|
|
687
|
+
|
|
688
|
+
if not file_path.suffix == ".png":
|
|
689
|
+
return JSONResponse({"error": "Only .png files allowed"}, status_code=403)
|
|
690
|
+
try:
|
|
691
|
+
file_path.resolve().relative_to(evidence_dir.resolve())
|
|
692
|
+
except ValueError:
|
|
693
|
+
return JSONResponse({"error": "Access denied"}, status_code=403)
|
|
694
|
+
|
|
695
|
+
if not file_path.exists():
|
|
696
|
+
return JSONResponse({"error": "Screenshot not found"}, status_code=404)
|
|
697
|
+
|
|
698
|
+
from starlette.responses import FileResponse
|
|
699
|
+
return FileResponse(file_path, media_type="image/png")
|