"""Exercise the zero-dependency screenshot CLI against a real local browser.""" import json import os from pathlib import Path import shutil import subprocess import sys import tempfile import unittest ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "shoot.mjs" NODE = shutil.which("node") def find_browser(): candidates = [os.environ.get("CHROME_PATH")] if sys.platform == "darwin": candidates += [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", ] elif sys.platform == "win32": for variable in ("PROGRAMFILES", "PROGRAMFILES(X86)"): root = os.environ.get(variable) if root: candidates += [ str(Path(root) / "Google/Chrome/Application/chrome.exe"), str(Path(root) / "Microsoft/Edge/Application/msedge.exe"), ] candidates += [shutil.which(name) for name in ( "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", )] return next((path for path in candidates if path and Path(path).is_file()), None) @unittest.skipUnless(NODE, "Node is not installed") class ShootCLITests(unittest.TestCase): @unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg is needed to inspect encoded frames') def test_encoder_keeps_short_feedback_at_its_original_time(self): with tempfile.TemporaryDirectory(prefix='oil-shoot-encoding-') as temp: folder = Path(temp) for name, color in [('red', (255, 0, 0)), ('blue', (0, 0, 255)), ('green', (0, 255, 0))]: ppm = b'P6\n64 64\n255\n' + bytes(color) * (64 * 64) subprocess.run(['ffmpeg', '-y', '-v', 'error', '-f', 'image2pipe', '-i', '-', '-frames:v', '1', str(folder / (name + '.jpg'))], input=ppm, check=True, capture_output=True, timeout=10) # Exercise the production concat writer without timing a real browser animation. javascript = '''import {readFileSync} from 'node:fs'; import {basename} from 'node:path'; const source=readFileSync(process.argv[1],'utf8'); const writer=source.slice(source.indexOf('function screencastList('),source.indexOf('async function record(')); const frames=JSON.parse(process.argv[2]);const finished=Number(process.argv[3]); process.stdout.write(eval(writer+'; screencastList(frames,finished)'));''' frames = [{'name': name + '.jpg', 't': t} for name, t in [('red', 0), ('blue', .949), ('green', .965), ('blue', 2.13)]] result = subprocess.run([NODE, '--input-type=module', '-e', javascript, str(SCRIPT), json.dumps(frames), '2.146'], check=True, capture_output=True, text=True, timeout=10, encoding='utf-8') (folder / 'list.txt').write_text(result.stdout, encoding='utf-8') video = folder / 'record.mp4' subprocess.run(['ffmpeg', '-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', str(folder / 'list.txt'), '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2,fps=30', '-pix_fmt', 'yuv420p', str(video)], check=True, capture_output=True, timeout=10) decoded = subprocess.run(['ffmpeg', '-v', 'error', '-i', str(video), '-vf', 'format=rgb24,crop=1:1:32:32', '-pix_fmt', 'rgb24', '-f', 'rawvideo', '-'], check=True, capture_output=True, timeout=10).stdout # The 949ms blue feedback overlaps the 30fps sample at frame 28; default JPEG # time-base rounding used to erase it while the separately saved JPEG survived. red, green, blue = decoded[28 * 3:29 * 3] self.assertLess(red, 50) self.assertLess(green, 50) self.assertGreater(blue, 180) # The end screenshot must also survive an ending halfway between output ticks. tail_frames = [{'name': 'red.jpg', 't': 0}, {'name': 'blue.jpg', 't': .950}] result = subprocess.run([NODE, '--input-type=module', '-e', javascript, str(SCRIPT), json.dumps(tail_frames), '.950'], check=True, capture_output=True, text=True, timeout=10, encoding='utf-8') (folder / 'list.txt').write_text(result.stdout, encoding='utf-8') subprocess.run(['ffmpeg', '-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', str(folder / 'list.txt'), '-vf', 'fps=30', '-pix_fmt', 'yuv420p', str(video)], check=True, capture_output=True, timeout=10) decoded = subprocess.run(['ffmpeg', '-v', 'error', '-i', str(video), '-vf', 'format=rgb24,crop=1:1:32:32', '-pix_fmt', 'rgb24', '-f', 'rawvideo', '-'], check=True, capture_output=True, timeout=10).stdout self.assertGreater(decoded[-1], 180, 'The appended screenshot must be the encoded last frame') def test_help(self): result = subprocess.run([NODE, str(SCRIPT), "--help"], cwd=ROOT, capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("用法", result.stdout) self.assertIn("type 在当前光标处插入文字", result.stdout) self.assertIn("fill 先清空原内容", result.stdout) self.assertNotIn("fill=type", result.stdout) self.assertIn("不进入 iframe 或 Shadow DOM", result.stdout) self.assertIn("中心点遮挡和禁用状态", result.stdout) self.assertIn("显式 --zoom 按倍数截每个状态", result.stdout) def test_help_carries_the_reference_and_report_structure_example(self): """参考内容放在 --help 里;references/tools.md 只讲什么时候用,并指到这里。""" documentation = subprocess.run([NODE, str(SCRIPT), "--help"], cwd=ROOT, capture_output=True, text=True, timeout=10, encoding='utf-8').stdout self.assertIn('最外层是数组', documentation) self.assertIn('不是把页面放大到 200% 后的重排', documentation) self.assertIn('录屏短于 3 秒是正常的', documentation) self.assertIn('frames.start/mid/end', documentation) lines = documentation.split('结构示意:', 1)[1].strip().splitlines()[:2] report = json.loads(''.join(line.strip() for line in lines)) self.assertIsInstance(report, list) self.assertIn('lint', report[0]) self.assertIn('motion', report[1]) self.assertTrue(all('issues' in entry for entry in report)) guide = (ROOT / 'references/tools.md').read_text(encoding='utf-8') self.assertIn('--help', guide) self.assertLess(len(guide), 3500, 'tools.md 只留什么时候用和最常用的命令,参考内容写进 --help') def test_timeout_scale_validation_and_help(self): plan = self.dry('--timeout-scale', '2') self.assertEqual(plan['options']['timeout-scale'], '2') for value in ('0', '-1', '0.5', 'NaN', 'Infinity', 'slow'): with self.subTest(value=value): message = self.dry('--timeout-scale', value, ok=False) self.assertIn('收到 ' + value, message) self.assertIn('--timeout-scale 2', message) result = subprocess.run([NODE, str(SCRIPT), '--help'], capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertIn('90 秒', result.stdout) self.assertIn('--timeout-scale', result.stdout) def test_missing_target(self): result = subprocess.run([NODE, str(SCRIPT)], cwd=ROOT, capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertNotEqual(result.returncode, 0) def test_unknown_option(self): help_result = subprocess.run([NODE, str(SCRIPT), "--help"], cwd=ROOT, capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertEqual(help_result.returncode, 0, help_result.stderr) options = [line.split()[0] for line in help_result.stdout.splitlines() if line.startswith(" --")] for args in (("--xxx",), ("--xxx", "value")): with self.subTest(args=args): result = subprocess.run([NODE, str(SCRIPT), *args], cwd=ROOT, capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertEqual(result.returncode, 1) self.assertIn("不认识的选项 --xxx", result.stderr) self.assertIn("是不是想写", result.stderr) self.assertIn("可用选项:" + " ".join(options), result.stderr) self.assertEqual(result.stdout, "") def test_missing_option_value(self): for option in ("--out", "--size", "--states", "--param", "--zoom", "--steps", "--hold", "--wait", "--compare", "--timeout-scale"): for following in ((), ("--force",)): with self.subTest(option=option, following=following): result = subprocess.run([NODE, str(SCRIPT), option, *following], cwd=ROOT, capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertEqual(result.returncode, 1) self.assertEqual(result.stderr, f"shoot:{option} 需要一个值\n") def test_force_is_ignored(self): result = subprocess.run([NODE, str(SCRIPT), "--force"], cwd=ROOT, capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertEqual(result.returncode, 1) self.assertEqual(result.stderr, "shoot:缺少页面地址或文件。\n") def dry(self, *args, ok=True): result = subprocess.run([NODE, str(SCRIPT), "page.html", "--dry-run", *args], cwd=ROOT, capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertEqual(result.returncode, 0 if ok else 1, result.stdout + result.stderr) return json.loads(result.stdout) if ok else result.stderr def test_dry_run_corpus_regressions(self): # Twenty corpus patterns; project-specific classes, labels and text are neutralized. cases = [ ('click "[data-thread=sample]"', "click", "[data-thread=sample]"), ('click [data-id="sample"]', "click", '[data-id="sample"]'), ('click \'.cause [data-latest-release]\'', "click", '.cause [data-latest-release]'), ('click .nav button[data-view=sample]', "click", '.nav button[data-view=sample]'), ('click [aria-label="Send money"]', "click", '[aria-label="Send money"]'), ('click "[aria-label=\\"Send money\\"]"', "click", '[aria-label="Send money"]'), ('click \'[aria-label="Start Sample task"]\'', "click", '[aria-label="Start Sample task"]'), ('click ".nav button:nth-child(3)"', "click", '.nav button:nth-child(3)'), ('hover "[data-message=sample] .reaction"', "hover", '[data-message=sample] .reaction'), ('drag "[data-tile=\\"1\\"] .tile-paper" 240 0', "drag", '[data-tile="1"] .tile-paper'), ('drag [data-id="sample"] 214 -350', "drag", '[data-id="sample"]'), ('drag ".sample-line:nth-child(2)" 45 0', "drag", '.sample-line:nth-child(2)'), ('type #message-input 示例内容', "type", '#message-input'), ('type [aria-label="Message box"] 你好', "type", '[aria-label="Message box"]'), ('type "#search" Sample', "type", '#search'), ('click text=More', "click", 'More'), ('click button:has-text("Undo")', "click", 'Undo'), ('wait450', "wait", None), ('key ControlOrMeta+A', "key", None), ('key Meta+r', "key", None), ] for raw, action, selector in cases: with self.subTest(raw=raw): step = self.dry("--steps", raw)["groups"][0]["steps"][0] self.assertEqual(step["action"], action) if selector is not None: self.assertEqual(step["selector"]["value"], selector) def test_dry_run_balanced_steps_and_parameters(self): parsed = self.dry("--steps", 'open: click [data-label="a;b"]; click :is([data-label="a;b"]); ' 'type ".form input" "hello; world"; select #choice "Second item"; ' 'wait450; wait 450ms; wait 0.5s; key Shift+Tab', "--steps", 'click :has-text(\'Undo; action\')', "--mark", '1=text="a;b"') steps = parsed["groups"][0]["steps"] self.assertEqual(parsed["groups"][0]["name"], "open") self.assertEqual(len(steps), 8) self.assertEqual(steps[2]["params"], {"text": "hello; world"}) self.assertEqual(steps[3]["params"], {"value": "Second item"}) self.assertEqual([s["params"]["ms"] for s in steps[4:7]], [450, 450, 500]) self.assertEqual(parsed["groups"][1]["name"], None) self.assertEqual(parsed["marks"][0]["selector"]["kind"], "text") for raw, expected in [('type #input "text [without a closing bracket"', 'text [without a closing bracket'), ("type #input don't stop", "don't stop"), ('type "body [aria-label="Message box"]" "hello world"', 'hello world')]: self.assertEqual(self.dry("--steps", raw)["groups"][0]["steps"][0]["params"]["text"], expected) for raw, action in [('doubleclick .a', 'dblclick'), ('press Control+A', 'key'), ('fill #input "hello"', 'fill'), ('sleep 0.5s', 'wait'), ('waitfor text=Ready', 'waitfor')]: self.assertEqual(self.dry("--steps=" + raw)["groups"][0]["steps"][0]["action"], action) def test_fill_parses_as_replacement_action(self): for raw, kind, value, text in [ ('fill ".form input" "hello world"', 'css', '.form input', 'hello world'), ('fill [aria-label="Message box"] ""', 'css', '[aria-label="Message box"]', ''), ('fill text="Old message" new', 'text', 'Old message', 'new'), ("fill textarea:has-text('Old message') new", 'has-text', 'Old message', 'new'), ]: with self.subTest(raw=raw): step = self.dry('--steps', raw)['groups'][0]['steps'][0] self.assertEqual(step['action'], 'fill') self.assertEqual(step['selector']['kind'], kind) self.assertEqual(step['selector']['value'], value) self.assertEqual(step['params'], {'text': text}) self.assertIn('例如 fill #field "hello world"', self.dry('--steps', 'fill #field', ok=False)) def test_dry_run_does_not_launch_or_write_and_shares_errors(self): with tempfile.TemporaryDirectory(prefix="oil-shoot-dry-") as folder: output = Path(folder) / "never-created" env = dict(os.environ, CHROME_PATH=str(Path(folder) / "no-browser")) args = [NODE, str(SCRIPT), "missing-page.html", "--out", str(output)] result = subprocess.run([*args, "--dry-run", "--steps", "click .nav button"], cwd=ROOT, env=env, capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(json.loads(result.stdout)["groups"][0]["steps"][0]["selector"]["kind"], "css") self.assertFalse(output.exists()) errors = [] for extra in ([], ["--dry-run"]): result = subprocess.run([*args, *extra, "--steps", "drag .tile 20 nope"], cwd=ROOT, env=env, capture_output=True, text=True, timeout=10, encoding='utf-8') self.assertEqual(result.returncode, 1) errors.append(result.stderr) self.assertEqual(errors[0], errors[1]) def test_option_forms_aliases_and_errors(self): parsed = self.dry("--size=desktop,phone,mobile,800x600", "--zoom", "1,2", "--query=a=1&b=2", "--states", "idle,done") self.assertEqual(parsed["sizes"], [{"w": 1440, "h": 900}, {"w": 390, "h": 844}, {"w": 800, "h": 600}]) self.assertEqual(parsed["zooms"], [1, 2]) self.assertEqual(self.dry("--size", "mobile")["sizes"], [{"w": 390, "h": 844}]) self.assertTrue(any("尺寸 已去重" in n for n in parsed["notices"])) self.assertEqual(self.dry()["sizes"], [{"w": 390, "h": 844}]) for flag in ("--full", "--mask", "--sheet", "--record", "--motion", "--entry", "--evidence"): with self.subTest(flag=flag): error = self.dry(flag, "extra", ok=False) self.assertIn("page.html 和 extra", error) self.assertIn(flag + " 不带参数", error) for args, expected in [ (("--hold1000",), "--hold 1000"), (("--zoon", "2"), "--zoom"), (("--motion", ".respond"), '--motion 不带参数'), (("other.html",), "page.html 和 other.html"), (("--full", "extra"), "page.html 和 extra"), (("--record=true",), "不带参数"), (("--zoom=0",), "--zoom 1,2"), (("--size=0x900",), "--size desktop"), (("--wait=abc",), "--wait 1000"), (("--steps", "clik .open"), "是不是想写 click"), (("--steps", "drag .a 1 nope"), "drag .tiles .paper 240 0"), (("--steps", "wait nope"), "wait 450ms"), (("--steps", "click [aria-label=\"x]"), "没有配对"), (("--steps", "key Invalid+A"), "ControlOrMeta+A"), (("--steps", "same: click .a", "--steps", "same: click .b"), "名字重复"), ]: with self.subTest(args=args): error = self.dry(*args, ok=False) self.assertIn(expected, error) if args == ("--hold1000",): self.assertEqual(error.count("--hold 1000"), 1) self.assertNotIn("例如", error) class ShootBrowserTests(unittest.TestCase): @classmethod def setUpClass(cls): if not NODE: raise unittest.SkipTest("Node 22+ is not installed") cls.browser = find_browser() if not cls.browser: raise unittest.SkipTest("Chrome, Chromium or Edge is not installed") def setUp(self): self.tmp = tempfile.TemporaryDirectory(prefix="oil-shoot-test-") self.addCleanup(self.tmp.cleanup) self.folder = Path(self.tmp.name) self.page = self.folder / "sample.html" self.page.write_text('''

''', encoding="utf-8") self.env = dict(os.environ, CHROME_PATH=self.browser) self.profile_root = self.folder / "profiles" self.profile_root.mkdir() self.env.update(TMPDIR=str(self.profile_root), TMP=str(self.profile_root), TEMP=str(self.profile_root)) def shoot(self, output, *args): result = subprocess.run([NODE, str(SCRIPT), str(self.page), "--out", str(output), *args], cwd=ROOT, env=self.env, capture_output=True, text=True, timeout=90, encoding='utf-8') self.assertEqual(result.returncode, 0, result.stdout + result.stderr) return result def browser_leftovers(self): """Processes still holding this test's temporary profiles, and the profile folders themselves.""" import time needle = self.folder.name for _ in range(40): if sys.platform == 'win32': script = ("Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -and " "$_.CommandLine.Contains('" + needle + "') -and $_.CommandLine.Contains('user-data-dir') } | ForEach-Object { $_.Name }") found = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', script], capture_output=True, text=True, timeout=60, encoding='utf-8', errors='replace').stdout.split() else: # Other programs' command lines are not ours to decode strictly; only our own folder name matters here. listing = subprocess.run(['ps', '-axww', '-o', 'command='], capture_output=True, text=True, encoding='utf-8', errors='replace').stdout found = [line[:120] for line in listing.splitlines() if needle in line and '--user-data-dir=' in line] folders = [path.name for path in self.profile_root.glob('oil-shoot-*')] if not found and not folders: break time.sleep(0.25) return found, folders def assert_no_browser_leftovers(self): processes, folders = self.browser_leftovers() self.assertEqual(processes, [], 'browser processes were left running') self.assertEqual(folders, [], 'temporary profiles were left behind') def assert_artifacts(self, output, names): for name in names: with self.subTest(file=name): artifact = output / name self.assertTrue(artifact.is_file(), name) self.assertGreater(artifact.stat().st_size, 0, name) if artifact.suffix == ".png": self.assertTrue(artifact.read_bytes().startswith(b"\x89PNG\r\n\x1a\n"), name) elif artifact.suffix == ".jpg": self.assertTrue(artifact.read_bytes().startswith(b"\xff\xd8"), name) def test_states_masks_and_sheets(self): output = self.folder / "shots" self.shoot(output, "--states", "a,b", "--mask", "--sheet") self.assert_artifacts(output, ( "a.png", "b.png", "a-masked.png", "b-masked.png", "sheet.png", "sheet-masked.png", "report.json", )) report = json.loads((output / "report.json").read_text(encoding="utf-8")) self.assertEqual([entry["state"] for entry in report], ["a", "b"]) self.assertEqual([entry["issues"] for entry in report], [[], []]) self.assertEqual([entry["lint"] for entry in report], [[], []]) self.assertNotEqual((output / "a.png").read_bytes(), (output / "b.png").read_bytes()) self.assertNotEqual((output / "a.png").read_bytes(), (output / "a-masked.png").read_bytes()) self.assertEqual(list(self.profile_root.glob("oil-shoot-*")), [], "Temporary browser profiles leaked") def test_mark_draws_numbered_boxes_without_touching_plain_shot(self): output = self.folder / "marked" result = self.shoot(output, "--mark", "#go; h1") self.assert_artifacts(output, ("page.png", "page-marked.png")) self.assertIn("page-marked.png", result.stdout) self.assertNotEqual((output / "page.png").read_bytes(), (output / "page-marked.png").read_bytes()) plain = self.folder / "plain" self.shoot(plain) self.assertEqual((output / "page.png").read_bytes(), (plain / "page.png").read_bytes(), "Marks must not leak into the plain screenshot") def test_mark_accepts_explicit_numbers(self): numbered, by_order, reversed_order = self.folder / "numbered", self.folder / "by-order", self.folder / "reversed" self.shoot(numbered, "--mark", "2=#go; 1=h1") self.shoot(by_order, "--mark", "h1; #go") self.shoot(reversed_order, "--mark", "#go; h1") self.assertEqual((numbered / "page-marked.png").read_bytes(), (by_order / "page-marked.png").read_bytes()) self.assertNotEqual((numbered / "page-marked.png").read_bytes(), (reversed_order / "page-marked.png").read_bytes(), "Explicit numbers must decide the labels, not the order") def test_mark_reports_missing_elements(self): output = self.folder / "missing" result = subprocess.run([NODE, str(SCRIPT), str(self.page), "--out", str(output), "--mark", "#go; .nope"], cwd=ROOT, env=self.env, capture_output=True, text=True, timeout=90, encoding='utf-8') self.assertNotEqual(result.returncode, 0) self.assertIn(".nope", result.stderr) def test_force_overwrites_existing_output(self): output = self.folder / "shots" output.mkdir() (output / "page.png").write_bytes(b"old screenshot") self.shoot(output, "--force") self.assert_artifacts(output, ("page.png", "report.json")) def test_preview_denies_sibling_paths_and_escaping_symlinks(self): outside = Path(str(self.folder) + '-private') outside.mkdir() self.addCleanup(shutil.rmtree, outside) (outside / 'secret.txt').write_text('test-only-secret', encoding='utf-8') (self.folder / 'leak.txt').symlink_to(outside / 'secret.txt') (self.folder / 'nested').mkdir() (self.folder / 'nested/okay.txt').write_text('allowed-resource', encoding='utf-8') (self.folder / '.env.production.local').write_text('synthetic-secret', encoding='utf-8') (self.folder / '.git').mkdir() (self.folder / '.git/config').write_text('synthetic-git-config', encoding='utf-8') (self.folder / 'credentials.json').write_text('{"token":"synthetic"}', encoding='utf-8') (self.folder / 'server.pem').write_text('synthetic-private-key', encoding='utf-8') (self.folder / 'env.txt').symlink_to(self.folder / '.env.production.local') (self.folder / 'nested/hidden').symlink_to(self.folder / '.git', target_is_directory=True) (self.folder / 'nested/module.mjs').write_text('export const value = "module-works";', encoding='utf-8') blocked = ['/..%2f' + outside.name + '%2fsecret.txt', '/leak.txt', '/%E0%A4%A', '/.env.production.local', '/%2eenv.production.local', '/.git/config', '/credentials.json', '/server.pem', '/env.txt', '/nested/hidden/config'] probe = ''''''.replace('PATHS', json.dumps(blocked)) self.page.write_text(self.page.read_text(encoding='utf-8').replace('', probe + ''), encoding='utf-8') output = self.folder / 'boundary-shots' self.shoot(output, '--wait', '4000') report = json.loads((output / 'report.json').read_text(encoding='utf-8')) issues = '\n'.join(report[0]['issues']) self.assertIn('AUDIT_FINISHED', issues) for marker in ('SECURITY_LEAK', 'LEGIT_RESOURCE_BLOCKED', 'AUDIT_FAILED'): self.assertNotIn(marker, issues) def test_state_labels_do_not_become_output_paths(self): output = self.folder / 'safe-states' states = ['../escaped', '