cct-cli 0.7.9.0__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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""CAT browser/ — the REAL browser engine: Chromium via Playwright.
|
|
2
|
+
|
|
3
|
+
A dedicated worker thread owns the Playwright sync API (its only
|
|
4
|
+
supported mode outside asyncio). Every public call marshals a job onto
|
|
5
|
+
that thread and waits for the result, so callers (UI workers) never
|
|
6
|
+
touch Playwright from the wrong thread and never block the UI.
|
|
7
|
+
|
|
8
|
+
What runs is genuine Chromium: HTML, CSS, JavaScript, animations,
|
|
9
|
+
responsive layout, images, SVG, fonts, fetch, WebSockets — everything
|
|
10
|
+
the real engine supports. The terminal pane renders a structured
|
|
11
|
+
snapshot of the LIVE DOM (see dom_outline) plus console/errors, which
|
|
12
|
+
keeps CAT terminal-first while the actual rendering work happens in
|
|
13
|
+
Chromium. The architecture keeps a screenshot hook so a graphical
|
|
14
|
+
embedded surface can be added later without touching callers.
|
|
15
|
+
|
|
16
|
+
If playwright/chromium isn't installed, start() raises
|
|
17
|
+
BrowserUnavailableError with the exact fix; nothing else in CAT breaks.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import queue as _queue
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Callable, List, Optional
|
|
27
|
+
|
|
28
|
+
from .state import PreviewSnapshot
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from playwright.sync_api import sync_playwright # noqa: F401
|
|
32
|
+
PLAYWRIGHT_AVAILABLE = True
|
|
33
|
+
except Exception:
|
|
34
|
+
PLAYWRIGHT_AVAILABLE = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BrowserUnavailableError(RuntimeError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_OUTLINE_JS = """
|
|
42
|
+
() => {
|
|
43
|
+
const out = [];
|
|
44
|
+
const push = (tag, cls, text, extra) => {
|
|
45
|
+
text = (text || '').replace(/\\s+/g, ' ').trim();
|
|
46
|
+
if (!text && !extra) return;
|
|
47
|
+
out.push({t: tag, c: cls || '', x: text.slice(0, 160), e: extra || ''});
|
|
48
|
+
};
|
|
49
|
+
document.querySelectorAll(
|
|
50
|
+
'h1,h2,h3,h4,p,li,a,button,input,textarea,select,img,section,nav,footer,header'
|
|
51
|
+
).forEach(el => {
|
|
52
|
+
if (el.offsetParent === null && el.tagName !== 'INPUT') {
|
|
53
|
+
// skip hidden nodes but keep form fields (they have no offsetParent)
|
|
54
|
+
const st = getComputedStyle(el);
|
|
55
|
+
if (st.display === 'none' || st.visibility === 'hidden') return;
|
|
56
|
+
}
|
|
57
|
+
const tag = el.tagName.toLowerCase();
|
|
58
|
+
if (tag === 'img') {
|
|
59
|
+
const ok = el.complete && el.naturalWidth > 0;
|
|
60
|
+
push('img', '', el.alt || el.src.split('/').pop(), ok ? '' : 'BROKEN');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
64
|
+
push(tag, el.type || '', el.placeholder ||
|
|
65
|
+
el.getAttribute('aria-label') || tag.toUpperCase(), '');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let text = '';
|
|
69
|
+
if (tag === 'a' || tag === 'button') text = el.textContent;
|
|
70
|
+
else if (tag === 'li') text = el.textContent;
|
|
71
|
+
else text = el.textContent;
|
|
72
|
+
push(tag, el.className && String(el.className).slice(0, 40),
|
|
73
|
+
text,
|
|
74
|
+
tag === 'a' ? (el.getAttribute('href') || '').slice(0, 80) : '');
|
|
75
|
+
});
|
|
76
|
+
return out.slice(0, 400);
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
_COUNTS_JS = """
|
|
81
|
+
() => ({
|
|
82
|
+
elements: document.querySelectorAll('*').length,
|
|
83
|
+
imagesLoaded: Array.from(document.images)
|
|
84
|
+
.filter(i => i.complete && i.naturalWidth > 0).length,
|
|
85
|
+
imagesBroken: Array.from(document.images)
|
|
86
|
+
.filter(i => i.complete && i.naturalWidth === 0).length,
|
|
87
|
+
})
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class _Job:
|
|
93
|
+
fn: Callable
|
|
94
|
+
result: list = field(default_factory=list)
|
|
95
|
+
done: threading.Event = field(default_factory=threading.Event)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class BrowserEngine:
|
|
99
|
+
"""Chromium worker. All calls are synchronous from the caller's
|
|
100
|
+
perspective and executed on the engine's own thread."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, headless: bool = True):
|
|
103
|
+
self._headless = headless
|
|
104
|
+
self._queue: "_queue.Queue[Optional[_Job]]" = _queue.Queue()
|
|
105
|
+
self._thread: Optional[threading.Thread] = None
|
|
106
|
+
self._ready = threading.Event()
|
|
107
|
+
self._stop = False
|
|
108
|
+
self._pw = None
|
|
109
|
+
self._browser = None
|
|
110
|
+
self._context = None
|
|
111
|
+
self._page = None
|
|
112
|
+
self.console: List[str] = []
|
|
113
|
+
self.js_errors: List[str] = []
|
|
114
|
+
self.last_status: Optional[int] = None
|
|
115
|
+
self._nav_error: Optional[str] = None
|
|
116
|
+
self.viewport = {"width": 1024, "height": 700}
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------ jobs --
|
|
119
|
+
def _submit(self, fn, timeout=30.0):
|
|
120
|
+
if self._thread is None or not self._thread.is_alive():
|
|
121
|
+
raise RuntimeError("engine not started")
|
|
122
|
+
job = _Job(fn=fn)
|
|
123
|
+
self._queue.put(job)
|
|
124
|
+
if not job.done.wait(timeout):
|
|
125
|
+
raise TimeoutError("browser engine did not respond")
|
|
126
|
+
if job.result and isinstance(job.result[0], Exception):
|
|
127
|
+
raise job.result[0]
|
|
128
|
+
return job.result[0] if job.result else None
|
|
129
|
+
|
|
130
|
+
def _loop(self):
|
|
131
|
+
try:
|
|
132
|
+
from playwright.sync_api import sync_playwright
|
|
133
|
+
self._pw = sync_playwright().start()
|
|
134
|
+
self._browser = self._pw.chromium.launch(headless=self._headless)
|
|
135
|
+
self._context = self._browser.new_context(viewport=self.viewport)
|
|
136
|
+
self._page = self._context.new_page()
|
|
137
|
+
page = self._page
|
|
138
|
+
|
|
139
|
+
def _on_console(msg):
|
|
140
|
+
kind = msg.type
|
|
141
|
+
if kind in ("error", "warning"):
|
|
142
|
+
line = f"[{kind}] {msg.text}"
|
|
143
|
+
self.console.append(line)
|
|
144
|
+
del self.console[:-100:]
|
|
145
|
+
elif kind == "log" and len(self.console) < 200:
|
|
146
|
+
self.console.append(f"[log] {msg.text}")
|
|
147
|
+
|
|
148
|
+
def _on_pageerror(err):
|
|
149
|
+
line = f"ReferenceError-class: {err}" \
|
|
150
|
+
if "ReferenceError" in str(err) else str(err)
|
|
151
|
+
self.js_errors.append(str(err))
|
|
152
|
+
del self.js_errors[:-50:]
|
|
153
|
+
self.console.append(f"[pageerror] {line}")
|
|
154
|
+
del self.console[:-100:]
|
|
155
|
+
|
|
156
|
+
def _on_response(resp):
|
|
157
|
+
try:
|
|
158
|
+
if resp.url == page.url:
|
|
159
|
+
self.last_status = resp.status
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
page.on("console", _on_console)
|
|
164
|
+
page.on("pageerror", _on_pageerror)
|
|
165
|
+
page.on("response", _on_response)
|
|
166
|
+
except BrowserUnavailableError:
|
|
167
|
+
raise
|
|
168
|
+
except Exception as e:
|
|
169
|
+
self._launch_error = e
|
|
170
|
+
finally:
|
|
171
|
+
self._ready.set()
|
|
172
|
+
while not self._stop:
|
|
173
|
+
try:
|
|
174
|
+
job = self._queue.get(timeout=0.25)
|
|
175
|
+
except _queue.Empty:
|
|
176
|
+
continue
|
|
177
|
+
if job is None:
|
|
178
|
+
break
|
|
179
|
+
try:
|
|
180
|
+
res = job.fn()
|
|
181
|
+
job.result.append(res)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
job.result.append(e)
|
|
184
|
+
finally:
|
|
185
|
+
job.done.set()
|
|
186
|
+
|
|
187
|
+
# ----------------------------------------------------------- start --
|
|
188
|
+
def start(self, timeout=45.0):
|
|
189
|
+
"""Launch Chromium on the engine thread. Raises
|
|
190
|
+
BrowserUnavailableError when the stack isn't installed."""
|
|
191
|
+
if self._thread is not None and self._thread.is_alive():
|
|
192
|
+
return True
|
|
193
|
+
if not PLAYWRIGHT_AVAILABLE:
|
|
194
|
+
raise BrowserUnavailableError(
|
|
195
|
+
"Playwright is not installed. Run:\n"
|
|
196
|
+
" pip install playwright\n"
|
|
197
|
+
" playwright install chromium")
|
|
198
|
+
self._launch_error = None
|
|
199
|
+
self._thread = threading.Thread(
|
|
200
|
+
target=self._loop, name="cat-browser-engine", daemon=True)
|
|
201
|
+
self._thread.start()
|
|
202
|
+
if not self._ready.wait(timeout):
|
|
203
|
+
raise TimeoutError("Chromium did not launch in time")
|
|
204
|
+
if not PLAYWRIGHT_AVAILABLE or self._browser is None:
|
|
205
|
+
err = getattr(self, "_launch_error", None)
|
|
206
|
+
raise BrowserUnavailableError(
|
|
207
|
+
f"Chromium failed to launch: {err}. "
|
|
208
|
+
"Try: playwright install chromium")
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
# -------------------------------------------------------- internals --
|
|
212
|
+
def _navigate(self, url):
|
|
213
|
+
page = self._page
|
|
214
|
+
del self.console[:]
|
|
215
|
+
del self.js_errors[:]
|
|
216
|
+
self._nav_error = None
|
|
217
|
+
self.last_status = None
|
|
218
|
+
try:
|
|
219
|
+
resp = page.goto(url, wait_until="load", timeout=25000)
|
|
220
|
+
if resp is not None:
|
|
221
|
+
self.last_status = resp.status
|
|
222
|
+
except Exception as e:
|
|
223
|
+
self._nav_error = str(e).split("\n")[0][:200]
|
|
224
|
+
try:
|
|
225
|
+
page.wait_for_timeout(120) # let late JS paint settle briefly
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
return self._snapshot_locked(url)
|
|
229
|
+
|
|
230
|
+
def _snapshot_locked(self, url: str) -> PreviewSnapshot:
|
|
231
|
+
page = self._page
|
|
232
|
+
snap = PreviewSnapshot(url=url)
|
|
233
|
+
snap.console_tail = tuple(self.console[-8:])
|
|
234
|
+
snap.js_errors = tuple(self.js_errors[-5:])
|
|
235
|
+
if self._nav_error:
|
|
236
|
+
snap.ok = False
|
|
237
|
+
snap.error = self._nav_error
|
|
238
|
+
return snap
|
|
239
|
+
try:
|
|
240
|
+
snap.title = page.title() or ""
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
snap.status_code = self.last_status
|
|
244
|
+
if self.last_status and self.last_status >= 400:
|
|
245
|
+
snap.ok = False
|
|
246
|
+
snap.error = f"HTTP {self.last_status} while loading {url}"
|
|
247
|
+
return snap
|
|
248
|
+
try:
|
|
249
|
+
counts = page.evaluate(_COUNTS_JS) or {}
|
|
250
|
+
snap.elements = int(counts.get("elements", 0))
|
|
251
|
+
snap.images_loaded = int(counts.get("imagesLoaded", 0))
|
|
252
|
+
snap.images_broken = int(counts.get("imagesBroken", 0))
|
|
253
|
+
raw = page.evaluate(_OUTLINE_JS) or []
|
|
254
|
+
lines = []
|
|
255
|
+
for item in raw[:160]:
|
|
256
|
+
t, x = item.get("t", ""), item.get("x", "")
|
|
257
|
+
e = item.get("e", "")
|
|
258
|
+
prefix = {
|
|
259
|
+
"h1": "# ", "h2": "## ", "h3": "### ",
|
|
260
|
+
"a": " ↗ ", "button": " [ ] ",
|
|
261
|
+
"li": " • ", "input": " ⌨ ", "textarea": " ⌨ ",
|
|
262
|
+
"select": " ⌨ ", "img": " 🖼 ", "p": " ",
|
|
263
|
+
"nav": "§ nav", "header": "§ header",
|
|
264
|
+
"footer": "§ footer", "section": "§ section",
|
|
265
|
+
}.get(t, " ")
|
|
266
|
+
line = f"{prefix}{x}".rstrip()
|
|
267
|
+
if t == "img":
|
|
268
|
+
line += "" if e != "BROKEN" else " ✗"
|
|
269
|
+
if line.strip():
|
|
270
|
+
lines.append(line)
|
|
271
|
+
snap.outline_lines = tuple(lines)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
snap.error = f"snapshot failed: {e}"
|
|
274
|
+
return snap
|
|
275
|
+
|
|
276
|
+
# ----------------------------------------------------------- public --
|
|
277
|
+
@property
|
|
278
|
+
def available(self) -> bool:
|
|
279
|
+
return self._thread is not None and self._thread.is_alive() \
|
|
280
|
+
and self._page is not None
|
|
281
|
+
|
|
282
|
+
def navigate(self, url: str, timeout=35.0) -> PreviewSnapshot:
|
|
283
|
+
return self._submit(lambda: self._navigate(url), timeout)
|
|
284
|
+
|
|
285
|
+
def reload(self, timeout=35.0) -> PreviewSnapshot:
|
|
286
|
+
def do_reload():
|
|
287
|
+
url = self._page.url
|
|
288
|
+
try:
|
|
289
|
+
self._page.reload(wait_until="load", timeout=25000)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
return self._snapshot_locked(url)
|
|
293
|
+
return self._submit(do_reload, timeout)
|
|
294
|
+
|
|
295
|
+
def back(self, timeout=20.0) -> Optional[PreviewSnapshot]:
|
|
296
|
+
def go_back():
|
|
297
|
+
page = self._page
|
|
298
|
+
try:
|
|
299
|
+
page.go_back(wait_until="load", timeout=15000)
|
|
300
|
+
except Exception:
|
|
301
|
+
return None
|
|
302
|
+
time.sleep(0.05)
|
|
303
|
+
return self._snapshot_locked(page.url)
|
|
304
|
+
return self._submit(go_back, timeout)
|
|
305
|
+
|
|
306
|
+
def forward(self, timeout=20.0) -> Optional[PreviewSnapshot]:
|
|
307
|
+
def go_fwd():
|
|
308
|
+
page = self._page
|
|
309
|
+
try:
|
|
310
|
+
page.go_forward(wait_until="load", timeout=15000)
|
|
311
|
+
except Exception:
|
|
312
|
+
return None
|
|
313
|
+
time.sleep(0.05)
|
|
314
|
+
return self._snapshot_locked(page.url)
|
|
315
|
+
return self._submit(go_fwd, timeout)
|
|
316
|
+
|
|
317
|
+
def hot_css(self, hrefs) -> bool:
|
|
318
|
+
"""CSS-only hot update through the page's own WebSocket channel
|
|
319
|
+
listener (server.notify_hot_css triggers it); falls back to a
|
|
320
|
+
direct style re-fetch when no WS client is connected."""
|
|
321
|
+
def inject():
|
|
322
|
+
page = self._page
|
|
323
|
+
try:
|
|
324
|
+
page.evaluate(
|
|
325
|
+
"""(hrefs) => {
|
|
326
|
+
document.querySelectorAll(
|
|
327
|
+
'link[rel=\\"stylesheet\\"]').forEach(l => {
|
|
328
|
+
const key = (l.getAttribute('href') || '');
|
|
329
|
+
if (!hrefs.length || hrefs.some(h =>
|
|
330
|
+
key.indexOf(h.split('?')[0]) !== -1)) {
|
|
331
|
+
const clone = l.cloneNode();
|
|
332
|
+
clone.href = l.href.replace(/([?&])__cat=\\d+/, '')
|
|
333
|
+
+ (l.href.indexOf('?') === -1 ? '?' : '&')
|
|
334
|
+
+ '__cat=' + Date.now();
|
|
335
|
+
l.parentNode.replaceChild(clone, l);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}""", list(hrefs or []))
|
|
339
|
+
return True
|
|
340
|
+
except Exception:
|
|
341
|
+
return False
|
|
342
|
+
try:
|
|
343
|
+
return bool(self._submit(inject, timeout=10.0))
|
|
344
|
+
except Exception:
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
def evaluate(self, js: str, timeout=10.0):
|
|
348
|
+
return self._submit(lambda: self._page.evaluate(js), timeout)
|
|
349
|
+
|
|
350
|
+
def screenshot(self, path: str) -> bool:
|
|
351
|
+
def shot():
|
|
352
|
+
try:
|
|
353
|
+
self._page.screenshot(path=path, full_page=False)
|
|
354
|
+
return True
|
|
355
|
+
except Exception:
|
|
356
|
+
return False
|
|
357
|
+
try:
|
|
358
|
+
return bool(self._submit(shot, timeout=15.0))
|
|
359
|
+
except Exception:
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
def resize_viewport(self, width: int, height: int):
|
|
363
|
+
def do_resize():
|
|
364
|
+
try:
|
|
365
|
+
self._page.set_viewport_size({"width": int(width),
|
|
366
|
+
"height": int(height)})
|
|
367
|
+
except Exception:
|
|
368
|
+
pass
|
|
369
|
+
try:
|
|
370
|
+
self._submit(do_resize, timeout=8.0)
|
|
371
|
+
except Exception:
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
def click(self, selector_or_text: str, timeout: float = 10.0) -> bool:
|
|
375
|
+
def do_click():
|
|
376
|
+
page = self._page
|
|
377
|
+
try:
|
|
378
|
+
if selector_or_text.startswith(("#", ".", "[", "button", "a", "input")):
|
|
379
|
+
page.click(selector_or_text, timeout=int(timeout * 1000))
|
|
380
|
+
return True
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
try:
|
|
384
|
+
page.get_by_text(selector_or_text, exact=False).first.click(timeout=int(timeout * 1000))
|
|
385
|
+
return True
|
|
386
|
+
except Exception:
|
|
387
|
+
pass
|
|
388
|
+
try:
|
|
389
|
+
page.click(f"text={selector_or_text}", timeout=int(timeout * 1000))
|
|
390
|
+
return True
|
|
391
|
+
except Exception as e:
|
|
392
|
+
raise RuntimeError(f"Could not click '{selector_or_text}': {e}")
|
|
393
|
+
return bool(self._submit(do_click, timeout=timeout + 2.0))
|
|
394
|
+
|
|
395
|
+
def type_text(self, selector_or_placeholder: str, text: str, timeout: float = 10.0) -> bool:
|
|
396
|
+
def do_type():
|
|
397
|
+
page = self._page
|
|
398
|
+
try:
|
|
399
|
+
if selector_or_placeholder.startswith(("#", ".", "[", "input", "textarea")):
|
|
400
|
+
page.fill(selector_or_placeholder, text, timeout=int(timeout * 1000))
|
|
401
|
+
return True
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
try:
|
|
405
|
+
page.get_by_placeholder(selector_or_placeholder).first.fill(text, timeout=int(timeout * 1000))
|
|
406
|
+
return True
|
|
407
|
+
except Exception:
|
|
408
|
+
pass
|
|
409
|
+
try:
|
|
410
|
+
page.fill(f"input[name='{selector_or_placeholder}']", text, timeout=int(timeout * 1000))
|
|
411
|
+
return True
|
|
412
|
+
except Exception as e:
|
|
413
|
+
raise RuntimeError(f"Could not type into '{selector_or_placeholder}': {e}")
|
|
414
|
+
return bool(self._submit(do_type, timeout=timeout + 2.0))
|
|
415
|
+
|
|
416
|
+
def get_interactive_elements(self, timeout: float = 10.0) -> list:
|
|
417
|
+
js = """
|
|
418
|
+
() => {
|
|
419
|
+
const els = [];
|
|
420
|
+
document.querySelectorAll('a, button, input, textarea, select, [role="button"]').forEach((el, idx) => {
|
|
421
|
+
if (el.offsetParent === null && el.tagName !== 'INPUT') return;
|
|
422
|
+
const tag = el.tagName.toLowerCase();
|
|
423
|
+
const text = (el.textContent || el.placeholder || el.value || el.getAttribute('aria-label') || '').trim();
|
|
424
|
+
const id = el.id ? '#' + el.id : '';
|
|
425
|
+
const href = el.getAttribute('href') || '';
|
|
426
|
+
els.push({
|
|
427
|
+
tag: tag,
|
|
428
|
+
text: text.slice(0, 80),
|
|
429
|
+
id: id,
|
|
430
|
+
selector: id || (tag + (el.className ? '.' + el.className.trim().split(/\\s+/)[0] : '')),
|
|
431
|
+
href: href,
|
|
432
|
+
type: el.type || ''
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
return els.slice(0, 100);
|
|
436
|
+
}
|
|
437
|
+
"""
|
|
438
|
+
return self._submit(lambda: self._page.evaluate(js), timeout) or []
|
|
439
|
+
|
|
440
|
+
def workflow_verify(self, expected_selectors: list = None, timeout: float = 10.0) -> dict:
|
|
441
|
+
def do_verify():
|
|
442
|
+
page = self._page
|
|
443
|
+
res = {
|
|
444
|
+
"url": page.url,
|
|
445
|
+
"title": page.title() or "",
|
|
446
|
+
"console_errors": len(self.js_errors),
|
|
447
|
+
"js_errors": list(self.js_errors),
|
|
448
|
+
"http_status": self.last_status,
|
|
449
|
+
"missing_selectors": [],
|
|
450
|
+
"verified": True,
|
|
451
|
+
}
|
|
452
|
+
if self.last_status and self.last_status >= 400:
|
|
453
|
+
res["verified"] = False
|
|
454
|
+
res["error"] = f"HTTP {self.last_status}"
|
|
455
|
+
if self.js_errors:
|
|
456
|
+
res["verified"] = False
|
|
457
|
+
if expected_selectors:
|
|
458
|
+
for sel in expected_selectors:
|
|
459
|
+
try:
|
|
460
|
+
count = page.locator(sel).count()
|
|
461
|
+
if count == 0:
|
|
462
|
+
res["missing_selectors"].append(sel)
|
|
463
|
+
res["verified"] = False
|
|
464
|
+
except Exception:
|
|
465
|
+
res["missing_selectors"].append(sel)
|
|
466
|
+
res["verified"] = False
|
|
467
|
+
return res
|
|
468
|
+
return self._submit(do_verify, timeout)
|
|
469
|
+
|
|
470
|
+
# ------------------------------------------------------------- stop --
|
|
471
|
+
def close(self, timeout=6.0):
|
|
472
|
+
"""Idempotent shutdown: kills Chromium + the worker thread."""
|
|
473
|
+
self._stop = True
|
|
474
|
+
try:
|
|
475
|
+
if self._thread is not None and self._thread.is_alive():
|
|
476
|
+
self._queue.put(None)
|
|
477
|
+
self._thread.join(timeout=timeout)
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
for closer in ("_page", "_context", "_browser", "_pw"):
|
|
481
|
+
obj = getattr(self, closer, None)
|
|
482
|
+
setattr(self, closer, None)
|
|
483
|
+
if obj is None:
|
|
484
|
+
continue
|
|
485
|
+
try:
|
|
486
|
+
if closer == "_page":
|
|
487
|
+
obj.close()
|
|
488
|
+
elif closer in ("_context", "_browser"):
|
|
489
|
+
obj.close()
|
|
490
|
+
else:
|
|
491
|
+
obj.stop()
|
|
492
|
+
except Exception:
|
|
493
|
+
pass
|
|
494
|
+
self._thread = None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""CAT browser/ — navigation history (back / forward / reload).
|
|
2
|
+
|
|
3
|
+
Pure logic, no I/O — the controller asks this class where to go next
|
|
4
|
+
and then drives the engine. Duplicate consecutive entries are not
|
|
5
|
+
pushed (a reload must not create history), and pushing truncates the
|
|
6
|
+
forward branch exactly like every real browser.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NavigationHistory:
|
|
13
|
+
"""A bounded back/forward stack of URLs."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, limit: int = 100):
|
|
16
|
+
self._limit = max(2, int(limit))
|
|
17
|
+
self._entries: List[str] = []
|
|
18
|
+
self._index = -1
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------- state --
|
|
21
|
+
@property
|
|
22
|
+
def current(self) -> Optional[str]:
|
|
23
|
+
if 0 <= self._index < len(self._entries):
|
|
24
|
+
return self._entries[self._index]
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def index(self) -> int:
|
|
29
|
+
return self._index
|
|
30
|
+
|
|
31
|
+
def __len__(self):
|
|
32
|
+
return len(self._entries)
|
|
33
|
+
|
|
34
|
+
def can_back(self) -> bool:
|
|
35
|
+
return self._index > 0
|
|
36
|
+
|
|
37
|
+
def can_forward(self) -> bool:
|
|
38
|
+
return self._index < len(self._entries) - 1
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------ moves --
|
|
41
|
+
def push(self, url: str) -> Optional[str]:
|
|
42
|
+
"""Record a navigation. Returns the URL that should be loaded
|
|
43
|
+
(None when `url` is a duplicate of the current entry — nothing
|
|
44
|
+
to do). Truncates any forward branch."""
|
|
45
|
+
if not url:
|
|
46
|
+
return None
|
|
47
|
+
if self.current == url:
|
|
48
|
+
return None
|
|
49
|
+
# drop forward branch + enforce the cap
|
|
50
|
+
self._entries = self._entries[:self._index + 1]
|
|
51
|
+
self._entries.append(url)
|
|
52
|
+
if len(self._entries) > self._limit:
|
|
53
|
+
self._entries = self._entries[-self._limit:]
|
|
54
|
+
self._index = len(self._entries) - 1
|
|
55
|
+
return url
|
|
56
|
+
|
|
57
|
+
def replace_current(self, url: str) -> None:
|
|
58
|
+
"""Rewrite the current entry in place (redirects)."""
|
|
59
|
+
if not url:
|
|
60
|
+
return
|
|
61
|
+
if self._entries and self._index >= 0:
|
|
62
|
+
self._entries[self._index] = url
|
|
63
|
+
else:
|
|
64
|
+
self.push(url)
|
|
65
|
+
|
|
66
|
+
def back(self) -> Optional[str]:
|
|
67
|
+
if not self.can_back():
|
|
68
|
+
return None
|
|
69
|
+
self._index -= 1
|
|
70
|
+
return self._entries[self._index]
|
|
71
|
+
|
|
72
|
+
def forward(self) -> Optional[str]:
|
|
73
|
+
if not self.can_forward():
|
|
74
|
+
return None
|
|
75
|
+
self._index += 1
|
|
76
|
+
return self._entries[self._index]
|
|
77
|
+
|
|
78
|
+
def reload_url(self) -> Optional[str]:
|
|
79
|
+
return self.current
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------ reset --
|
|
82
|
+
def clear(self) -> None:
|
|
83
|
+
self._entries = []
|
|
84
|
+
self._index = -1
|