zcbot/tests/test_static_vendor.py

307 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_embed_safe_software_job_workspace(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="right-tab-jobs"', html)
self.assertIn('id="right-jobs-panel"', html)
self.assertIn('id="software-job-badge"', html)
self.assertIn(".right-workspace-panel {", html)
self.assertLess(
html.index('id="right-tab-jobs"'),
html.index('id="storage-foot"'),
)
self.assertNotIn('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("查看结果", source)
self.assertIn("Job ID", source)
self.assertIn("工程关系:${escapeHtml(workspaceRelation)}", source)
self.assertIn("续接自 Job ${sourceJobId.slice(0, 8)}", source)
self.assertIn("<dt>来源 Job</dt>", source)
self.assertIn("refreshCurrentTaskAfterSoftwareJob(job.task_id)", source)
self.assertIn("job.execution_runtime", source)
self.assertIn("执行版本未记录", source)
self.assertIn("执行版本</dt><dd>${escapeHtml(jobRuntimeText(job))}", source)
self.assertIn(
'document.addEventListener("software-job-submitted", refreshSoftwareJobs)',
source,
)
self.assertIn("export function refreshCurrentTaskFiles(taskId)", chat_source)
self.assertIn("export async function openSoftwareJobResults", 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.assertNotIn('capabilities: ["origin.plot@v2"]', admin_js)
self.assertIn("节点能力由客户端自动发现并上报", html)
self.assertIn("runtime.capability_runtime", admin_js)
self.assertIn("item.adapter_version", admin_js)
self.assertIn("item.software_version", admin_js)
self.assertIn('runtime.origin ? [["origin.plot@v2", runtime.origin]]', 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.assertRegex(html, r'id="btn-toggle-hidden" class="[^"]*\bsmall\b')
self.assertNotIn(
'id="btn-toggle-hidden" class="small" title="显示点目录" '
'aria-label="显示点目录" aria-pressed="false" hidden',
html,
)
self.assertNotIn("hiddenToggle.hidden", files_js)
self.assertRegex(html, r'id="btn-file-add" class="[^"]*\bsmall\b[^"]*\bdd-toggle\b')
self.assertRegex(
html,
r'id="btn-file-add"[^>]*aria-label="添加文件或文件夹"[^>]*></button>',
)
self.assertIn("@container (max-width: 280px)", 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('<div class="role">科研助手</div>', frontend)
self.assertIn(".tool-call.activity > summary", css)
self.assertIn("#task-progress-dock .task-progress", css)
def test_frontend_surfaces_context_and_cache_stats(self) -> None:
src = _frontend_source()
self.assertIn("formatContextStats", src)
self.assertIn("context_original_chars", src)
self.assertIn("context_stripped_reasoning_messages", src)
self.assertIn("当前请求视图压缩", src)
self.assertIn("历史已整理", src)
self.assertIn('p > 1 ? "100%+"', src)
self.assertIn("cache_hit_tokens", src)
def test_sse_consumer_yields_for_streaming_text_paints(self) -> None:
chat_js = (JS_DIR / "chat.js").read_text(encoding="utf-8")
self.assertIn("renderEventsSincePaint", chat_js)
self.assertIn("renderEventsSincePaint >= 24", chat_js)
self.assertIn("await yieldToStreamPaint()", chat_js)
self.assertIn('document.visibilityState === "hidden"', chat_js)
def test_custom_task_name_is_optional_with_explicit_directory(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")
newtask_js = (JS_DIR / "newtask.js").read_text(encoding="utf-8")
self.assertIn("任务名(可选)", html)
self.assertIn("工作目录(必填)", html)
self.assertIn('id="nt-wd-combobox" class="new-chat-dir-combobox"', html)
self.assertIn('id="nt-wd-picker" role="combobox"', html)
self.assertNotIn('id="nt-wd-sel"', html)
self.assertNotIn('id="nt-wd-new"', html)
self.assertIn("if (name) body.name = name;", newtask_js)
self.assertNotIn("任务名为必填项", newtask_js)
def test_quick_new_conversation_can_choose_model(self) -> None:
chat_js = (JS_DIR / "chat.js").read_text(encoding="utf-8")
state_js = (JS_DIR / "state.js").read_text(encoding="utf-8")
html = DEV_HTML.read_text(encoding="utf-8")
self.assertIn('id="new-chat-model-sel"', chat_js)
self.assertIn("draftModelProfile", state_js)
self.assertIn("state.draftModelProfile = defaultDraftModelProfile();", chat_js)
self.assertIn('model_profile: state.draftModelProfile || ""', chat_js)
self.assertIn("function renderDraftChatMeta()", chat_js)
self.assertIn("${renderMediaModelTrigger()}", chat_js)
self.assertIn("bindMediaModelTrigger();", chat_js)
self.assertIn("#chat-meta.draft-meta { flex-wrap:nowrap; }", html)
self.assertIn(".mdl-wrap { white-space: nowrap; }", html)
self.assertNotIn("new-chat-model-row", chat_js)
self.assertIn("function positionDraftFolderOptions()", chat_js)
self.assertIn("#new-chat-dir-options { position:fixed;", html)
def test_embed_initial_task_skips_root_directory_bootstrap(self) -> None:
main_js = (JS_DIR / "main.js").read_text(encoding="utf-8")
embed_js = (JS_DIR / "embed.js").read_text(encoding="utf-8")
self.assertIn('export function enterApp(initialTaskId = "")', main_js)
self.assertIn("selectTask(initialTaskId);", main_js)
self.assertIn(
"showNewConversationDraft(); // 登录空态与“+ 新对话”共用同一草稿页",
main_js,
)
self.assertIn("enterApp(takeInitialTaskId());", embed_js)
self.assertNotIn("gotoInitialTask()", embed_js)
self.assertIn('state.token = "";', embed_js)
self.assertNotIn(
'if (state.token) {\n enterApp(takeInitialTaskId());',
embed_js,
)
def test_external_system_connections_use_separate_create_and_edit_modal(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")
external_js = (JS_DIR / "external_systems.js").read_text(encoding="utf-8")
self.assertIn('id="ext-add" class="primary"', html)
self.assertIn('id="external-connection-modal" class="modal"', html)
self.assertLess(html.index('id="external-modal"'), html.index('id="external-connection-modal"'))
self.assertIn("function unconnectedDefinitions()", external_js)
self.assertIn("function connectorBadgeHtml(connector)", external_js)
self.assertIn('class="ext-connector ext-connector-openapi"', external_js)
self.assertIn('class="ext-connector ext-connector-mcp"', external_js)
self.assertIn('$("ext-add").onclick = () => openConnectionModal();', external_js)
self.assertIn("openConnectionModal(systemsById.get(id));", external_js)
self.assertIn('await api("PUT", `/v1/external-systems/${editingId}/credentials`', external_js)
self.assertIn(
"result.operation_count ?? result.tool_count ?? 0",
external_js,
)
self.assertNotIn("function connectionForDefinition(", external_js)
def test_static_js_is_served_with_revalidation_header(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
static_dir = Path(tmp)
(static_dir / "app.js").write_text("export const ok = true;\n", encoding="utf-8")
app = FastAPI()
app.mount("/static", NoCacheStaticFiles(directory=str(static_dir)), name="static")
client = TestClient(app)
resp = client.get("/static/app.js")
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.headers.get("cache-control"), "no-cache")
def test_ui_css_is_served_as_css_with_revalidation(self) -> None:
app = FastAPI()
app.mount("/static", NoCacheStaticFiles(directory=str(STATIC_DIR)), name="static")
client = TestClient(app)
resp = client.get("/static/ui.css")
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.headers.get("content-type", "").startswith("text/css"))
self.assertEqual(resp.headers.get("cache-control"), "no-cache")
self.assertIn("--accent:", resp.text)
if __name__ == "__main__":
unittest.main()