CI / test (push) Canceled after 0s
## Summary What changed and why? ## Change Type - [ ] Cowork feature - [ ] Bug fix - [x] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Co-authored-by: NamPDT <minhanhpkpro@gmail.com> Reviewed-on: #3
367 lines
16 KiB
Python
367 lines
16 KiB
Python
"""Capture every CoworkLocal screen to PNG, offscreen, for the UI audit page.
|
|
|
|
Run: python tools/capture_screens.py
|
|
|
|
Two safety measures, both mandatory — this script drives the REAL application:
|
|
|
|
1. **Data isolation.** ``config.CONFIG_DIR`` is ``Path.home() / ".cowork_local"``, a
|
|
module-level constant resolved at import time. We copy that folder to a temp
|
|
directory and repoint ``USERPROFILE``/``HOME`` at it *before* importing
|
|
``cowork_local``, so every write the app makes lands in the copy. The user's
|
|
real data is never opened for writing.
|
|
|
|
2. **Schedulers disabled.** ``MainWindow.__init__`` starts ``TaskScheduler`` and
|
|
``RoutingScheduler``, which would *execute the user's scheduled tasks* — real
|
|
agent turns writing real files. Both ``start`` methods are patched to no-ops
|
|
before the window is built.
|
|
|
|
We also construct ``MainWindow`` directly rather than calling ``app.run()``:
|
|
``run()`` seeds built-in skills/flows and calls ``ctx.config.save()``.
|
|
|
|
Screens that fail to render (QtWebEngine generally cannot initialise offscreen)
|
|
are recorded in the manifest with their error. They are never silently skipped —
|
|
the audit page renders an explicit "could not capture" placeholder for them.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import traceback
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent # …/cowork_local
|
|
OUT_DIR = REPO / "docs" / "screens"
|
|
THEMES = ("dark", "light")
|
|
|
|
|
|
def _isolate_home() -> Path:
|
|
"""Copy the real config dir into a temp HOME and repoint the env at it."""
|
|
real = Path.home() / ".cowork_local"
|
|
sandbox = Path(tempfile.mkdtemp(prefix="cowork-capture-"))
|
|
if real.exists():
|
|
shutil.copytree(real, sandbox / ".cowork_local", dirs_exist_ok=True)
|
|
else:
|
|
(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True)
|
|
for var in ("USERPROFILE", "HOME"):
|
|
os.environ[var] = str(sandbox)
|
|
os.environ.pop("HOMEDRIVE", None)
|
|
os.environ.pop("HOMEPATH", None)
|
|
return sandbox
|
|
|
|
|
|
def _load_fonts() -> int:
|
|
"""Register system fonts with the offscreen platform.
|
|
|
|
The offscreen plugin ships with NO font database (``QFontDatabase.families()``
|
|
returns an empty list), so every glyph renders as a tofu box — unusable when
|
|
the screenshots are the deliverable. Loading the real Windows faces fixes
|
|
both Latin and Vietnamese diacritics, and Consolas covers the code views.
|
|
"""
|
|
from PySide6.QtGui import QFontDatabase
|
|
|
|
wanted = [
|
|
"SegUIVar.ttf", "segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf",
|
|
"seguisb.ttf", "consola.ttf", "consolab.ttf", "arial.ttf",
|
|
]
|
|
root = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Fonts"
|
|
loaded = 0
|
|
for name in wanted:
|
|
path = root / name
|
|
if path.exists() and QFontDatabase.addApplicationFont(str(path)) != -1:
|
|
loaded += 1
|
|
return loaded
|
|
|
|
|
|
def _apply_theme(app, name: str | None = None) -> str:
|
|
"""Load the app's real stylesheet onto `app`.
|
|
|
|
`MainWindow` does not style itself — `run()` calls `app.setStyleSheet` — so a
|
|
checker that builds the window directly measures a window with no padding,
|
|
no margins and no borders. Every QSS-driven layout bug is invisible there.
|
|
"""
|
|
from cowork_local import theme
|
|
from cowork_local.config import AppConfig
|
|
|
|
name = name or AppConfig.load().theme
|
|
theme.set_active_theme(name)
|
|
app.setStyleSheet(theme.stylesheet(name))
|
|
return name
|
|
|
|
|
|
def _freeze_schedulers() -> None:
|
|
"""No-op the background engines so nothing is executed while we capture."""
|
|
from cowork_local.core.task_scheduler import TaskScheduler
|
|
TaskScheduler.start = lambda self: None # type: ignore[assignment]
|
|
try:
|
|
from cowork_local.core.routing.scheduler import RoutingScheduler
|
|
RoutingScheduler.start = lambda self: None # type: ignore[assignment]
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def main() -> int:
|
|
os.environ["QT_QPA_PLATFORM"] = "offscreen"
|
|
sandbox = _isolate_home()
|
|
sys.path.insert(0, str(REPO.parent)) # so `import cowork_local` works
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling: seed_demo_data
|
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtWidgets import QApplication
|
|
app = QApplication([])
|
|
|
|
n_fonts = _load_fonts()
|
|
print(f"[fonts] registered {n_fonts} face(s) with the offscreen platform")
|
|
if not n_fonts:
|
|
print(" WARNING: no fonts loaded — every screenshot will render as tofu boxes")
|
|
|
|
_freeze_schedulers()
|
|
|
|
import cowork_local.theme as theme
|
|
from cowork_local.config import AppConfig, CONFIG_DIR
|
|
from cowork_local.i18n import set_language, tr
|
|
from cowork_local.state import AppContext
|
|
|
|
assert str(sandbox) in str(CONFIG_DIR), (
|
|
f"isolation failed: CONFIG_DIR={CONFIG_DIR} is not inside {sandbox}")
|
|
print(f"[isolated] CONFIG_DIR -> {CONFIG_DIR}")
|
|
|
|
# Fill the sandbox with demo data so the screenshots show a working app.
|
|
# Safe by construction: seed() re-asserts it is inside a capture sandbox.
|
|
from seed_demo_data import seed
|
|
counts = seed()
|
|
print("[seeded] " + " · ".join(f"{k}={v}" for k, v in counts.items()))
|
|
|
|
from cowork_local.app import MainWindow
|
|
|
|
cfg = AppConfig.load()
|
|
set_language("vi")
|
|
ctx = AppContext(cfg)
|
|
|
|
manifest: list[dict] = []
|
|
# Label of the nav row selected right now, recorded into every shot so the
|
|
# "is the rail pointing at the right thing?" question is machine-checked
|
|
# instead of eyeballed across 54 images.
|
|
nav_state = {"label": "", "expected": ""}
|
|
|
|
def nav_to(win, page: int, sub=None, expect: str = "") -> None:
|
|
"""Navigate the way a user does, and record where the rail ends up.
|
|
|
|
Since the rail became a flat list, ``_goto`` moves the highlight itself
|
|
(``_select_nav_row``), so this no longer needs the two-step workaround
|
|
that existed while selecting a Workspace child destroyed the row being
|
|
selected.
|
|
"""
|
|
win._ensure_page(page)
|
|
win._goto(page, sub)
|
|
app.processEvents()
|
|
app.processEvents()
|
|
cur = next((t.currentItem() for t in (win.nav, win.nav_bottom)
|
|
if t.currentItem() is not None and t.currentItem().isSelected()),
|
|
None)
|
|
nav_state["label"] = cur.text(0) if cur is not None else ""
|
|
nav_state["expected"] = expect or nav_state["label"]
|
|
|
|
def shot(widget, slug: str, title: str, note: str = "") -> None:
|
|
"""Grab `widget` for the active theme; record success or the error."""
|
|
rec = {"slug": slug, "title": title, "theme": theme.current_theme(),
|
|
"note": note, "file": "", "error": "",
|
|
"nav": nav_state["label"], "nav_expected": nav_state["expected"]}
|
|
try:
|
|
app.processEvents()
|
|
app.processEvents()
|
|
pm = widget.grab()
|
|
if pm.isNull() or pm.width() < 2:
|
|
raise RuntimeError("grab() returned an empty pixmap")
|
|
name = f"{slug}-{theme.current_theme()}.png"
|
|
pm.save(str(OUT_DIR / name))
|
|
rec["file"] = f"screens/{name}"
|
|
print(f" ok {name} ({pm.width()}x{pm.height()})")
|
|
except Exception as exc: # noqa: BLE001
|
|
rec["error"] = f"{type(exc).__name__}: {exc}"
|
|
print(f" FAIL {slug}: {rec['error']}")
|
|
manifest.append(rec)
|
|
|
|
for th in THEMES:
|
|
print(f"\n=== theme: {th} ===")
|
|
ctx.config.theme = th
|
|
theme.set_active_theme(th)
|
|
app.setStyleSheet(theme.stylesheet(th))
|
|
|
|
win = MainWindow(ctx, user_name="local")
|
|
win.resize(1600, 1000)
|
|
win.show()
|
|
app.processEvents()
|
|
|
|
# ---- main screens, driven through the app's own navigation API ------
|
|
ROW_DASH, ROW_SCHED, ROW_WS, ROW_MON = 0, 1, 2, 3
|
|
|
|
nav_to(win, ROW_DASH, None, expect=tr("app.tab.dashboard"))
|
|
shot(win, "dashboard", "Dashboard", "ui/dashboard_tab.py:35")
|
|
|
|
nav_to(win, ROW_SCHED, None, expect=tr("app.tab.schedule"))
|
|
sched = win._page_widgets[ROW_SCHED]
|
|
shot(win, "schedule-kanban", "Schedule Task — Kanban", "ui/schedule_task_tab.py:70")
|
|
try: # combo index 1 == Calendar view
|
|
sched.view_combo.setCurrentIndex(1)
|
|
app.processEvents()
|
|
shot(win, "schedule-calendar", "Schedule Task — Calendar", "ui/calendar_view.py:88")
|
|
sched.view_combo.setCurrentIndex(0)
|
|
except Exception as exc: # noqa: BLE001
|
|
manifest.append({"slug": "schedule-calendar", "title": "Schedule Task — Calendar",
|
|
"theme": th, "note": "ui/calendar_view.py:88", "file": "",
|
|
"error": f"{type(exc).__name__}: {exc}"})
|
|
print(f" FAIL schedule-calendar: {exc}")
|
|
|
|
# Workspace: capture with no project selected, then with one selected so
|
|
# the project-gated sub-tabs (Cowork, GraphRAG) actually exist.
|
|
nav_to(win, ROW_WS, None, expect=tr("app.tab.workspace"))
|
|
ws = win.workspace
|
|
shot(win, "workspace-project", "Workspace ▸ Project", "ui/workspace_tab.py:188")
|
|
try:
|
|
if ws.project_list.count():
|
|
ws.project_list.setCurrentRow(0)
|
|
app.processEvents()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
for attr, slug, title, note in (
|
|
("_cowork_tab_idx", "workspace-cowork", "Workspace ▸ Cowork", "ui/cowork_tab.py:21"),
|
|
("_co4e_tab_idx", "workspace-co4e", "Workspace ▸ Co4E", "ui/co4e_tab.py:228"),
|
|
("_folder_tab_idx", "workspace-folder", "Workspace ▸ Folder", "ui/folder_tab.py:238"),
|
|
("_graphrag_tab_idx", "workspace-graphrag", "Workspace ▸ GraphRAG", "ui/structure_graph_view.py:188"),
|
|
):
|
|
idx = getattr(ws, attr, None)
|
|
if idx is None:
|
|
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
|
|
"file": "", "error": "sub-tab index not present"})
|
|
continue
|
|
nav_to(win, ROW_WS, idx, expect=ws.tabs.tabText(idx))
|
|
shot(win, slug, title, note)
|
|
|
|
# Monitoring: enumerate its sub-tabs from the app itself.
|
|
nav_to(win, ROW_MON, None, expect=tr("app.tab.monitoring"))
|
|
mon = win._page_widgets[ROW_MON]
|
|
try:
|
|
subs = mon.nav_subtabs()
|
|
except Exception as exc: # noqa: BLE001
|
|
subs = []
|
|
print(f" FAIL monitoring subtabs: {exc}")
|
|
for label, sub, _icon in subs:
|
|
nav_to(win, ROW_MON, sub, expect=label)
|
|
slug = "monitoring-" + "".join(
|
|
c.lower() if c.isalnum() else "-" for c in label).strip("-")
|
|
shot(win, slug, f"Monitoring ▸ {label}", "ui/monitoring_tab.py:132")
|
|
|
|
# Dialogs/overlays below are not nav destinations.
|
|
nav_state["label"] = nav_state["expected"] = ""
|
|
|
|
# ---- dialogs: built directly and shown (never exec(), it blocks) -----
|
|
for slug, title, note, build in _dialog_specs(ctx, win):
|
|
try:
|
|
dlg = build()
|
|
dlg.show()
|
|
app.processEvents()
|
|
shot(dlg, slug, title, note)
|
|
dlg.close()
|
|
except Exception as exc: # noqa: BLE001
|
|
manifest.append({"slug": slug, "title": title, "theme": th, "note": note,
|
|
"file": "", "error": f"{type(exc).__name__}: {exc}"})
|
|
print(f" FAIL {slug}: {type(exc).__name__}: {exc}")
|
|
|
|
# ---- overlays --------------------------------------------------------
|
|
try:
|
|
help_dock = win.help_agent
|
|
help_dock._expand()
|
|
app.processEvents()
|
|
shot(help_dock, "overlay-help-panel", "Help dock — expanded panel",
|
|
"ui/help_agent_widget.py:79")
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" FAIL overlay-help-panel: {exc}")
|
|
|
|
win.close()
|
|
|
|
(OUT_DIR / "manifest.json").write_text(
|
|
json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
|
|
bad_nav = [r for r in manifest
|
|
if r["nav_expected"] and r["nav"] != r["nav_expected"]]
|
|
checked = sum(1 for r in manifest if r["nav_expected"])
|
|
print(f"\n[nav] rail selection matches the screen: {checked - len(bad_nav)}/{checked}")
|
|
for r in bad_nav:
|
|
print(f" MISMATCH {r['slug']} [{r['theme']}]: "
|
|
f"rail says '{r['nav']}', screen is '{r['nav_expected']}'")
|
|
|
|
ok = sum(1 for r in manifest if r["file"])
|
|
bad = [r for r in manifest if not r["file"]]
|
|
print(f"\ncaptured {ok}/{len(manifest)}")
|
|
if bad:
|
|
print("could NOT capture (recorded in manifest, shown as placeholders):")
|
|
for r in bad:
|
|
print(f" - {r['slug']} [{r['theme']}]: {r['error']}")
|
|
print(f"sandbox (safe to delete): {sandbox}")
|
|
return 0
|
|
|
|
|
|
def _dialog_specs(ctx, win):
|
|
"""(slug, title, note, factory) for each dialog we can build headlessly.
|
|
|
|
Signatures differ per dialog (some take ctx first, some take parent first,
|
|
some require a real model object) — each factory below matches the actual
|
|
``__init__`` it calls, not a guessed one.
|
|
"""
|
|
# NOTE: two different classes share the name `CustomAgent` —
|
|
# core/custom_agents.py:23 and core/co4e.py:117. Co4EAgentDialog uses the
|
|
# co4e one (it has `.role`); importing the other raises AttributeError.
|
|
from cowork_local.core.co4e import CustomAgent
|
|
from cowork_local.ui.settings_dialog import SettingsDialog
|
|
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
|
from cowork_local.ui.skills_dialog import SkillsDialog, SkillEditDialog
|
|
from cowork_local.ui.file_edit_dialog import FileEditDialog
|
|
from cowork_local.ui.co4e_agent_dialog import Co4EAgentDialog
|
|
from cowork_local.ui.ext_connector_dialog import ExtConnectorEditDialog
|
|
from cowork_local.ui.permission_dialog import PermissionDialog
|
|
from cowork_local.ui.agents_admin_tab import AgentEditDialog
|
|
from cowork_local.ui.login_dialog import LoginDialog
|
|
|
|
return [
|
|
# SettingsDialog(ctx, parent)
|
|
("dialog-settings", "Settings", "ui/settings_dialog.py:26",
|
|
lambda: SettingsDialog(ctx, win)),
|
|
# TaskEditorDialog(task, all_tasks, parent, ctx)
|
|
("dialog-task-editor", "Task Editor", "ui/task_editor_dialog.py:55",
|
|
lambda: TaskEditorDialog(None, [], win, ctx)),
|
|
# SkillsDialog(parent, ctx)
|
|
("dialog-skills", "Skills manager", "ui/skills_dialog.py:108",
|
|
lambda: SkillsDialog(win, ctx)),
|
|
# SkillEditDialog(parent, skill, ctx)
|
|
("dialog-skill-edit", "Skill editor", "ui/skills_dialog.py:23",
|
|
lambda: SkillEditDialog(win, None, ctx)),
|
|
("dialog-file-edit", "File view & AI edit", "ui/file_edit_dialog.py:50",
|
|
lambda: FileEditDialog(ctx, "", win)),
|
|
# Co4EAgentDialog(ctx, agent, skill_names, parent) — agent must be real
|
|
("dialog-co4e-agent", "Co4E agent editor", "ui/co4e_agent_dialog.py:23",
|
|
lambda: Co4EAgentDialog(ctx, CustomAgent(id="preview"), [], win)),
|
|
# ExtConnectorEditDialog(parent, category, connector)
|
|
("dialog-ext-connector", "External connector", "ui/ext_connector_dialog.py:23",
|
|
lambda: ExtConnectorEditDialog(win, "other", None)),
|
|
# PermissionDialog(action, parent) — `preview` is a dict, not a string
|
|
("dialog-permission", "Permission request", "ui/permission_dialog.py:13",
|
|
lambda: PermissionDialog(
|
|
{"name": "run_command",
|
|
"preview": {"title": "Run command", "kind": "command",
|
|
"text": "npm install --save-dev vitest"}}, win)),
|
|
# AgentEditDialog(parent, ctx, agent, default_model_hint)
|
|
("dialog-agent-edit", "Admin agent editor", "ui/agents_admin_tab.py:35",
|
|
lambda: AgentEditDialog(win, ctx, None, "")),
|
|
("dialog-login", "Login (dead screen — not wired)", "ui/login_dialog.py:57",
|
|
lambda: LoginDialog(ctx, win)),
|
|
]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|