diff options
| author | historia <historiavg@proton.me> | 2026-09-12 15:25:06 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-12 15:25:06 -0400 |
| commit | 464edbc4fd658728f06d3a7d98340aa28ff3e3ec (patch) | |
| tree | a182f62ef0365f2c2143f88436de78a03d752367 /lib/project/tests/test_gui_browser.py | |
| parent | 1bbe59f78be9a8355bb0bee2bc8ab14ca42ac71c (diff) | |
| download | producer-464edbc4fd658728f06d3a7d98340aa28ff3e3ec.tar.gz | |
feat: slop web gui
Diffstat (limited to 'lib/project/tests/test_gui_browser.py')
| -rw-r--r-- | lib/project/tests/test_gui_browser.py | 257 |
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() |
