# -*- coding: utf-8 -*- """前端的门槛:固定主包与页面包、清单、契约与 vitest。 ADR-0022 的取舍是「构建产物进 Git」:运行时的 Python 服务、PyInstaller 包和 macOS 上的检出都没有 Node,`web/dist/peach-app.js` 必须是仓库里现成的文件。代价是产物会和 源码脱节,所以这里分两类断言: - **不需要 Node 的**:产物在不在、页面加载路径、独立模块图、清单是否 精确钉版本、共享客户端是否唯一。这些在任何机器上都跑。 - **需要 Node 的**:tsc、lint 与 vitest。npm 或 `frontend/node_modules` 不在时本机显式 跳过——本机可能根本没装 Node,让整个测试域红掉只会让人绕过入口,而不是去装 Node。 CI 的 Test 工作流由 `web-bundle` 统一执行三个工具;其他任务与本机经正式入口运行。 CI 实际执行工具的任务缺前置条件时判失败。 「产物是否由当前源码构建出来」这一条不在这里:不装 Node 就无法重建,无从比较。 那道门槛在 CI 的 `web-bundle` job 里,`npm run build` 之后 `git diff --exit-code web/dist`。 """ from __future__ import annotations import hashlib from html.parser import HTMLParser import json import os from pathlib import Path import re import subprocess import tempfile import unittest from peach.diagnostic_browser import BUNDLE_CHUNK_NAME from scripts import test_history from test_frontend_preflight import frontend_npm ROOT = Path(__file__).resolve().parents[1] FRONTEND = ROOT / "frontend" DIST = ROOT / "web" / "dist" BUNDLE = DIST / "peach-app.js" APP_STYLES = DIST / "peach-app.css" PAGES_BUNDLE = DIST / "peach-pages.js" PAGES_STYLES = DIST / "peach-pages.css" #: 主包按页拆出的 chunk(ADR-0118),形态由服务端放行规则定义。 CHUNKS = sorted(path for path in DIST.glob("*.js") if BUNDLE_CHUNK_NAME.fullmatch(path.name)) #: 主界面的全部 JavaScript:入口加 chunk。文案与写法落在哪一份由构建决定,扫产物时要一起扫。 APP_SCRIPTS = (BUNDLE, *CHUNKS) STATIC_IMPORTS = re.compile(r'(?m)^\s*(?:import\b[^;]*?|export\s*\*\s*)from\s*["\']([^"\']+)["\']' r'|^\s*import\s*["\']([^"\']+)["\']') DYNAMIC_IMPORTS = re.compile(r'\bimport\s*\(\s*["\']([^"\']+)["\']\s*\)') def app_text() -> str: return "\n".join(path.read_text(encoding="utf-8") for path in APP_SCRIPTS) class PageAssets(HTMLParser): """页面声明的脚本和样式,按浏览器的加载顺序收集。""" def __init__(self, html): super().__init__() self.scripts = [] self.styles = [] self.feed(html) def handle_starttag(self, tag, attrs): attrs = dict(attrs) if tag == "script" and attrs.get("src"): self.scripts.append(attrs) elif tag == "link" and attrs.get("rel") == "stylesheet": self.styles.append(attrs.get("href")) class MainBundleTests(unittest.TestCase): """提交进 Git 的产物必须是浏览器能直接 import 的那一份。""" @classmethod def setUpClass(cls): cls.bundle = app_text() def test_dist_ships_fixed_entries_and_exactly_the_referenced_chunks(self): """入口与页面包用固定名字;其余只有主包 chunk,并且正好是产物引用到的那几份。 chunk 名字带内容哈希,每次构建换一批:多出来的是上一次构建留下、还被 Git 跟踪着的旧文件, 缺的是新构建出来却没提交的文件,浏览器打开那一页时取不到。 """ names = sorted(path.name for path in DIST.iterdir() if path.is_file()) self.assertEqual([name for name in names if not BUNDLE_CHUNK_NAME.fullmatch(name)], ["peach-app.css", "peach-app.js", "peach-pages.css", "peach-pages.js"]) referenced = set() for path in APP_SCRIPTS: text = path.read_text(encoding="utf-8") imported = [a or b for a, b in STATIC_IMPORTS.findall(text)] + DYNAMIC_IMPORTS.findall(text) referenced |= {name[2:] for name in imported if name.startswith("./") and name != "./peach-app.js"} self.assertEqual(sorted(referenced), [path.name for path in CHUNKS]) def test_the_main_document_loads_one_module_entry(self): assets = PageAssets((ROOT / "web" / "index.html").read_text(encoding="utf-8")) self.assertEqual([script["src"] for script in assets.scripts], ["/dist/peach-app.js"]) self.assertEqual(assets.scripts[0].get("type"), "module") def test_the_startup_switches_and_their_payload_are_in_the_shipped_bundle(self): """「开机自启」那三颗开关的标签和它们发出去的键,判据落在产物上。 `web/dist/` 是提交进 Git 的产物。改了 `frontend/src` 不重建,浏览器拿到 的仍是旧的那一份,而 tsc 和 vitest 都只看源码,谁都不会红——只有扫产物这一条会。 配置页在按页拆出的 chunk 里,入口与 chunk 一起扫。 """ for label in ("开机后启动 Peach", "只启动托盘,不打开网页", "在桌面创建快捷方式"): self.assertIn(label, self.bundle, f"产物里没有「{label}」,先跑 npm --prefix frontend run build") payload = self.bundle[self.bundle.index('"/api/configuration/startup"'):][:240] for key in ("enabled:", "silent:", "desktop:"): self.assertIn(key, payload, f"保存开机自启没带上 {key}") def test_react_bundle_records_stable_dependency_paths(self): """区域注释只能从 `node_modules/` 起,不得把生成它的工作树路径写进产物。""" for bundle in (*APP_SCRIPTS, PAGES_BUNDLE): with self.subTest(bundle.name): captured = [line for line in bundle.read_text(encoding="utf-8").splitlines() if line.startswith("//#region ") and "node_modules/" in line and not line.startswith("//#region node_modules/")] self.assertEqual(captured, [], f"{bundle.name} 带了工作树相对路径;在当前工作树安装依赖后重新构建") class BoardTokenTests(unittest.TestCase): """BoardUI 的语义 token 在 `web/board.css` 里另有一份定值,两份各管一片页面。""" @staticmethod def declarations(css, selector): """同一选择器可能分几块写,合并后返回其中的 `--color-*` 声明。""" found = {} for block in re.finditer(rf"(?ms)^{re.escape(selector)} \{{\n(.*?)^\}}", css): found.update(re.findall(r"(--color-[\w-]+):\s*([^;]+);", block.group(1))) return found #: Peach 自己定值、不跟上游的 token。每一条都要在 `boardui/ORIGIN.md` 的差异表里 #: 有一行写清原因:默认逐字照抄,例外必须是写下来的决定,不是谁顺手改的一次。 LOCAL_TOKEN_VALUES = { (".dark", "--color-border-checkbox-default"): "var(--color-neutral-600)", (".dark", "--color-separator-border"): "var(--color-neutral-700)", } def test_the_react_subtree_redeclares_shared_tokens_with_upstream_values(self): """board.css 在 `:root` 上另定同名 token 且排在后面;React 容器上的值逐字等于 theme.css, 登记在 `ORIGIN.md` 差异表里的那几条除外。""" legacy = "".join(path.read_text(encoding="utf-8") for path in [ROOT / "web" / "board.css", *sorted((ROOT / "web" / "css").glob("*.css"))]) shared = set(re.findall(r"(--color-[\w-]+):", legacy)) theme = (FRONTEND / "src" / "react" / "boardui" / "styles" / "theme.css").read_text(encoding="utf-8") styles = (FRONTEND / "src" / "react" / "base.css").read_text(encoding="utf-8") origin = (FRONTEND / "src" / "react" / "boardui" / "ORIGIN.md").read_text(encoding="utf-8") for upstream, local in ((":root", ".peach-react"), (".dark", ".dark .peach-react")): expected = {name: value for name, value in self.declarations(theme, upstream).items() if name in shared} self.assertTrue(expected, f"theme.css 的 {upstream} 块里没找到同名 token") for (scope, name), value in self.LOCAL_TOKEN_VALUES.items(): if scope == upstream and name in expected: self.assertIn(name, origin, f"{name} 偏离上游,要在 ORIGIN.md 的差异表里写明原因") expected[name] = value self.assertEqual(self.declarations(styles, local), expected, f"base.css 的 {local} 要与 theme.css 的 {upstream} 同名 token 逐条一致") def test_manual_and_system_dark_palettes_in_board_css_agree(self): """手动选深色与跟随系统深色是同一副配色,两块分开写,只改一块时文字色会差一档。""" board = (ROOT / "web" / "board.css").read_text(encoding="utf-8") manual = re.search(r':root\[data-theme="dark"\]\{(--color-text-primary:[^}]*)\}', board) system = re.search(r"@media\(prefers-color-scheme:dark\)\{:root:not\(\[data-theme\]\)\{" r"(--color-text-primary:[^}]*)\}", board) self.assertIsNotNone(manual) self.assertIsNotNone(system) self.assertEqual(sorted(system.group(1).split(";")), sorted(manual.group(1).split(";"))) class ReactBundleTests(unittest.TestCase): """React 子树(BoardUI 源码 + Tailwind)与旧样式表同处一页的门槛。 这几条都是「tsc 和 vitest 看不见、页面上才出事」的约束:产物引用路径、样式表顺序、 工具类有没有被层叠层压住、Preflight 有没有漏到整页。 """ @classmethod def setUpClass(cls): cls.app = app_text() cls.css = APP_STYLES.read_text(encoding="utf-8") cls.pages = PAGES_BUNDLE.read_text(encoding="utf-8") cls.pages_css = PAGES_STYLES.read_text(encoding="utf-8") def test_shipped_bundles_do_not_require_node_globals(self): for name, bundle in (("peach-app.js 与 chunk", self.app), ("peach-pages.js", self.pages)): self.assertNotIn("process.env", bundle, f"{name}:库模式没替换 NODE_ENV,浏览器里没有 process") def test_shipped_bundles_have_independent_module_graphs(self): """主包入口与页面包首屏不静态导入任何文件;主包的 chunk 只从入口与别的 chunk 导入,同一模块只有一份。 开发标注工具由本机开关单独加载。""" def chunk(name): return name.startswith("./") and BUNDLE_CHUNK_NAME.fullmatch(name[2:]) is not None for path in (*APP_SCRIPTS, PAGES_BUNDLE): with self.subTest(path.name): text = path.read_text(encoding="utf-8") static = [a or b for a, b in STATIC_IMPORTS.findall(text)] dynamic = set(DYNAMIC_IMPORTS.findall(text)) if path == PAGES_BUNDLE: self.assertEqual((static, dynamic), ([], set())) elif path == BUNDLE: self.assertEqual(static, []) self.assertEqual({name for name in dynamic if not chunk(name)} - {"/dev/agentation.js"}, set()) else: self.assertEqual([name for name in static if name != "./peach-app.js" and not chunk(name)], []) self.assertEqual({name for name in dynamic if not chunk(name)}, set()) def test_utilities_stay_outside_cascade_layers(self): """旧样式表不分层。工具类放进层里,`button,input,textarea{color:inherit}` 这类标签规则就会压过它。""" for css in (self.css, self.pages_css): layers = set(re.findall(r"@layer\s+([\w-]+)", css)) self.assertNotIn("utilities", layers) self.assertNotIn("base", layers) def test_preflight_only_reaches_the_react_subtree(self): for css in (self.css, self.pages_css): self.assertEqual(css.count("@scope"), 1) self.assertRegex(css, r"@scope\s*\(\.peach-react\)") scoped = (FRONTEND / "src" / "react" / "preflight-scoped.css").read_text(encoding="utf-8") upstream = FRONTEND / "node_modules" / "tailwindcss" / "preflight.css" if not upstream.is_file(): self.skipTest("跳过 Preflight 原文比对:frontend/node_modules 还没装") opening = "@scope (.peach-react) {\n" self.assertIn(opening, scoped) self.assertTrue(scoped.endswith("}\n")) self.assertEqual(scoped[scoped.index(opening) + len(opening):-2], upstream.read_text(encoding="utf-8"), "preflight-scoped.css 与 tailwindcss 依赖里的原文不一致,按文件开头的说明重新生成") def test_react_styles_load_before_the_legacy_stylesheets(self): """同名 `--color-*` token 由后面的 board.css 定值,未迁移页面的颜色才不受影响。""" assets = PageAssets((ROOT / "web" / "index.html").read_text(encoding="utf-8")) order = [assets.styles.index(href) for href in ("/dist/peach-app.css", "/app.css", "/board.css")] self.assertEqual(order, sorted(order)) def test_the_first_frame_script_sets_the_dark_class(self): """BoardUI 的深色 token 挂在 `.dark` 上,首帧脚本要在主包加载前按实际深浅加减它。 之后切换主题由 `applyTheme()` 负责,`frontend/test/appearance/layout.test.ts` 在 DOM 上验。 """ index = (ROOT / "web" / "index.html").read_text(encoding="utf-8") self.assertIn("classList.toggle('dark',", index) class BoardUiUpstreamTests(unittest.TestCase): """`frontend/src/react/boardui/` 与 `evilcharts/` 是上游注册表源码的逐字副本,只加不改 (ADR-0031、ADR-0076)。 每个目录的 `UPSTREAM.sha256` 记下复制时每个文件的 SHA-256。改了副本、多出没登记的文件、 登记了却删掉,都在这里红。比的是登记的快照而不是线上注册表:上游随时会改,测试不能联网。 升级上游时重新复制文件、重算对应行,并更新同目录 `ORIGIN.md` 的条目哈希。 """ VENDORED = ("boardui", "evilcharts") RECORDS = ("ORIGIN.md", "UPSTREAM.sha256") def test_the_copied_sources_match_their_recorded_upstream_hashes(self): for name in self.VENDORED: with self.subTest(name): self.assert_matches_upstream(FRONTEND / "src" / "react" / name, name) def assert_matches_upstream(self, root: Path, name: str): recorded = {} for line in (root / "UPSTREAM.sha256").read_text(encoding="utf-8").splitlines(): digest, path = line.split(" ", 1) recorded[path] = digest present = {file.relative_to(root).as_posix(): file for file in root.rglob("*") if file.is_file() and file.name not in self.RECORDS} self.assertEqual(sorted(present), sorted(recorded), f"{name}/ 的文件要与 UPSTREAM.sha256 一一对应;Peach 自己的组合放在 {name}/ 外面") for path, file in present.items(): self.assertEqual(hashlib.sha256(file.read_bytes()).hexdigest(), recorded[path], f"{name}/{path} 与复制时的上游内容不同;外观差异在 {name}/ 外面组合") class FrontendManifestTests(unittest.TestCase): """依赖清单和根 `package.json` 是两份,各自的口径都要精确。""" def setUp(self): self.manifest = json.loads((FRONTEND / "package.json").read_text(encoding="utf-8")) def test_versions_are_exactly_pinned_and_locked(self): self.assertTrue(self.manifest["private"]) declared = {**self.manifest.get("dependencies", {}), **self.manifest.get("devDependencies", {})} self.assertTrue(declared) for name, version in declared.items(): self.assertRegex(version, r"^\d+\.\d+\.\d+$", f"{name} 没有钉死版本,两台机器会装出不同的产物") lock = json.loads((FRONTEND / "package-lock.json").read_text(encoding="utf-8")) self.assertEqual(lock["name"], self.manifest["name"]) for name, version in declared.items(): entry = lock["packages"].get(f"node_modules/{name}") self.assertIsNotNone(entry, f"{name} 不在 lockfile 里") self.assertEqual(entry["version"], version, name) def test_the_root_manifest_stays_limited_to_the_vendored_packages(self): """根清单只登记手工 vendor 的那几个包,构建依赖不许混进去。""" root = json.loads((ROOT / "package.json").read_text(encoding="utf-8")) self.assertNotIn("vite", root.get("devDependencies", {})) self.assertNotIn("preact", root.get("dependencies", {})) def test_the_frontend_has_no_preact_left(self): """前端只有 React 一档(ADR-0031):留着 Preact 就是两套运行时各打一份进产物。""" manifest = json.loads((FRONTEND / "package.json").read_text(encoding="utf-8")) for section in ("dependencies", "devDependencies"): self.assertNotIn("preact", manifest.get(section, {})) config = json.loads(re.sub(r"^\s*//.*$", "", (FRONTEND / "tsconfig.json") .read_text(encoding="utf-8"), flags=re.M)) self.assertNotIn("jsxImportSource", config["compilerOptions"]) def test_dependabot_watches_the_frontend_manifest(self): dependabot = (ROOT / ".github" / "dependabot.yml").read_text(encoding="utf-8") self.assertIn("directory: /frontend", dependabot) def test_ci_owns_the_only_gate_that_can_catch_a_stale_bundle(self): """本机验不了「产物是否由当前源码构建」,这道门槛只在 CI。 它一旦被删掉,源码和 `web/dist/` 就能无声地分叉,而所有本地测试照样全绿—— 那正是这条断言要拦的。`git add --intent-to-add` 不能省:光 `git diff` 只看 已跟踪的文件,构建新出的产物是未跟踪的,会被直接放过。 """ workflow = (ROOT / ".github" / "workflows" / "test.yml").read_text(encoding="utf-8") for step in ("actions/setup-node", "npm --prefix frontend ci", "npm --prefix frontend run typecheck", "npm --prefix frontend run lint", "npm --prefix frontend test", "npm --prefix frontend run build", "git add --intent-to-add -- web/dist", "git diff --exit-code --stat -- web/dist"): self.assertIn(step, workflow, f"CI 缺了 {step}") def test_the_bundle_is_committed_and_the_toolchain_is_not(self): tracked = subprocess.run( ["git", "-C", str(ROOT), "check-ignore", "web/dist/peach-app.js"], capture_output=True, text=True, encoding="utf-8", errors="replace", check=False) self.assertEqual(tracked.returncode, 1, "web/dist 被 .gitignore 排除了,产物发不出去") for ignored in ("frontend/node_modules/react/package.json",): result = subprocess.run(["git", "-C", str(ROOT), "check-ignore", ignored], capture_output=True, text=True, encoding="utf-8", errors="replace", check=False) self.assertEqual(result.returncode, 0, f"{ignored} 没有被忽略") class SharedStateContractTests(unittest.TestCase): """一份数据有第二个读者时,两个读者读同一个 `queryKey`(ADR-0031)。 共享数据的家是 `src/query/client.ts` 那一个 QueryClient,壳与 React 岛都从 `@peach/query` 取它。这条门槛盯的是源码布局:等到跑起来才发现两处各存一份,已经晚了。 """ def test_the_site_has_exactly_one_query_client(self): sources = sorted(path for path in (FRONTEND / "src").rglob("*.ts*")) declared = [path.relative_to(FRONTEND / "src").as_posix() for path in sources if "new QueryClient(" in path.read_text(encoding="utf-8")] self.assertEqual(declared, ["query/client.ts"], f"QueryClient 建在 {declared}") def test_pages_do_not_keep_a_second_copy_of_shared_data(self): """跨页共享的数据不另起一套订阅:`@preact/signals` 已经没有读者。""" outside = [path.name for path in sorted((FRONTEND / "src").rglob("*.ts*")) if "@preact/signals" in path.read_text(encoding="utf-8")] self.assertEqual(outside, [], f"{outside} 用了 signal:跨页共享请读同一个 queryKey,局部状态用 hooks") class VitestTests(unittest.TestCase): """Vitest 的行为回归与独立的类型、样式检查共同构成前端验收。""" def test_the_island_suite_has_a_bounded_worker_pool(self): config = (FRONTEND / "vitest.config.ts").read_text(encoding="utf-8") self.assertIn("maxWorkers: 4", config) def test_the_island_suite_passes(self): npm = frontend_npm("vitest", "vitest") # 定向入口(含全量失败后的单独重跑)只跑指定的几个文件,路径相对 frontend/。 selected = json.loads(os.environ.get("PEACH_VITEST_FILES", "[]")) # 另挂一个 JSON 报告器,把逐条结果交给测试结果历史;控制台输出与判据不变。 with tempfile.TemporaryDirectory() as folder: report = Path(folder) / "vitest.json" completed = subprocess.run( [npm, "--prefix", str(FRONTEND), "test", "--silent", "--", "--reporter=default", "--reporter=json", f"--outputFile.json={report}", *selected], capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(FRONTEND), check=False) if report.is_file(): data = json.loads(report.read_text(encoding="utf-8")) test_history.record_external(test_history.vitest_rows(data, FRONTEND) + test_history.vitest_files(data, FRONTEND)) output = f"{completed.stdout}\n{completed.stderr}" # vitest 的着色随宿主终端能力漂移,计数断言只认去码后的纯文本; # 退出码与用例数两道判据不受着色影响。 plain = re.sub(r"\x1b\[[0-9;]*m", "", output) self.assertEqual(completed.returncode, 0, output) self.assertRegex(plain, r"Tests\s+\d+ passed", output) self.assertNotRegex(plain, r"Tests\s+0 passed", "vitest 一个用例都没跑") counted = re.search(r"Tests\s+(\d+) passed", plain) assert counted is not None self.assertGreaterEqual(int(counted.group(1)), 1 if selected else 10, output) class BrowserSuiteWorkflowTests(unittest.TestCase): """CI 里的浏览器冒烟与设计决定断言只能执行或失败,不能静默跳过。 缺前置条件在 CI 判失败(`tests/support/conditions.py`)只做了一半:工作流还得真的装齐 Node、依赖、ffmpeg 与 Chrome,并让结果进入 `verified` 的汇总。任一处被删掉,冒烟就回到 从未在 CI 执行的状态,或者让 `python` 矩阵的全量行判失败。 """ @classmethod def setUpClass(cls): cls.workflow = (ROOT / ".github" / "workflows" / "test.yml").read_text(encoding="utf-8") def job(self, name: str) -> str: found = re.search(rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ \S|\Z)", self.workflow) self.assertIsNotNone(found, f"CI 没有 {name} job") return found.group(1) def test_the_browser_job_installs_every_prerequisite_and_runs_the_web_scope(self): job = self.job("web-e2e") for step in ("runs-on: windows-latest", "actions/setup-node", 'node-version: "24"', "npm --prefix frontend ci", "./.github/actions/setup-ffmpeg", 'ffmpeg-version: "9.0.1"', "PEACH_E2E_CHROME=", "& .\\scripts\\test.ps1 -Scope web"): self.assertIn(step, job, f"web-e2e job 缺了 {step}") def test_the_summary_requires_browser_and_frontend_jobs(self): needs = re.search(r"(?ms)^ verified:\n.*?^ needs: \[([^\]]*)\]", self.workflow) self.assertIsNotNone(needs, "verified job 没有 needs 列表") for required in ("web-e2e", "web-bundle"): self.assertIn(required, [name.strip() for name in needs.group(1).split(",")]) def test_matrix_rows_beyond_core_install_what_the_node_suites_need(self): job = self.job("python") for step in ("actions/setup-node", "npm --prefix frontend ci", "./.github/actions/setup-ffmpeg", 'ffmpeg-version: "9.0.1"', "PEACH_E2E_CHROME="): self.assertIn(step, job, f"python 矩阵缺了 {step},全量行上的用例会判失败") if __name__ == "__main__": unittest.main()