216 lines
11 KiB
Python
216 lines
11 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):
|
||
def test_help(self):
|
||
result = subprocess.run([NODE, str(SCRIPT), "--help"], cwd=ROOT,
|
||
capture_output=True, text=True, timeout=10)
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
self.assertIn("用法", result.stdout)
|
||
|
||
def test_missing_target(self):
|
||
result = subprocess.run([NODE, str(SCRIPT)], cwd=ROOT,
|
||
capture_output=True, text=True, timeout=10)
|
||
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)
|
||
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)
|
||
self.assertEqual(result.returncode, 1)
|
||
self.assertEqual(result.stderr.splitlines(), [
|
||
"shoot:不认识的选项 --xxx",
|
||
"可用选项:" + " ".join(options),
|
||
])
|
||
self.assertEqual(result.stdout, "")
|
||
|
||
def test_missing_option_value(self):
|
||
for option in ("--out", "--size", "--states", "--param", "--zoom", "--steps", "--hold", "--wait"):
|
||
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)
|
||
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)
|
||
self.assertEqual(result.returncode, 1)
|
||
self.assertEqual(result.stderr, "shoot:缺少页面地址或文件。\n")
|
||
|
||
|
||
class ShootBrowserTests(unittest.TestCase):
|
||
@classmethod
|
||
def setUpClass(cls):
|
||
if not NODE:
|
||
raise unittest.SkipTest("Node 22+ is not installed")
|
||
version = subprocess.run([NODE, "--version"], capture_output=True, text=True, timeout=10)
|
||
if version.returncode or int(version.stdout.strip().lstrip("v").split(".")[0]) < 22:
|
||
raise unittest.SkipTest("Node 22+ is required")
|
||
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)
|
||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||
return result
|
||
|
||
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.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_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_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 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)
|
||
self.assertEqual(result.returncode, 0, result.stderr)
|
||
self.assertGreaterEqual(float(json.loads(result.stdout)["format"]["duration"]), 2.0)
|
||
|
||
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)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|