aboutsummaryrefslogtreecommitdiff
path: root/lib/project/tests/test_gui_browser.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/project/tests/test_gui_browser.py')
-rw-r--r--lib/project/tests/test_gui_browser.py257
1 files changed, 257 insertions, 0 deletions
diff --git a/lib/project/tests/test_gui_browser.py b/lib/project/tests/test_gui_browser.py
new file mode 100644
index 0000000..1e38964
--- /dev/null
+++ b/lib/project/tests/test_gui_browser.py
@@ -0,0 +1,257 @@
+"""Opt-in real Chromium/loopback smoke; no AI models or recording uploads.
+
+Install Playwright externally, then run:
+PRODUCER_BROWSER_TESTS=1 python -m pytest tests/test_gui_browser.py -q
+Screenshots go to PRODUCER_BROWSER_ARTIFACTS (default /tmp/producer-browser-smoke).
+The long overview case uses mocked fixed-size scan metadata, not 4-hour AI inference.
+"""
+from dataclasses import asdict
+import hashlib
+import json
+import os
+import re
+from pathlib import Path
+import socket
+import threading
+import time
+
+import numpy as np
+import pytest
+
+pytestmark = pytest.mark.skipif(os.environ.get("PRODUCER_BROWSER_TESTS") != "1", reason="Opt-in Playwright browser smoke")
+
+
+@pytest.fixture
+def studio(tmp_path, wav, bypass, ffmpeg, monkeypatch):
+ pytest.importorskip("playwright.sync_api")
+ import uvicorn
+ from producer.gui import create_app
+ from producer import gui_jobs
+
+ source = wav(0.15 * np.sin(2 * np.pi * 440 * np.arange(48000 * 8) / 48000), name="Narration.wav")
+ wav(np.zeros((100, 2)), name="Stereo.wav")
+ (tmp_path / "delivery").mkdir()
+ (tmp_path / "subfolder").mkdir()
+ control = {"hold": False, "started": threading.Event(), "release": threading.Event()}
+ actual = gui_jobs.render_preview
+
+ def controlled_preview(*args):
+ if control["hold"]:
+ control["started"].set()
+ assert control["release"].wait(15), "Browser test did not release held preview"
+ control["hold"] = False
+ return actual(*args)
+
+ monkeypatch.setattr(gui_jobs, "render_preview", controlled_preview)
+ token = "browser-test-private-credential"
+ sock = socket.socket()
+ sock.bind(("127.0.0.1", 0))
+ sock.listen(128)
+ port = sock.getsockname()[1]
+ app = create_app(tmp_path, port, token, overrides=asdict(bypass))
+ server = uvicorn.Server(uvicorn.Config(app, log_level="error", access_log=False, proxy_headers=False))
+ thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
+ thread.start()
+ deadline = time.monotonic() + 10
+ while not server.started and time.monotonic() < deadline:
+ time.sleep(0.02)
+ assert server.started
+ try:
+ yield {"url": f"http://127.0.0.1:{port}", "token": token, "root": tmp_path, "source": source, "control": control}
+ finally:
+ control["release"].set()
+ server.should_exit = True
+ thread.join(15)
+ sock.close()
+ assert not thread.is_alive()
+
+
+def test_gui_browser_real_workflow(studio):
+ from playwright.sync_api import sync_playwright, expect
+
+ screenshots = Path(os.environ.get("PRODUCER_BROWSER_ARTIFACTS", "/tmp/producer-browser-smoke"))
+ screenshots.mkdir(parents=True, exist_ok=True)
+ before = hashlib.sha256(studio["source"].read_bytes()).hexdigest()
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ context = browser.new_context(viewport={"width": 1360, "height": 1000}, accept_downloads=True)
+ page = context.new_page()
+ errors, console_errors, media_requests = [], [], []
+ page.on("pageerror", lambda exc: errors.append(str(exc)))
+ page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
+ page.on("request", lambda req: media_requests.append(req.url) if "/api/results/" in req.url else None)
+ page.goto(studio["url"] + "/#token=" + studio["token"])
+ expect(page.locator("#workspace")).not_to_have_attribute("disabled", "")
+ assert "#" not in page.url
+ assert studio["token"] not in page.locator("body").inner_text()
+ assert page.locator("[data-setting]").count() == 47
+ expect(page.locator("#setting-denoiser")).to_have_value("none")
+ expect(page.locator("#folder-up")).to_be_disabled()
+ # Unsupported input errors are human-readable, not raw HTML.
+ page.get_by_role("button", name=re.compile(r"WAV\s*Stereo.wav")).click()
+ expect(page.locator("#error")).to_be_visible()
+ expect(page.locator("#error-text")).to_contain_text("Stereo")
+ page.locator("#dismiss-error").click()
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=15000)
+ expect(page.locator("#input-info")).to_contain_text("00:00:08")
+ # Both drag selection and keyboard-accessible exact inputs.
+ page.locator("#waveform").scroll_into_view_if_needed()
+ box = page.locator("#waveform").bounding_box()
+ page.mouse.move(box["x"] + box["width"] * .25, box["y"] + 80)
+ page.mouse.down()
+ page.mouse.move(box["x"] + box["width"] * .65, box["y"] + 80, steps=5)
+ page.mouse.up()
+ assert page.locator("#selection-start").input_value() != "00:00:00"
+ page.locator("#selection-start").fill("00:00:02")
+ page.locator("#selection-end").fill("00:00:05")
+ page.locator("#zoom-selection").click()
+ page.locator("#zoom-full").click()
+ # Explicit render, requested pacing+MP3 preserved in snapshot but bypassed internally.
+ page.locator("#setting-trim_silence").check()
+ page.locator("#setting-mp3").check()
+ page.locator("#render-preview").click()
+ expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000)
+ assert len(media_requests) == 2
+ page.locator("#compare").scroll_into_view_if_needed()
+ page.locator("#play").click()
+ expect(page.locator("#play")).to_have_text("Pause")
+ page.wait_for_timeout(350)
+ position = page.locator("#audio-original").evaluate("audio => audio.currentTime")
+ page.locator("#listen-processed").click()
+ expect(page.locator("#listen-processed")).to_have_attribute("aria-pressed", "true")
+ assert page.locator("#audio-processed").evaluate("audio => audio.currentTime") >= position - .15
+ assert page.locator("#audio-original").evaluate("audio => audio.paused")
+ assert page.locator("#audio-processed").evaluate("audio => audio.loop")
+ page.locator("#match").check()
+ expect(page.locator("#monitoring-note")).to_contain_text("Monitoring only")
+ page.locator("#play").click()
+ expect(page.locator("#meters")).to_contain_text("not a full-export measurement")
+ page.locator("#comparison-name").fill("Gentle <snapshot>")
+ page.locator("#save-comparison").click()
+ expect(page.locator("#comparisons")).to_contain_text("Gentle <snapshot>")
+ assert page.locator("#comparisons snapshot").count() == 0
+ page.locator("#presets-panel summary").click()
+ page.locator("#preset-name").fill("My sound")
+ page.locator("#save-preset").click()
+ expect(page.locator("#presets")).to_contain_text("My sound")
+ with page.expect_download() as downloaded:
+ page.locator("#download-preset").click()
+ toml_path = screenshots / "roundtrip-preset.toml"
+ downloaded.value.save_as(toml_path)
+ assert "trim_silence = true" in toml_path.read_text()
+ assert studio["token"] not in toml_path.read_text()
+ page.locator("#setting-denoise_strength").fill("0.4")
+ expect(page.locator("#comparison-status")).to_contain_text("changed since preview")
+ page.locator("#import-preset").set_input_files(toml_path)
+ expect(page.locator("#setting-denoise_strength")).to_have_value("0.85")
+ # The saved copy cannot change when controls are edited later.
+ page.locator("#setting-denoise_strength").fill("0.5")
+ saved = page.evaluate("JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k => k.startsWith('producer-studio-v1:'))))")
+ assert saved["comparisons"][0]["snapshot"]["settings"]["denoise_strength"] == .85
+ assert saved["presets"][0]["settings"]["denoise_strength"] == .85
+ assert studio["token"] not in json.dumps(saved)
+ # Delay real preview completion. Editing after submission must never publish it as current.
+ studio["control"]["hold"] = True
+ page.locator("#render-preview").click()
+ assert studio["control"]["started"].wait(10)
+ old_source = page.locator("#comparison-source").inner_text()
+ page.locator("#setting-denoise_strength").fill("0.6")
+ studio["control"]["release"].set()
+ expect(page.locator("#jobs")).to_contain_text("Older snapshot; not loaded", timeout=30000)
+ assert page.locator("#comparison-source").inner_text() == old_source
+ assert len(media_requests) == 2
+ # Restore is an explicit render and restores the original immutable requested settings.
+ page.locator("#comparisons button", has_text="Restore & render").click()
+ expect(page.locator("#setting-denoise_strength")).to_have_value("0.85")
+ expect(page.locator("#comparison-status")).to_contain_text("Ready", timeout=30000)
+ # Cross-field errors do not silently enable the limiter.
+ page.locator("#setting-normalize").check()
+ page.locator("#render-preview").click()
+ expect(page.locator("#error-text")).to_contain_text("limiter")
+ assert not page.locator("#setting-limiter").is_checked()
+ page.locator("#dismiss-error").click()
+ page.locator("#setting-normalize").uncheck()
+ page.locator("#output-dir").fill("delivery")
+ preview_requests = len(media_requests)
+ page.locator("#prepare-export").click()
+ expect(page.locator("#export-dialog")).to_be_visible()
+ expect(page.locator("#export-pacing")).to_contain_text("NOT auditioned")
+ expect(page.locator("#export-summary")).to_contain_text("whole original 00:00:08")
+ page.locator("#confirm-export").click()
+ expect(page.locator("#export-results")).to_contain_text("Full recording exported", timeout=30000)
+ outputs = list((studio["root"] / "delivery").glob("producer-export-*"))
+ assert len(outputs) == 1
+ assert (outputs[0] / "master.wav").is_file() and (outputs[0] / "master.mp3").is_file()
+ assert (outputs[0] / "logs/master.report.json").is_file()
+ import soundfile as sf
+ assert sf.info(outputs[0] / "master.wav").duration == pytest.approx(8, abs=.01)
+ assert len(media_requests) == preview_requests, "Full exports must not be fetched into browser blobs"
+ page.locator("#prepare-export").click()
+ page.locator("#confirm-export").click()
+ expect(page.locator("#export-results .export-result")).to_have_count(2, timeout=30000)
+ assert len(list((studio["root"] / "delivery").glob("producer-export-*"))) == 2
+ assert hashlib.sha256(studio["source"].read_bytes()).hexdigest() == before
+ page.locator("#compare").scroll_into_view_if_needed()
+ page.screenshot(path=str(screenshots / "desktop-comparison.png"), full_page=True)
+ page.locator("#compare").screenshot(path=str(screenshots / "comparison-panel.png"))
+ # Reload retains per-tab authorization and local snapshots, not misleading current audio.
+ page.reload()
+ expect(page.locator("#workspace")).not_to_have_attribute("disabled", "")
+ expect(page.locator("#comparisons")).to_contain_text("Gentle <snapshot>")
+ expect(page.locator("#play")).to_be_disabled()
+ expect(page.locator("#export-results .export-result")).to_have_count(2, timeout=15000)
+ page.set_viewport_size({"width": 390, "height": 844})
+ page.screenshot(path=str(screenshots / "mobile-studio.png"), full_page=True)
+ page.evaluate("window.scrollTo(0, 0)")
+ page.screenshot(path=str(screenshots / "mobile-entry.png"))
+ assert page.evaluate("document.documentElement.scrollWidth <= innerWidth")
+ # A fresh tab without the private fragment cannot read API data.
+ unauth = context.new_page()
+ unauth.goto(studio["url"])
+ expect(unauth.locator("#error-text")).to_contain_text("private URL")
+ expect(unauth.locator("#workspace")).to_have_attribute("disabled", "")
+ unauth.close()
+ # Simulate server restart credential expiry and verify recovery guidance.
+ page.route("**/api/jobs", lambda route: route.fulfill(status=401, json={"detail": "expired"}))
+ expect(page.locator("#workspace")).to_have_attribute("disabled", "", timeout=10000)
+ expect(page.locator("#connection")).to_contain_text("Session expired")
+ assert errors == []
+ # Expected HTTP errors for stereo, invalid constraint and the expiry test are allowed.
+ assert not [msg for msg in console_errors if "Content Security Policy" in msg or "Refused" in msg]
+ browser.close()
+
+
+def test_gui_browser_long_overview(studio):
+ from playwright.sync_api import sync_playwright, expect
+
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch(headless=True, args=["--no-sandbox"])
+ page = browser.new_page(viewport={"width": 1200, "height": 900})
+ requests, errors = [], []
+ page.on("request", lambda request: requests.append(request.url))
+ page.on("pageerror", lambda exc: errors.append(str(exc)))
+ page.goto(studio["url"] + "/#token=" + studio["token"])
+ expect(page.locator("#workspace")).not_to_have_attribute("disabled", "")
+ # Four-hour metadata plus fixed-size peaks: browser never receives full recording samples.
+ info = {"path": "Narration.wav", "input_id": "long-identity", "frames": 14400 * 8000,
+ "duration": 14400, "sample_rate": 8000, "channels": 1, "subtype": "PCM_16"}
+ result = {"input_id": "long-identity", "duration": 14400, "frames": info["frames"], "sample_rate": 8000,
+ "bins": 2048, "min": [-.3] * 2048, "max": [.4] * 2048}
+ job = {"id": "mock-scan", "kind": "scan", "snapshot": {"path": "Narration.wav", "input_id": "long-identity"},
+ "status": "succeeded", "progress": {"stage": "Done", "completed": 1, "total": 1},
+ "created": time.time(), "result": result, "error": None, "cancel_requested": False, "cached": False}
+ page.route("**/api/input?*", lambda route: route.fulfill(json=info))
+ page.route("**/api/jobs", lambda route: route.fulfill(json=job if route.request.method == "POST" else {"jobs": [job]}))
+ page.get_by_role("button", name=re.compile(r"WAV\s*Narration.wav")).click()
+ expect(page.locator("#wave-status")).to_contain_text("2,048", timeout=10000)
+ expect(page.locator("#wave-end")).to_have_text("04:00:00")
+ page.locator("#selection-start").fill("03:59:30")
+ page.locator("#selection-end").fill("04:00:00")
+ expect(page.locator("#selection-note")).to_contain_text("30 seconds selected")
+ page.locator("#zoom-selection").click()
+ expect(page.locator("#wave-start")).to_have_text("03:59:15")
+ assert len(json.dumps(result)) < 50000
+ assert not [url for url in requests if "/api/results/" in url]
+ assert errors == []
+ browser.close()