import tempfile import unittest from pathlib import Path from fastapi import FastAPI from fastapi.testclient import TestClient from web.static_files import NoCacheStaticFiles ROOT = Path(__file__).resolve().parents[1] STATIC_DIR = ROOT / "web" / "static" DEV_HTML = STATIC_DIR / "dev.html" ADMIN_HTML = STATIC_DIR / "admin.html" UI_CSS = STATIC_DIR / "ui.css" JS_DIR = STATIC_DIR / "js" VENDOR_DIR = STATIC_DIR / "vendor" / "markdown" MERMAID_VENDOR_DIR = STATIC_DIR / "vendor" / "mermaid" PDFJS_VENDOR_DIR = STATIC_DIR / "vendor" / "pdfjs" def _frontend_source() -> str: """dev.html + 拆出的 ES module 源(路径 1 模块化后逻辑散落 js/*.js)合并文本。""" parts = [DEV_HTML.read_text(encoding="utf-8")] parts += [p.read_text(encoding="utf-8") for p in sorted(JS_DIR.glob("*.js"))] return "\n".join(parts) class StaticVendorTests(unittest.TestCase): def test_dev_console_has_software_job_center(self) -> None: html = DEV_HTML.read_text(encoding="utf-8") source = (JS_DIR / "software_jobs.js").read_text(encoding="utf-8") chat_source = (JS_DIR / "chat.js").read_text(encoding="utf-8") self.assertIn('id="software-job-center"', html) self.assertIn('id="software-job-toggle"', html) self.assertIn("#software-job-center {", html) self.assertIn("#software-job-toggle {", html) self.assertLess( html.index('id="software-job-center"'), html.index('id="storage-foot"'), ) self.assertIn('id="software-job-panel"', html) self.assertIn("before_created_at", source) self.assertIn("before_job_id", source) self.assertIn("onListScroll", source) self.assertIn("/cancel`", source) self.assertIn("分析结果", source) self.assertIn("refreshCurrentTaskFiles(job.task_id)", source) self.assertIn( 'document.addEventListener("software-job-submitted", refreshSoftwareJobs)', source, ) self.assertIn("export function refreshCurrentTaskFiles(taskId)", chat_source) self.assertIn( 'document.dispatchEvent(new Event("software-job-submitted"))', chat_source, ) def test_admin_can_create_windows_node_enrollment_code(self) -> None: html = ADMIN_HTML.read_text(encoding="utf-8") admin_js = (JS_DIR / "admin.js").read_text(encoding="utf-8") self.assertIn('id="node-enrollment-modal" class="modal"', html) self.assertIn("生成 Windows Node 注册码", html) self.assertIn('"/v1/admin/software-node-enrollments"', admin_js) self.assertIn('capabilities: ["origin.plot@v1"]', admin_js) self.assertIn('origin.health === "ready"', admin_js) self.assertIn("ttl_seconds: 600", admin_js) self.assertIn("navigator.clipboard.writeText(value)", admin_js) self.assertIn('apiGet("/v1/admin/software-nodes")', admin_js) self.assertIn('apiSend("PATCH", `/v1/admin/software-nodes/${node.node_id}`', admin_js) self.assertIn('apiSend("DELETE", `/v1/admin/software-nodes/${node.node_id}`', admin_js) self.assertIn("最近心跳", admin_js) self.assertIn("重新启用", admin_js) self.assertIn("永久删除", admin_js) self.assertNotIn("localStorage.setItem", admin_js) def test_dev_html_uses_local_markdown_vendor_assets(self) -> None: html = DEV_HTML.read_text(encoding="utf-8") self.assertNotIn("cdn.jsdelivr.net", html) self.assertIn('src="vendor/markdown/marked.umd.js"', html) self.assertIn('src="vendor/markdown/purify.min.js"', html) self.assertIn('src="vendor/markdown/highlight.min.js"', html) self.assertIn('src="vendor/mermaid/mermaid.min.js"', html) self.assertIn('href="vendor/markdown/github.min.css"', html) for name in ( "marked.umd.js", "purify.min.js", "highlight.min.js", "github.min.css", ): path = VENDOR_DIR / name self.assertTrue(path.exists(), f"missing vendored asset: {path}") self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}") mermaid_bundle = MERMAID_VENDOR_DIR / "mermaid.min.js" mermaid_license = MERMAID_VENDOR_DIR / "LICENSE.txt" self.assertTrue(mermaid_bundle.exists(), f"missing vendored asset: {mermaid_bundle}") self.assertGreater(mermaid_bundle.stat().st_size, 1_000_000) self.assertTrue(mermaid_license.exists(), f"missing vendored license: {mermaid_license}") def test_pdfjs_assets_are_vendored_with_license(self) -> None: for name in ("pdf.min.js", "pdf.worker.min.js", "LICENSE.txt"): path = PDFJS_VENDOR_DIR / name self.assertTrue(path.exists(), f"missing vendored asset: {path}") self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}") def test_html_preview_host_is_shipped(self) -> None: host = STATIC_DIR / "html_preview_host.html" self.assertTrue(host.exists()) source = host.read_text(encoding="utf-8") self.assertIn("zcbot-html-preview", source) self.assertIn("event.source !== parent", source) def test_dev_html_uses_local_ui_foundation(self) -> None: html = DEV_HTML.read_text(encoding="utf-8") self.assertIn('href="ui.css"', html) self.assertTrue(UI_CSS.exists(), f"missing UI foundation: {UI_CSS}") css = UI_CSS.read_text(encoding="utf-8") self.assertIn("--accent:", css) self.assertIn(":focus-visible", css) self.assertIn("@media (max-width: 640px)", css) def test_file_toolbar_keeps_hidden_toggle_and_groups_add_actions(self) -> None: html = DEV_HTML.read_text(encoding="utf-8") files_js = (JS_DIR / "files.js").read_text(encoding="utf-8") self.assertIn('id="btn-toggle-hidden" class="small"', html) self.assertNotIn( 'id="btn-toggle-hidden" class="small" title="显示点目录" ' 'aria-label="显示点目录" aria-pressed="false" hidden', html, ) self.assertNotIn("hiddenToggle.hidden", files_js) self.assertIn('id="btn-file-add" class="small dd-toggle"', html) self.assertNotIn('id="btn-new-folder"', html) self.assertNotIn('id="btn-src-pick"', html) self.assertNotIn('id="btn-upload"', html) self.assertIn('{ act: "mkdir", label: "新建文件夹"', files_js) self.assertIn('{ act: "pick", label: "从其他目录选入"', files_js) self.assertIn('{ act: "upload", label: "上传文件"', files_js) self.assertIn('await api("POST", "/v1/files/mkdir"', files_js) def test_ui_polish_keeps_mobile_composer_and_reading_rail_contracts(self) -> None: html = DEV_HTML.read_text(encoding="utf-8") css = UI_CSS.read_text(encoding="utf-8") frontend = _frontend_source() self.assertIn("--list-divider:", css) self.assertIn(".msg.assistant", css) self.assertIn(".composer-tool .composer-label", css) self.assertIn('aria-label="添加附件"', html) self.assertIn("await ensureTaskFromDraft();", frontend) self.assertIn("attachments: pendingAttachments.map", frontend) self.assertIn("parseUserAttachments", frontend) self.assertNotIn("发送第一条消息创建对话后即可添加附件", frontend) self.assertIn('aria-label="语音输入"', html) self.assertIn('aria-label="润色提示词"', html) self.assertIn("hasVisibleAssistantContent", frontend) self.assertIn('input.placeholder = mqPhone.matches', frontend) self.assertIn("#chat-hint:empty", css) self.assertNotIn("请选择文件保存位置", frontend) self.assertIn("function setMicButtonState(", frontend) self.assertNotIn('_micBtn.textContent = "■ 停止"', frontend) self.assertIn("const toolCallsById = new Map()", frontend) self.assertIn("const runningDet = finishRunningTool(ctx)", frontend) self.assertNotIn('