marionette / tests /e2e /test_ui.py
RemiFabre
Expand test suite to ~170 tests across 3 tiers
7659b58
Raw
History Blame
25.5 kB
"""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"