Files
cowork-local/tools/capture_screens.py
T
1419587401
CI / test (push) Canceled after 0s
Feature/fsg gamma team ui fix (#3)
## 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
2026-08-20 12:12:56 +00:00

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())