1652 lines
109 KiB
Python
1652 lines
109 KiB
Python
"""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()
|