"""Tier 3 — Playwright E2E browser tests for Marionette. Run without hardware, without daemon. Tests the frontend UI in real browsers. Uses a real Marionette server with temp paths (no robot connection needed for these tests — they only test the web UI behavior). """ import re import pytest from playwright.sync_api import Page, expect # ──────── Page load tests ───────────────────────────────────────────── class TestPageLoad: def test_page_loads_successfully(self, page: Page, base_url: str): page.goto(base_url) expect(page).to_have_title(re.compile("Marionette", re.IGNORECASE)) def test_main_sections_visible(self, page: Page, base_url: str): page.goto(base_url) # Record section expect(page.locator("#record-form")).to_be_visible() # Moves section expect(page.locator("#moves-list")).to_be_visible() # Record button expect(page.locator("#record-btn")).to_be_visible() def test_mode_pill_shows_idle(self, page: Page, base_url: str): page.goto(base_url) pill = page.locator("#mode-pill") expect(pill).to_be_visible() # Wait for first poll to update the pill page.wait_for_timeout(2000) expect(pill).to_contain_text(re.compile("idle", re.IGNORECASE)) # ──────── Idle state display ────────────────────────────────────────── class TestIdleState: def test_record_button_enabled(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) btn = page.locator("#record-btn") expect(btn).to_be_enabled() def test_stop_buttons_hidden(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) expect(page.locator("#stop-recording-btn")).to_be_hidden() expect(page.locator("#stop-playback-btn")).to_be_hidden() def test_progress_bar_empty(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) fill = page.locator("#progress-fill") width = fill.evaluate("el => el.style.width") assert width == "0%" or width == "" def test_phase_display_hidden_when_idle(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) expect(page.locator("#phase-display")).to_be_hidden() # ──────── Form validation tests ─────────────────────────────────────── class TestSimplifiedForm: def test_name_and_duration_visible_by_default(self, page: Page, base_url: str): page.goto(base_url) expect(page.locator("#label")).to_be_visible() expect(page.locator("#duration")).to_be_visible() expect(page.locator("#record-btn")).to_be_visible() def test_options_collapsed_by_default(self, page: Page, base_url: str): """Audio source should be hidden in Options until user expands it.""" page.goto(base_url) expect(page.locator("#audio-source-mic")).to_be_hidden() def test_description_field_removed(self, page: Page, base_url: str): """P1: description field was removed to simplify the form.""" page.goto(base_url) assert page.locator("#description").count() == 0 class TestFormValidation: def test_duration_field_exists(self, page: Page, base_url: str): page.goto(base_url) duration = page.locator("#duration") expect(duration).to_be_visible() # Check it has a default value value = duration.input_value() assert float(value) > 0 def test_duration_accepts_decimal(self, page: Page, base_url: str): page.goto(base_url) duration = page.locator("#duration") duration.fill("3.7") assert duration.input_value() == "3.7" def test_duration_accepts_non_half_second(self, page: Page, base_url: str): """P0-2 regression: duration field must accept values like 5.2, not just 0.5 multiples.""" page.goto(base_url) duration = page.locator("#duration") for val in ["5.2", "12.3", "0.7", "201.7"]: duration.fill(val) assert duration.input_value() == val # Verify the field passes HTML5 validity is_valid = duration.evaluate("el => el.checkValidity()") assert is_valid, f"Duration {val} should be valid but HTML5 validation rejected it" def test_audio_source_radios_exist(self, page: Page, base_url: str): page.goto(base_url) # Audio source is inside collapsible Options — open it first page.locator("#record-options summary").click() expect(page.locator("#audio-source-mic")).to_be_visible() expect(page.locator("#audio-source-none")).to_be_visible() def test_label_field_exists(self, page: Page, base_url: str): page.goto(base_url) expect(page.locator("#label")).to_be_visible() # ──────── Recording submission tests ────────────────────────────────── class TestRecordingSubmission: def test_submit_recording_changes_mode(self, page: Page, base_url: str, test_marionette): page.goto(base_url) page.wait_for_timeout(2000) # Open Options and set audio source to "none" to avoid audio backend requirement page.locator("#record-options summary").click() page.locator("#audio-source-none").check() page.locator("#duration").fill("2") page.locator("#label").fill("e2e-test") # Submit page.locator("#record-btn").click() # Wait for state to update page.wait_for_timeout(2000) # Mode pill should show something other than "idle" pill_text = page.locator("#mode-pill").text_content() assert pill_text is not None # Mode should be queued (since no robot is running to process it) assert "queued" in pill_text.lower() or "countdown" in pill_text.lower() or "recording" in pill_text.lower() # Reset for other tests test_marionette._set_idle_state() test_marionette._pending_recording = None # ──────── Dataset UI tests ──────────────────────────────────────────── class TestDatasetUI: def test_dataset_selector_visible(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) expect(page.locator("#dataset-select")).to_be_visible() def test_new_dataset_button_exists(self, page: Page, base_url: str): page.goto(base_url) expect(page.locator("#new-dataset-btn")).to_be_visible()