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,304 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LiveTestRunner — Orchestrates all test agents and streams results live.
|
|
3
|
+
|
|
4
|
+
Manages the full test lifecycle:
|
|
5
|
+
1. Discovers which agents to run based on test type
|
|
6
|
+
2. Executes agents sequentially (or parallel for independent ones)
|
|
7
|
+
3. Streams real-time results via WebSocket events
|
|
8
|
+
4. Generates summary report
|
|
9
|
+
5. Saves results to memory for historical tracking
|
|
10
|
+
|
|
11
|
+
This is the bridge between the CLI test command, web UI, and test agents.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from ..agents.base import AgentGroup, AgentInput, AgentResult, AgentStatus
|
|
24
|
+
|
|
25
|
+
_log = logging.getLogger("patchi.testing.live_test_runner")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class TestRunConfig:
|
|
30
|
+
"""Configuration for a test run."""
|
|
31
|
+
|
|
32
|
+
test_types: list[str] = field(default_factory=lambda: ["unit", "regression"])
|
|
33
|
+
area: str | None = None
|
|
34
|
+
base_url: str | None = None
|
|
35
|
+
parallel: bool = False
|
|
36
|
+
timeout_per_agent: int = 120
|
|
37
|
+
on_event: Callable | None = None # async callback for live streaming
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class TestRunResult:
|
|
42
|
+
"""Aggregated result of a full test run."""
|
|
43
|
+
|
|
44
|
+
started_at: float = 0
|
|
45
|
+
completed_at: float = 0
|
|
46
|
+
total_duration_ms: int = 0
|
|
47
|
+
agents_run: list[str] = field(default_factory=list)
|
|
48
|
+
results: list[AgentResult] = field(default_factory=list)
|
|
49
|
+
total_findings: int = 0
|
|
50
|
+
total_passed: int = 0
|
|
51
|
+
total_failed: int = 0
|
|
52
|
+
summary: dict = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"started_at": self.started_at,
|
|
57
|
+
"completed_at": self.completed_at,
|
|
58
|
+
"total_duration_ms": self.total_duration_ms,
|
|
59
|
+
"agents_run": self.agents_run,
|
|
60
|
+
"total_findings": self.total_findings,
|
|
61
|
+
"total_passed": self.total_passed,
|
|
62
|
+
"total_failed": self.total_failed,
|
|
63
|
+
"summary": self.summary,
|
|
64
|
+
"results": [r.to_dict() for r in self.results],
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Map of test type names to agent class names
|
|
69
|
+
TEST_TYPE_MAP = {
|
|
70
|
+
"unit": ["UnitTestAgent"],
|
|
71
|
+
"regression": ["RegressionAgent"],
|
|
72
|
+
"browser": ["BrowserTestAgent"],
|
|
73
|
+
"stress": ["StressTestAgent"],
|
|
74
|
+
"accessibility": ["UIAccessibilityAgent"],
|
|
75
|
+
"api": ["APIContractAgent"],
|
|
76
|
+
"buttons": ["UIButtonAgent"],
|
|
77
|
+
"layout": ["UILayoutAgent"],
|
|
78
|
+
"e2e": ["E2EFlowAgent"],
|
|
79
|
+
"visual": ["VisualRegressionAgent"],
|
|
80
|
+
"smoke": ["UIButtonAgent", "UILayoutAgent", "UIAccessibilityAgent"],
|
|
81
|
+
"full": [
|
|
82
|
+
"UnitTestAgent",
|
|
83
|
+
"RegressionAgent",
|
|
84
|
+
"UIButtonAgent",
|
|
85
|
+
"UIAccessibilityAgent",
|
|
86
|
+
"UILayoutAgent",
|
|
87
|
+
"E2EFlowAgent",
|
|
88
|
+
"VisualRegressionAgent",
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class LiveTestRunner:
|
|
94
|
+
"""Orchestrates test execution and streams live results."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, root: Path):
|
|
97
|
+
self.root = root
|
|
98
|
+
self._event_queue: asyncio.Queue | None = None
|
|
99
|
+
self._event_callback: Callable | None = None
|
|
100
|
+
|
|
101
|
+
async def run(self, config: TestRunConfig) -> TestRunResult:
|
|
102
|
+
"""Execute a full test run with live event streaming."""
|
|
103
|
+
# Import test agents to trigger registration
|
|
104
|
+
import patchi.core.testing.test_agents # noqa
|
|
105
|
+
import patchi.core.testing.ui_button_agent # noqa
|
|
106
|
+
import patchi.core.testing.ui_accessibility_agent # noqa
|
|
107
|
+
import patchi.core.testing.ui_layout_agent # noqa
|
|
108
|
+
import patchi.core.testing.e2e_flow_agent # noqa
|
|
109
|
+
import patchi.core.testing.visual_regression_agent # noqa
|
|
110
|
+
|
|
111
|
+
from ..agents.base import get_agent
|
|
112
|
+
|
|
113
|
+
result = TestRunResult(started_at=time.time())
|
|
114
|
+
|
|
115
|
+
# Resolve which agents to run
|
|
116
|
+
agent_names = self._resolve_agents(config.test_types)
|
|
117
|
+
result.agents_run = agent_names
|
|
118
|
+
|
|
119
|
+
# Load brain and config
|
|
120
|
+
from .. import config as cfg
|
|
121
|
+
from .. import memory as mem
|
|
122
|
+
|
|
123
|
+
brain = mem.get_brain(self.root)
|
|
124
|
+
try:
|
|
125
|
+
app_config = cfg.load(self.root)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
_log.warning("LiveTestRunner.run failed: %s", e)
|
|
128
|
+
app_config = {}
|
|
129
|
+
|
|
130
|
+
# Determine scope
|
|
131
|
+
scope = []
|
|
132
|
+
if config.area:
|
|
133
|
+
area_path = self.root / config.area
|
|
134
|
+
if area_path.is_dir():
|
|
135
|
+
scope = [str(p.relative_to(self.root)) for p in area_path.rglob("*.py")]
|
|
136
|
+
|
|
137
|
+
# Fire start event
|
|
138
|
+
await self._emit(
|
|
139
|
+
"test_run_started",
|
|
140
|
+
{
|
|
141
|
+
"agents": agent_names,
|
|
142
|
+
"test_types": config.test_types,
|
|
143
|
+
"area": config.area,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Run agents
|
|
148
|
+
for agent_name in agent_names:
|
|
149
|
+
agent_cls = get_agent(agent_name)
|
|
150
|
+
if not agent_cls:
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
await self._emit(
|
|
154
|
+
"agent_started",
|
|
155
|
+
{
|
|
156
|
+
"agent": agent_name,
|
|
157
|
+
"description": agent_cls.description
|
|
158
|
+
if hasattr(agent_cls, "description")
|
|
159
|
+
else "",
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
t0 = time.monotonic()
|
|
164
|
+
try:
|
|
165
|
+
inp = AgentInput(
|
|
166
|
+
root=self.root,
|
|
167
|
+
scope=scope,
|
|
168
|
+
brain=brain,
|
|
169
|
+
config=app_config,
|
|
170
|
+
extra={"base_url": config.base_url} if config.base_url else {},
|
|
171
|
+
)
|
|
172
|
+
agent = agent_cls()
|
|
173
|
+
agent_result = await asyncio.to_thread(agent.run, inp)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
agent_result = AgentResult(
|
|
176
|
+
agent_name=agent_name,
|
|
177
|
+
agent_group=AgentGroup.TEST,
|
|
178
|
+
status=AgentStatus.FAILED,
|
|
179
|
+
)
|
|
180
|
+
agent_result.add_error(str(e))
|
|
181
|
+
|
|
182
|
+
agent_result.duration_ms = int((time.monotonic() - t0) * 1000)
|
|
183
|
+
result.results.append(agent_result)
|
|
184
|
+
|
|
185
|
+
# Count findings
|
|
186
|
+
result.total_findings += agent_result.finding_count
|
|
187
|
+
|
|
188
|
+
# Count passed/failed from suite data
|
|
189
|
+
suite = agent_result.data.get("suite", {})
|
|
190
|
+
result.total_passed += suite.get("passed", 0)
|
|
191
|
+
result.total_failed += suite.get("failed", 0)
|
|
192
|
+
|
|
193
|
+
# Fire agent done event
|
|
194
|
+
await self._emit(
|
|
195
|
+
"agent_done",
|
|
196
|
+
{
|
|
197
|
+
"agent": agent_name,
|
|
198
|
+
"status": agent_result.status.value,
|
|
199
|
+
"finding_count": agent_result.finding_count,
|
|
200
|
+
"duration_ms": agent_result.duration_ms,
|
|
201
|
+
"passed": suite.get("passed", 0),
|
|
202
|
+
"failed": suite.get("failed", 0),
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# Fire individual findings
|
|
207
|
+
for finding in agent_result.findings:
|
|
208
|
+
await self._emit(
|
|
209
|
+
"agent_finding",
|
|
210
|
+
{
|
|
211
|
+
"agent": agent_name,
|
|
212
|
+
"severity": finding.severity.value,
|
|
213
|
+
"type": finding.type,
|
|
214
|
+
"message": finding.message,
|
|
215
|
+
"file": finding.file,
|
|
216
|
+
},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
result.completed_at = time.time()
|
|
220
|
+
result.total_duration_ms = int((result.completed_at - result.started_at) * 1000)
|
|
221
|
+
|
|
222
|
+
# Build summary
|
|
223
|
+
result.summary = self._build_summary(result)
|
|
224
|
+
|
|
225
|
+
# Fire completion event
|
|
226
|
+
await self._emit("test_run_completed", result.summary)
|
|
227
|
+
|
|
228
|
+
# Save to memory
|
|
229
|
+
self._save_results(result)
|
|
230
|
+
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
def _resolve_agents(self, test_types: list[str]) -> list[str]:
|
|
234
|
+
"""Resolve test type names to agent class names."""
|
|
235
|
+
agents = []
|
|
236
|
+
for t in test_types:
|
|
237
|
+
t_lower = t.lower()
|
|
238
|
+
if t_lower in TEST_TYPE_MAP:
|
|
239
|
+
agents.extend(TEST_TYPE_MAP[t_lower])
|
|
240
|
+
else:
|
|
241
|
+
# Try direct agent name
|
|
242
|
+
from ..agents.base import get_agent
|
|
243
|
+
|
|
244
|
+
if get_agent(t):
|
|
245
|
+
agents.append(t)
|
|
246
|
+
return list(dict.fromkeys(agents)) # dedupe preserving order
|
|
247
|
+
|
|
248
|
+
def _build_summary(self, result: TestRunResult) -> dict:
|
|
249
|
+
"""Build a summary of the test run."""
|
|
250
|
+
by_status = {}
|
|
251
|
+
by_severity = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
252
|
+
|
|
253
|
+
for r in result.results:
|
|
254
|
+
status = r.status.value
|
|
255
|
+
by_status[status] = by_status.get(status, 0) + 1
|
|
256
|
+
for f in r.findings:
|
|
257
|
+
sev = f.severity.value
|
|
258
|
+
by_severity[sev] = by_severity.get(sev, 0) + 1
|
|
259
|
+
|
|
260
|
+
all_passed = by_status.get("failed", 0) == 0 and by_status.get("skipped", 0) == 0
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"total_agents": len(result.results),
|
|
264
|
+
"by_status": by_status,
|
|
265
|
+
"by_severity": by_severity,
|
|
266
|
+
"total_findings": result.total_findings,
|
|
267
|
+
"total_passed": result.total_passed,
|
|
268
|
+
"total_failed": result.total_failed,
|
|
269
|
+
"all_passed": all_passed,
|
|
270
|
+
"duration_ms": result.total_duration_ms,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def _save_results(self, result: TestRunResult) -> None:
|
|
274
|
+
"""Save test results to memory for historical tracking."""
|
|
275
|
+
try:
|
|
276
|
+
from .. import memory as mem
|
|
277
|
+
|
|
278
|
+
scan_data = {
|
|
279
|
+
"status": "done" if result.summary.get("all_passed") else "failed",
|
|
280
|
+
"duration_ms": result.total_duration_ms,
|
|
281
|
+
"finding_count": result.total_findings,
|
|
282
|
+
"passed": result.total_passed,
|
|
283
|
+
"failed": result.total_failed,
|
|
284
|
+
"agents": result.agents_run,
|
|
285
|
+
"summary": result.summary,
|
|
286
|
+
}
|
|
287
|
+
mem.save_scan_result("TestRunner", scan_data, self.root)
|
|
288
|
+
except Exception as e:
|
|
289
|
+
_log.warning("LiveTestRunner._save_results failed: %s", e)
|
|
290
|
+
|
|
291
|
+
async def _emit(self, event: str, data: dict) -> None:
|
|
292
|
+
"""Emit a live event for WebSocket streaming."""
|
|
293
|
+
if self._event_queue:
|
|
294
|
+
await self._event_queue.put({"event": event, "data": data, "ts": time.time()})
|
|
295
|
+
|
|
296
|
+
if self._event_callback:
|
|
297
|
+
try:
|
|
298
|
+
await self._event_callback(event, data)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
_log.debug("LiveTestRunner._emit failed: %s", e)
|
|
301
|
+
|
|
302
|
+
def set_event_callback(self, callback: Callable) -> None:
|
|
303
|
+
"""Set an async callback for live event streaming."""
|
|
304
|
+
self._event_callback = callback
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Live testing v2: browser pool, stress orchestration, screenshots, video recording."""
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Browser Pool — Manages browser instances for parallel testing.
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
- Pool of reusable browser contexts
|
|
6
|
+
- Automatic lifecycle management
|
|
7
|
+
- Resource limits (max browsers, max pages per browser)
|
|
8
|
+
- Health checks and auto-recovery
|
|
9
|
+
- Support for Chrome, Firefox, WebKit
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
_log = logging.getLogger("patchi.testing.browser_pool")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class BrowserInstance:
|
|
25
|
+
"""A single browser instance in the pool."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
browser_type: str # "chromium", "firefox", "webkit"
|
|
29
|
+
created_at: float
|
|
30
|
+
last_used: float
|
|
31
|
+
page_count: int = 0
|
|
32
|
+
healthy: bool = True
|
|
33
|
+
_browser: Any = None # Playwright browser
|
|
34
|
+
_context: Any = None # Browser context
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BrowserConfig:
|
|
39
|
+
"""Configuration for browser pool."""
|
|
40
|
+
|
|
41
|
+
max_browsers: int = 5
|
|
42
|
+
max_pages_per_browser: int = 10
|
|
43
|
+
browser_type: str = "chromium" # chromium, firefox, webkit
|
|
44
|
+
headless: bool = True
|
|
45
|
+
viewport: dict = field(default_factory=lambda: {"width": 1280, "height": 720})
|
|
46
|
+
default_timeout: int = 30000 # ms
|
|
47
|
+
launch_args: list[str] = field(
|
|
48
|
+
default_factory=lambda: [
|
|
49
|
+
"--no-sandbox",
|
|
50
|
+
"--disable-setuid-sandbox",
|
|
51
|
+
"--disable-dev-shm-usage",
|
|
52
|
+
"--disable-gpu",
|
|
53
|
+
]
|
|
54
|
+
)
|
|
55
|
+
# Stealth settings
|
|
56
|
+
stealth: bool = False
|
|
57
|
+
user_agent: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class BrowserPool:
|
|
61
|
+
"""
|
|
62
|
+
Manages a pool of browser instances for parallel test execution.
|
|
63
|
+
|
|
64
|
+
Usage:
|
|
65
|
+
pool = BrowserPool(config)
|
|
66
|
+
await pool.initialize()
|
|
67
|
+
|
|
68
|
+
# Get a page for testing
|
|
69
|
+
page = await pool.get_page()
|
|
70
|
+
try:
|
|
71
|
+
await page.goto("https://example.com")
|
|
72
|
+
# ... test actions
|
|
73
|
+
finally:
|
|
74
|
+
await pool.release_page(page)
|
|
75
|
+
|
|
76
|
+
await pool.shutdown()
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, config: BrowserConfig = None):
|
|
80
|
+
self.config = config or BrowserConfig()
|
|
81
|
+
self._browsers: dict[str, BrowserInstance] = {}
|
|
82
|
+
self._playwright = None
|
|
83
|
+
self._initialized = False
|
|
84
|
+
self._lock = asyncio.Lock()
|
|
85
|
+
self._stats = {
|
|
86
|
+
"total_created": 0,
|
|
87
|
+
"total_reused": 0,
|
|
88
|
+
"total_failed": 0,
|
|
89
|
+
"current_active": 0,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async def initialize(self):
|
|
93
|
+
"""Initialize the browser pool."""
|
|
94
|
+
if self._initialized:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
from playwright.async_api import async_playwright
|
|
99
|
+
|
|
100
|
+
self._playwright = await async_playwright().start()
|
|
101
|
+
self._initialized = True
|
|
102
|
+
_log.info(f"Browser pool initialized ({self.config.browser_type})")
|
|
103
|
+
except ImportError:
|
|
104
|
+
_log.error(
|
|
105
|
+
"Playwright not installed. "
|
|
106
|
+
"Install with: pip install playwright && playwright install"
|
|
107
|
+
)
|
|
108
|
+
raise
|
|
109
|
+
except Exception as e:
|
|
110
|
+
_log.error(f"Failed to initialize browser pool: {e}")
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
async def get_page(self) -> Any:
|
|
114
|
+
"""Get a browser page from the pool."""
|
|
115
|
+
async with self._lock:
|
|
116
|
+
# Try to reuse existing browser
|
|
117
|
+
for browser in self._browsers.values():
|
|
118
|
+
if browser.healthy and browser.page_count < self.config.max_pages_per_browser:
|
|
119
|
+
browser.page_count += 1
|
|
120
|
+
browser.last_used = time.time()
|
|
121
|
+
self._stats["total_reused"] += 1
|
|
122
|
+
self._stats["current_active"] += 1
|
|
123
|
+
return await browser._context.new_page()
|
|
124
|
+
|
|
125
|
+
# Create new browser if under limit
|
|
126
|
+
if len(self._browsers) < self.config.max_browsers:
|
|
127
|
+
return await self._create_browser_and_page()
|
|
128
|
+
|
|
129
|
+
# Wait for available browser
|
|
130
|
+
while True:
|
|
131
|
+
for browser in self._browsers.values():
|
|
132
|
+
if browser.healthy and browser.page_count < self.config.max_pages_per_browser:
|
|
133
|
+
browser.page_count += 1
|
|
134
|
+
browser.last_used = time.time()
|
|
135
|
+
self._stats["total_reused"] += 1
|
|
136
|
+
self._stats["current_active"] += 1
|
|
137
|
+
return await browser._context.new_page()
|
|
138
|
+
await asyncio.sleep(0.1)
|
|
139
|
+
|
|
140
|
+
async def _create_browser_and_page(self) -> Any:
|
|
141
|
+
"""Create a new browser instance and return a page."""
|
|
142
|
+
browser_type = getattr(self._playwright, self.config.browser_type)
|
|
143
|
+
|
|
144
|
+
# Launch browser
|
|
145
|
+
browser = await browser_type.launch(
|
|
146
|
+
headless=self.config.headless,
|
|
147
|
+
args=self.config.launch_args,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Create context
|
|
151
|
+
context = await browser.new_context(
|
|
152
|
+
viewport=self.config.viewport,
|
|
153
|
+
user_agent=self.config.user_agent,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Apply stealth if enabled
|
|
157
|
+
if self.config.stealth:
|
|
158
|
+
await self._apply_stealth(context)
|
|
159
|
+
|
|
160
|
+
# Create instance record
|
|
161
|
+
instance_id = f"{self.config.browser_type}-{len(self._browsers)}"
|
|
162
|
+
instance = BrowserInstance(
|
|
163
|
+
id=instance_id,
|
|
164
|
+
browser_type=self.config.browser_type,
|
|
165
|
+
created_at=time.time(),
|
|
166
|
+
last_used=time.time(),
|
|
167
|
+
page_count=1,
|
|
168
|
+
_browser=browser,
|
|
169
|
+
_context=context,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
self._browsers[instance_id] = instance
|
|
173
|
+
self._stats["total_created"] += 1
|
|
174
|
+
self._stats["current_active"] += 1
|
|
175
|
+
|
|
176
|
+
_log.debug(f"Created new browser: {instance_id}")
|
|
177
|
+
|
|
178
|
+
# Return first page
|
|
179
|
+
page = await context.new_page()
|
|
180
|
+
page.set_default_timeout(self.config.default_timeout)
|
|
181
|
+
return page
|
|
182
|
+
|
|
183
|
+
async def _apply_stealth(self, context: Any):
|
|
184
|
+
"""Apply stealth settings to avoid detection."""
|
|
185
|
+
await context.add_init_script("""
|
|
186
|
+
// Remove webdriver property
|
|
187
|
+
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
|
188
|
+
|
|
189
|
+
// Mock permissions
|
|
190
|
+
const originalQuery = window.navigator.permissions.query;
|
|
191
|
+
window.navigator.permissions.query = (parameters) => (
|
|
192
|
+
parameters.name === 'notifications' ?
|
|
193
|
+
Promise.resolve({ state: Notification.permission }) :
|
|
194
|
+
originalQuery(parameters)
|
|
195
|
+
);
|
|
196
|
+
""")
|
|
197
|
+
|
|
198
|
+
async def release_page(self, page: Any):
|
|
199
|
+
"""Release a page back to the pool."""
|
|
200
|
+
async with self._lock:
|
|
201
|
+
self._stats["current_active"] = max(0, self._stats["current_active"] - 1)
|
|
202
|
+
|
|
203
|
+
# Find which browser this page belongs to
|
|
204
|
+
for browser in self._browsers.values():
|
|
205
|
+
# This is a simplification - in reality we'd track page->browser mapping
|
|
206
|
+
if browser.page_count > 0:
|
|
207
|
+
browser.page_count -= 1
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
await page.close()
|
|
212
|
+
except Exception as e:
|
|
213
|
+
_log.debug(f"Error closing page: {e}")
|
|
214
|
+
|
|
215
|
+
async def get_browser_for_recording(self, video_dir: str | None = None) -> tuple[Any, BrowserInstance]:
|
|
216
|
+
"""Get a dedicated browser for video recording.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
video_dir: Directory to save video recordings.
|
|
220
|
+
Defaults to .patchi/evidence/video/ if not specified.
|
|
221
|
+
"""
|
|
222
|
+
import os
|
|
223
|
+
from pathlib import Path
|
|
224
|
+
|
|
225
|
+
async with self._lock:
|
|
226
|
+
# Resolve video directory
|
|
227
|
+
if video_dir is None:
|
|
228
|
+
video_dir = str(Path.cwd() / ".patchi" / "evidence" / "video")
|
|
229
|
+
os.makedirs(video_dir, exist_ok=True)
|
|
230
|
+
|
|
231
|
+
# Create a fresh browser for recording
|
|
232
|
+
browser_type = getattr(self._playwright, self.config.browser_type)
|
|
233
|
+
browser = await browser_type.launch(
|
|
234
|
+
headless=self.config.headless,
|
|
235
|
+
args=self.config.launch_args,
|
|
236
|
+
)
|
|
237
|
+
context = await browser.new_context(
|
|
238
|
+
viewport=self.config.viewport,
|
|
239
|
+
record_video_dir=video_dir,
|
|
240
|
+
record_video_size=self.config.viewport,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
instance_id = f"recording-{int(time.time())}"
|
|
244
|
+
instance = BrowserInstance(
|
|
245
|
+
id=instance_id,
|
|
246
|
+
browser_type=self.config.browser_type,
|
|
247
|
+
created_at=time.time(),
|
|
248
|
+
last_used=time.time(),
|
|
249
|
+
page_count=1,
|
|
250
|
+
_browser=browser,
|
|
251
|
+
_context=context,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
self._browsers[instance_id] = instance
|
|
255
|
+
return context, instance
|
|
256
|
+
|
|
257
|
+
async def health_check(self) -> dict:
|
|
258
|
+
"""Check health of all browsers in pool."""
|
|
259
|
+
healthy = 0
|
|
260
|
+
unhealthy = 0
|
|
261
|
+
|
|
262
|
+
for instance in list(self._browsers.values()):
|
|
263
|
+
try:
|
|
264
|
+
# Try to create a test page
|
|
265
|
+
page = await instance._context.new_page()
|
|
266
|
+
await page.close()
|
|
267
|
+
instance.healthy = True
|
|
268
|
+
healthy += 1
|
|
269
|
+
except Exception:
|
|
270
|
+
instance.healthy = False
|
|
271
|
+
unhealthy += 1
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
"total": len(self._browsers),
|
|
275
|
+
"healthy": healthy,
|
|
276
|
+
"unhealthy": unhealthy,
|
|
277
|
+
"stats": self._stats,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async def cleanup_idle(self, max_idle_seconds: int = 300):
|
|
281
|
+
"""Clean up idle browsers."""
|
|
282
|
+
async with self._lock:
|
|
283
|
+
now = time.time()
|
|
284
|
+
to_remove = []
|
|
285
|
+
|
|
286
|
+
for instance_id, instance in self._browsers.items():
|
|
287
|
+
if instance.page_count == 0 and (now - instance.last_used) > max_idle_seconds:
|
|
288
|
+
to_remove.append(instance_id)
|
|
289
|
+
|
|
290
|
+
for instance_id in to_remove:
|
|
291
|
+
instance = self._browsers.pop(instance_id)
|
|
292
|
+
try:
|
|
293
|
+
await instance._context.close()
|
|
294
|
+
await instance._browser.close()
|
|
295
|
+
except Exception as _exc:
|
|
296
|
+
_log.warning('cleanup_idle failed: %s', _exc)
|
|
297
|
+
_log.debug(f"Cleaned up idle browser: {instance_id}")
|
|
298
|
+
|
|
299
|
+
async def shutdown(self):
|
|
300
|
+
"""Shutdown all browsers and playwright."""
|
|
301
|
+
async with self._lock:
|
|
302
|
+
for instance in self._browsers.values():
|
|
303
|
+
try:
|
|
304
|
+
await instance._context.close()
|
|
305
|
+
await instance._browser.close()
|
|
306
|
+
except Exception as _exc:
|
|
307
|
+
_log.warning('shutdown failed: %s', _exc)
|
|
308
|
+
|
|
309
|
+
self._browsers.clear()
|
|
310
|
+
|
|
311
|
+
if self._playwright:
|
|
312
|
+
await self._playwright.stop()
|
|
313
|
+
self._playwright = None
|
|
314
|
+
|
|
315
|
+
self._initialized = False
|
|
316
|
+
_log.info("Browser pool shut down")
|
|
317
|
+
|
|
318
|
+
def get_stats(self) -> dict:
|
|
319
|
+
"""Get pool statistics."""
|
|
320
|
+
return {
|
|
321
|
+
**self._stats,
|
|
322
|
+
"browser_count": len(self._browsers),
|
|
323
|
+
"config": {
|
|
324
|
+
"max_browsers": self.config.max_browsers,
|
|
325
|
+
"max_pages_per_browser": self.config.max_pages_per_browser,
|
|
326
|
+
"browser_type": self.config.browser_type,
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# Global pool instance
|
|
332
|
+
_browser_pool: BrowserPool | None = None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def get_browser_pool(config: BrowserConfig = None) -> BrowserPool:
|
|
336
|
+
"""Get or create the global browser pool."""
|
|
337
|
+
global _browser_pool
|
|
338
|
+
if _browser_pool is None or not _browser_pool._initialized:
|
|
339
|
+
_browser_pool = BrowserPool(config)
|
|
340
|
+
await _browser_pool.initialize()
|
|
341
|
+
return _browser_pool
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
async def shutdown_browser_pool():
|
|
345
|
+
"""Shutdown the global browser pool."""
|
|
346
|
+
global _browser_pool
|
|
347
|
+
if _browser_pool:
|
|
348
|
+
await _browser_pool.shutdown()
|
|
349
|
+
_browser_pool = None
|