Spaces:
Running
Running
| """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" | |