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,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AppLauncher — install + start + health + teardown for Playwright/attack.
|
|
3
|
+
|
|
4
|
+
Detect via FrameworkDetector + project_reader, install if plain, start on free port,
|
|
5
|
+
poll GET /health or / 200, log to .patchi/launcher/app.log, atexit teardown.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import socket
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from patchi.core.brain.file_corpus import FileCorpus
|
|
20
|
+
from patchi.core.brain.framework import FrameworkDetector
|
|
21
|
+
|
|
22
|
+
_log = logging.getLogger("patchi.testing.launcher")
|
|
23
|
+
|
|
24
|
+
_PROC: subprocess.Popen | None = None
|
|
25
|
+
_PORT: int | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _free_port() -> int:
|
|
29
|
+
s = socket.socket()
|
|
30
|
+
s.bind(("", 0))
|
|
31
|
+
_, port = s.getsockname()
|
|
32
|
+
s.close()
|
|
33
|
+
return port
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _port_free(port: int) -> bool:
|
|
37
|
+
s = socket.socket()
|
|
38
|
+
try:
|
|
39
|
+
s.bind(("127.0.0.1", port))
|
|
40
|
+
return True
|
|
41
|
+
except OSError:
|
|
42
|
+
return False
|
|
43
|
+
finally:
|
|
44
|
+
s.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _preferred_port(root: Path) -> int | None:
|
|
48
|
+
"""Configured port chain: .patchi/config.json → env PORT → package.json dev -p."""
|
|
49
|
+
try:
|
|
50
|
+
from patchi.core.testing._browser import read_web_port
|
|
51
|
+
|
|
52
|
+
p = read_web_port(root)
|
|
53
|
+
if p and 1 <= p <= 65535:
|
|
54
|
+
return p
|
|
55
|
+
except Exception as _exc:
|
|
56
|
+
_log.debug('suppressed: %s', _exc)
|
|
57
|
+
try:
|
|
58
|
+
env_p = int(os.environ.get("PORT", ""))
|
|
59
|
+
if 1 <= env_p <= 65535:
|
|
60
|
+
return env_p
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
import json
|
|
65
|
+
import re
|
|
66
|
+
|
|
67
|
+
pkg = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
68
|
+
dev = (pkg.get("scripts") or {}).get("dev", "")
|
|
69
|
+
m = re.search(r"(?:-p|--port)[= ](\d{2,5})", dev)
|
|
70
|
+
if m and 1 <= int(m.group(1)) <= 65535:
|
|
71
|
+
return int(m.group(1))
|
|
72
|
+
except Exception as _exc:
|
|
73
|
+
_log.debug('suppressed: %s', _exc)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _detect_start_cmd(root: Path) -> list[str] | None:
|
|
78
|
+
corpus = FileCorpus(root)
|
|
79
|
+
stack = FrameworkDetector(root, corpus=corpus).detect()
|
|
80
|
+
fws = {f.name.lower() for f in stack.frameworks}
|
|
81
|
+
# package.json scripts
|
|
82
|
+
if (root / "package.json").exists():
|
|
83
|
+
try:
|
|
84
|
+
import json
|
|
85
|
+
|
|
86
|
+
pkg = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
87
|
+
scripts = pkg.get("scripts", {})
|
|
88
|
+
if "dev" in scripts:
|
|
89
|
+
return ["npm", "run", "dev"]
|
|
90
|
+
if "start" in scripts:
|
|
91
|
+
return ["npm", "start"]
|
|
92
|
+
except Exception as _exc:
|
|
93
|
+
_log.debug('suppressed: %s', _exc)
|
|
94
|
+
if "fastapi" in fws:
|
|
95
|
+
# find app module
|
|
96
|
+
for cand in ["app.main:app", "main:app", "server:app"]:
|
|
97
|
+
return ["uvicorn", cand, "--host", "127.0.0.1", "--port", "{port}"]
|
|
98
|
+
if "flask" in fws:
|
|
99
|
+
return ["flask", "run", "--host=127.0.0.1", "--port={port}"]
|
|
100
|
+
if "django" in fws:
|
|
101
|
+
return ["python", "manage.py", "runserver", "127.0.0.1:{port}"]
|
|
102
|
+
if "next.js" in fws:
|
|
103
|
+
return ["npm", "run", "dev", "--", "-p", "{port}"]
|
|
104
|
+
if "go" in fws and shutil.which("go"):
|
|
105
|
+
return ["go", "run", "."]
|
|
106
|
+
if "rust" in fws and shutil.which("cargo"):
|
|
107
|
+
return ["cargo", "run"]
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ensure_running(root: Path, config: dict | None = None, extra: dict | None = None) -> str | None:
|
|
112
|
+
global _PROC, _PORT
|
|
113
|
+
from patchi.core.testing._browser import find_server
|
|
114
|
+
|
|
115
|
+
# Reuse if already running
|
|
116
|
+
base = find_server(root, config or {}, extra or {})
|
|
117
|
+
if base:
|
|
118
|
+
return base
|
|
119
|
+
|
|
120
|
+
if os.environ.get("PATCHI_LAUNCHER_DISABLE") == "1":
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# Side install check: if node_modules missing and package-lock exists, npm ci already via InstallAgent
|
|
124
|
+
cmd = _detect_start_cmd(root)
|
|
125
|
+
if not cmd or not cmd[0] or not shutil.which(cmd[0]):
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
preferred = _preferred_port(root)
|
|
129
|
+
port = preferred if preferred and _port_free(preferred) else _free_port()
|
|
130
|
+
# replace {port}
|
|
131
|
+
cmd = [str(c).format(port=port) for c in cmd]
|
|
132
|
+
env = os.environ.copy()
|
|
133
|
+
env["PORT"] = str(port)
|
|
134
|
+
env["HOST"] = "127.0.0.1"
|
|
135
|
+
|
|
136
|
+
log_dir = root / ".patchi" / "launcher"
|
|
137
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
log_path = log_dir / "app.log"
|
|
139
|
+
try:
|
|
140
|
+
_PROC = subprocess.Popen(cmd, cwd=str(root), env=env, stdout=log_path.open("w", encoding="utf-8"), stderr=subprocess.STDOUT)
|
|
141
|
+
_PORT = port
|
|
142
|
+
atexit.register(stop)
|
|
143
|
+
# poll health
|
|
144
|
+
import httpx
|
|
145
|
+
|
|
146
|
+
url = f"http://127.0.0.1:{port}"
|
|
147
|
+
for _ in range(15):
|
|
148
|
+
time.sleep(2)
|
|
149
|
+
if _PROC.poll() is not None:
|
|
150
|
+
_log.warning("launcher exited early code %s", _PROC.returncode)
|
|
151
|
+
return None
|
|
152
|
+
try:
|
|
153
|
+
resp = httpx.get(f"{url}/health", timeout=2, follow_redirects=True)
|
|
154
|
+
if resp.status_code < 500:
|
|
155
|
+
return url
|
|
156
|
+
except Exception as _exc:
|
|
157
|
+
_log.debug('suppressed: %s', _exc)
|
|
158
|
+
try:
|
|
159
|
+
resp = httpx.get(url, timeout=2)
|
|
160
|
+
if resp.status_code < 500:
|
|
161
|
+
return url
|
|
162
|
+
except Exception as _exc:
|
|
163
|
+
_log.debug('suppressed: %s', _exc)
|
|
164
|
+
# fallback: try anyway if process still alive
|
|
165
|
+
if _PROC.poll() is None:
|
|
166
|
+
return url
|
|
167
|
+
except Exception as exc: # noqa: BLE001
|
|
168
|
+
_log.warning("launcher failed %s: %s", cmd, exc)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def stop() -> None:
|
|
173
|
+
global _PROC, _PORT
|
|
174
|
+
if _PROC and _PROC.poll() is None:
|
|
175
|
+
try:
|
|
176
|
+
_PROC.terminate()
|
|
177
|
+
_PROC.wait(timeout=5)
|
|
178
|
+
except Exception:
|
|
179
|
+
try:
|
|
180
|
+
_PROC.kill()
|
|
181
|
+
except Exception as _exc:
|
|
182
|
+
_log.debug('suppressed: %s', _exc)
|
|
183
|
+
_PROC = None
|
|
184
|
+
_PORT = None
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BrowserTestAgent — Playwright tests for critical flows.
|
|
3
|
+
|
|
4
|
+
Runs Playwright tests for:
|
|
5
|
+
- Login flows
|
|
6
|
+
- Checkout processes
|
|
7
|
+
- Form submissions
|
|
8
|
+
- API calls through the UI
|
|
9
|
+
- Error states
|
|
10
|
+
- Navigation paths
|
|
11
|
+
- Responsive design checks
|
|
12
|
+
|
|
13
|
+
Can use existing Playwright tests or generate new ones based on the app contract.
|
|
14
|
+
Captures screenshots on failures and reports console/network errors.
|
|
15
|
+
|
|
16
|
+
Does NOT write to disk (unless creating new tests).
|
|
17
|
+
May call AI for generating new test scripts.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from ..agents.base import (
|
|
29
|
+
AgentDomain,
|
|
30
|
+
AgentGroup,
|
|
31
|
+
AgentInput,
|
|
32
|
+
AgentResult,
|
|
33
|
+
AgentStatus,
|
|
34
|
+
BaseAgent,
|
|
35
|
+
Severity,
|
|
36
|
+
make_finding,
|
|
37
|
+
register,
|
|
38
|
+
)
|
|
39
|
+
from ..ai.client import call_ai
|
|
40
|
+
from ..ai.prompts import Skill, build_prompt, get_system_prompt
|
|
41
|
+
|
|
42
|
+
_log = logging.getLogger("patchi.testing.browser_test_agent")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@register
|
|
46
|
+
class BrowserTestAgent(BaseAgent):
|
|
47
|
+
"""Agent for running browser tests with Playwright."""
|
|
48
|
+
|
|
49
|
+
group = AgentGroup.TEST
|
|
50
|
+
domain = AgentDomain.TESTING
|
|
51
|
+
name = "BrowserTestAgent"
|
|
52
|
+
description = "Playwright tests for critical flows: login, checkout, forms, navigation"
|
|
53
|
+
|
|
54
|
+
def _run(self, inp: AgentInput, result: AgentResult) -> None:
|
|
55
|
+
"""Run Playwright tests for critical user flows."""
|
|
56
|
+
start_time = time.time()
|
|
57
|
+
findings = []
|
|
58
|
+
|
|
59
|
+
# Check if Playwright is available
|
|
60
|
+
try:
|
|
61
|
+
import playwright # noqa: F401 — used to check availability
|
|
62
|
+
except ImportError:
|
|
63
|
+
findings.append(
|
|
64
|
+
make_finding(
|
|
65
|
+
severity=Severity.INFO,
|
|
66
|
+
file="__playwright__",
|
|
67
|
+
line_start=0,
|
|
68
|
+
title="Playwright not installed",
|
|
69
|
+
description="Playwright is required for browser tests. Install with: pip install playwright",
|
|
70
|
+
evidence="Playwright not found in environment",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
result.status = AgentStatus.SKIPPED
|
|
74
|
+
result.findings = findings
|
|
75
|
+
result.data.update({"playwright_available": False, "needs_ai": False})
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# ── Live probe against a running app (opt-in) ─────────────────────────
|
|
79
|
+
# Real navigation through the shared browser pool: captures console
|
|
80
|
+
# errors, page crashes, 5xx responses and screenshots of failures.
|
|
81
|
+
# Opt-in via extra["live_probe"]=True (web Live Tests tab sets it) so
|
|
82
|
+
# plain test runs never launch a browser implicitly.
|
|
83
|
+
base_url = (inp.extra or {}).get("base_url")
|
|
84
|
+
wants_probe = bool((inp.extra or {}).get("live_probe"))
|
|
85
|
+
if base_url and wants_probe:
|
|
86
|
+
probe = self._live_probe(inp, base_url)
|
|
87
|
+
if probe:
|
|
88
|
+
result.data["live_probe"] = probe
|
|
89
|
+
for pf in probe.get("findings", []):
|
|
90
|
+
findings.append(pf)
|
|
91
|
+
|
|
92
|
+
# Check for existing Playwright tests
|
|
93
|
+
existing_tests = self._find_existing_playwright_tests(inp.root)
|
|
94
|
+
|
|
95
|
+
if existing_tests:
|
|
96
|
+
# Run existing tests
|
|
97
|
+
test_results = self._run_existing_tests(existing_tests, inp.root)
|
|
98
|
+
else:
|
|
99
|
+
# Check if we have an app contract to generate tests
|
|
100
|
+
app_contract = self._get_app_contract(inp)
|
|
101
|
+
if app_contract:
|
|
102
|
+
# Generate new tests based on app contract
|
|
103
|
+
generated_tests = self._generate_tests_from_contract(app_contract, inp.root)
|
|
104
|
+
test_results = self._run_generated_tests(generated_tests, inp.root)
|
|
105
|
+
else:
|
|
106
|
+
findings.append(
|
|
107
|
+
make_finding(
|
|
108
|
+
severity=Severity.INFO,
|
|
109
|
+
file="__browser_tests__",
|
|
110
|
+
line_start=0,
|
|
111
|
+
title="No browser tests available",
|
|
112
|
+
description="No existing Playwright tests found and no app contract to generate tests",
|
|
113
|
+
evidence="Need either existing tests or confirmed app contract",
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
result.status = AgentStatus.SKIPPED
|
|
117
|
+
result.findings = findings
|
|
118
|
+
result.data.update({"tests_run": 0, "needs_ai": True})
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
# Process test results
|
|
122
|
+
if test_results["success"]:
|
|
123
|
+
findings.append(
|
|
124
|
+
make_finding(
|
|
125
|
+
severity=Severity.INFO,
|
|
126
|
+
file="__browser_summary__",
|
|
127
|
+
line_start=0,
|
|
128
|
+
title=f"Browser Tests Passed: {test_results['passed']}/{test_results['total']}",
|
|
129
|
+
description=f"Browser tests passed: {test_results['passed']}/{test_results['total']} tests",
|
|
130
|
+
evidence=f"Passed: {test_results['passed']}, Failed: {test_results['failed']}",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
findings.append(
|
|
135
|
+
make_finding(
|
|
136
|
+
severity=Severity.HIGH,
|
|
137
|
+
file="__browser_summary__",
|
|
138
|
+
line_start=0,
|
|
139
|
+
title=f"Browser Tests Failed: {test_results['failed']}/{test_results['total']}",
|
|
140
|
+
description=f"Browser tests failed: {test_results['failed']}/{test_results['total']} tests",
|
|
141
|
+
evidence=f"Passed: {test_results['passed']}, Failed: {test_results['failed']}",
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Add individual test results
|
|
146
|
+
for test in test_results.get("test_details", []):
|
|
147
|
+
if test.get("status") == "FAILED":
|
|
148
|
+
findings.append(
|
|
149
|
+
make_finding(
|
|
150
|
+
severity=Severity.HIGH,
|
|
151
|
+
file=test.get("flow", "__unknown__"),
|
|
152
|
+
line_start=0,
|
|
153
|
+
title=f"Browser Test Failure: {test['name']}",
|
|
154
|
+
description=test.get("error", "Unknown error"),
|
|
155
|
+
evidence=f"Flow: {test['name']}\nError: {test.get('error', 'N/A')}\nScreenshot: {test.get('screenshot', 'N/A')}",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
elif test.get("status") == "PASSED":
|
|
159
|
+
findings.append(
|
|
160
|
+
make_finding(
|
|
161
|
+
severity=Severity.INFO,
|
|
162
|
+
file=test.get("flow", "__unknown__"),
|
|
163
|
+
line_start=0,
|
|
164
|
+
title=f"Browser Test Passed: {test['name']}",
|
|
165
|
+
description="Browser test passed successfully",
|
|
166
|
+
evidence=f"Flow: {test['name']}",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
duration = time.time() - start_time
|
|
171
|
+
|
|
172
|
+
result.status = AgentStatus.SUCCEEDED if test_results["success"] else AgentStatus.FAILED
|
|
173
|
+
result.findings = findings
|
|
174
|
+
result.data.update(
|
|
175
|
+
{
|
|
176
|
+
"total_tests": test_results["total"],
|
|
177
|
+
"passed_tests": test_results["passed"],
|
|
178
|
+
"failed_tests": test_results["failed"],
|
|
179
|
+
"duration": round(duration, 2),
|
|
180
|
+
"needs_ai": True, # Generating new tests requires AI
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
# ── Live probe (shared browser pool) ──────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def _live_probe(self, inp: AgentInput, base_url: str) -> dict | None:
|
|
188
|
+
"""Navigate the running app with a pooled browser.
|
|
189
|
+
|
|
190
|
+
Collects per page: console errors, uncaught page errors, HTTP >= 500
|
|
191
|
+
responses, and a screenshot whenever navigation or rendering fails.
|
|
192
|
+
Findings are capped so one noisy app can't flood the report.
|
|
193
|
+
"""
|
|
194
|
+
import asyncio
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
return asyncio.run(self._probe_async(inp, base_url))
|
|
198
|
+
except Exception as e:
|
|
199
|
+
_log.warning("BrowserTestAgent live probe failed: %s", e)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
async def _probe_async(self, inp: AgentInput, base_url: str) -> dict:
|
|
203
|
+
from .live_v2.browser_pool import get_browser_pool
|
|
204
|
+
|
|
205
|
+
pool = await get_browser_pool()
|
|
206
|
+
page = await pool.get_page()
|
|
207
|
+
|
|
208
|
+
console_errors: list[dict] = []
|
|
209
|
+
http_errors: list[dict] = []
|
|
210
|
+
nav_failures: list[dict] = []
|
|
211
|
+
screenshots: list[str] = []
|
|
212
|
+
|
|
213
|
+
artifacts = inp.root / ".patchi" / "artifacts" / "browser"
|
|
214
|
+
artifacts.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
|
|
216
|
+
def _on_console(msg):
|
|
217
|
+
if msg.type == "error":
|
|
218
|
+
console_errors.append({"page": page.url, "text": msg.text[:200]})
|
|
219
|
+
|
|
220
|
+
def _on_pageerror(err):
|
|
221
|
+
console_errors.append({"page": page.url, "text": f"pageerror: {err}"[:200]})
|
|
222
|
+
|
|
223
|
+
def _on_response(resp):
|
|
224
|
+
if resp.status >= 500:
|
|
225
|
+
http_errors.append({"page": page.url, "status": resp.status, "url": resp.url[:200]})
|
|
226
|
+
|
|
227
|
+
page.on("console", _on_console)
|
|
228
|
+
page.on("pageerror", _on_pageerror)
|
|
229
|
+
page.on("response", _on_response)
|
|
230
|
+
|
|
231
|
+
urls = [base_url]
|
|
232
|
+
for route in (inp.brain or {}).get("routes", [])[:6]:
|
|
233
|
+
path = route.get("path") if isinstance(route, dict) else getattr(route, "path", "")
|
|
234
|
+
method = (
|
|
235
|
+
route.get("method") if isinstance(route, dict) else getattr(route, "method", "get")
|
|
236
|
+
) or "get"
|
|
237
|
+
if (
|
|
238
|
+
str(method).lower() in ("get", "")
|
|
239
|
+
and path
|
|
240
|
+
and not path.startswith(("api/", "/api"))
|
|
241
|
+
):
|
|
242
|
+
urls.append(base_url.rstrip("/") + ("/" + path.lstrip("/")))
|
|
243
|
+
seen = set()
|
|
244
|
+
urls = [u for u in urls if not (u in seen or seen.add(u))][:5]
|
|
245
|
+
|
|
246
|
+
findings_payload: list[dict] = []
|
|
247
|
+
try:
|
|
248
|
+
for url in urls:
|
|
249
|
+
try:
|
|
250
|
+
await page.goto(url, wait_until="networkidle", timeout=15000)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
slug = "nav-failure"
|
|
253
|
+
shot = artifacts / f"{slug}-{len(screenshots)}.png"
|
|
254
|
+
try:
|
|
255
|
+
await page.screenshot(path=str(shot), full_page=False)
|
|
256
|
+
screenshots.append(shot.name)
|
|
257
|
+
except Exception as _exc:
|
|
258
|
+
_log.warning('_probe_async failed: %s', _exc)
|
|
259
|
+
nav_failures.append({"url": url, "error": str(e)[:160]})
|
|
260
|
+
continue
|
|
261
|
+
finally:
|
|
262
|
+
await pool.release_page(page)
|
|
263
|
+
|
|
264
|
+
# Cap noise, convert to finding payloads (agent-level severity mapping)
|
|
265
|
+
for ce in console_errors[:10]:
|
|
266
|
+
findings_payload.append(
|
|
267
|
+
{
|
|
268
|
+
"kind": "console_error",
|
|
269
|
+
"severity": "medium",
|
|
270
|
+
"page": ce["page"],
|
|
271
|
+
"detail": ce["text"],
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
for he in http_errors[:10]:
|
|
275
|
+
findings_payload.append(
|
|
276
|
+
{
|
|
277
|
+
"kind": "http_5xx",
|
|
278
|
+
"severity": "high",
|
|
279
|
+
"page": he.get("page", ""),
|
|
280
|
+
"detail": f"{he['status']} on {he['url']}",
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
for nf in nav_failures:
|
|
284
|
+
findings_payload.append(
|
|
285
|
+
{
|
|
286
|
+
"kind": "navigation_failure",
|
|
287
|
+
"severity": "high",
|
|
288
|
+
"page": nf["url"],
|
|
289
|
+
"detail": nf["error"],
|
|
290
|
+
}
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
"base_url": base_url,
|
|
295
|
+
"pages_probed": len(urls),
|
|
296
|
+
"console_errors": len(console_errors),
|
|
297
|
+
"http_errors": len(http_errors),
|
|
298
|
+
"nav_failures": len(nav_failures),
|
|
299
|
+
"screenshots": screenshots,
|
|
300
|
+
"findings": findings_payload,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
def _find_existing_playwright_tests(self, root: Path) -> list[Path]:
|
|
304
|
+
"""Find existing Playwright test files."""
|
|
305
|
+
test_files = []
|
|
306
|
+
|
|
307
|
+
# Look for common Playwright test locations
|
|
308
|
+
playwright_patterns = [
|
|
309
|
+
"tests/e2e/**/*.py",
|
|
310
|
+
"tests/e2e/**/*.js",
|
|
311
|
+
"tests/e2e/**/*.ts",
|
|
312
|
+
"e2e/**/*.py",
|
|
313
|
+
"e2e/**/*.js",
|
|
314
|
+
"e2e/**/*.ts",
|
|
315
|
+
"tests/playwright/**/*.py",
|
|
316
|
+
"tests/playwright/**/*.js",
|
|
317
|
+
"tests/playwright/**/*.ts",
|
|
318
|
+
"**/playwright*.js",
|
|
319
|
+
"**/playwright*.ts",
|
|
320
|
+
"**/playwright*.py",
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
for pattern in playwright_patterns:
|
|
324
|
+
for test_file in root.rglob(pattern):
|
|
325
|
+
if test_file.is_file():
|
|
326
|
+
test_files.append(test_file)
|
|
327
|
+
|
|
328
|
+
return test_files
|
|
329
|
+
|
|
330
|
+
def _get_app_contract(self, inp: AgentInput) -> list[dict] | None:
|
|
331
|
+
"""Get the app contract with critical flows."""
|
|
332
|
+
try:
|
|
333
|
+
from .. import memory as mem
|
|
334
|
+
|
|
335
|
+
brain = mem.get_brain(inp.root)
|
|
336
|
+
if brain and "confirmed_flows" in brain:
|
|
337
|
+
return brain["confirmed_flows"]
|
|
338
|
+
return None
|
|
339
|
+
except Exception as e:
|
|
340
|
+
_log.warning("BrowserTestAgent._get_app_contract failed: %s", e)
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
def _generate_tests_from_contract(self, app_contract: list[dict], root: Path) -> list[Path]:
|
|
344
|
+
"""Generate Playwright tests based on the app contract using AI."""
|
|
345
|
+
generated: list[Path] = []
|
|
346
|
+
from .. import config as cfg
|
|
347
|
+
|
|
348
|
+
config = cfg.load(root)
|
|
349
|
+
if not config:
|
|
350
|
+
return generated
|
|
351
|
+
|
|
352
|
+
flows_text = "\n".join(
|
|
353
|
+
f"- {f.get('name', '?')}: {f.get('description', '')}" for f in app_contract
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
system = get_system_prompt(Skill.TEST_GENERATE)
|
|
357
|
+
user_prompt = build_prompt(
|
|
358
|
+
Skill.TEST_GENERATE,
|
|
359
|
+
{
|
|
360
|
+
"file_path": "browser/critical_flows.py",
|
|
361
|
+
"file_content": f"# App Contract Critical Flows\n{flows_text}",
|
|
362
|
+
"existing_tests_section": "Generate Playwright (Python) end-to-end tests for each critical flow.",
|
|
363
|
+
"language": "python",
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
response = call_ai(config, system, user_prompt, max_tokens=4000)
|
|
368
|
+
if not response:
|
|
369
|
+
return generated
|
|
370
|
+
|
|
371
|
+
import re
|
|
372
|
+
|
|
373
|
+
blocks = re.findall(r"```(?:\w+)?\n(.*?)```", response, re.DOTALL)
|
|
374
|
+
out_dir = root / ".patchi" / "tests" / "browser"
|
|
375
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
|
|
377
|
+
for i, block in enumerate(blocks):
|
|
378
|
+
test_path = out_dir / f"generated_flow_{i}.py"
|
|
379
|
+
test_path.write_text(block, encoding="utf-8")
|
|
380
|
+
generated.append(test_path)
|
|
381
|
+
|
|
382
|
+
return generated
|
|
383
|
+
|
|
384
|
+
def _run_existing_tests(self, test_files: list[Path], root: Path) -> dict:
|
|
385
|
+
"""Run existing Playwright tests."""
|
|
386
|
+
if not test_files:
|
|
387
|
+
return {
|
|
388
|
+
"success": True,
|
|
389
|
+
"total": 0,
|
|
390
|
+
"passed": 0,
|
|
391
|
+
"failed": 0,
|
|
392
|
+
"test_details": [],
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
# Try to run with pytest-playwright
|
|
397
|
+
cmd = (
|
|
398
|
+
[sys.executable, "-m", "pytest"]
|
|
399
|
+
+ [str(tf.relative_to(root)) for tf in test_files]
|
|
400
|
+
+ ["-v"]
|
|
401
|
+
)
|
|
402
|
+
result = subprocess.run(
|
|
403
|
+
cmd, cwd=root, capture_output=True, text=True, timeout=300
|
|
404
|
+
) # 5 min timeout
|
|
405
|
+
|
|
406
|
+
if result.returncode in [0, 1]: # 0 = all passed, 1 = some failed
|
|
407
|
+
return self._parse_pytest_playwright_output(result.stdout + result.stderr)
|
|
408
|
+
else:
|
|
409
|
+
return {
|
|
410
|
+
"success": False,
|
|
411
|
+
"error": f"Test execution failed: {result.stderr}",
|
|
412
|
+
"total": 0,
|
|
413
|
+
"passed": 0,
|
|
414
|
+
"failed": 0,
|
|
415
|
+
"test_details": [],
|
|
416
|
+
}
|
|
417
|
+
except subprocess.TimeoutExpired:
|
|
418
|
+
return {
|
|
419
|
+
"success": False,
|
|
420
|
+
"error": "Browser tests timed out after 300 seconds",
|
|
421
|
+
"total": 0,
|
|
422
|
+
"passed": 0,
|
|
423
|
+
"failed": 0,
|
|
424
|
+
"test_details": [],
|
|
425
|
+
}
|
|
426
|
+
except Exception as e:
|
|
427
|
+
return {
|
|
428
|
+
"success": False,
|
|
429
|
+
"error": f"Error running existing tests: {str(e)}",
|
|
430
|
+
"total": 0,
|
|
431
|
+
"passed": 0,
|
|
432
|
+
"failed": 0,
|
|
433
|
+
"test_details": [],
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
def _run_generated_tests(self, test_files: list[Path], root: Path) -> dict:
|
|
437
|
+
"""Run generated Playwright tests."""
|
|
438
|
+
if not test_files:
|
|
439
|
+
return {"success": True, "total": 0, "passed": 0, "failed": 0, "test_details": []}
|
|
440
|
+
try:
|
|
441
|
+
import subprocess
|
|
442
|
+
import sys
|
|
443
|
+
|
|
444
|
+
targets = [str(tf.relative_to(root)) for tf in test_files]
|
|
445
|
+
cmd = [sys.executable, "-m", "pytest"] + targets + ["-v", "--tb=short"]
|
|
446
|
+
proc = subprocess.run(cmd, cwd=root, capture_output=True, text=True, timeout=300)
|
|
447
|
+
output = f"{proc.stdout}\n{proc.stderr}"
|
|
448
|
+
passed = failed = 0
|
|
449
|
+
for line in output.splitlines():
|
|
450
|
+
if "PASSED" in line:
|
|
451
|
+
passed += 1
|
|
452
|
+
elif "FAILED" in line:
|
|
453
|
+
failed += 1
|
|
454
|
+
return {
|
|
455
|
+
"success": proc.returncode == 0,
|
|
456
|
+
"total": passed + failed,
|
|
457
|
+
"passed": passed,
|
|
458
|
+
"failed": failed,
|
|
459
|
+
"test_details": [],
|
|
460
|
+
}
|
|
461
|
+
except subprocess.TimeoutExpired:
|
|
462
|
+
return {"success": False, "total": 0, "passed": 0, "failed": 0, "error": "Timeout"}
|
|
463
|
+
except Exception as e:
|
|
464
|
+
return {"success": False, "total": 0, "passed": 0, "failed": 0, "error": str(e)}
|
|
465
|
+
|
|
466
|
+
def _parse_pytest_playwright_output(self, output: str) -> dict:
|
|
467
|
+
"""Parse pytest output with Playwright tests."""
|
|
468
|
+
lines = output.split("\n")
|
|
469
|
+
total = 0
|
|
470
|
+
passed = 0
|
|
471
|
+
failed = 0
|
|
472
|
+
test_details = []
|
|
473
|
+
|
|
474
|
+
current_test = None
|
|
475
|
+
in_failure_section = False
|
|
476
|
+
|
|
477
|
+
for line in lines:
|
|
478
|
+
if "PASSED" in line and "::" in line:
|
|
479
|
+
# Example: "test_login.py::test_valid_login PASSED"
|
|
480
|
+
test_name = line.split()[0]
|
|
481
|
+
passed += 1
|
|
482
|
+
test_details.append({"name": test_name, "status": "PASSED", "flow": test_name})
|
|
483
|
+
elif "FAILED" in line and "::" in line:
|
|
484
|
+
# Example: "test_login.py::test_invalid_login FAILED"
|
|
485
|
+
test_name = line.split()[0]
|
|
486
|
+
failed += 1
|
|
487
|
+
in_failure_section = True
|
|
488
|
+
current_test = test_name
|
|
489
|
+
test_details.append(
|
|
490
|
+
{
|
|
491
|
+
"name": test_name,
|
|
492
|
+
"status": "FAILED",
|
|
493
|
+
"flow": test_name,
|
|
494
|
+
"error": "Test failed",
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
elif in_failure_section and line.strip().startswith("_"):
|
|
498
|
+
in_failure_section = False
|
|
499
|
+
elif in_failure_section and current_test:
|
|
500
|
+
# Capture error details
|
|
501
|
+
for detail in test_details:
|
|
502
|
+
if detail["name"] == current_test and detail["status"] == "FAILED":
|
|
503
|
+
if "error" in detail:
|
|
504
|
+
detail["error"] += f"\n{line.strip()}"
|
|
505
|
+
else:
|
|
506
|
+
detail["error"] = line.strip()
|
|
507
|
+
|
|
508
|
+
total = passed + failed
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
"success": failed == 0,
|
|
512
|
+
"total": total or 1, # Default to 1 if parsing failed
|
|
513
|
+
"passed": passed,
|
|
514
|
+
"failed": failed,
|
|
515
|
+
"test_details": test_details,
|
|
516
|
+
}
|