Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions docs/profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ nicht, entfernst du es mit ✕. Schritte im Block sind eingerückt, die Operatio
5. Mit `./run.sh -s <profil>` 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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion editor/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion editor/recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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

Expand Down
32 changes: 28 additions & 4 deletions editor/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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."
);
}

Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions editor/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ <h3>Live-Browser</h3>
<div class="viewer">
<img id="live" alt="Live-Ansicht des Browsers" draggable="false">
<div id="pick-banner" class="pick-banner" hidden></div>
<div id="pause-banner" class="pick-banner pause-banner" hidden>Aufzeichnung pausiert</div>
</div>

<div id="rec-pause-bar" class="rec-bar">
<button type="button" id="rec-pause" title="Aktionen im Bild werden ausgeführt, aber nicht als Schritte eingetragen">Aufzeichnung pausieren</button>
<span id="rec-pause-text" class="hint">Pausiert: Klicks und Eingaben laufen im Live-Browser, werden aber nicht eingetragen.</span>
</div>

<div id="rec-picks" class="segment-row">
Expand Down
5 changes: 5 additions & 0 deletions editor/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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; }

Expand Down
26 changes: 26 additions & 0 deletions tests/test_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
44 changes: 44 additions & 0 deletions tests/test_recording_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Loading