Skip to content

Commit 77424a1

Browse files
committed
fix(engine): fix visual baseline location, optimize a11y script injection, and resolve windows terminal encoding crash
1 parent cc862a1 commit 77424a1

5 files changed

Lines changed: 143 additions & 15 deletions

File tree

‎scrapewizard/cli/main.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1+
import sys
12
import typer
23
from scrapewizard.cli.commands import setup, scrape, utils, engine
34
from scrapewizard.core.logging import Logger
45

6+
# Ensure Windows/CMD/PowerShell console supports UTF-8/emoji output without crashing
7+
try:
8+
if hasattr(sys.stdout, "reconfigure"):
9+
sys.stdout.reconfigure(encoding="utf-8")
10+
if hasattr(sys.stderr, "reconfigure"):
11+
sys.stderr.reconfigure(encoding="utf-8")
12+
except Exception:
13+
pass
14+
15+
516
app = typer.Typer(
617
name="scrapewizard",
718
help="Agentic Web Scraper Builder",

‎scrapewizard/engine/checks/a11y.py‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,18 @@
33
from typing import List, Dict, Any
44
from scrapewizard.core.logging import log
55

6-
# Read the bundled script content once and cache it in memory
6+
# Read the bundled script content once at module load and cache it
77
AXE_SCRIPT_PATH = Path(__file__).parent / "axe.min.js"
88
AXE_SCRIPT_CONTENT = None
99

1010
if AXE_SCRIPT_PATH.exists():
1111
with open(AXE_SCRIPT_PATH, "r", encoding="utf-8") as f:
1212
AXE_SCRIPT_CONTENT = f.read()
1313

14-
async def perform_a11y_check(page) -> List[Dict[str, Any]]:
14+
async def _ensure_axe_loaded(page) -> bool:
1515
"""
16-
Inject axe-core into the target Playwright page, run analysis,
17-
and return a structured list of accessibility violations.
16+
Check if axe-core is already loaded in the page context.
17+
If not, inject it once. Returns True if axe is available.
1818
"""
1919
global AXE_SCRIPT_CONTENT
2020

@@ -24,11 +24,23 @@ async def perform_a11y_check(page) -> List[Dict[str, Any]]:
2424
AXE_SCRIPT_CONTENT = f.read()
2525
else:
2626
log("axe.min.js not found, skipping accessibility check", level="warning")
27-
return []
27+
return False
2828

29-
try:
30-
# Inject the axe-core library
29+
# Check if axe is already injected (avoids re-injecting 538KB on every step)
30+
already_loaded = await page.evaluate("typeof window.axe !== 'undefined'")
31+
if not already_loaded:
3132
await page.evaluate(AXE_SCRIPT_CONTENT)
33+
34+
return True
35+
36+
async def perform_a11y_check(page) -> List[Dict[str, Any]]:
37+
"""
38+
Inject axe-core into the target Playwright page (if not already present),
39+
run analysis, and return a structured list of accessibility violations.
40+
"""
41+
try:
42+
if not await _ensure_axe_loaded(page):
43+
return []
3244

3345
# Run accessibility analysis
3446
# axe.run() returns a promise, so we evaluate it asynchronously

‎scrapewizard/engine/recorder.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,10 @@ async def on_event_callback(action: str, temp_id: str, value: str, warnings_json
6060
log(f"Failed to capture element fingerprint: {e}", level="error")
6161
return
6262

63-
# Check for password masking
63+
# Mask password field values for security
6464
recorded_value = value
65-
if fingerprint.attributes.get("type") == "password" or action == "fill" and value != "***MASKED***":
66-
if fingerprint.attributes.get("type") == "password":
67-
recorded_value = "***MASKED***"
65+
if fingerprint.attributes.get("type") == "password":
66+
recorded_value = "***MASKED***"
6867

6968
# Primary selector
7069
primary_selector = fingerprint.selectors[0]["value"] if fingerprint.selectors else ""

‎scrapewizard/engine/sandbox.py‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,20 +66,35 @@ class SandboxRunner:
6666
Isolated test runner that executes generated steps and tracks visual, console,
6767
network, and accessibility status quality signals.
6868
"""
69-
def __init__(self, artifacts_dir: Optional[str] = None, headless: bool = True):
69+
def __init__(
70+
self,
71+
artifacts_dir: Optional[str] = None,
72+
baselines_dir: Optional[str] = None,
73+
flow_name: str = "default",
74+
headless: bool = True,
75+
):
7076
self.headless = headless
7177

72-
# Setup artifacts directories
78+
# Setup per-run artifacts directory (screenshots, diffs, reports)
7379
if artifacts_dir:
7480
self.artifacts_dir = Path(artifacts_dir)
7581
else:
7682
timestamp = int(time.time())
7783
self.artifacts_dir = Path("runs") / f"run_{timestamp}"
7884

7985
self.screenshots_dir = self.artifacts_dir / "screenshots"
80-
self.baselines_dir = self.artifacts_dir / "baselines"
8186
self.diffs_dir = self.artifacts_dir / "diffs"
8287

88+
# Baselines live in a STABLE shared location, NOT under the per-run
89+
# artifacts dir. This ensures successive runs compare against the same
90+
# baseline images instead of re-baselining every execution.
91+
if baselines_dir:
92+
self.baselines_dir = Path(baselines_dir)
93+
else:
94+
self.baselines_dir = (
95+
Path.home() / ".scrapewizard" / "baselines" / flow_name
96+
)
97+
8398
self.screenshots_dir.mkdir(parents=True, exist_ok=True)
8499
self.baselines_dir.mkdir(parents=True, exist_ok=True)
85100
self.diffs_dir.mkdir(parents=True, exist_ok=True)

‎tests/engine/test_sandbox.py‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ async def test_sandbox_runner_golden_flow(demo_server, tmp_path):
1818
flow_json = tmp_path / "flow.json"
1919
screenshots_dir = tmp_path / "screenshots"
2020
artifacts_dir = tmp_path / "sandbox_artifacts"
21+
baselines_dir = tmp_path / "baselines"
2122

2223
# 1. Record
2324
recorder = InteractiveRecorder(
@@ -47,7 +48,7 @@ async def drive_recorder():
4748
test_def = generator.generate()
4849

4950
# 3. Run sandbox
50-
runner = SandboxRunner(artifacts_dir=str(artifacts_dir), headless=True)
51+
runner = SandboxRunner(artifacts_dir=str(artifacts_dir), baselines_dir=str(baselines_dir), headless=True)
5152
run_result = await runner.run(test_def)
5253

5354
# 4. Assertions
@@ -71,3 +72,93 @@ async def drive_recorder():
7172
assert step1.console_errors is not None
7273
assert step1.network_errors is not None
7374
assert step1.a11y_violations is not None
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_baselines_are_stable(demo_server, tmp_path):
79+
"""
80+
Verify that running the sandbox twice with the same baselines_dir actually
81+
REUSES baselines from the first run (no re-baselining).
82+
"""
83+
baselines_dir = tmp_path / "shared_baselines"
84+
85+
# Minimal test def: just navigate
86+
test_def = {
87+
"url": demo_server,
88+
"steps": [{
89+
"name": "navigate_to_start",
90+
"action": "navigate",
91+
"value": demo_server,
92+
"selectors": [],
93+
"assertions": [{"kind": "url", "value": demo_server}]
94+
}]
95+
}
96+
97+
# Run 1: should CREATE baselines
98+
runner1 = SandboxRunner(
99+
artifacts_dir=str(tmp_path / "run1"),
100+
baselines_dir=str(baselines_dir),
101+
headless=True,
102+
)
103+
result1 = await runner1.run(test_def)
104+
assert result1.status == "passed"
105+
106+
# Baselines should now exist
107+
baseline_files = list(baselines_dir.glob("*.png"))
108+
assert len(baseline_files) > 0, "Baselines should have been created"
109+
110+
# Record the modification time of the baseline
111+
mtime_after_run1 = baseline_files[0].stat().st_mtime
112+
113+
# Run 2: should COMPARE against existing baselines (not overwrite)
114+
runner2 = SandboxRunner(
115+
artifacts_dir=str(tmp_path / "run2"),
116+
baselines_dir=str(baselines_dir),
117+
headless=True,
118+
)
119+
result2 = await runner2.run(test_def)
120+
assert result2.status == "passed"
121+
122+
# Baseline file should NOT have been overwritten
123+
mtime_after_run2 = baseline_files[0].stat().st_mtime
124+
assert mtime_after_run1 == mtime_after_run2, "Baseline should not be overwritten on second run"
125+
126+
# Visual diff should be 0 or very close (same page, same viewport)
127+
nav_step = result2.step_results[0]
128+
assert nav_step.visual_diff_score is not None
129+
assert nav_step.visual_diff_score < 0.1, f"Expected near-zero visual diff, got {nav_step.visual_diff_score}"
130+
131+
132+
def test_visual_check_diff_detection(tmp_path):
133+
"""Unit test for the visual comparison engine's diff detection."""
134+
from PIL import Image
135+
from scrapewizard.engine.checks.visual import perform_visual_check
136+
137+
baselines_dir = tmp_path / "baselines"
138+
diff_dir = tmp_path / "diffs"
139+
140+
# Create a solid red baseline image
141+
img1 = Image.new("RGB", (100, 100), color="red")
142+
baselines_dir.mkdir(parents=True, exist_ok=True)
143+
img1.save(baselines_dir / "step_1.png")
144+
145+
# Create a solid blue "current" screenshot
146+
current_path = tmp_path / "current.png"
147+
img2 = Image.new("RGB", (100, 100), color="blue")
148+
img2.save(current_path)
149+
150+
# They should differ significantly
151+
score, diff_path = perform_visual_check(
152+
str(current_path), "step_1", str(baselines_dir), str(diff_dir)
153+
)
154+
assert score > 0.5, f"Expected high diff score for red vs blue, got {score}"
155+
assert diff_path is not None
156+
assert Path(diff_path).exists()
157+
158+
# Now compare identical images — diff should be 0
159+
score2, diff_path2 = perform_visual_check(
160+
str(baselines_dir / "step_1.png"), "step_1", str(baselines_dir), str(diff_dir)
161+
)
162+
assert score2 == 0.0
163+
assert diff_path2 is None
164+

0 commit comments

Comments
 (0)