Files
workbuddy_skills/oil-ui-pro/tests/test_shoot.py
T
admin d9445b5888 oil-ui-pro 0.15.4 → 0.22.2(上游 ui.oiloil.org 正式包)+ 记 P0-98 跨盘 rename 坑
- oil-ui-pro 升到 0.22.2:references 13→28 份(新增 16、删 imagery.md)、assets 增 cards/(22 组)与 fonts/、SKILL.md 顶层五步结构未改名。
- 升级路径:官方 `npx github:oil-oil/oil-cli update` 稳定失败(error=replace、报「检查目录权限」)。
  真身为 EXDEV —— 解压在 os.tmpdir()(C 盘)、技能目录在 E 盘,Windows 上 rename 不能跨盘;
  CLI 的 catch 把非 CliError 原生异常一律兜底成 replaceFailed,真错误一个字不吐。
  做法=借其 prepare() 只做下载+校验,再 cp 到同盘 stage、rename 交换;官方命令复验 up_to_date。
- 回归:product-planning 按名引用的 visual-review.md/tools.md/design-direction.md/style-explorer.md 全部健在;命名闸门 2061|0|27。
- session-mechanism/references/pitfalls.md 新增 P0-98(含通则:更新器把原生错误兜底成指错方向的人话)。
2026-10-14 10:21:46 +08:00

1653 lines
109 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.
"""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('''<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<style>
body { margin: 0; padding: 24px; background: #fde68a; font: 20px sans-serif; }
body[data-state="b"] { background: #bfdbfe; }
button { padding: 16px; }
</style></head><body><h1 id="state"></h1><button id="go">切换</button>
<script>
const state = new URLSearchParams(location.search).get('state') || 'a';
function show(value) {
document.body.dataset.state = value;
document.querySelector('#state').textContent = value;
}
show(state);
document.querySelector('#go').onclick = () => show(document.body.dataset.state === 'a' ? 'b' : 'a');
</script></body></html>''', 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 = '''<script>(async () => {
for (const path of PATHS) {
const response = await fetch(path);
if (response.status !== 404) console.error('SECURITY_LEAK:' + path);
}
const allowed = await fetch('/nested/okay.txt');
if (await allowed.text() !== 'allowed-resource') console.error('LEGIT_RESOURCE_BLOCKED');
const module = await import('/nested/module.mjs');
if (module.value !== 'module-works') console.error('LEGIT_RESOURCE_BLOCKED');
console.error('AUDIT_FINISHED');
})().catch(() => console.error('AUDIT_FAILED'));</script>'''.replace('PATHS', json.dumps(blocked))
self.page.write_text(self.page.read_text(encoding='utf-8').replace('</body>', probe + '</body>'), 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', '<label & "quoted">']
self.shoot(output, '--states', ','.join(states), '--mask', '--sheet')
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))
self.assertEqual([entry['state'] for entry in report], states)
for entry in report:
self.assertRegex(entry['file'], r'^state-\d+-[0-9a-f]{12}\.png$')
self.assertTrue((output / entry['file']).is_file())
self.assertFalse((self.folder / 'escaped.png').exists())
self.assert_artifacts(output, ('sheet.png', 'sheet-masked.png'))
def test_type_accepts_selectors_with_quotes(self):
self.page.write_text(self.page.read_text(encoding='utf-8').replace('</body>', '<input data-x="value"></body>'), encoding='utf-8')
output = self.folder / 'quoted-selector'
self.shoot(output, '--steps', '''type 'input[data-x="value"]' hello''')
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))
self.assertEqual(report[0]['issues'], [])
def test_record_steps(self):
output = self.folder / "record's output"
result = self.shoot(output, "--record", "--steps", "click #go; wait 300", "--hold", "300")
self.assert_artifacts(output, ("motion-start.jpg", "motion-mid.jpg", "motion-end.jpg"))
self.assertNotEqual((output / "motion-start.jpg").read_bytes(), (output / "motion-end.jpg").read_bytes())
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
self.assertEqual([entry["issues"] for entry in report], [[]])
self.assertEqual(list(self.profile_root.glob("oil-shoot-*")), [], "Temporary browser profiles leaked")
if shutil.which("ffmpeg"):
self.assertTrue((output / "record.mp4").is_file(), result.stdout + result.stderr)
self.assert_artifacts(output, ("record.mp4",))
def frame_pixel(self, path, x=20, y=20):
result = subprocess.run(['ffmpeg', '-v', 'error', '-i', str(path), '-vf',
f'format=rgb24,crop=1:1:{x}:{y}', '-frames:v', '1',
'-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1'],
capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertEqual(len(result.stdout), 3)
return tuple(result.stdout)
@unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg is needed to inspect frame pixels')
def test_record_frames_follow_last_action_and_capture_short_feedback(self):
if os.environ.get('OIL_SHOOT_SLOW_HOST'):
self.skipTest('needs real-time frame capture: a 0.36s flash is missed on slow hosted runners')
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
body{margin:0}#box{position:absolute;left:20px;top:20px;width:120px;height:120px;background:red}
#box.prepared{background:lime}#box.feedback{animation:shake .36s linear}
@keyframes shake{0%,100%{background:red;transform:none}50%{background:blue;transform:translateX(40px)}}
#controls{position:absolute;top:180px}#drag{display:inline-block;width:80px;height:30px}
#tail:hover{animation:tail .8s linear}@keyframes tail{from{opacity:.3}to{opacity:1}}
@keyframes loop{to{transform:rotate(360deg)}}#loop{position:absolute;top:270px;width:40px;height:40px;background:gray;animation:loop 2s linear infinite}
</style><div id="box"></div><div id="controls"><button id="prepare">Prepare</button>
<button id="click">Click</button><button id="double">Double</button><div id="drag">Drag</div>
<input id="edit" value="old"><select id="choice"><option value="a">A</option><option value="b">B</option></select>
<button id="tail">Tail</button></div><div id="loop"></div><script>
const box=document.querySelector('#box');
const feedback=()=>{box.className='feedback'};
document.querySelector('#prepare').onclick=()=>box.className='prepared';
document.querySelector('#click').onclick=feedback;
document.querySelector('#double').ondblclick=feedback;
document.querySelector('#drag').onmouseup=feedback;
document.querySelector('#edit').oninput=feedback;
document.querySelector('#choice').onchange=feedback;
addEventListener('keydown',e=>{if(e.key==='Enter')feedback()});
</script>''', encoding='utf-8')
actions = [('click #click', 'click'), ('dblclick #double', 'dblclick'),
('drag #drag 20 0', 'drag'), ('type #edit X', 'type'),
('fill #edit replacement', 'fill'), ('select #choice b', 'select'), ('key Enter', 'key')]
for raw, action in actions:
with self.subTest(action=action):
output = self.folder / action
self.shoot(output, '--size', 'desktop', '--record', '--wait', '0', '--hold', '100',
'--steps', f'click #prepare; wait 700; {raw}; wait 500; hover #tail; scroll 0')
entry = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]
self.assertEqual(entry['sampling'], {'mode': 'action', 'step': 3, 'action': action, 'raw': raw})
self.assertEqual(entry['issues'], [])
frames = entry['frames']
self.assertGreater(frames['start']['timeMs'], 1000)
self.assertLess(frames['start']['timeMs'], frames['mid']['timeMs'])
self.assertLess(frames['mid']['timeMs'], frames['end']['timeMs'])
self.assertEqual(frames['end']['timeMs'], entry['durationMs'])
self.assertGreater(frames['end']['timeMs'] - frames['mid']['timeMs'], 500)
before = self.frame_pixel(output / frames['start']['file'], 80, 80)
middle = self.frame_pixel(output / frames['mid']['file'], 80, 80)
after = self.frame_pixel(output / frames['end']['file'], 80, 80)
self.assertGreater(before[1], 220, before)
self.assertLess(before[0], 40, before)
self.assertGreater(middle[2], 170, middle)
self.assertLess(middle[0], 100, middle)
self.assertGreater(after[0], 220, after)
self.assertLess(after[2], 40, after)
@unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg is needed to inspect frame pixels')
def test_entry_recording_starts_white_when_the_browser_is_in_dark_mode(self):
if sys.platform == 'win32':
self.skipTest('POSIX launch wrapper; on Windows the CI job with the dark system theme covers this')
import shlex
# A dark system theme makes the browser paint about:blank dark grey. The opening frame of an entry
# recording must not depend on the host theme.
wrapper = self.folder / 'dark-browser'
wrapper.write_text('#!/bin/sh\nexec ' + shlex.quote(self.browser) + ' --force-dark-mode "$@"\n', encoding='utf-8')
wrapper.chmod(0o755)
self.page.write_text('<!doctype html><link rel="icon" href="data:,"><style>body{margin:0;background:#ffd000}</style><p>entry</p>', encoding='utf-8')
output = self.folder / 'dark-entry'
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output), '--record', '--entry', '--wait', '0', '--hold', '50'],
cwd=ROOT, env=dict(self.env, CHROME_PATH=str(wrapper)), capture_output=True, text=True, encoding='utf-8', timeout=90)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
first = self.frame_pixel(output / 'record.mp4')
self.assertTrue(all(channel > 245 for channel in first), first)
@unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg is needed to inspect frame pixels')
def test_entry_frames_span_content_to_stable_and_short_recording_is_valid(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
body{margin:0;background:#ffd000}#box{width:200px;height:200px;background:blue;animation:appear .6s linear both}
@keyframes appear{from{opacity:0}to{opacity:1}}
</style><div id="box"></div>''', encoding='utf-8')
output = self.folder / 'entry-timing'
self.shoot(output, '--record', '--entry', '--wait', '0', '--hold', '50')
entry = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]
self.assertEqual(entry['sampling'], {'mode': 'entry'})
frames = entry['frames']
self.assertLess(frames['start']['timeMs'], frames['mid']['timeMs'])
self.assertLess(frames['mid']['timeMs'], frames['end']['timeMs'])
self.assertEqual(frames['end']['timeMs'], entry['durationMs'])
self.assertLess(entry['durationMs'], 3000)
self.assertGreater(entry['durationMs'], 600)
first = self.frame_pixel(output / 'record.mp4')
self.assertTrue(all(channel > 245 for channel in first), first)
start = self.frame_pixel(output / frames['start']['file'])
middle = self.frame_pixel(output / frames['mid']['file'])
end = self.frame_pixel(output / frames['end']['file'])
self.assertGreater(start[0], middle[0] + 30, (start, middle))
self.assertGreater(middle[0], end[0] + 30, (middle, end))
self.assertLess(end[0], 20, end)
self.assertGreater(end[2], 230, end)
def test_record_without_changing_actions_keeps_head_middle_tail_sampling(self):
output = self.folder / 'no-action-timing'
self.shoot(output, '--record', '--steps', 'wait 200; hover #go', '--hold', '100')
entry = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]
self.assertEqual(entry['sampling'], {'mode': 'recording'})
frames = entry['frames']
self.assertEqual(frames['start']['timeMs'], 0)
self.assertLessEqual(frames['start']['timeMs'], frames['mid']['timeMs'])
self.assertLessEqual(frames['mid']['timeMs'], frames['end']['timeMs'])
self.assertLessEqual(frames['end']['timeMs'], entry['durationMs'])
self.assertEqual((output / frames['start']['file']).read_bytes(), (output / frames['end']['file']).read_bytes())
def test_mask_preserves_current_color_icons(self):
self.page.write_text('''<!doctype html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,"></head><body>
<svg width="80" height="80" viewBox="0 0 80 80" style="color:#16a34a">
<circle cx="40" cy="40" r="32" fill="currentColor" /></svg>
</body></html>''', encoding="utf-8")
output = self.folder / "icons"
self.shoot(output, "--mask")
self.assertEqual((output / "page.png").read_bytes(), (output / "page-masked.png").read_bytes(),
"Masking text must preserve icons using currentColor")
def test_record_preserves_final_hold(self):
if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
self.skipTest("ffmpeg and ffprobe are required to check recording duration")
output = self.folder / "hold"
self.shoot(output, "--record", "--steps", "click #go; wait 300", "--hold", "2000")
result = subprocess.run([
"ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json",
str(output / "record.mp4"),
], capture_output=True, text=True, timeout=10, encoding='utf-8')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertGreaterEqual(float(json.loads(result.stdout)["format"]["duration"]), 2.0)
def test_motion_probe(self):
self.page.write_text('''<!doctype html><html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,"><style>
body{margin:0}.hero{height:1800px}.stage{position:sticky;top:0;height:800px;overflow:hidden}
#figure,#word{position:absolute;left:300px;width:400px;height:300px;background:#888}#word{top:400px;background:#444}
@keyframes rise{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
h1{animation:rise .5s ease-out both}#go{transition:transform .3s}#go.on{transform:translateX(40px)}
.reveal{height:600px;opacity:0;transition:opacity .3s}.reveal.in{opacity:1}
</style></head><body><section class="hero"><div class="stage"><div id="figure"></div><div id="word"></div></div></section>
<h1>Hi</h1><button id="go" onclick="this.classList.add('on')">Go</button>
<div style="height:1200px"></div><div class="reveal">Later</div>
<script>addEventListener('scroll',()=>{const p=Math.min(scrollY/1000,1);figure.style.transform=`scale(${1-p*.2})`;word.style.transform=`scale(${1+p*.25})`});
new IntersectionObserver(e=>e.forEach(x=>x.isIntersecting&&x.target.classList.add('in'))).observe(document.querySelector('.reveal'))</script>
</body></html>''', encoding="utf-8")
output = self.folder / "motion"
self.shoot(output, "--motion", "--size", "1280x800", "--steps", "click #go")
probe = json.loads((output / "report.json").read_text(encoding="utf-8"))[0]
self.assertEqual(probe["issues"], [])
self.assertTrue(all(probe["motion"][k]["elements"] for k in ("load", "steps", "hero", "scroll")))
self.assertGreaterEqual(probe["motion"]["hero"]["layers"], 2)
self.page.write_text('''<!doctype html><html><head><link rel="icon" href="data:,"></head>
<body><h1>Still</h1><div style="height:1600px"></div></body></html>''', encoding="utf-8")
output = self.folder / "still"
self.shoot(output, "--motion", "--size", "1280x800")
issues = "\n".join(json.loads((output / "report.json").read_text(encoding="utf-8"))[0]["issues"])
self.assertIn("首次进入:没有检测到动画", issues)
self.assertIn("滚动:没有检测到", issues)
def test_steps_accept_unquoted_selectors_with_spaces(self):
self.shoot(self.folder / "unquoted", "--steps", "click body #go")
self.shoot(self.folder / "quoted", "--steps", 'click "body #go"')
def test_motion_skips_scroll_check_on_single_screen(self):
self.page.write_text('''<!doctype html><html><head><link rel="icon" href="data:,"><style>
html,body{margin:0;height:100%;overflow:hidden}
@keyframes rise{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}}
h1{animation:rise .5s ease-out both}</style></head><body><h1>Game</h1></body></html>''', encoding="utf-8")
output = self.folder / "single-screen"
result = self.shoot(output, "--motion")
probe = json.loads((output / "report.json").read_text(encoding="utf-8"))[0]
self.assertEqual(probe["issues"], [])
self.assertFalse(probe["motion"]["scroll"]["scrollable"])
self.assertIn("页面不滚动", result.stdout)
def test_record_entry_captures_first_appearance(self):
self.page.write_text('''<!doctype html><html><head><link rel="icon" href="data:,"><style>
body{margin:0;background:#fde68a}
@keyframes rise{from{opacity:0;transform:translateY(160px)}to{opacity:1;transform:none}}
h1{margin:40px;height:300px;background:#1e3a8a;animation:rise .3s ease-out both}</style></head>
<body><h1></h1></body></html>''', encoding="utf-8")
# 出场 0.3 秒就结束:默认录屏开录时已经播完,只有 --entry 能录到它
late = self.folder / "late"
self.shoot(late, "--record", "--hold", "300")
self.assertEqual((late / "motion-start.jpg").read_bytes(), (late / "motion-end.jpg").read_bytes())
output = self.folder / "entry"
self.shoot(output, "--record", "--entry", "--hold", "300")
self.assert_artifacts(output, ("motion-start.jpg", "motion-mid.jpg", "motion-end.jpg"))
self.assertNotEqual((output / "motion-start.jpg").read_bytes(), (output / "motion-end.jpg").read_bytes())
def test_missing_favicon_is_not_reported_as_a_page_problem(self):
self.page.write_text('<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>小样</title></head>'
'<body><h1 style="font-size:32px">没有图标的小样</h1></body></html>', encoding="utf-8")
output = self.folder / "no-favicon"
self.shoot(output, "--size", "1280x900")
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
self.assertEqual(report[0]["issues"], [])
def test_reports_page_problems(self):
self.page.write_text(self.page.read_text(encoding="utf-8").replace("</body>", '''
<div style="width:2000px">溢出</div><img src="missing.png">
<script>console.error('shoot-test-error'); throw new Error('shoot-test-exception');</script>
</body>'''), encoding="utf-8")
for args in ((), ("--record", "--hold", "300")):
with self.subTest(record=bool(args)):
output = self.folder / ("problem-record" if args else "problem-shots")
self.shoot(output, "--size", "1280x900", *args)
report = json.loads((output / "report.json").read_text(encoding="utf-8"))
issues = "\n".join(report[0]["issues"])
for expected in ("shoot-test-error", "shoot-test-exception", "横向溢出", "图片没加载出来"):
self.assertIn(expected, issues)
def test_lint_flags_default_patterns(self):
self.page.write_text('''<!doctype html><html lang="zh"><head><meta charset="utf-8"><link rel="icon" href="data:,"><style>
body{margin:0;padding:40px;font:16px/1.7 sans-serif;color:#222;background:#fff}
.eyebrow{font-size:12px;letter-spacing:.12em;text-transform:uppercase;margin:0}
h1{font-size:48px;margin:0 0 24px;background:linear-gradient(90deg,#7c3aed,#06b6d4);background-clip:text;-webkit-background-clip:text;color:transparent}
.num{font-size:12px;margin:0}h2{font-size:28px;margin:4px 0 24px}
.card{border:1px solid #ddd;border-radius:16px;padding:24px;margin:16px 0}
.quote{border-left:4px solid #e11d48;padding:12px 16px}.low{color:#c8c8c8}
.dense{font-size:11px;line-height:1.2;width:600px}.ghost{opacity:0}
</style></head><body><p class="eyebrow">Smart Ledger</p><h1>让记账更简单</h1>
<p class="num">01 / 账户</p><h2>所有账户一目了然</h2>
<div class="card">外层<div class="card">里层卡片里有一些文字</div></div>
<div class="quote">好的工具让人忘记它的存在。</div><p class="low">很淡的说明文字,颜色几乎看不见。</p>
<p class="dense">这是一段很长的正文,用了很小的字号和很紧的行高,一行特别长,读的时候眼睛要跑很远才能换行,这是一段很长的正文,用了很小的字号和很紧的行高,一行特别长,读的时候眼睛要跑很远才能换行。</p>
<ul><li><span>🚀</span> 快</li><li><span>💰</span> 省</li></ul><div><span>WORKSPACE OVERVIEW</span> <span>CONTENT OBJECT</span></div>
<p class="ghost">这一段一直停在透明状态</p>
<p>这一页的中文足够多,用来判断这是中文界面。这一页的中文足够多,用来判断这是中文界面。这一页的中文足够多,用来判断这是中文界面。</p>
</body></html>''', encoding="utf-8")
output = self.folder / "lint"
result = self.shoot(output, "--size", "1440x900")
entry = json.loads((output / "report.json").read_text(encoding="utf-8"))[0]
self.assertEqual(entry["issues"], [])
rules = {item["rule"] for item in entry["lint"]}
for rule in ("eyebrow", "numbered", "gradientText", "sideStripe", "nestedCards", "emojiIcon",
"englishLabel", "contrastLow", "smallText", "tightLeading", "longMeasure", "stuck"):
self.assertIn(rule, rules)
self.assertIn("默认做法提示", result.stdout)
def test_lint_spares_reactions_but_keeps_navigation_and_feature_emoji(self):
self.page.write_text('''<!doctype html><meta charset="utf-8"><link rel="icon" href="data:,">
<button aria-pressed="false"><span>😍</span><span>5</span></button>
<button><span>🔥</span> 3</button><button role="button" aria-pressed="true"><span>😂</span></button>
<p>用户内容 <span>🚀</span></p><blockquote><span>💰</span></blockquote>
<div role="listbox"><button>🧡</button><button>☀️</button></div>
<nav><button aria-pressed="true"><span>🚀</span> 启动</button><a><span>💰</span> 账户</a></nav>
<ul><li><span>🧭</span> 方向</li><li><span>🎉</span> 成果</li></ul>''', encoding='utf-8')
output = self.folder / 'semantic-emoji'
self.shoot(output)
hints = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['lint']
emoji = next(h for h in hints if h['rule'] == 'emojiIcon')
self.assertEqual(emoji['count'], 4)
self.assertFalse(any(symbol in ' '.join(emoji['examples']) for symbol in ('😍', '🔥', '😂', '🧡', '☀️')))
def test_lint_spares_interaction_boundaries_attachments_and_empty_slots(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
body{margin:0}.outer{border-radius:12px;background:#eee;padding:12px;width:340px}
.box{border:1px solid #777;border-radius:8px;padding:8px;width:260px;min-height:56px;background:white}
.popup{position:absolute;top:0;left:420px}.fixed{position:fixed;top:90px;left:420px}
</style><form class="outer"><textarea></textarea>
<div class="box"><strong>draft.svg</strong><small>SVG · 1 KB</small><button>移除</button></div>
<div class="box"><strong>draft.svg</strong><small>正在读取</small><button>重试</button></div>
<div class="box popup">候选 <div class="box">选项</div></div>
<div class="box fixed">候选</div></form>
<div class="outer"><div class="box" role="listbox">候选</div><div class="box" role="menu">菜单</div>
<div class="box" role="dialog">对话</div><div class="box" role="tooltip">说明</div>
<div class="box" popover="manual" id="popover">弹层</div><div class="box"></div></div>
<script>popover.showPopover()</script>''', encoding='utf-8')
output = self.folder / 'semantic-cards'
self.shoot(output, '--size', 'desktop')
hints = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['lint']
self.assertNotIn('nestedCards', {h['rule'] for h in hints})
def test_lint_keeps_content_cards_inside_panels_and_editors(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
.outer{border-radius:12px;background:#eee;padding:12px;width:340px}
.box{border:1px solid #777;border-radius:8px;padding:8px;width:260px;min-height:56px}
</style><div class="outer" style="position:relative;height:100px"><div class="box" style="position:absolute;left:12px;top:12px">真正的内容卡片</div></div>
<form class="outer"><textarea></textarea><div class="box"><h3>另一个内容面板</h3><p>说明</p><button>操作</button></div></form>''', encoding='utf-8')
output = self.folder / 'real-cards'
self.shoot(output, '--size', 'desktop')
hints = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['lint']
self.assertEqual(next(h for h in hints if h['rule'] == 'nestedCards')['count'], 2)
def test_lint_requires_body_contrast_of_4_5(self):
self.page.write_text('''<!doctype html><html><head><link rel="icon" href="data:,"><style>
body{margin:0;padding:40px;font:16px/1.6 sans-serif;color:#222;background:#fff}
.soft{color:#8a8a8a}
</style></head><body><p class="soft">Body text at about 3.5 to 1 contrast still fails the 4.5 to 1 floor.</p></body></html>''', encoding="utf-8")
output = self.folder / "soft"
self.shoot(output, "--size", "1280x800")
lint = json.loads((output / "report.json").read_text(encoding="utf-8"))[0]["lint"]
self.assertIn("contrastLow", {item["rule"] for item in lint})
def test_lint_spares_css_triangles_and_neutral_dividers(self):
self.page.write_text('''<!doctype html><html><head><link rel="icon" href="data:,"><style>
body{margin:0;padding:40px;font:16px/1.6 sans-serif;color:#222}
.play{width:0;height:0;border-top:12px solid transparent;border-bottom:12px solid transparent;border-left:20px solid #e11d48}
aside{border-right:1px solid #ddd;height:200px;width:200px}
@keyframes rise{from{opacity:0}to{opacity:1}}h1{animation:rise .6s ease-out both;font-size:40px;margin:48px 0 12px}
</style></head><body><span class="play"></span><aside>Sidebar</aside><p>Intro paragraph above the heading.</p>
<h1>Plain heading</h1><p>Body text that follows the heading closely.</p></body></html>''', encoding="utf-8")
output = self.folder / "clean"
self.shoot(output, "--size", "1280x800")
self.assertEqual(json.loads((output / "report.json").read_text(encoding="utf-8"))[0]["lint"], [])
def test_compare_against_reference(self):
reference = self.folder / "reference"
self.shoot(reference, "--states", "a", "--size", "800x600")
same = self.folder / "same"
self.shoot(same, "--states", "a", "--size", "800x600", "--compare", str(reference / "a.png"))
entry = json.loads((same / "report.json").read_text(encoding="utf-8"))[0]
self.assert_artifacts(same, ("a-compare.png",))
self.assertLess(entry["compare"]["overall"], 1)
other = self.folder / "other"
result = self.shoot(other, "--states", "b", "--size", "800x600", "--compare", str(reference / "a.png"))
entry = json.loads((other / "report.json").read_text(encoding="utf-8"))[0]
self.assertGreater(entry["compare"]["overall"], 20)
self.assertEqual(len(entry["compare"]["cells"]), 9)
self.assertIn("差异明显的像素占", result.stdout)
missing = subprocess.run([NODE, str(SCRIPT), str(self.page), "--compare", str(self.folder / "nope.png")],
cwd=ROOT, env=self.env, capture_output=True, text=True, timeout=30, encoding='utf-8')
self.assertNotEqual(missing.returncode, 0)
self.assertIn("找不到参考图", missing.stderr)
def test_reports_blank_webgl_canvas(self):
# 同一块画布已经拿了 2d 上下文,再要 webgl 必然失败,用它模拟“截图成功但画布是空的”
self.page.write_text(self.page.read_text(encoding="utf-8").replace("</body>", '''
<canvas id="bad"></canvas><canvas id="good"></canvas>
<script>
const bad = document.querySelector('#bad'); bad.getContext('2d'); bad.getContext('webgl');
const good = document.querySelector('#good'); good.getContext('webgl2') || good.getContext('webgl');
</script></body>'''), encoding="utf-8")
output = self.folder / "webgl"
self.shoot(output)
issues = "\n".join(json.loads((output / "report.json").read_text(encoding="utf-8"))[0]["issues"])
self.assertIn("WebGL:1 个画布没能创建绘图上下文", issues)
def run_failure(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=110, encoding='utf-8')
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
return result
def test_fill_replaces_and_fires_events_while_type_inserts_at_cursor(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,">
<input id="insert" value="seed"><input id="replace" value="old value">
<textarea aria-label="Message box">old text</textarea><input id="empty" value="old value">
<div id="rich" contenteditable="true">old content</div><button id="verify">Verify</button>
<script>
const insert=document.querySelector('#insert'); insert.onfocus=()=>insert.setSelectionRange(2,2);
const changes={};
for(const el of document.querySelectorAll('#replace,textarea,#empty,#rich')) {
changes[el.id||'textarea']=[];
for(const event of ['input','change'])el.addEventListener(event,e=>{
if(!e.bubbles)console.error('fill-event-not-bubbling');
changes[el.id||'textarea'].push([event,el.isContentEditable?el.textContent:el.value]);
});
}
document.querySelector('#verify').onclick=()=>{
if(insert.value!=='seXed')console.error('type-did-not-insert-at-cursor');
for(const [key,value] of Object.entries({replace:'new value',textarea:'new text',empty:'',rich:'new content'})) {
const el=key==='textarea'?document.querySelector('textarea'):document.getElementById(key);
if((el.isContentEditable?el.textContent:el.value)!==value ||
JSON.stringify(changes[key])!==JSON.stringify([['input',value],['change',value]]))console.error('fill-failed-'+key);
}
};</script>''', encoding='utf-8')
output = self.folder / 'fill'
self.shoot(output, '--size', 'desktop', '--steps', 'type #insert X; fill #replace "new value"; '
'fill [aria-label="Message box"] "new text"; fill #empty ""; '
'fill #rich "new content"; click #verify')
self.assertEqual(json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['issues'], [])
def test_fill_rejects_non_editable_elements_with_context(self):
result = self.run_failure(self.folder / 'fill-invalid', '--steps', 'fill #go hello')
self.assertIn('第 1 步「fill #go hello」', result.stderr)
self.assertIn('fill 需要输入框、文本域或可编辑元素', result.stderr)
self.assertIn('例如 fill #message "hello world"', result.stderr)
def test_click_doubleclick_and_hover_warn_about_blocker_and_still_execute(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
#target,.veil{position:absolute;left:20px;top:20px;width:120px;height:60px}.veil{z-index:2}
#verify{position:absolute;left:200px;top:20px}</style>
<button id="target">Target</button><div class="veil">Overlay</div><button id="verify">Verify</button>
<script>
let clicks=0,doubles=0,hovers=0,hits=0;
document.querySelector('.veil').onclick=()=>clicks++;
document.querySelector('.veil').ondblclick=()=>doubles++;
document.querySelector('.veil').onmouseenter=()=>hovers++;
document.querySelector('#target').onclick=()=>hits++;
document.querySelector('#verify').onclick=()=>{
if(clicks!==3 || doubles!==1 || hovers!==1 || hits!==0)console.error('blocked-actions-not-executed');
};</script>''', encoding='utf-8')
output = self.folder / 'blocked'
result = self.shoot(output, '--steps', 'hover #target; click #target; dblclick #target; click #verify')
issues = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['issues']
self.assertEqual(len(issues), 3, issues)
for i, action in enumerate(['hover', 'click', 'dblclick'], 1):
message = f'第 {i} 步「{action} #target」:中心点被 div.veil 挡住'
self.assertIn(message, issues[i - 1])
self.assertIn(message, result.stdout)
self.assertIn('动作仍照常执行', issues[i - 1])
self.assertIn('发现 3 个问题', result.stdout)
def test_descendant_at_center_is_not_a_blocker_and_mark_does_not_warn(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
#target{width:120px;height:60px}#target span{display:block;width:100%;height:100%}</style>
<button id="target"><span>Target</span></button><button id="disabled" disabled>Disabled</button>
<script>let count=0;document.querySelector('#target').onclick=()=>count++;
document.querySelector('#target').ondblclick=()=>{if(count!==3)console.error('descendant-click-failed')};</script>''', encoding='utf-8')
output = self.folder / 'descendant'
result = self.shoot(output, '--steps', 'hover #target; click #target; dblclick #target', '--mark', '#disabled')
self.assertEqual(json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['issues'], [])
self.assertNotIn('提示:第', result.stdout)
def test_disabled_reasons_are_warnings_and_do_not_stop_actions(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>button{width:100px;height:50px}</style>
<button id="disabled" disabled>Disabled</button><button id="aria" aria-disabled="true">Aria</button>
<button id="pointer" style="pointer-events:none">Pointer</button>
<fieldset disabled><button id="inherited">Inherited</button></fieldset><button id="verify">Verify</button>
<script>let count=0;document.querySelector('#aria').onclick=()=>count++;
document.querySelector('#verify').onclick=()=>{if(count!==1)console.error('aria-action-stopped')};</script>''', encoding='utf-8')
output = self.folder / 'disabled'
result = self.shoot(output, '--size', 'desktop', '--steps', 'hover #disabled; click #aria; '
'dblclick #pointer; click #inherited; click #verify')
issues = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['issues']
for step, reason in [('hover #disabled', 'disabled'), ('click #aria', 'aria-disabled="true"'),
('dblclick #pointer', 'pointer-events: none'), ('click #inherited', 'disabled')]:
self.assertTrue(any(f'「{step}」:目标处于禁用状态({reason})' in issue for issue in issues), issues)
self.assertIn(reason, result.stdout)
self.assertFalse(any('控制台错误' in issue for issue in issues), issues)
self.assertIn(f'发现 {len(issues)} 个问题', result.stdout)
def test_action_warnings_are_in_recording_and_motion_reports(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
#target,#overlay{position:absolute;top:20px;left:20px;width:100px;height:50px}#overlay{z-index:2}</style>
<button id="target" aria-disabled="true">Target</button><div id="overlay">Overlay</div>''', encoding='utf-8')
output = self.folder / 'warning-record'
result = self.shoot(output, '--motion', '--record', '--steps', 'click #target', '--hold', '0')
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))
self.assertEqual(len(report), 2)
self.assertIn('motion', report[0])
for entry in report:
self.assertTrue(any('中心点被 div#overlay 挡住' in issue for issue in entry['issues']), entry)
self.assertTrue(any('aria-disabled="true"' in issue for issue in entry['issues']), entry)
self.assertIn('中心点被 div#overlay 挡住', result.stdout)
self.assertIn(f"发现 {sum(len(r['issues']) for r in report)} 个问题", result.stdout)
# A later failure must not erase warnings already raised by the recording.
failed = self.folder / 'warning-before-failure'
result = self.run_failure(failed, '--record', '--steps', 'click #target; click .missing', '--hold', '0')
entry = json.loads((failed / 'report.json').read_text(encoding='utf-8'))[0]
self.assertTrue(entry['failed'])
self.assertTrue(any('中心点被 div#overlay 挡住' in issue for issue in entry['issues']), entry)
self.assertTrue(any('aria-disabled="true"' in issue for issue in entry['issues']), entry)
self.assertTrue(any('找不到元素 .missing' in issue for issue in entry['issues']), entry)
self.assertIn(f"发现 {len(entry['issues'])} 个问题", result.stdout)
self.assertEqual(list(failed.glob('frames*')), [])
def test_missing_elements_explain_iframe_and_open_shadow_root_scope(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,">
<iframe srcdoc="<button id='framed'>Framed</button>"></iframe><iframe srcdoc="<p>Another frame</p>"></iframe>
<div id="host"></div><div id="closed"></div><script>
const root=document.querySelector('#host').attachShadow({mode:'open'});
root.innerHTML='<button id="shadowed">Shadowed</button><div id="nested"></div>';
root.querySelector('#nested').attachShadow({mode:'open'}).innerHTML='<button>Nested</button>';
document.querySelector('#closed').attachShadow({mode:'closed'}).innerHTML='<button>Closed</button>';
</script>''', encoding='utf-8')
for args in [('--steps', 'click #framed'), ('--steps', 'click text=Shadowed'),
('--steps', 'fill #shadowed hello'), ('--mark', '#shadowed')]:
with self.subTest(args=args):
result = self.run_failure(self.folder / 'scope', *args)
self.assertIn('找不到元素', result.stderr)
self.assertIn('选择器只在主文档里找,页面里有 2 个 iframe、2 个开放的 shadow root', result.stderr)
self.page.write_text('<!doctype html><link rel="icon" href="data:,"><body></body>', encoding='utf-8')
result = self.run_failure(self.folder / 'no-roots', '--steps', 'click .missing')
self.assertNotIn('选择器只在主文档里找', result.stderr)
def test_missing_element_scope_counts_iframes_and_shadow_roots_independently(self):
for markup, expected in [
('<iframe srcdoc="<p>Frame</p>"></iframe>', '1 个 iframe、0 个开放的 shadow root'),
('''<div id="host"></div><script>document.querySelector('#host').attachShadow({mode:'open'}).innerHTML='<p>Shadow</p>';</script>''',
'0 个 iframe、1 个开放的 shadow root'),
]:
with self.subTest(expected=expected):
self.page.write_text('<!doctype html><link rel="icon" href="data:,">' + markup, encoding='utf-8')
result = self.run_failure(self.folder / 'root-counts', '--steps', 'click .missing')
self.assertIn(expected, result.stderr)
def test_actions_text_selectors_and_marks(self):
self.page.write_text('''<!doctype html><meta charset="utf-8"><link rel="icon" href="data:,"><style>
body{margin:0;padding:20px}button{padding:12px}.hidden{display:none}</style>
<div class="nav"><button aria-label="Send money">More</button><button id="double">Undo</button></div>
<input aria-label="Message box"><select id="choice"><option value="a">First</option><option value="b">Second item</option></select>
<div id="status"></div><button class="hidden">More</button><div data-label="a;b">Semicolon</div>
<script>
let count=0; document.querySelector('[aria-label]').onclick=()=>count++;
document.querySelector('#double').ondblclick=()=>count+=10;
document.querySelector('#double').onmouseenter=()=>document.body.dataset.hover='yes';
for(const event of ['input','change']) document.querySelector('#choice').addEventListener(event,()=>document.body.dataset[event]='yes');
setTimeout(()=>{const b=document.createElement('button');b.textContent='Ready';document.body.append(b)},700);
setInterval(()=>document.querySelector('#status').textContent=JSON.stringify({count,text:document.querySelector('input').value,choice:document.querySelector('select').value,hover:document.body.dataset.hover,input:document.body.dataset.input,change:document.body.dataset.change}),30);
</script>''', encoding="utf-8")
output = self.folder / "actions"
result = self.shoot(output, "--steps", 'click text=More; click [aria-label="Send money"]; '
'click "[aria-label=\\"Send money\\"]"; hover button:has-text("Undo"); '
'doubleclick button:has-text(\'Undo\'); fill [aria-label="Message box"] "hello world"; '
'press ControlOrMeta+A; type "body input" "replacement"; select #choice b; '
'select #choice "Second item"; waitfor text=Ready; sleep 0.1s; '
'click [data-label="a;b"]; click :has-text("Semicolon")',
"--mark", '1=text=More; 2=button:has-text("Undo")')
self.assert_artifacts(output, ("page.png", "page-marked.png"))
# The report includes console output only on errors, so assert browser state through a guard.
self.page.write_text(self.page.read_text(encoding='utf-8').replace('setInterval(()=>document', '''setTimeout(()=>{
if(count!==13 || document.querySelector('input').value!=='replacement' || document.querySelector('select').value!=='b' || document.body.dataset.hover!=='yes' || document.body.dataset.input!=='yes' || document.body.dataset.change!=='yes') console.error('action-guard-failed');
},2600);setInterval(()=>document'''), encoding='utf-8')
self.shoot(self.folder / "guard", "--steps", 'click text=More; click [aria-label="Send money"]; '
'click "[aria-label=\\"Send money\\"]"; hover button:has-text("Undo"); '
'dblclick button:has-text("Undo"); type [aria-label="Message box"] "hello world"; '
'key ControlOrMeta+A; type "body input" replacement; select #choice "Second item"; wait 2000')
report = json.loads((self.folder / "guard/report.json").read_text(encoding='utf-8'))
self.assertEqual(report[0]["issues"], [], result.stdout)
def test_selector_errors_include_context_and_hints(self):
for raw, expected in [
('wait 0; click [id="missing"]', ('第 2 步', 'click [id="missing"]', 'id="go"')),
('click text=切', ('',)), # containment fallback succeeds, tested separately
('click text=切换不存在', ('可见文字', '切换')),
('click body .missing', ('"body" 匹配 1 个元素',)),
('click button:unknown', ('不是合法的 CSS', 'text="Send money"', 'button:has-text("Undo")')),
('waitfor .missing', ('第 1 步', 'waitfor .missing', '找不到元素')),
]:
if expected == ('',):
# POSIX hands argv over as bytes in the locale's encoding and node reads them as UTF-8, so under a
# non-UTF-8 locale this selector is garbled before the tool sees it. Windows passes Unicode.
if sys.platform == 'win32' or sys.getfilesystemencoding().lower().replace('-', '') == 'utf8':
self.shoot(self.folder / "contained", "--steps", raw)
continue
with self.subTest(raw=raw):
result = self.run_failure(self.folder / "missing", "--steps", raw)
for text in expected:
self.assertIn(text, result.stderr)
self.run_failure(self.folder / "mark-invalid", "--mark", "button:unknown")
def test_visible_element_does_not_scroll_and_offscreen_does(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
body{margin:0;height:2400px}#top{position:absolute;top:40px}#bottom{position:absolute;top:1800px}</style>
<button id="top">Top</button><button id="bottom">Bottom</button><script>
let last=0;topButton=document.querySelector('#top');topButton.onmouseenter=()=>{if(scrollY!==0)console.error('visible-scrolled')};
topButton.onclick=()=>{if(scrollY!==0)console.error('visible-scrolled')};
document.querySelector('#bottom').onclick=()=>{if(scrollY===0)console.error('offscreen-not-scrolled')};
</script>''', encoding='utf-8')
self.shoot(self.folder / "scroll", "--size", "desktop", "--steps", "hover #top; click #top; click #bottom")
self.assertEqual(json.loads((self.folder / "scroll/report.json").read_text(encoding='utf-8'))[0]["issues"], [])
def test_query_multiple_zooms_and_groups_reset(self):
self.page.write_text(self.page.read_text(encoding='utf-8').replace('</body>', '''<script>
if(new URLSearchParams(location.search).get('a')!=='1' || new URLSearchParams(location.search).get('b')!=='two words')console.error('query-missing');
document.querySelector('#go').addEventListener('click',()=>{if(document.body.dataset.state!=='b')console.error('group-not-reset')});
</script></body>'''), encoding='utf-8')
output = self.folder / "multiple"
self.shoot(output, "--size=desktop", "--zoom=1,2", "--query", "a=1&b=two+words", "--states", "a",
"--steps", "open: click body #go", "--steps", "send: click #go")
self.assert_artifacts(output, ("a-open.png", "a-send.png", "[email protected]", "[email protected]"))
report = json.loads((output / "report.json").read_text(encoding='utf-8'))
self.assertTrue(all(r["issues"] == [] for r in report))
self.assertEqual([r["zoom"] for r in report], [1, 1, 2, 2])
aliases = self.folder / "aliases"
self.shoot(aliases, "--size", "phone,800x600", "--query", "a=1&b=two+words")
self.assert_artifacts(aliases, ("page-390x844.png", "page-800x600.png"))
def test_named_and_unnamed_record_groups(self):
output = self.folder / "record-groups"
self.shoot(output, "--record", "--hold=100", "--wait=0", "--steps", "open: click #go", "--steps", "click #go")
self.assert_artifacts(output, ("motion-open-start.jpg", "motion-open-mid.jpg", "motion-open-end.jpg", "motion-2-end.jpg"))
if shutil.which('ffmpeg'):
self.assert_artifacts(output, ("record-open.mp4", "record-2.mp4"))
self.assertEqual(len(json.loads((output / "report.json").read_text(encoding='utf-8'))), 2)
@unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg is needed for complete evidence')
def test_evidence_all_artifacts_and_partial_failure(self):
output = self.folder / "evidence"
self.shoot(output, "--evidence", "--states=a,b", "--hold=100", "--steps", "toggle: click #go")
self.assert_artifacts(output, ("a.png", "b.png", "a-masked.png", "b-masked.png", "sheet.png", "sheet-masked.png",
"[email protected]", "record-entry.mp4", "motion-entry-start.jpg", "motion-entry-mid.jpg", "motion-entry-end.jpg",
"record-toggle.mp4", "motion-toggle-start.jpg", "motion-toggle-mid.jpg", "motion-toggle-end.jpg"))
report = json.loads((output / "report.json").read_text(encoding='utf-8'))
self.assertTrue(any(r["file"] == "motion-probe-toggle" for r in report))
self.assertFalse((output / '[email protected]').exists(), 'Default evidence still gives only the first state a 2x shot')
failed = self.folder / "partial"
result = self.run_failure(failed, "--evidence", "--hold=100", "--steps", "broken: click .missing", "--steps", "good: click #go")
self.assert_artifacts(failed, ("page.png", "[email protected]", "record-entry.mp4", "record-good.mp4", "report.json"))
report = json.loads((failed / "report.json").read_text(encoding='utf-8'))
self.assertEqual(sum(r.get("failed", False) for r in report), 2)
self.assertIn("失败", result.stdout)
@unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg is needed for complete evidence')
def test_evidence_explicit_zooms_cover_each_state_and_entry_starts_blank(self):
import struct
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
body{margin:0;background:#223344}#box{width:120px;height:120px;background:blue;animation:appear .36s linear both}
@keyframes appear{from{opacity:0}to{opacity:1}}#go{position:absolute;top:200px}
</style><div id="box"></div><button id="go">Change</button><script>
if(innerWidth!==800 || innerHeight!==600)console.error('zoom-changed-layout');
if(new URLSearchParams(location.search).get('state')==='b')document.body.style.background='#441122';
document.querySelector('#go').onclick=()=>document.body.style.background='#151515';
</script>''', encoding='utf-8')
for option, zooms in [('--zoom=1', [1]), ('--zoom=1,2', [1, 2]), ('--zoom=2,3', [2, 3])]:
with self.subTest(option=option):
output = self.folder / ('evidence-' + '-'.join(map(str, zooms)))
self.shoot(output, '--evidence', '--states', 'a,b', '--size', '800x600', option,
'--hold', '50', '--steps', 'change: click #go')
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))
shots = [entry for entry in report if entry['file'].endswith('.png')]
self.assertEqual([(entry['zoom'], entry['state']) for entry in shots],
[(zoom, state) for zoom in zooms for state in ['a', 'b']])
self.assertTrue(all(entry['issues'] == [] for entry in shots), shots)
for zoom in zooms:
suffix = '' if zoom == 1 else f'-@{zoom}x'
for state in ['a', 'b']:
name = f'{state}{suffix}.png'
dimensions = struct.unpack('>II', (output / name).read_bytes()[16:24])
self.assertEqual(dimensions, (800 * zoom, 600 * zoom))
self.assert_artifacts(output, (name, f'{state}{suffix}-masked.png'))
# Single-zoom sheets keep their existing names.
sheet_suffix = suffix if len(zooms) > 1 else ''
self.assert_artifacts(output, (f'sheet{sheet_suffix}.png', f'sheet{sheet_suffix}-masked.png'))
if 1 not in zooms:
self.assertFalse((output / 'a.png').exists())
if 2 not in zooms:
self.assertFalse((output / '[email protected]').exists())
entry = next(item for item in report if item.get('group') == 'entry')
self.assertEqual(entry['sampling']['mode'], 'entry')
first = self.frame_pixel(output / 'record-entry.mp4')
self.assertTrue(all(channel > 245 for channel in first), first)
# Content frames must belong to the new page, not the previous dark completed page.
self.assertNotEqual(self.frame_pixel(output / entry['frames']['start']['file']), (21, 21, 21))
self.assertEqual(list(output.glob('frames*')), [])
def test_browser_timeout_retries_once(self):
if sys.platform == 'win32':
self.skipTest('POSIX launch wrapper')
wrapper = self.folder / 'chrome-wrapper'
marker = self.folder / 'launch-count'
# First launch really times out after 45 seconds; the retry uses the real browser.
import shlex
wrapper.write_text('#!/bin/sh\nif [ ! -f ' + shlex.quote(str(marker)) + ' ]; then\n'
'touch ' + shlex.quote(str(marker)) + '\nexec sleep 60\nfi\nexec '
+ shlex.quote(self.browser) + ' "$@"\n', encoding='utf-8')
wrapper.chmod(0o755)
env = dict(self.env, CHROME_PATH=str(wrapper))
output = self.folder / 'retried'
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output)],
cwd=ROOT, env=env, capture_output=True, text=True, timeout=110, encoding='utf-8')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('45 秒内没有启动', result.stderr)
self.assertIn('正在重试一次', result.stderr)
self.assertIn('同时开了很多个任务时会变慢', result.stderr)
self.assert_artifacts(output, ('page.png', 'report.json'))
self.assertEqual(list(self.profile_root.glob('oil-shoot-*')), [])
def test_navigation_aborted_once_is_retried(self):
import http.server
import threading
requests = []
class AbortFirst(http.server.BaseHTTPRequestHandler):
def do_GET(handler):
if handler.path != '/page':
handler.send_response(404)
handler.end_headers()
return
requests.append(handler.path)
if len(requests) == 1:
# A response without content cancels the navigation: the browser reports net::ERR_ABORTED.
handler.send_response(204)
handler.end_headers()
return
body = b'<!doctype html><link rel="icon" href="data:,"><h1>loaded</h1>'
handler.send_response(200)
handler.send_header('Content-Type', 'text/html; charset=utf-8')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(body)
def log_message(*args):
pass
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), AbortFirst)
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
threading.Thread(target=server.serve_forever, daemon=True).start()
output = self.folder / 'aborted-once'
result = subprocess.run([NODE, str(SCRIPT), f'http://127.0.0.1:{server.server_port}/page', '--out', str(output)],
cwd=ROOT, env=self.env, capture_output=True, text=True, encoding='utf-8', timeout=90)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(len(requests), 2, requests)
self.assert_artifacts(output, ('page.png', 'report.json'))
def test_run_leaves_no_browser_process_or_profile(self):
output = self.folder / 'clean'
self.shoot(output)
self.assert_artifacts(output, ('page.png', 'report.json'))
self.assert_no_browser_leftovers()
def test_launcher_that_hands_off_and_exits_is_ready_and_cleaned(self):
if sys.platform == 'win32':
self.skipTest('POSIX launch wrapper')
import shlex
# The spawned process starts the real browser in the background and exits at once. Its stderr never
# reaches the tool, so readiness has to come from the DevToolsActivePort file in the profile.
wrapper = self.folder / 'hand-off'
wrapper.write_text('#!/bin/sh\nnohup ' + shlex.quote(self.browser) + ' "$@" >/dev/null 2>&1 &\nexit 0\n', encoding='utf-8')
wrapper.chmod(0o755)
output = self.folder / 'handed-off'
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output)], cwd=ROOT,
env=dict(self.env, CHROME_PATH=str(wrapper)), capture_output=True, text=True, timeout=90, encoding='utf-8')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assert_artifacts(output, ('page.png', 'report.json'))
self.assert_no_browser_leftovers()
def test_browser_that_never_becomes_ready_is_killed_by_profile(self):
if sys.platform == 'win32':
self.skipTest('POSIX launch wrapper')
# The launcher exits while a real browser keeps running on a profile inside the tool's temporary folder,
# so no readiness signal ever appears. Killing the spawned process would not reach that browser.
wrapper = self.folder / 'never-ready'
wrapper.write_text('#!' + sys.executable + '\nimport subprocess, sys\n'
'args = [a + "/elsewhere" if a.startswith("--user-data-dir=") else a for a in sys.argv[1:]]\n'
'subprocess.Popen([' + repr(self.browser) + ', *args], stdout=subprocess.DEVNULL, '
'stderr=subprocess.DEVNULL, start_new_session=True)\n', encoding='utf-8')
wrapper.chmod(0o755)
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(self.folder / 'never')], cwd=ROOT,
env=dict(self.env, CHROME_PATH=str(wrapper)), capture_output=True, text=True, timeout=90, encoding='utf-8')
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn('浏览器启动失败', result.stderr)
self.assert_no_browser_leftovers()
def test_text_matching_priority_and_combo_modifiers(self):
self.page.write_text('''<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,"><style>button{padding:12px}</style>
<section><span>More</span><button id="clickable">More</button></section>
<div><button id="deep"><span>Deep</span></button></div><button id="partial">Send money now</button>
<div hidden><div><button>More</button></div></div><input id="edit"><button id="verify">Verify</button>
<script>
const hits=[];for(const id of ['clickable','deep','partial'])document.getElementById(id).onclick=()=>hits.push(id);
const keys=[];addEventListener('keydown',e=>keys.push([e.key,e.metaKey,e.ctrlKey,e.shiftKey]));
document.getElementById('verify').onclick=()=>{
if(hits.join(',')!=='clickable,deep,partial')console.error('text-priority-failed');
if(!keys.some(k=>k[0]==='A'&&k[1]) || !keys.some(k=>k[0]==='A'&&k[2]) || !keys.some(k=>k[0]==='Tab'&&k[3]))console.error('combo-modifiers-failed');
};</script>''', encoding='utf-8')
output = self.folder / 'priorities'
self.shoot(output, '--steps', 'click text=More; click text=Deep; click text="Send money"; '
'click #edit; key Meta+A; key Control+A; key Shift+Tab; click #verify')
self.assertEqual(json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['issues'], [])
def test_mark_and_waitfor_accept_later_visible_css_match(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><button class="target" hidden>Hidden</button>
<button class="target">Shown</button><div class="delayed" style="opacity:0">Ready</div>
<script>setTimeout(()=>document.querySelector('.delayed').style.opacity=1,800)</script>''', encoding='utf-8')
output = self.folder / 'visible-match'
self.shoot(output, '--steps', 'waitfor .target; waitfor .delayed', '--mark', '.target')
self.assert_artifacts(output, ('page.png', 'page-marked.png'))
def test_single_zoom_preserves_sheet_and_record_names(self):
output = self.folder / 'zoom-sheet'
self.shoot(output, '--zoom', '2', '--states', 'a,b', '--sheet', '--mask')
self.assert_artifacts(output, ('[email protected]', 'sheet.png', 'sheet-masked.png'))
output = self.folder / 'zoom-record'
self.shoot(output, '--zoom', '2', '--record', '--steps', 'click #go', '--hold', '100')
self.assert_artifacts(output, ('motion-start.jpg', 'motion-mid.jpg', 'motion-end.jpg'))
if shutil.which('ffmpeg'):
self.assert_artifacts(output, ('record.mp4',))
def test_hidden_matches_report_count_reason_and_visible_example(self):
cases = [
('style="display:none"', '', 'display:none'),
('style="visibility:hidden"', '', 'visibility:hidden'),
('style="width:0;height:0;padding:0;border:0"', '', '尺寸为 0'),
('', 'display:none', '被祖先 div#container 隐藏:display:none'),
('', 'visibility:hidden', '被祖先 div#container 隐藏:visibility:hidden'),
('', 'opacity:0', '被祖先 div#container 隐藏:opacity:0'),
]
for attrs, parent_style, reason in cases:
with self.subTest(reason=reason):
self.page.write_text(f'''<!doctype html><link rel="icon" href="data:,">
<button data-view="activity">Activity</button><div id="container" style="{parent_style}">
<button data-view="accounts" {attrs}>Accounts</button><button data-view="accounts" {attrs}>Accounts 2</button></div>''', encoding='utf-8')
result = self.run_failure(self.folder / 'hidden', '--size', 'desktop', '--steps', 'click [data-view="accounts"]')
self.assertIn('匹配到 2 个元素', result.stderr)
self.assertIn(reason, result.stderr)
self.assertNotIn('找不到元素', result.stderr)
self.assertIn('先执行让它出现的那一步', result.stderr)
self.assertIn('例如 click [data-view="activity"]', result.stderr)
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><details id="tools">
<summary>Tools</summary><button data-view="accounts">Accounts</button></details>''', encoding='utf-8')
result = self.run_failure(self.folder / 'closed', '--steps', 'click [data-view="accounts"]')
self.assertIn('details 未展开', result.stderr)
self.assertIn('例如 click #tools > summary', result.stderr)
self.shoot(self.folder / 'opened', '--steps', 'click #tools > summary; click [data-view="accounts"]')
def test_all_actions_choose_first_visible_match(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>button{padding:12px}</style>
<button class="target" hidden>Hidden</button><button class="target">Visible</button>
<input class="edit" hidden><input class="edit">
<select class="choice" hidden><option value="a">A</option><option value="b">B</option></select>
<select class="choice"><option value="a">A</option><option value="b">B</option></select>
<button class="double" hidden>Hidden</button><button class="double">Double</button>
<div class="drag" hidden>Hidden</div><div class="drag" style="width:100px;height:50px">Drag</div>
<button id="verify">Verify</button><script>
let clicked=0, hovered=false, doubled=false, dragged=false;
const target=document.querySelectorAll('.target')[1]; target.onclick=()=>clicked++; target.onmouseenter=()=>hovered=true;
document.querySelectorAll('.double')[1].ondblclick=()=>doubled=true;
document.querySelectorAll('.drag')[1].onmousedown=()=>dragged=true;
document.querySelector('#verify').onclick=()=>{
if(clicked!==1 || !hovered || !doubled || !dragged || document.querySelectorAll('.edit')[1].value!=='hello' || document.querySelectorAll('.choice')[1].value!=='b')console.error('visible-match-failed');
};</script>''', encoding='utf-8')
output = self.folder / 'visible-actions'
self.shoot(output, '--size', 'desktop', '--steps', 'hover .target; click .target; dblclick .double; '
'drag .drag 20 0; type .edit hello; select .choice b; waitfor .target; click #verify')
self.assertEqual(json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['issues'], [])
# waitfor must continue polling matches that exist but are still hidden.
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><button id="later" hidden>Ready</button>
<script>setTimeout(()=>document.querySelector('#later').hidden=false,700)</script>''', encoding='utf-8')
self.shoot(self.folder / 'later', '--wait', '0', '--steps', 'waitfor #later; click #later')
def test_selector_examples_and_text_hints_are_relevant(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><p>Alpha unrelated</p><p>Send monez</p>
<button>Send money now</button><button data-view="accounts" hidden>Hidden</button>
<button data-view="activity">Activity</button>''', encoding='utf-8')
for action in ['click', 'hover', 'dblclick']:
result = self.run_failure(self.folder / action, '--steps', action + ' [data-view="missing"]')
self.assertIn('例如 ' + action + ' [data-view="activity"]', result.stderr)
self.assertLess(result.stderr.index('data-view="activity"'), result.stderr.index('data-view="accounts"'))
# A CSS-filtered text search misses; the text hint search covers the visible page.
result = self.run_failure(self.folder / 'text-hint', '--steps', 'click a:has-text("Send money")')
self.assertIn('例如 click text="Send money now"', result.stderr)
self.assertLess(result.stderr.index('可见文字 "Send money now"'), result.stderr.index('可见文字 "Send monez"'))
result = self.run_failure(self.folder / 'similar', '--steps', 'click text="Send monet"')
self.assertLess(result.stderr.index('可见文字 "Send monez"'), result.stderr.index('可见文字 "Alpha unrelated"'))
self.page.write_text('<!doctype html><link rel="icon" href="data:,"><body></body>', encoding='utf-8')
result = self.run_failure(self.folder / 'generic', '--steps', 'hover .missing')
self.assertIn('例如 hover .open', result.stderr)
def test_failed_recordings_clean_temporary_frames(self):
failed = self.folder / 'failed-record'
self.run_failure(failed, '--record', '--steps', 'broken: click .missing', '--hold', '0')
self.assertEqual(list(failed.glob('frames*')), [])
failed = self.folder / 'failed-evidence'
self.run_failure(failed, '--evidence', '--steps', 'broken: click .missing', '--steps', 'good: click #go', '--hold', '100')
self.assertEqual(list(failed.glob('frames*')), [])
self.assert_artifacts(failed, ('motion-good-end.jpg', 'motion-entry-end.jpg', 'report.json'))
self.assertEqual(list(self.profile_root.glob('oil-shoot-*')), [])
def fault_env(self, drops='', stale=None):
# Keep a real Chrome/CDP transport. Only selected responses are suppressed; accelerate
# those watchdogs after recording the production timeout value, leaving all other timers intact.
preload = self.folder / 'fault.mjs'
journal = self.folder / 'fault.jsonl'
preload.write_text('''import {appendFileSync,readFileSync} from 'node:fs';
const originalTimer=globalThis.setTimeout;let watchdog;
globalThis.setTimeout=(callback,delay,...args)=>{const timer=originalTimer(callback,delay,...args);if(delay>=90000)watchdog={callback,delay,timer};return timer};
const Native=globalThis.WebSocket;let captures=0,connections=0;
const drops=new Set((process.env.SHOOT_TEST_DROPS||'').split(',').map(Number));
const write=row=>appendFileSync(process.env.SHOOT_TEST_JOURNAL,JSON.stringify(row)+'\\n');
globalThis.WebSocket=class extends Native {
constructor(...args){super(...args);write({connection:++connections});
const substituted=new WeakSet();
this.addEventListener('message',event=>{
if(substituted.has(event)||!process.env.SHOOT_TEST_STALE)return;
const msg=JSON.parse(event.data);
if(msg.method!=='Page.screencastFrame')return;
event.stopImmediatePropagation();msg.params.data=readFileSync(process.env.SHOOT_TEST_STALE).toString('base64');
const replacement=new MessageEvent('message',{data:JSON.stringify(msg)});substituted.add(replacement);this.dispatchEvent(replacement);
});
}
send(data){const msg=JSON.parse(data);if(msg.method==='Page.captureScreenshot'){
write({capture:++captures,timeout:watchdog.delay});
if(drops.has(captures)){clearTimeout(watchdog.timer);originalTimer(watchdog.callback,100);return;}
}return super.send(data)}
};''', encoding='utf-8')
return dict(self.env, NODE_OPTIONS='--import=' + preload.as_uri(), SHOOT_TEST_DROPS=drops,
SHOOT_TEST_JOURNAL=str(journal), **({'SHOOT_TEST_STALE': str(stale)} if stale else {})), journal
def test_screenshot_timeout_reopens_and_retries_once(self):
env, journal = self.fault_env('1')
output = self.folder / 'screenshot-retry'
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output), '--timeout-scale', '2'],
cwd=ROOT, env=env, capture_output=True, text=True, timeout=90, encoding='utf-8')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
events = [json.loads(line) for line in journal.read_text(encoding='utf-8').splitlines()]
self.assertEqual(sum('connection' in row for row in events), 2)
self.assertEqual([row['timeout'] for row in events if 'capture' in row], [180000, 180000])
self.assertIn('正在重开浏览器重试 page.png 一次', result.stderr)
self.assertEqual(len(json.loads((output / 'report.json').read_text(encoding='utf-8'))), 1)
self.assert_artifacts(output, ('page.png', 'report.json'))
self.assertEqual(list(self.profile_root.glob('oil-shoot-*')), [])
def test_masked_screenshot_timeout_replays_state_and_finishes_batch(self):
env, journal = self.fault_env('2')
output = self.folder / 'masked-retry'
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output), '--states', 'a,b', '--mask'],
cwd=ROOT, env=env, capture_output=True, text=True, timeout=90, encoding='utf-8')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('[4/4]', result.stderr)
self.assertIn('--mask', result.stderr)
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))
self.assertEqual([entry['file'] for entry in report], ['a.png', 'b.png'])
self.assertEqual([entry['issues'] for entry in report], [[], []])
self.assert_artifacts(output, ('a.png', 'a-masked.png', 'b.png', 'b-masked.png'))
self.assertNotEqual((output / 'a.png').read_bytes(), (output / 'a-masked.png').read_bytes())
self.assertNotEqual((output / 'a-masked.png').read_bytes(), (output / 'b-masked.png').read_bytes())
events = [json.loads(line) for line in journal.read_text(encoding='utf-8').splitlines()]
self.assertEqual(sum('connection' in row for row in events), 2)
self.assertEqual(list(self.profile_root.glob('oil-shoot-*')), [])
@unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg is needed for evidence')
def test_second_screenshot_timeout_marks_item_failed_and_continues_evidence(self):
env, journal = self.fault_env('1,2')
output = self.folder / 'screenshot-failed-evidence'
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output), '--evidence',
'--hold', '100', '--steps', 'toggle: click #go'],
cwd=ROOT, env=env, capture_output=True, text=True, timeout=90, encoding='utf-8')
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))
self.assertTrue(report[0]['failed'])
self.assertIn('Page.captureScreenshot 90 秒', report[0]['issues'][-1])
self.assertFalse(any(row['file'] == 'browser' for row in report))
self.assertTrue(any(row['file'] == 'motion-probe-toggle' for row in report))
self.assert_artifacts(output, ('[email protected]', 'record-entry.mp4', 'record-toggle.mp4', 'report.json'))
self.assertIn('[5/5]', result.stderr)
self.assertNotRegex(result.stdout, r'\[\d+/\d+\]')
self.assertEqual(list(self.profile_root.glob('oil-shoot-*')), [])
def test_screenshot_retry_cannot_reopen_browser_aborts(self):
if sys.platform == 'win32':
self.skipTest('POSIX browser wrapper')
import shlex
env, _ = self.fault_env('1')
wrapper = self.folder / 'cannot-reopen'
marker = self.folder / 'launched'
wrapper.write_text('#!/bin/sh\nif [ -f ' + shlex.quote(str(marker)) + ' ]; then exit 1; fi\n'
'touch ' + shlex.quote(str(marker)) + '\nexec ' + shlex.quote(self.browser) + ' "$@"\n', encoding='utf-8')
wrapper.chmod(0o755)
output = self.folder / 'restart-failed'
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output), '--evidence'],
cwd=ROOT, env=dict(env, CHROME_PATH=str(wrapper)), capture_output=True, text=True, timeout=90, encoding='utf-8')
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn('浏览器重开失败', result.stderr)
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))
self.assertEqual(report[-1]['file'], 'browser')
self.assertEqual(list(output.glob('record*.mp4')), [])
self.assertEqual(list(self.profile_root.glob('oil-shoot-*')), [])
@unittest.skipUnless(shutil.which('ffmpeg'), 'ffmpeg is needed to inspect recording')
def test_stale_screencast_warns_preserves_stream_and_appends_screenshot(self):
self.page.write_text('<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,"><style>body{margin:0;background:#182838}'
'#box{position:absolute;left:60px;top:40px;width:240px;height:160px;border:4px solid #ffff00}</style><div id="box"></div>', encoding='utf-8')
stale = self.folder / 'stale.jpg'
subprocess.run(['ffmpeg', '-y', '-v', 'error', '-f', 'lavfi', '-i',
'color=c=0x182838:s=390x844,drawbox=x=60:y=40:w=248:h=168:color=yellow:t=4,'
'drawbox=x=304:y=44:w=4:h=160:color=0x182838:t=fill',
'-frames:v', '1', str(stale)], check=True, capture_output=True, timeout=10)
env, _ = self.fault_env(stale=stale)
output = self.folder / 'stale-stream'
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output), '--record', '--hold', '100'],
cwd=ROOT, env=env, capture_output=True, text=True, timeout=90, encoding='utf-8')
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
entry = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]
self.assertEqual(entry['consistency']['status'], 'mismatch')
self.assertIn('可能没拍全', entry['warnings'][0])
self.assertIn('静止终态以 motion-end.jpg 为准', result.stdout)
self.assertEqual((output / 'record-stream-end.jpg').read_bytes(), stale.read_bytes())
self.assertLess(entry['consistency']['pixelRatio'], .005, 'A thin missing border must survive whole-viewport dilution')
self.assertGreater(self.frame_pixel(output / 'motion-end.jpg', 306, 80)[1], 220)
last = subprocess.run(['ffmpeg', '-v', 'error', '-sseof', '-0.04', '-i', str(output / 'record.mp4'),
'-frames:v', '1', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-vf', 'format=rgb24,crop=1:1:306:80', '-'],
capture_output=True, timeout=10)
self.assertEqual(last.returncode, 0, last.stderr)
self.assertGreater(last.stdout[1], 220)
self.assertEqual(list(output.glob('frames*')), [])
def test_cdp_request_times_out_in_current_step(self):
import time
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><button id="prepare">Prepare</button><button id="hang">Hang</button>
<script>document.querySelector('#prepare').onclick=()=>{document.querySelector('#hang').getBoundingClientRect=()=>{while(true){}}};</script>''', encoding='utf-8')
output = self.folder / 'cdp-timeout'
start = time.monotonic()
result = self.run_failure(output, '--size', 'desktop', '--steps', 'click #prepare; click #hang')
elapsed = time.monotonic() - start
self.assertIn('第 2 步「click #hang」', result.stderr)
self.assertIn('CDP Runtime.evaluate 30 秒内没有响应', result.stderr)
self.assertGreaterEqual(elapsed, 30)
self.assertLess(elapsed, 42)
self.assertEqual(list(self.profile_root.glob('oil-shoot-*')), [])
self.assertTrue(any(r.get('failed') for r in json.loads((output / 'report.json').read_text(encoding='utf-8'))))
def test_browser_disconnect_interrupts_steps_and_cleans_frames(self):
if sys.platform == 'win32':
self.skipTest('POSIX browser kill wrapper')
import http.server
import shlex
import signal
import threading
import time
pid_file = self.folder / 'chrome.pid'
wrapper = self.folder / 'chrome-kill-wrapper'
wrapper.write_text('#!/bin/sh\necho $$ > ' + shlex.quote(str(pid_file)) + '\nexec ' + shlex.quote(self.browser) + ' "$@"\n', encoding='utf-8')
wrapper.chmod(0o755)
class KillHandler(http.server.BaseHTTPRequestHandler):
def do_GET(handler):
handler.send_response(200)
handler.end_headers()
handler.wfile.write(b'ok')
os.kill(int(pid_file.read_text(encoding='utf-8').strip()), signal.SIGKILL)
def log_message(*args):
pass
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), KillHandler)
self.addCleanup(server.server_close)
self.addCleanup(server.shutdown)
threading.Thread(target=server.serve_forever, daemon=True).start()
self.page.write_text(f'''<!doctype html><link rel="icon" href="data:,"><button id="kill">Kill</button>
<script>document.querySelector('#kill').onclick=()=>setTimeout(()=>fetch('http://127.0.0.1:{server.server_port}/kill'),250);</script>''', encoding='utf-8')
output = self.folder / 'disconnect'
start = time.monotonic()
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output), '--record', '--hold', '100',
'--steps', 'disconnect: click #kill; wait 60000'], cwd=ROOT,
env=dict(self.env, CHROME_PATH=str(wrapper)), capture_output=True, text=True, timeout=15, encoding='utf-8')
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn('第 2 步「wait 60000」', result.stderr)
self.assertIn('浏览器连接意外断开', result.stderr)
self.assertLess(time.monotonic() - start, 10)
self.assertEqual(list(output.glob('frames*')), [])
self.assertEqual(list(self.profile_root.glob('oil-shoot-*')), [])
def test_deduplicated_states_sizes_zooms_and_filename_collisions(self):
output = self.folder / 'deduplicated'
result = self.shoot(output, '--states', 'a,a,b,a', '--size', 'desktop,1440x900,phone,mobile', '--zoom', '1,1.0')
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))
self.assertEqual(len(report), 4)
for message in ['状态 已去重', '尺寸 已去重', '倍数 已去重']:
self.assertIn(message, result.stdout)
output = self.folder / 'group-collision'
result = self.shoot(output, '--states', 'a,a-b', '--steps', 'b-c: wait 0', '--steps', 'c: wait 0')
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))
self.assertEqual(len(set(r['file'] for r in report)), 4)
self.assert_artifacts(output, ('a-b-c.png', 'a-b-c-2.png'))
self.assertIn('文件名冲突,已区分', result.stdout)
output = self.folder / 'variant-collision'
self.shoot(output, '--states', 'a,a-masked,sheet', '--mask', '--sheet')
self.assert_artifacts(output, ('a.png', 'a-masked.png', 'a-masked-2.png', 'a-masked-2-masked.png',
'sheet.png', 'sheet-masked.png', 'sheet-2.png', 'sheet-2-masked.png'))
self.assertEqual(len(list(output.glob('*.png'))), 8)
def test_screenshots_wait_for_finite_animations_and_transitions(self):
self.page.write_text('''<!doctype html><link rel="icon" href="data:,"><style>
@keyframes enter{from{opacity:0;transform:translateY(40px)}to{opacity:1;transform:none}}
#box{animation:enter .7s both;transition:margin-left .6s;margin-left:0}
</style><button id="go">Go</button><div id="box">Ready</div><script>
document.querySelector('#go').onclick=()=>document.querySelector('#box').style.marginLeft='100px';
const capture=()=>{
const s=getComputedStyle(document.querySelector('#box'));
if(s.opacity!=='1'||parseFloat(s.marginLeft)<99)console.error('captured-during-motion');
};
// Screenshot checks read document.images; inspect its computed styles at that moment.
Object.defineProperty(document,'images',{get(){capture();return []}});
</script>''', encoding='utf-8')
output = self.folder / 'settled'
self.shoot(output, '--wait', '0', '--steps', 'click #go')
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]
self.assertEqual(report['issues'], [])
self.assertGreater(report['animationWaitMs'], 600)
self.assertLessEqual(report['animationWaitMs'], 2000)
def test_screenshot_animation_wait_is_capped_and_skips_infinite(self):
for duration, expected_wait in [('60s', True), ('60s infinite', False)]:
with self.subTest(duration=duration):
self.page.write_text(f'''<!doctype html><link rel="icon" href="data:,"><style>
@keyframes slow{{from{{transform:translateX(0)}}to{{transform:translateX(100px)}}}}
div{{animation:slow {duration}}}</style><div>Ready</div>''', encoding='utf-8')
output = self.folder / ('finite-cap' if expected_wait else 'infinite')
self.shoot(output, '--wait', '0')
waited = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]['animationWaitMs']
if expected_wait:
self.assertGreaterEqual(waited, 1950)
self.assertLessEqual(waited, 2000)
else:
self.assertEqual(waited, 0)
# Recording and motion probing continue to observe the animation from the original timing.
output = self.folder / 'record-unsettled'
self.shoot(output, '--wait', '0', '--record', '--entry', '--hold', '100')
report = json.loads((output / 'report.json').read_text(encoding='utf-8'))[0]
self.assertNotIn('animationWaitMs', report)
self.shoot(self.folder / 'motion-unsettled', '--wait', '0', '--motion')
report = json.loads((self.folder / 'motion-unsettled/report.json').read_text(encoding='utf-8'))[0]
self.assertNotIn('animationWaitMs', report)
def test_disconnect_during_encoding_exits_without_waiting_for_encoder(self):
if sys.platform == 'win32':
self.skipTest('POSIX browser and encoder wrappers')
import shlex
import signal
import threading
import time
browser_pid = self.folder / 'browser.pid'
wrapper = self.folder / 'chrome-encoder-wrapper'
wrapper.write_text('#!/bin/sh\necho $$ > ' + shlex.quote(str(browser_pid)) + '\nexec ' + shlex.quote(self.browser) + ' "$@"\n', encoding='utf-8')
wrapper.chmod(0o755)
encoder_ready = self.folder / 'encoder-ready'
fake_bin = self.folder / 'bin'
fake_bin.mkdir()
encoder = fake_bin / 'ffmpeg'
encoder.write_text('#!/bin/sh\nif [ "$1" = "-version" ]; then exit 0; fi\n'
'touch ' + shlex.quote(str(encoder_ready)) + '\nexec sleep 60\n', encoding='utf-8')
encoder.chmod(0o755)
stop = threading.Event()
def disconnect():
while not stop.wait(.02):
if encoder_ready.exists():
os.kill(int(browser_pid.read_text(encoding='utf-8')), signal.SIGKILL)
return
thread = threading.Thread(target=disconnect, daemon=True)
thread.start()
try:
output = self.folder / 'encoder-disconnect'
start = time.monotonic()
result = subprocess.run([NODE, str(SCRIPT), str(self.page), '--out', str(output), '--record', '--hold', '100'],
cwd=ROOT, env=dict(self.env, CHROME_PATH=str(wrapper), PATH=str(fake_bin) + os.pathsep + os.environ['PATH']),
capture_output=True, text=True, timeout=15, encoding='utf-8')
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertTrue(encoder_ready.exists())
self.assertIn('浏览器连接意外断开', result.stderr)
self.assertLess(time.monotonic() - start, 10)
self.assertEqual(list(output.glob('frames*')), [])
self.assertEqual(list(self.profile_root.glob('oil-shoot-*')), [])
finally:
stop.set()
thread.join(1)
if __name__ == "__main__":
unittest.main()