"""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() # ──────── Recording lifecycle tests ────────────────────────────────── class TestRecordingLifecycle: def test_submit_recording_shows_queued(self, page: Page, base_url: str, test_marionette): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#record-options summary").click() page.locator("#audio-source-none").check() page.locator("#duration").fill("2") page.locator("#label").fill("lifecycle-test") page.locator("#record-btn").click() page.wait_for_timeout(2000) pill_text = page.locator("#mode-pill").text_content() assert pill_text is not None assert "queued" in pill_text.lower() or "countdown" in pill_text.lower() test_marionette._set_idle_state() test_marionette._pending_recording = None def test_stop_button_visible_when_queued(self, page: Page, base_url: str, test_marionette): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#record-options summary").click() page.locator("#audio-source-none").check() page.locator("#duration").fill("2") page.locator("#record-btn").click() page.wait_for_timeout(2000) # Stop recording button should be visible in queued/countdown/recording mode # Note: in queued mode, the stop-recording-btn may not be shown by the JS # (JS only shows it in recording/countdown). Check either is visible. stop_rec = page.locator("#stop-recording-btn") # It may be hidden if mode is just "queued" — the UI only shows stop in recording/countdown # The important thing is the mode changed from idle pill = page.locator("#mode-pill").text_content() or "" assert "idle" not in pill.lower() test_marionette._set_idle_state() test_marionette._pending_recording = None def test_stop_recording_returns_to_idle(self, page: Page, base_url: str, test_marionette): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#record-options summary").click() page.locator("#audio-source-none").check() page.locator("#duration").fill("2") page.locator("#record-btn").click() page.wait_for_timeout(2000) # Stop via API (simpler than clicking a potentially hidden button) import httpx httpx.post(f"{base_url}/api/record/stop", timeout=5) page.wait_for_timeout(2000) pill_text = page.locator("#mode-pill").text_content() or "" assert "idle" in pill_text.lower() test_marionette._set_idle_state() test_marionette._pending_recording = None def test_injected_move_appears_in_list(self, page: Page, base_url: str, test_marionette): import json data_dir = test_marionette._dataset_dir move_data = { "description": "injected", "time": [0.0, 0.01, 0.02], "set_target_data": [ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]], "antennas": [0,0], "body_yaw": 0.0} for _ in range(3) ], } (data_dir / "e2e-injected.json").write_text(json.dumps(move_data)) test_marionette._refresh_recordings() page.goto(base_url) page.wait_for_timeout(2000) moves_text = page.locator("#moves-list").text_content() or "" assert "e2e-injected" in moves_text # Cleanup (data_dir / "e2e-injected.json").unlink(missing_ok=True) test_marionette._refresh_recordings() # ──────── Playback lifecycle tests ─────────────────────────────────── class TestPlaybackLifecycle: def _inject_move(self, test_marionette, move_id="e2e-play-test"): import json data_dir = test_marionette._dataset_dir move_data = { "description": "playback test", "time": [i * 0.01 for i in range(100)], "set_target_data": [ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]], "antennas": [0,0], "body_yaw": 0.0} for _ in range(100) ], } (data_dir / f"{move_id}.json").write_text(json.dumps(move_data)) test_marionette._refresh_recordings() return data_dir / f"{move_id}.json" def test_play_button_exists_for_move(self, page: Page, base_url: str, test_marionette): self._inject_move(test_marionette) page.goto(base_url) page.wait_for_timeout(2000) play_btns = page.locator(".play-btn") assert play_btns.count() > 0 # Cleanup (test_marionette._dataset_dir / "e2e-play-test.json").unlink(missing_ok=True) test_marionette._refresh_recordings() def test_click_play_queues_playback(self, page: Page, base_url: str, test_marionette): self._inject_move(test_marionette) page.goto(base_url) page.wait_for_timeout(2000) page.locator(".play-btn").first.click() page.wait_for_timeout(2000) pill_text = page.locator("#mode-pill").text_content() or "" assert "queued" in pill_text.lower() or "playing" in pill_text.lower() test_marionette._set_idle_state() test_marionette._pending_playback = None (test_marionette._dataset_dir / "e2e-play-test.json").unlink(missing_ok=True) test_marionette._refresh_recordings() def test_move_metadata_displayed(self, page: Page, base_url: str, test_marionette): self._inject_move(test_marionette) page.goto(base_url) page.wait_for_timeout(2000) # Check move label is visible label_el = page.locator(".move-label").first expect(label_el).to_be_visible() # Check move info (duration, etc.) is visible info_el = page.locator(".move-info").first expect(info_el).to_be_visible() (test_marionette._dataset_dir / "e2e-play-test.json").unlink(missing_ok=True) test_marionette._refresh_recordings() # ──────── Delete move tests ────────────────────────────────────────── class TestDeleteMove: def _inject_move(self, test_marionette, move_id="e2e-delete-test"): import json data_dir = test_marionette._dataset_dir move_data = { "description": "delete test", "time": [0.0, 0.01], "set_target_data": [ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]], "antennas": [0,0], "body_yaw": 0.0} for _ in range(2) ], } path = data_dir / f"{move_id}.json" path.write_text(json.dumps(move_data)) test_marionette._refresh_recordings() return path def test_delete_button_exists(self, page: Page, base_url: str, test_marionette): self._inject_move(test_marionette) page.goto(base_url) page.wait_for_timeout(2000) delete_btns = page.locator(".play-btn.danger") assert delete_btns.count() > 0 (test_marionette._dataset_dir / "e2e-delete-test.json").unlink(missing_ok=True) test_marionette._refresh_recordings() def test_delete_confirm_removes_move(self, page: Page, base_url: str, test_marionette): self._inject_move(test_marionette) page.goto(base_url) page.wait_for_timeout(2000) # Register dialog handler BEFORE clicking page.once("dialog", lambda dialog: dialog.accept()) page.locator(".play-btn.danger").first.click() page.wait_for_timeout(3000) moves_text = page.locator("#moves-list").text_content() or "" assert "e2e-delete-test" not in moves_text def test_delete_cancel_keeps_move(self, page: Page, base_url: str, test_marionette): self._inject_move(test_marionette) page.goto(base_url) page.wait_for_timeout(2000) # Register dialog handler BEFORE clicking page.once("dialog", lambda dialog: dialog.dismiss()) page.locator(".play-btn.danger").first.click() page.wait_for_timeout(2000) moves_text = page.locator("#moves-list").text_content() or "" assert "e2e-delete-test" in moves_text (test_marionette._dataset_dir / "e2e-delete-test.json").unlink(missing_ok=True) test_marionette._refresh_recordings() # ──────── Create dataset tests ─────────────────────────────────────── class TestCreateDataset: def test_new_dataset_button_shows_form(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#new-dataset-btn").click() expect(page.locator("#inline-dataset-form")).to_be_visible() def test_cancel_hides_form(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#new-dataset-btn").click() expect(page.locator("#inline-dataset-form")).to_be_visible() page.locator("#inline-dataset-cancel").click() expect(page.locator("#inline-dataset-form")).to_be_hidden() def test_create_dataset_appears_in_dropdown(self, page: Page, base_url: str, test_marionette): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#new-dataset-btn").click() page.locator("#inline-dataset-name").fill("e2e-test-ds") page.locator("#inline-dataset-create").click() page.wait_for_timeout(2000) # Check the dropdown contains the new dataset options_text = page.locator("#dataset-select").text_content() or "" assert "e2e-test-ds" in options_text.lower() def test_empty_name_not_submitted(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#new-dataset-btn").click() page.locator("#inline-dataset-name").fill("") page.locator("#inline-dataset-create").click() page.wait_for_timeout(500) # Form should still be visible (validation prevented submission) expect(page.locator("#inline-dataset-form")).to_be_visible() # ──────── Switch dataset tests ─────────────────────────────────────── class TestSwitchDataset: def test_switch_dataset_changes_moves(self, page: Page, base_url: str, test_marionette): import json, httpx # Create a second dataset via API resp = httpx.post(f"{base_url}/api/datasets", json={"name": "e2e-switch-a"}, timeout=5) assert resp.status_code == 200 # Inject a move into it data_dir = test_marionette._dataset_dir move_data = { "description": "switch test", "time": [0.0, 0.01], "set_target_data": [ {"head": [[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]], "antennas": [0,0], "body_yaw": 0.0} for _ in range(2) ], } (data_dir / "switch-move.json").write_text(json.dumps(move_data)) test_marionette._refresh_recordings() # Get dataset list state = httpx.get(f"{base_url}/api/state", timeout=5).json() entries = state["datasets"]["entries"] other_ds = [e for e in entries if e["id"] != state["datasets"]["active_id"]] page.goto(base_url) page.wait_for_timeout(2000) # Verify the injected move is visible assert "switch-move" in (page.locator("#moves-list").text_content() or "") if other_ds: # Switch to a different dataset page.locator("#dataset-select").select_option(other_ds[0]["id"]) page.wait_for_timeout(2000) # The moves list should change (no "switch-move" in the other dataset) moves_text = page.locator("#moves-list").text_content() or "" assert "switch-move" not in moves_text # Cleanup (data_dir / "switch-move.json").unlink(missing_ok=True) test_marionette._refresh_recordings() def test_dropdown_lists_all_datasets(self, page: Page, base_url: str): import httpx # Create a dataset via API httpx.post(f"{base_url}/api/datasets", json={"name": "e2e-dropdown-check"}, timeout=5) page.goto(base_url) page.wait_for_timeout(2000) options = page.locator("#dataset-select option") assert options.count() >= 2 # at least default + newly created # ──────── Audio source radio tests ─────────────────────────────────── class TestAudioSourceRadios: def test_default_audio_source(self, page: Page, base_url: str): page.goto(base_url) page.locator("#record-options summary").click() # Default may be mic (if audio available) or none mic = page.locator("#audio-source-mic") none = page.locator("#audio-source-none") # At least one should be checked mic_checked = mic.is_checked() none_checked = none.is_checked() assert mic_checked or none_checked def test_upload_radio_shows_upload_area(self, page: Page, base_url: str): page.goto(base_url) page.locator("#record-options summary").click() page.locator("#audio-source-upload").check() page.wait_for_timeout(500) expect(page.locator("#audio-upload-group")).to_be_visible() def test_none_radio_hides_upload_area(self, page: Page, base_url: str): page.goto(base_url) page.locator("#record-options summary").click() page.locator("#audio-source-upload").check() page.wait_for_timeout(500) expect(page.locator("#audio-upload-group")).to_be_visible() page.locator("#audio-source-none").check() page.wait_for_timeout(500) expect(page.locator("#audio-upload-group")).to_be_hidden() # ──────── Settings panel tests ─────────────────────────────────────── class TestSettingsPanel: def test_settings_expands(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) # Use direct child selector to avoid matching nested summary summary = page.locator("#config-details > summary") summary.scroll_into_view_if_needed() summary.click() expect(page.locator("#dataset-root-form")).to_be_visible() def test_experimental_toggle(self, page: Page, base_url: str): import httpx # Enable motion_models via API first httpx.post(f"{base_url}/api/experiments", json={"motion_models": True}, timeout=5) page.goto(base_url) # Wait for poll to update the UI (experimental panel hidden until first poll) page.wait_for_timeout(3000) # After poll, the experimental panel should be visible and checkbox attached checkbox = page.locator("#feature-motion-models") expect(checkbox).to_be_attached() def test_dataset_root_displayed(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) summary = page.locator("#config-details > summary") summary.scroll_into_view_if_needed() summary.click() root_input = page.locator("#dataset-root-input") value = root_input.input_value() assert len(value) > 0 # ──────── Form edge case tests ─────────────────────────────────────── class TestFormEdgeCases: def test_empty_label_accepted(self, page: Page, base_url: str, test_marionette): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#record-options summary").click() page.locator("#audio-source-none").check() page.locator("#label").fill("") page.locator("#duration").fill("2") page.locator("#record-btn").click() page.wait_for_timeout(2000) pill = page.locator("#mode-pill").text_content() or "" assert "queued" in pill.lower() or "countdown" in pill.lower() test_marionette._set_idle_state() test_marionette._pending_recording = None def test_long_label_accepted(self, page: Page, base_url: str, test_marionette): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#record-options summary").click() page.locator("#audio-source-none").check() long_label = "a" * 80 page.locator("#label").fill(long_label) page.locator("#duration").fill("2") page.locator("#record-btn").click() page.wait_for_timeout(2000) pill = page.locator("#mode-pill").text_content() or "" assert "queued" in pill.lower() or "countdown" in pill.lower() test_marionette._set_idle_state() test_marionette._pending_recording = None def test_special_chars_in_label(self, page: Page, base_url: str, test_marionette): page.goto(base_url) page.wait_for_timeout(2000) page.locator("#record-options summary").click() page.locator("#audio-source-none").check() page.locator("#label").fill("my move @#$!") page.locator("#duration").fill("2") page.locator("#record-btn").click() page.wait_for_timeout(2000) pill = page.locator("#mode-pill").text_content() or "" assert "queued" in pill.lower() or "countdown" in pill.lower() test_marionette._set_idle_state() test_marionette._pending_recording = None def test_label_persists_in_localstorage(self, page: Page, base_url: str): page.goto(base_url) page.wait_for_timeout(2000) label_input = page.locator("#label") label_input.fill("persistent-label") # Trigger the change event so JS stores it label_input.dispatch_event("change") page.wait_for_timeout(500) # Reload and check page.reload() page.wait_for_timeout(2000) restored = page.locator("#label").input_value() assert restored == "persistent-label"