"""Observable builder contracts. Run with Python's unittest discovery.""" import base64 from contextlib import redirect_stderr import importlib.util from html.parser import HTMLParser import io import json import re from pathlib import Path import shutil import subprocess import sys import tempfile import unittest ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location("build_explorer", ROOT / "scripts" / "build_explorer.py") builder = importlib.util.module_from_spec(spec) spec.loader.exec_module(builder) class ExplorerBuildTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.folder = Path(self.tmp.name) self.manifest = self.folder / "manifest.json" self.output = self.folder / "output" / "explore.html" self.source = self.folder / "sample.html" self.source.write_text('同一内容', encoding="utf-8") self.data = {"schemaVersion": 1, "project": "项目", "brief": "同一内容比较", "round": "01", "candidates": [{"id": "a", "name": "方向一", "concept": "编辑式", "typography": "宋体与黑体", "palette": ["#112233", "#fff"], "traits": ["大标题"], "kind": "html", "source": "sample.html"}]} self.save() def save(self): self.manifest.write_text(json.dumps(self.data, ensure_ascii=False), encoding="utf-8") def payload(self, page): return json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0]) def run_browser_probe(self, result_id="i18n-result", profile="chrome-probe"): chrome = (shutil.which("google-chrome") or shutil.which("chromium") or "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") runner = r""" import {spawn} from 'node:child_process'; const chrome=spawn(process.argv[1],['--headless=new','--remote-debugging-pipe', '--no-first-run','--no-default-browser-check','--disable-extensions', '--force-prefers-reduced-motion',`--user-data-dir=${process.argv[2]}`,'about:blank'], {stdio:['ignore','ignore','ignore','pipe','pipe']}); let seq=0,buffer='';const pending=new Map(); chrome.on('error',error=>{console.error(error);process.exitCode=1;}); chrome.stdio[4].on('data',data=>{ buffer+=data.toString();let end; while((end=buffer.indexOf('\0'))>=0){ const message=JSON.parse(buffer.slice(0,end));buffer=buffer.slice(end+1); if(pending.has(message.id)){const {resolve,reject}=pending.get(message.id);pending.delete(message.id); message.error?reject(Error(message.error.message)):resolve(message.result);} } }); const send=(method,params={},sessionId)=>new Promise((resolve,reject)=>{ const id=++seq;pending.set(id,{resolve,reject}); chrome.stdio[3].write(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})+'\0'); }); const deadline=setTimeout(()=>{console.error('Browser probe timed out');chrome.kill('SIGKILL');process.exitCode=1;},15000); try{ const {targetId}=await send('Target.createTarget',{url:'about:blank'}); const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); await send('Page.enable',{},sessionId); await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:'reduce'}]},sessionId); await send('Page.navigate',{url:process.argv[3]},sessionId); let result; for(let i=0;i<100;i++){ const response=await send('Runtime.evaluate',{expression:"document.querySelector('#i18n-result')?.textContent",returnByValue:true},sessionId); result=response.result.value;if(result)break; await new Promise(resolve=>setTimeout(resolve,50)); } if(!result)throw Error('The page did not complete its interface/copy probe'); console.log(result); }catch(error){console.error(error);process.exitCode=1;} finally{clearTimeout(deadline);chrome.kill();} """ runner = runner.replace("#i18n-result", "#" + result_id) return subprocess.run([shutil.which("node"), "--input-type=module", "-e", runner, chrome, str(self.folder / profile), self.output.as_uri()], capture_output=True, text=True, timeout=25, encoding="utf-8") def test_lang_is_optional_and_preserved_when_explicit(self): builder.build(self.manifest, self.output) self.assertNotIn("lang", self.payload(self.output.read_text(encoding="utf-8"))) for lang in ("zh", "en"): with self.subTest(lang=lang): self.data["lang"] = lang self.save() builder.build(self.manifest, self.output, force=True) self.assertEqual(self.payload(self.output.read_text(encoding="utf-8"))["lang"], lang) def test_invalid_lang_preserves_existing_output(self): builder.build(self.manifest, self.output) original = self.output.read_bytes() for lang in (None, "", "ZH", "zh-CN", "en-US", "fr", 1, True, [], {}): with self.subTest(lang=lang): self.data["lang"] = lang self.save() with self.assertRaisesRegex(ValueError, "manifest.lang"): builder.build(self.manifest, self.output, force=True) self.assertEqual(original, self.output.read_bytes()) def test_native_viewport_formats_are_normalized_and_equivalent(self): fingerprints = set() for value in ("1920x1080", "1920×1080", " 1920 X 1080 ", {"width": 1920, "height": 1080}, {"width": 1920.0, "height": 1080.0}): for top_level in (True, False): with self.subTest(value=value, top_level=top_level): self.data.pop("nativeViewport", None) self.data["candidates"][0].pop("nativeViewport", None) target = self.data if top_level else self.data["candidates"][0] target["nativeViewport"] = value self.save() fingerprints.add(builder.build(self.manifest, self.output, force=True)["fingerprint"]) payload = self.payload(self.output.read_text(encoding="utf-8")) self.assertEqual(payload["candidates"][0]["nativeViewport"], {"width": 1920, "height": 1080}) self.assertEqual(len(fingerprints), 1) def test_native_viewport_inheritance_override_and_identity(self): default = builder.build(self.manifest, self.output) self.assertNotIn("nativeViewport", self.payload(self.output.read_text(encoding="utf-8"))["candidates"][0]) self.data["nativeViewport"] = "1920x720" self.data["candidates"].extend([ dict(self.data["candidates"][0], id="b", nativeViewport={"width": 1366, "height": 1024}), dict(self.data["candidates"][0], id="c", nativeViewport="198×242"), ]) self.save() result = builder.build(self.manifest, self.output, force=True) candidates = self.payload(self.output.read_text(encoding="utf-8"))["candidates"] self.assertEqual([c["nativeViewport"] for c in candidates], [ {"width": 1920, "height": 720}, {"width": 1366, "height": 1024}, {"width": 198, "height": 242}]) self.assertNotEqual(result["fingerprint"], default["fingerprint"]) self.data["candidates"][0]["nativeViewport"] = "1920x1080" self.save() self.assertNotEqual(builder.build(self.manifest, self.output, force=True)["fingerprint"], result["fingerprint"]) def test_invalid_native_viewports_report_received_values_and_preserve_output(self): builder.build(self.manifest, self.output) original = self.output.read_bytes() invalid = (None, True, 1920, [], {}, "", "1920", "1920:1080", "-1x1080", "0×1080", "1920.5x1080", {"width": 1920}, {"width": "1920", "height": 1080}, {"width": True, "height": 1080}, {"width": 1920, "height": 0}, {"width": 1920, "height": 1.5}) for value in invalid: for top_level in (True, False): with self.subTest(value=value, top_level=top_level): self.data.pop("nativeViewport", None) self.data["candidates"][0].pop("nativeViewport", None) target = self.data if top_level else self.data["candidates"][0] target["nativeViewport"] = value self.save() with self.assertRaises(builder.ManifestError) as caught: builder.build(self.manifest, self.output, force=True) message = str(caught.exception) for expected in ("候选 a", "nativeViewport", json.dumps(value, ensure_ascii=False), '"1920x1080"', '"1920×1080"', '{"width":1920,"height":1080}'): self.assertIn(expected, message) self.assertEqual(self.output.read_bytes(), original) def test_native_viewport_errors_are_collected_with_other_manifest_problems(self): self.data["nativeViewport"] = "bad-default" self.data["candidates"].append(dict(self.data["candidates"][0], id="b", nativeViewport="bad-b", palette=["red"])) self.data["candidates"].append(dict(self.data["candidates"][0], id="c", nativeViewport={"height": 242})) self.save() with self.assertRaises(builder.ManifestError) as caught: builder.build(self.manifest, self.output) self.assertEqual(len(caught.exception.problems), 4) for expected in ("manifest(用于候选 a)", "bad-default", "候选 b:nativeViewport", "bad-b", "候选 b:palette", "候选 c:nativeViewport", '{"height": 242}'): self.assertIn(expected, str(caught.exception)) @unittest.skipUnless(shutil.which("node"), "Node.js is needed to execute preview fitting") def test_default_viewports_and_narrow_native_canvas_fitting(self): template = builder.TEMPLATE.read_text(encoding="utf-8") fit = template.split(" function fit(s){", 1)[1].split("\n const fitAll=", 1)[0] program = r""" const SIZES={desktop:{w:1280,h:900},mobile:{w:390,h:844}},st={viewport:'desktop'}; const byId={a:{}},innerHeight=844,$=()=>({offsetHeight:100}); const narrow=()=>true,getComputedStyle=()=>({paddingLeft:'12',paddingRight:'12',paddingTop:'16',paddingBottom:'16'}); const m={style:{}},box={clientWidth:390,clientHeight:0,dataset:{zoom:'fit'},classList:{contains:()=>false}}; const s={isConnected:true,dataset:{id:'a'},parentElement:box,querySelector:()=>m,style:{}}; """ + "function fit(s){" + fit + r""" const result=[]; fit(s);result.push({...m.style,stage:{...s.style}}); st.viewport='mobile';fit(s);result.push({...m.style,stage:{...s.style}}); byId.a.nativeViewport={width:198,height:242};fit(s);result.push({...m.style,stage:{...s.style}}); byId.a.nativeViewport={width:1366,height:1024};box.classList.contains=()=>true; fit(s);result.push({...m.style,stage:{...s.style}}); byId.a.nativeViewport={width:198,height:2000};fit(s);result.push({...m.style,stage:{...s.style}}); box.dataset.zoom='actual';fit(s);result.push({...m.style,stage:{...s.style}}); console.log(JSON.stringify(result)); """ run = subprocess.run([shutil.which("node"), "-e", program], capture_output=True, text=True, timeout=10, encoding="utf-8") self.assertEqual(run.returncode, 0, run.stderr) results = json.loads(run.stdout) for result, (width, height, scale) in zip(results, [ (1280, 900, 390 / 1280), (390, 844, 1), (198, 242, 1), (1366, 1024, 366 / 1366), (198, 2000, 712 / 2000), (198, 2000, 1)]): self.assertEqual(result["width"], f"{width}px") self.assertEqual(result["height"], f"{height}px") self.assertAlmostEqual(float(result["transform"][6:-1]), scale) self.assertAlmostEqual(float(result["stage"]["width"][:-2]), width * scale) self.assertAlmostEqual(float(result["stage"]["height"][:-2]), height * scale) @unittest.skipUnless(shutil.which("node") and (shutil.which("google-chrome") or shutil.which("chromium") or Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome").is_file()), "Chrome/Chromium is needed for rendered viewport checks") def test_rendered_native_viewports_fit_cards_loupe_and_actual_size(self): self.data["nativeViewport"] = "1920x1080" self.data["candidates"].extend([ dict(self.data["candidates"][0], id="b", nativeViewport="1920×720"), dict(self.data["candidates"][0], id="c", nativeViewport={"width": 198, "height": 242}), ]) self.save() builder.build(self.manifest, self.output) probe = r"""""" self.output.write_text(self.output.read_text(encoding="utf-8").replace("", probe + ""), encoding="utf-8") run = self.run_browser_probe("viewport-result", "chrome-native") self.assertEqual(run.returncode, 0, run.stderr) result = json.loads(run.stdout) sizes = {"a": (1920, 1080), "b": (1920, 720), "c": (198, 242)} for mode, samples in result.items(): self.assertEqual(len(samples), 3, mode) for sample in samples: with self.subTest(mode=mode, id=sample["id"]): width, height = sizes[sample["id"]] self.assertEqual((sample["width"], sample["height"]), (width, height)) scale = sample["scale"] self.assertGreater(scale, 0) self.assertLessEqual(scale, 1) if mode == "actual": self.assertEqual(scale, 1) else: self.assertLessEqual(sample["stage"][0], sample["available"][0] + .1) if mode == "loupe": self.assertLessEqual(sample["stage"][1], sample["available"][1] + .1) for dimension, native in enumerate((width, height)): self.assertAlmostEqual(sample["stage"][dimension], native * scale, delta=.1) self.assertAlmostEqual(sample["media"][dimension], native * scale, delta=.1) def test_interface_strings_are_complete_and_centralized(self): template = builder.TEMPLATE.read_text(encoding="utf-8") table_text = template.split("const I18N = ", 1)[1].split(";\n", 1)[0] table = json.loads(table_text) self.assertEqual(set(table), {"zh", "en"}) self.assertEqual(set(table["zh"]), set(table["en"])) keys = set(re.findall(r"\bt\('([^']+)'", template)) keys.update(re.findall(r'data-i18n(?:-[\w-]+)?="([^"]+)"', template)) self.assertEqual(keys, set(table["zh"])) for key in keys: self.assertTrue(table["zh"][key], key) self.assertTrue(table["en"][key], key) self.assertNotRegex(table["en"][key], r"[\u3400-\u9fff]", key) placeholders = lambda value: set(re.findall(r"\{(\w+)\}", value)) self.assertEqual(placeholders(table["zh"][key]), placeholders(table["en"][key]), key) # Both locales are shipped for browser detection; neither may leak into # markup or JavaScript outside this single table (comments are not UI). shell = template.replace(table_text, "{}") shell = re.sub(r"/\*.*?\*/|(?m:^\s*//[^\n]*)", "", shell, flags=re.S) self.assertNotRegex(shell, r"[\u3400-\u9fff]") @unittest.skipUnless(shutil.which("node"), "Node.js is needed to execute the page's language and copy functions") def test_browser_language_fallback_and_copied_sentence(self): template = builder.TEMPLATE.read_text(encoding="utf-8") translations = template.split("// Interface strings:", 1)[1].split("(()=>{", 1)[0] translations = translations[translations.index("const I18N = "):] copy_function = template.split(" function copyText()", 1)[1].split("\n async function copy", 1)[0] number_function = template.split("no=c=>", 1)[1].split(",vtName=", 1)[0] cases = [(None, "zh-CN", "zh"), (None, "ZH-hant", "zh"), (None, "en-US", "en"), (None, "fr-FR", "en"), (None, "", "en"), ("zh", "en-US", "zh"), ("en", "zh-CN", "en")] program = "const results=[];\n" for lang, browser_lang, expected in cases: for round_value, notes, baseline in (("01", " More space ", False), ("", "", False), ("02", "", True)): data = {"round": round_value} if lang is not None: data["lang"] = lang program += "{\nconst DATA=" + json.dumps(data) + ";const navigator={language:" + json.dumps(browser_lang) + "};\n" program += translations program += "const c={name:'Direction A',baseline:" + json.dumps(baseline) + "};const DIRS=[c],byId={a:c},st={chosen:'a',notes:" + json.dumps(notes) + "};\n" program += "const no=c=>" + number_function + ";\nfunction copyText()" + copy_function program += "\nresults.push({lang:LANG,text:copyText()});\n}\n" program += "console.log(JSON.stringify(results));" # Passed as a file: the program is longer than a Windows command line may be. with tempfile.TemporaryDirectory() as folder: script = Path(folder) / "program.cjs" script.write_text(program, encoding="utf-8") run = subprocess.run([shutil.which("node"), str(script)], capture_output=True, text=True, encoding="utf-8", timeout=30) self.assertEqual(run.returncode, 0, run.stderr) results = iter(json.loads(run.stdout)) for _, _, expected in cases: number = "现状" if expected == "zh" else "Current" texts = (["01:选 01 Direction A", "选 01 Direction A", f"02:选 {number} Direction A"] if expected == "zh" else ["Round 01: Go with 01 Direction A", "Go with 01 Direction A", f"Round 02: Go with {number} Direction A"]) for text in texts: self.assertEqual(next(results), {"lang": expected, "text": text}) @unittest.skipUnless(shutil.which("node") and (shutil.which("google-chrome") or shutil.which("chromium") or Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome").is_file()), "Chrome/Chromium is needed for rendered interface checks") def test_rendered_interfaces_do_not_mix_languages(self): chrome = (shutil.which("google-chrome") or shutil.which("chromium") or "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") self.source.write_text('Sample', encoding="utf-8") self.data.update(project="Demo", brief="Compare directions", round="01") self.data["candidates"][0].update(name="Direction A", concept="Clear hierarchy", typography="System font", traits=["Large headings"]) self.data["candidates"].append(dict(self.data["candidates"][0], id="b", baseline=True, interactive=True)) probe = r"""""" table = json.loads(builder.TEMPLATE.read_text(encoding="utf-8").split("const I18N = ", 1)[1].split(";\n", 1)[0]) for lang in ("zh", "en"): with self.subTest(lang=lang): self.data["lang"] = lang self.save() builder.build(self.manifest, self.output, force=True) page = self.output.read_text(encoding="utf-8") key = "oil-ui:" + self.payload(page)["fingerprint"] legacy = {"layout": "loupe", "viewport": "mobile", "current": "b", "chosen": "a", "visible": [], "notesOn": False, "notes": "Old private note"} seed = "" page = page.replace("' self.save() builder.build(self.manifest, self.output) page = self.output.read_text(encoding="utf-8") self.assertNotIn(self.data["project"], page) self.assertIn('\\u003c/script>', page) def test_csp_is_inserted_in_actual_head_not_a_comment(self): self.source.write_text(''' content''', encoding="utf-8") content = builder.prepare_html(self.source) observed = [] class Tags(HTMLParser): def handle_starttag(self, tag, attrs): observed.append((tag, dict(attrs))) Tags().feed(content) meta = [attrs for tag, attrs in observed if tag == "meta"] self.assertEqual(meta, [{"http-equiv": "Content-Security-Policy", "content": builder.PREVIEW_CSP}]) self.assertEqual([tag for tag, attrs in observed][:3], ["html", "head", "meta"]) def test_nested_documents_are_rejected_before_replacing_output(self): builder.build(self.manifest, self.output) original = self.output.read_bytes() self.source.write_text(''' ''', encoding="utf-8") with self.assertRaises(ValueError): builder.build(self.manifest, self.output, force=True) self.assertEqual(original, self.output.read_bytes()) def test_existing_output_is_preserved_and_force_is_explicit(self): builder.build(self.manifest, self.output) original = self.output.read_bytes() self.data["project"] = "新项目" self.save() with self.assertRaises(FileExistsError): builder.build(self.manifest, self.output) self.assertEqual(original, self.output.read_bytes()) builder.build(self.manifest, self.output, force=True) self.assertNotEqual(original, self.output.read_bytes()) def test_invalid_resource_keeps_prior_output_even_with_force(self): builder.build(self.manifest, self.output) original = self.output.read_bytes() self.source.write_text('', encoding="utf-8") with self.assertRaises(ValueError): builder.build(self.manifest, self.output, force=True) self.assertEqual(original, self.output.read_bytes()) def test_duplicate_identifier_and_invalid_colors(self): self.data["candidates"].append(self.data["candidates"][0].copy()) self.save() with self.assertRaises(ValueError): builder.build(self.manifest, self.output) self.data["candidates"].pop() self.data["candidates"][0]["palette"] = ["url(example)"] self.save() with self.assertRaises(ValueError): builder.build(self.manifest, self.output) self.assertFalse(self.output.exists()) def test_text_and_preserved_script_are_not_css_resources(self): self.source.write_text('''

Explain @import and url(example.png)

''', encoding="utf-8") builder.build(self.manifest, self.output) self.assertTrue(self.output.is_file()) def test_css_sources_are_checked_in_styles_and_inline_attributes(self): fragments = [ '', '', '', '', ] for fragment in fragments: with self.subTest(fragment=fragment): self.source.write_text(f'{fragment}content', encoding="utf-8") with self.assertRaises(ValueError): builder.build(self.manifest, self.output) self.source.write_text('content', encoding="utf-8") with self.assertRaises(ValueError): builder.build(self.manifest, self.output) def test_source_escape_and_overwriting_inputs_are_rejected(self): self.data["candidates"][0]["source"] = "../outside.html" self.save() with self.assertRaisesRegex(ValueError, "把文件复制进 manifest 目录"): builder.build(self.manifest, self.output) self.data["candidates"][0]["source"] = "sample.html" self.save() with self.assertRaises(ValueError): builder.build(self.manifest, self.source, force=True) with self.assertRaises(ValueError): builder.build(self.manifest, self.manifest, force=True) def test_static_image_embedded_and_content_changes_identity(self): image = self.folder / "preview.png" image.write_bytes(base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j7ioAAAAASUVORK5CYII=')) self.data["candidates"][0].update(kind="image", source="preview.png") self.save() first = builder.build(self.manifest, self.output) self.assertIn("data:image/png;base64,", self.output.read_text(encoding="utf-8")) self.data["round"] = "02" self.save() second = builder.build(self.manifest, self.output, force=True) self.assertNotEqual(first["fingerprint"], second["fingerprint"]) def test_relative_local_assets_are_embedded(self): png = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=") (self.folder / "pages" / "img").mkdir(parents=True) (self.folder / "pages" / "img" / "dot.png").write_bytes(png) (self.folder / "pages" / "a.html").write_text('', encoding="utf-8") self.data["candidates"][0]["source"] = "pages/a.html" self.save() builder.build(self.manifest, self.output) page = self.output.read_text(encoding="utf-8") self.assertNotIn("img/dot.png", page) self.assertIn("data:image/png;base64,", page) def test_local_assets_outside_the_manifest_folder_stay_rejected(self): outside = Path(self.tmp.name).parent / f"{Path(self.tmp.name).name}-outside.png" outside.write_bytes(b"\x89PNG\r\n\x1a\n") self.addCleanup(outside.unlink) for fragment in (f'', f'', f'', f''): with self.subTest(fragment=fragment): self.source.write_text(f'{fragment}', encoding="utf-8") with self.assertRaises(ValueError) as caught: builder.build(self.manifest, self.output) for expected in ("候选 a", outside.name, "把文件复制进 manifest 目录"): self.assertIn(expected, str(caught.exception)) def test_live_candidates_accept_only_local_dev_servers(self): self.data["candidates"].append({"id": "live", "name": "现状", "concept": "当前版本", "typography": "系统字体", "palette": ["#fff"], "traits": ["现有页面"], "kind": "url", "url": "http://localhost:5173/orders", "baseline": True}) self.save() builder.build(self.manifest, self.output) page = self.output.read_text(encoding="utf-8") payload = json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0]) self.assertEqual([c["id"] for c in payload["candidates"]], ["live", "a"]) for url in ("https://example.com/", "file:///etc/passwd", "http://user:pw@localhost:3000/"): self.data["candidates"][1]["url"] = url self.save() with self.assertRaises(ValueError): builder.build(self.manifest, self.output, force=True) def test_serve_accepts_command_and_optional_absolute_cwd_and_local_url(self): for serve in ( {"command": "pnpm dev"}, {"command": "pnpm dev", "cwd": str(self.folder)}, {"command": "pnpm dev", "cwd": str(self.folder), "url": "http://localhost:3456"}, {"command": "pnpm dev", "url": "https://127.0.0.1:3456/"}, {"command": "pnpm dev", "url": "http://[::1]:3456/"}, ): with self.subTest(serve=serve): self.data["serve"] = serve self.save() builder.build(self.manifest, self.output, force=True) page = self.output.read_text(encoding="utf-8") payload = json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0]) self.assertEqual(payload["serve"], serve) def test_serve_requires_a_nonempty_string_command(self): for serve in ({}, {"command": ""}, {"command": " "}, {"command": 123}, {"command": None}): with self.subTest(serve=serve): self.data["serve"] = serve self.save() with self.assertRaisesRegex(ValueError, "serve.command 必须是非空字符串"): builder.build(self.manifest, self.output) for serve in (None, [], "pnpm dev"): with self.subTest(serve=serve): self.data["serve"] = serve self.save() with self.assertRaisesRegex(ValueError, "serve 必须是对象"): builder.build(self.manifest, self.output) self.assertFalse(self.output.exists()) def test_serve_cwd_must_be_an_absolute_path(self): for cwd in ("project", "./project", "~/project", "", None, 123): with self.subTest(cwd=cwd): self.data["serve"] = {"command": "pnpm dev", "cwd": cwd} self.save() with self.assertRaisesRegex(ValueError, "serve.cwd 必须是绝对路径"): builder.build(self.manifest, self.output) def test_serve_url_must_be_local_http_or_https(self): for url in ("https://example.com/", "file:///tmp/index.html", "ftp://localhost/", "http://user:pw@localhost:3456/", "http://localhost:bad/", "http://[::1", "", None, 123): with self.subTest(url=url): self.data["serve"] = {"command": "pnpm dev", "url": url} self.save() with self.assertRaisesRegex(ValueError, "serve.*url.*本机"): builder.build(self.manifest, self.output) def test_serve_is_embedded_unchanged_without_script_escape(self): serve = {"command": " printf ''\u2028\u2029 ", "cwd": str(self.folder / 'a"$`\\b'), "url": "http://localhost:3456", "note": "保留额外元数据"} self.data["serve"] = serve self.save() builder.build(self.manifest, self.output) page = self.output.read_text(encoding="utf-8") payload = json.loads(page.split("const DATA = ", 1)[1].split(";\n", 1)[0]) self.assertEqual(payload["serve"], serve) self.assertNotIn(serve["command"], page) self.assertIn('\\u003c/script>', page) self.assertIn('\\u2028\\u2029', page) def test_url_candidates_without_serve_warn_but_still_build(self): self.data["candidates"][0].update(kind="url", url="http://localhost:3456/") self.save() messages = io.StringIO() with redirect_stderr(messages): builder.build(self.manifest, self.output) self.assertTrue(self.output.is_file()) self.assertEqual(len(messages.getvalue().splitlines()), 1) self.assertIn("建议在 manifest 顶层补上 serve", messages.getvalue()) self.data["serve"] = {"command": "pnpm dev"} self.save() messages = io.StringIO() with redirect_stderr(messages): builder.build(self.manifest, self.output, force=True) self.assertEqual(messages.getvalue(), "") def test_shell_connections_allow_only_this_rounds_candidate_origins(self): self.data["serve"] = {"command": "pnpm dev", "url": "http://localhost:9999/"} urls = ["http://localhost:3456/a?note=\"', encoding="utf-8") with self.assertRaises(ValueError): builder.build(self.manifest, self.output, force=True) self.data["candidates"][0].update(kind="image", interactive=True) self.save() with self.assertRaises(ValueError): builder.build(self.manifest, self.output, force=True) def test_local_stylesheets_and_scripts_are_inlined(self): (self.folder / "vendor").mkdir() (self.folder / "vendor" / "lib.js").write_text("window.lib=''", encoding="utf-8") (self.folder / "app.css").write_text("body{color:#123}", encoding="utf-8") self.source.write_text('' '

内容

', encoding="utf-8") static = builder.prepare_html(self.source, root=self.folder) self.assertIn("", static) self.assertNotIn("lib.js", static) self.assertNotIn("window.lib", static) live = builder.prepare_html(self.source, interactive=True, root=self.folder) self.assertIn("'unsafe-eval'", live) self.assertLess(live.index("

内容

"), live.index("window.lib")) self.assertIn("<\\/script>", live) self.data["candidates"][0]["interactive"] = True self.save() builder.build(self.manifest, self.output) def test_only_one_baseline(self): second = dict(self.data["candidates"][0], id="b", baseline=True) self.data["candidates"][0]["baseline"] = True self.data["candidates"].append(second) self.save() with self.assertRaises(ValueError): builder.build(self.manifest, self.output) def test_template_keeps_documented_comparison_features(self): builder.build(self.manifest, self.output) page = self.output.read_text(encoding="utf-8") hooks = { "并排与单张": 'data-layout="loupe"', "手机视口": 'data-viewport="mobile"', "实际尺寸": '实际尺寸 100%', "选择": "st.chosen", "选择即复制": 'navigator.clipboard', "本地地址候选": "c.kind==='url'", "服务未运行提示": "开发服务器没有运行", "复制启动命令": "button.dataset.copyServe", "现状基线": "c.baseline", "可操作小样": "c.interactive", "按轮次保存": "DATA.fingerprint", "展示北极星": "c.concept", "展示色板": "c.palette", } missing = [name for name, hook in hooks.items() if hook not in page] self.assertEqual(missing, [], "模板缺少对比页承诺的功能,见 .github/EXPLORER.md") for removed in ('class="bar-r"', 'id="round"', 'id="filter-list"', 'id="notes-toggle"', 'id="notes"', 'id="show-all"', "liveSource", "imageSource", "interactiveSource", "htmlSource"): self.assertNotIn(removed, page) def test_brand_assets_and_edition_survive_relocation(self): copy = self.folder / "relocated" shutil.copytree(ROOT / "scripts", copy / "scripts", ignore=shutil.ignore_patterns("__pycache__")) shutil.copytree(ROOT / "assets", copy / "assets") logo = "data:image/png;base64," + base64.b64encode((ROOT / "assets/logo.png").read_bytes()).decode("ascii") font = "data:font/ttf;base64," + base64.b64encode((ROOT / "assets/fonts/instrument-serif/InstrumentSerif-Regular.ttf").read_bytes()).decode("ascii") for name, edition in (("oil-ui-pro", "pro"), ("oil-ui", "open")): with self.subTest(edition=edition): (copy / "SKILL.md").write_text("---\nname: " + name + "\n---\n", encoding="utf-8") output = self.folder / (edition + ".html") run = subprocess.run([sys.executable, str(copy / "scripts/build_explorer.py"), str(self.manifest), "--output", str(output)], cwd=self.folder, capture_output=True, text=True, encoding="utf-8") self.assertEqual(run.returncode, 0, run.stderr) page = output.read_text(encoding="utf-8") self.assertEqual(self.payload(page)["edition"], edition) self.assertIn(logo, page) self.assertIn(font, page) self.assertNotIn('href="logo.png"', page) self.assertNotIn('src="logo.png"', page) self.assertNotIn('url("fonts/', page) def test_copied_skill_works_from_another_directory(self): copy = self.folder / "relocated" shutil.copytree(ROOT / "scripts", copy / "scripts", ignore=shutil.ignore_patterns("__pycache__")) shutil.copytree(ROOT / "assets", copy / "assets") run = subprocess.run([sys.executable, str(copy / "scripts" / "build_explorer.py"), str(self.manifest), "--output", str(self.output)], cwd=self.folder, capture_output=True, text=True, encoding="utf-8") self.assertEqual(run.returncode, 0, run.stderr) self.assertTrue(self.output.is_file()) def test_every_manifest_problem_is_reported_in_one_pass(self): self.data["schemaVersion"] = 2 self.data["candidates"][0].update(id="A", palette=["red", "#fff"], source="missing.html") self.data["candidates"].append({"id": "b", "concept": "c", "typography": "t", "palette": ["#123456"], "traits": [], "kind": "pdf"}) self.save() with self.assertRaises(builder.ManifestError) as caught: builder.build(self.manifest, self.output) text = str(caught.exception) self.assertGreaterEqual(len(caught.exception.problems), 6) for expected in ("schemaVersion", '候选 id "A" 无效', "missing.html", "red", 'kind 只能是', "候选 b:name", "候选 b:traits"): self.assertIn(expected, text) self.assertNotIn("#fff", text) self.assertFalse(self.output.exists()) def test_single_problem_reads_as_one_sentence(self): self.data["candidates"][0]["palette"] = ["blue"] self.save() with self.assertRaises(ValueError) as caught: builder.build(self.manifest, self.output) self.assertNotIn("处要改", str(caught.exception)) self.assertIn("候选 a:palette", str(caught.exception)) def test_output_defaults_to_a_file_beside_the_manifest(self): run = subprocess.run([sys.executable, str(ROOT / "scripts" / "build_explorer.py"), str(self.manifest)], capture_output=True, text=True, encoding="utf-8") self.assertEqual(run.returncode, 0, run.stderr) expected = self.folder.resolve() / "style-explorer.html" self.assertEqual(Path(json.loads(run.stdout)["output"]), expected) self.assertTrue(expected.is_file()) again = subprocess.run([sys.executable, str(ROOT / "scripts" / "build_explorer.py"), str(self.manifest)], capture_output=True, text=True, encoding="utf-8") self.assertEqual(again.returncode, 1) self.assertIn("--force", again.stderr) def test_missing_and_malformed_manifests_say_what_to_do(self): script = str(ROOT / "scripts" / "build_explorer.py") missing = subprocess.run([sys.executable, script, str(self.folder / "nope.json")], capture_output=True, text=True, encoding="utf-8") self.assertEqual(missing.returncode, 1) self.assertIn("找不到 manifest", missing.stderr) self.assertNotIn("Errno", missing.stderr) broken = self.folder / "broken.json" broken.write_text('{"schemaVersion": 1,', encoding="utf-8") malformed = subprocess.run([sys.executable, script, str(broken)], capture_output=True, text=True, encoding="utf-8") self.assertEqual(malformed.returncode, 1) self.assertRegex(malformed.stderr, r"不是合法的 JSON:第 \d+ 行第 \d+ 列") self.assertNotIn("Traceback", malformed.stderr) def test_help_shows_a_complete_example(self): run = subprocess.run([sys.executable, str(ROOT / "scripts" / "build_explorer.py"), "--help"], capture_output=True, text=True, encoding="utf-8") self.assertEqual(run.returncode, 0) for expected in ("manifest.json", '"schemaVersion": 1', "--force", "style-explorer.html"): self.assertIn(expected, run.stdout) if __name__ == "__main__": unittest.main()