diff --git a/CHANGELOG.md b/CHANGELOG.md index 32b2f6d..d5f1f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Alle nennenswerten Änderungen stehen hier. Das Format folgt ## [Unreleased] +### Hinzugefügt +- Recorder: **Aufzeichnung pausieren**. Im pausierten Zustand laufen Klicks, Eingaben und Tasten im Live-Browser, + werden aber nicht als Schritte eingetragen. So lässt sich die Seite in einen anderen Zustand bringen, ohne dass die + Bedienung im Profil landet. **Sonst-Zweig** pausiert dafür automatisch, **Weiter aufzeichnen** setzt fort. + +### Behoben +- Doku und Hinweis im Editor zum Sonst-Zweig behaupteten, das Herstellen des Gegenzustands werde nicht aufgezeichnet. + Das stimmte bisher nicht, jeder Klick im Live-Bild wurde als Schritt eingetragen. Mit der Pause stimmt die Aussage. + ## [1.2.1] - 2026-10-24 ### Behoben diff --git a/docs/profile.md b/docs/profile.md index 213ab22..084dc5b 100644 --- a/docs/profile.md +++ b/docs/profile.md @@ -116,6 +116,15 @@ nicht, entfernst du es mit ✕. Schritte im Block sind eingerückt, die Operatio 5. Mit `./run.sh -s ` testen und in der Weboberfläche des Geräts prüfen, ob die Änderung wirklich da ist. Bau nach dem Übernehmen eine `expect_*`-Prüfung ein (siehe oben), sonst meldet der Lauf Erfolg, ohne etwas zu prüfen. +### Aufzeichnung pausieren + +**Aufzeichnung pausieren** (über dem Live-Bild) führt Klicks, Eingaben und Tasten weiter im Live-Browser aus, trägt sie aber +nicht als Schritte ein. Damit bringst du die Seite in einen anderen Zustand, ohne dass die Bedienung im Profil landet. +Ein zweiter Klick (**Weiter aufzeichnen**) setzt die Aufnahme fort. Solange pausiert ist, sind die Knöpfe für +Prüfungen und Bedingungen gesperrt. Ohne Pause trägt der Recorder jede Aktion im Live-Bild ein, nur Scrollen und das +Setzen des Fokus in ein Textfeld erzeugen keinen Schritt. Während der Wiedergabe bis zur Stopmarke lässt sich nicht +pausieren. + ### Wenn-Block aufzeichnen Bedingungen wählst du im Live-Bild per Klick, die Schritte im Block zeichnest du wie gewohnt auf: @@ -124,9 +133,9 @@ Bedingungen wählst du im Live-Bild per Klick, die Schritte im Block zeichnest d Der Recorder legt `if_visible` bzw. `if_text` (mit Selektor und dem Text des Elements) und gleich dahinter `end_if` an. Die Bedingung wird dabei nur eingetragen, im Live-Browser passiert nichts. 2. Alles, was du jetzt aufzeichnest, kommt in den **Wenn-Zweig**, also zwischen Bedingung und `end_if`. -3. **Sonst-Zweig** legt ein `else` an, ab dann kommen die Schritte dorthin. Bring die Seite im Live-Bild vorher in - den Zustand, in dem die Bedingung nicht zutrifft. Das Herstellen wird nicht aufgezeichnet. Das `else` ist - optional, ohne den Knopf entsteht keins. +3. **Sonst-Zweig** legt ein `else` an und **pausiert die Aufzeichnung**. Bring die Seite im Live-Bild in den Zustand, + in dem die Bedingung nicht zutrifft; das wird nicht eingetragen. Dann auf **Weiter aufzeichnen** klicken, ab da + kommen die Schritte in den Sonst-Zweig. Das `else` ist optional, ohne den Knopf entsteht keins. 4. **Block beenden** schließt den Block ab, weitere Schritte kommen hinter `end_if`. Solange ein Block offen ist, sind die beiden Bedingungs-Knöpfe gesperrt (keine Verschachtelung). Beim `if_text` trägt diff --git a/editor/app.py b/editor/app.py index 56be786..bbb9453 100644 --- a/editor/app.py +++ b/editor/app.py @@ -343,7 +343,7 @@ async def rec_ws(ws: WebSocket): return recorder.viewer = ws - await ws.send_json({"t": "hello", "viewport": VIEWPORT, "url": session.current_url()}) + await ws.send_json({"t": "hello", "viewport": VIEWPORT, "url": session.current_url(), "paused": session.paused}) snapshot = session.replay_snapshot() # wer sich neu verbindet, sieht den Stand der Wiedergabe if snapshot is not None: await ws.send_json(snapshot) diff --git a/editor/recorder.py b/editor/recorder.py index 300b070..b7838b7 100644 --- a/editor/recorder.py +++ b/editor/recorder.py @@ -299,6 +299,7 @@ def __init__(self, pw, browser, context, page, allowed_hosts): self._lock = asyncio.Lock() self._blocked = set() self.replay = None + self.paused = False # pausiert: Aktionen im Live-Browser laufen, werden aber nicht als Schritt eingetragen def emit(self, event): try: @@ -350,8 +351,13 @@ def recording(self): """Schritte werden nur aufgezeichnet, solange keine Wiedergabe läuft oder wartet.""" return self.replay is None or self.replay.phase == "done" + def set_paused(self, on): + if on != self.paused: + self.paused = on + self.emit({"t": "paused", "on": on}) + def step(self, op, selector=None, value=None, comment=None): - if not self.recording: + if not self.recording or self.paused: return step = {"op": op} if selector: @@ -506,6 +512,12 @@ async def handle(self, msg): if kind != "scroll": # Mausrad-Ereignisse kommen in Serie, sie werden ohne Meldung verworfen self.emit({"t": "error", "message": "Die Wiedergabe läuft, bitte kurz warten."}) return + if kind == "pause": + if not self.recording: + self.emit({"t": "error", "message": "Während der Wiedergabe lässt sich die Aufzeichnung nicht pausieren."}) + else: + self.set_paused(msg.get("on") is True) + return try: async with self._lock: if kind == "click": @@ -558,6 +570,8 @@ async def _click(self, msg): if pick is not None: if not self.recording: raise RecorderError("Prüfungen lassen sich erst setzen, wenn die Wiedergabe fertig ist.") + if self.paused: + raise RecorderError("Die Aufzeichnung ist pausiert. Erst „Weiter aufzeichnen“, dann Prüfungen und Bedingungen setzen.") await self._pick(pick, info) return diff --git a/editor/static/app.js b/editor/static/app.js index 5e3ddc6..6066dd1 100644 --- a/editor/static/app.js +++ b/editor/static/app.js @@ -730,7 +730,7 @@ // ---------- Aufnahme-Modus ---------- // insert/after: neue Schritte kommen hinter den Schritt "after" (Stopmarke), statt ans Ende. // replaySteps: die Schritte, die der Recorder bis zur Marke abspielt (zum Zuordnen von "at"). - const rec = { ws: null, active: false, viewport: { width: 1280, height: 800 }, pick: null, frame: null, insert: false, after: null, replaySteps: null, suggestFor: null, block: null, played: new Set() }; + const rec = { ws: null, active: false, viewport: { width: 1280, height: 800 }, pick: null, frame: null, insert: false, after: null, replaySteps: null, suggestFor: null, block: null, played: new Set(), paused: false }; function note(kind, text) { const box = $("rec-note"); @@ -781,6 +781,7 @@ rec.block = null; rec.replaySteps = null; rec.played.clear(); + applyPaused(false); markerStep = null; // die Marke gehörte zu dieser Aufnahme suggestions.clear(); rec.suggestFor = null; @@ -874,10 +875,11 @@ rec.after = steps[steps.indexOf(b.end) - 1]; // ans Ende des Sonst-Zweigs scrollTarget = b.otherwise; renderSteps(); + send({ t: "pause", on: true }); note( "ok", - "Sonst-Zweig: Bringe die Seite im Live-Bild in den Zustand, in dem die Bedingung nicht zutrifft (das wird nicht aufgezeichnet). " + - "Was du jetzt aufzeichnest, läuft nur in diesem Fall." + "Sonst-Zweig angelegt, die Aufzeichnung ist pausiert. Bringe die Seite im Live-Bild in den Zustand, in dem die Bedingung " + + "nicht zutrifft (wird nicht eingetragen), und klicke dann auf „Weiter aufzeichnen“. Danach landen die Schritte im Sonst-Zweig." ); } @@ -892,6 +894,24 @@ note("ok", "Wenn-Block beendet. Neue Schritte kommen hinter end_if."); } + // Aufzeichnung pausieren: Der Server ist die Quelle, die Anzeige folgt seinem Ereignis "paused". + function updatePickButtons() { + document.querySelectorAll("#rec-picks button").forEach((b) => (b.disabled = rec.paused)); + document.querySelectorAll("#rec-if button[data-kind]").forEach((b) => (b.disabled = rec.paused || !!rec.block)); + } + + function applyPaused(on) { + rec.paused = !!on; + $("rec-pause").textContent = rec.paused ? "Weiter aufzeichnen" : "Aufzeichnung pausieren"; + $("rec-pause").classList.toggle("active", rec.paused); + $("rec-pause").setAttribute("aria-pressed", rec.paused ? "true" : "false"); + $("rec-pause-text").hidden = !rec.paused; + $("pause-banner").hidden = !rec.paused; + if (rec.paused) setPick(null); + updatePickButtons(); + } + $("rec-pause").addEventListener("click", () => send({ t: "pause", on: !rec.paused })); + function updateIfBar() { if (rec.block && !blockAlive()) rec.block = null; const b = rec.block; @@ -900,7 +920,7 @@ $("if-state-text").textContent = "Aufnahme im " + (b.branch === "else" ? "Sonst-Zweig" : "Wenn-Zweig"); $("if-else").hidden = b.branch === "else"; } - document.querySelectorAll("#rec-if button[data-kind]").forEach((btn) => (btn.disabled = !!b)); // keine Verschachtelung + updatePickButtons(); // während ein Block offen ist, sind die Bedingungs-Knöpfe gesperrt (keine Verschachtelung) } $("if-else").addEventListener("click", startElseBranch); $("if-end").addEventListener("click", endBlock); @@ -947,6 +967,9 @@ rec.viewport = event.viewport; showViewportSize(); $("rec-url").textContent = event.url || ""; + applyPaused(event.paused); + } else if (event.t === "paused") { + applyPaused(event.on); } else if (event.t === "step") { const added = { op: event.step.op, selector: event.step.selector || "", value: event.step.value || "", comment: event.step.comment || "" }; if (IF_OPS.includes(added.op)) return addConditionBlock(added); @@ -1045,6 +1068,7 @@ const res = await api("POST", "/api/rec/start", body); if (!res.ok) return say(apiError(res)); rec.replaySteps = replaySteps; + applyPaused(false); rec.played.clear(); rec.insert = false; rec.after = null; diff --git a/editor/static/index.html b/editor/static/index.html index 07afe62..c0e897a 100644 --- a/editor/static/index.html +++ b/editor/static/index.html @@ -161,6 +161,12 @@

Live-Browser

Live-Ansicht des Browsers + +
+ +
+ + Pausiert: Klicks und Eingaben laufen im Live-Browser, werden aber nicht eingetragen.
diff --git a/editor/static/style.css b/editor/static/style.css index 354898f..86a9c1e 100644 --- a/editor/static/style.css +++ b/editor/static/style.css @@ -538,6 +538,8 @@ button.add-end:hover { color: var(--text); border-color: var(--accent); } font-weight: 600; box-shadow: var(--shadow); } +.pause-banner { top: auto; bottom: 10px; background: var(--warn-soft); color: var(--warn); border: 1px solid var(--warn); } +.pause-banner[hidden] { display: none; } .segment-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 10px 0; } .segment-row .hint { margin: 0; } @@ -553,6 +555,9 @@ button.add-end:hover { color: var(--text); border-color: var(--accent); } .check { display: inline-flex; align-items: center; gap: 6px; } .check input { height: auto; } +#rec-pause-text[hidden] { display: none; } +#rec-pause-text { color: var(--warn); font-weight: 600; } +.rec-bar button:disabled { opacity: .5; cursor: not-allowed; } .rec-bar.replay { border: 1px solid var(--accent); border-radius: 10px; padding: 6px 8px; background: var(--accent-soft); } .rec-bar.replay span { flex: 1 1 240px; } diff --git a/tests/test_recorder.py b/tests/test_recorder.py index 37bc121..580b040 100644 --- a/tests/test_recorder.py +++ b/tests/test_recorder.py @@ -222,6 +222,32 @@ async def scenario(s): ] +def test_pause_runs_actions_but_records_nothing(server): + async def scenario(s): + await s.handle({"t": "pause", "on": True}) + await click(s, 80, 135) # Anmelden: läuft im Browser, kein Schritt + await click(s, 30, 210) # Freigabe aktivieren + panel_open = await s.page.is_visible("#enabled") + checked = await s.page.is_checked("#enabled") + await click(s, 250, 250, pick="expect_text") # Prüfungen gehen bei Pause nicht + events_paused = drain(s) + + await s.handle({"t": "pause", "on": False}) + await click(s, 260, 215) # Übernehmen wird wieder aufgezeichnet + events_after = drain(s) + await s.handle({"t": "pause", "on": "ja"}) # nur echtes true schaltet ein + return panel_open, checked, events_paused, events_after, s.paused + + panel_open, checked, events_paused, events_after, paused = run_session(server, scenario) + assert panel_open and checked # die Aktionen wurden ausgeführt + assert steps_of(events_paused) == [] + assert [e for e in events_paused if e["t"] == "paused"] == [{"t": "paused", "on": True}] + assert any(e["t"] == "error" and "pausiert" in e["message"] for e in events_paused) + assert [e for e in events_after if e["t"] == "paused"] == [{"t": "paused", "on": False}] + assert [st["op"] for st in steps_of(events_after)] == ["click"] + assert paused is False + + def test_invalid_input_is_rejected(server): async def scenario(s): await s.handle({"t": "click", "x": -1, "y": 5}) diff --git a/tests/test_recording_e2e.py b/tests/test_recording_e2e.py index 2ce9e45..c24f57b 100644 --- a/tests/test_recording_e2e.py +++ b/tests/test_recording_e2e.py @@ -518,6 +518,14 @@ def test_wenn_block_per_klick_aufnehmen_und_im_runner_ausfuehren(live, server, p page.click("#if-else") expect(page.locator("#rec-if-state")).to_contain_text("Sonst-Zweig") + # Der Sonst-Zweig pausiert die Aufzeichnung, damit man die Seite erst in den Gegenzustand bringen kann + expect(page.locator("#rec-pause")).to_have_text("Weiter aufzeichnen") + wait_steps(page, 5) # goto, if, click, else, end_if + live_click(page, 80, 135) # Vorbereitung, wird nicht eingetragen + page.wait_for_timeout(600) + wait_steps(page, 5) + page.click("#rec-pause") + expect(page.locator("#rec-pause")).to_have_text("Aufzeichnung pausieren") live_click(page, 450, 135) # "Weiter", kommt in den Sonst-Zweig wait_steps(page, 6) # goto, if, click, else, click, end_if @@ -580,3 +588,39 @@ def test_wiedergabe_zeigt_uebersprungene_schritte_nicht_als_abgespielt(live, ser expect(pills(4)).to_have_text("abgespielt") expect(pills(5)).to_have_text("abgespielt") page.click("#rec-stop") + + +def test_aufzeichnung_pausieren_fuehrt_aus_ohne_einzutragen(live, server, page): + url, profiles = live + connect(page, url) + page.click("#new-profile") + page.fill("#f-name", "pause") + page.fill("#f-base", server) + page.click("#rec-start") + expect(page.locator("#rec-live")).to_be_visible() + wait_steps(page, 1) + + expect(page.locator("#rec-pause")).to_have_text("Aufzeichnung pausieren") + expect(page.locator("#rec-pause-text")).to_be_hidden() + page.click("#rec-pause") + expect(page.locator("#rec-pause")).to_have_text("Weiter aufzeichnen") + expect(page.locator("#rec-pause-text")).to_be_visible() + expect(page.locator("#pause-banner")).to_be_visible() # Einblendung im Live-Bild + expect(page.locator('#rec-picks [data-kind="expect_text"]')).to_be_disabled() + expect(page.locator('#rec-if [data-kind="if_visible"]')).to_be_disabled() + + live_click(page, 80, 135) # Anmelden: läuft, aber kein Schritt + page.wait_for_timeout(600) + wait_steps(page, 1) + + page.click("#rec-pause") + expect(page.locator("#rec-pause-text")).to_be_hidden() + expect(page.locator("#pause-banner")).to_be_hidden() + expect(page.locator('#rec-picks [data-kind="expect_text"]')).to_be_enabled() + live_click(page, 30, 210) # Freigabe: wird eingetragen, das Panel ist dank des pausierten Klicks offen + wait_steps(page, 2) + ops = page.locator("#steps .step select").evaluate_all("els => els.map(e => e.value)") + assert ops == ["goto", "check"] + + page.click("#rec-stop") + expect(page.locator("#rec-live")).to_be_hidden()