"""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 ") page.locator("#save-comparison").click() expect(page.locator("#comparisons")).to_contain_text("Gentle ") 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 ") 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()