From c1ee2a1c3648a743df586d1a3d841ab5418749b4 Mon Sep 17 00:00:00 2001 From: Sam Schwartz Date: Sat, 8 Aug 2026 15:57:16 -0500 Subject: [PATCH] Add per-edit undo: revert one change before it ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every edit row in the sidebar gets an undo button while the change is still undoable: the SDK keeps a per-document registry (element refs, detached nodes, original positions) keyed by a per-block cid, reverts the DOM in-place preserving element identity, and the row is dropped server-side only after the frame confirms. Deleting a block also offers an immediate 'Deleted X — Undo' toast. - edit rows carry a durable server id and the block's cid; dedup is cid-first with a boot-timestamp-gated label+kind fallback, so two live blocks sharing a label stay separate rows while reloads keep one row - DELETE /api/page/:key/edit/:id undoes a single row - undo is offered only for rows the live frame advertises as revertible (eh:undoable), never while a batch is pending; on feedback-only pages (markdown, localhost, self-rendering) undo is row-removal - strict transaction order (purge queue -> revert -> save -> remove row) so a debounced flush can never resurrect an undone row - agent-facing batches are unchanged Co-Authored-By: Claude Opus 5 --- src/chrome-client.js | 112 +++++++++++++++++++++++++++++- src/chrome.css | 25 +++++++ src/sdk.js | 144 ++++++++++++++++++++++++++++++++++++++- src/server.js | 27 +++++++- src/state.js | 49 +++++++++++-- test/undo.test.js | 159 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 502 insertions(+), 14 deletions(-) create mode 100644 test/undo.test.js diff --git a/src/chrome-client.js b/src/chrome-client.js index 2af05c3..fcaaf4c 100644 --- a/src/chrome-client.js +++ b/src/chrome-client.js @@ -30,6 +30,8 @@ const state = { reloading: false, dynamic: false, framePolicy: null, + /** cids the current frame's SDK can still revert in the DOM. */ + undoable: new Set(), }; /** @@ -105,6 +107,7 @@ async function loadPage(key, { reload = true } = {}) { state.sent = false; state.dynamic = false; state.baseHash = null; + state.undoable = new Set(); clearTimeout(retryTimer); if (reload) { state.reloading = true; @@ -236,6 +239,11 @@ function render() { rows.textContent = ""; const LIMIT = 5; const shown = state.editsExpanded ? edits : edits.slice(0, LIMIT); + // Undo is offered only where it can actually deliver: while nothing is + // sent, and only for rows the live frame can still revert — except on + // feedback-only pages, where the row itself is the whole change. + const feedbackOnly = page.kind === "url" || page.markdown || state.dynamic; + const locked = state.agent === "working" || state.agent === "stranded" || state.sent; for (const edit of shown) { const row = document.createElement("div"); row.className = `edit-row${edit.kind === "deleted" ? " deleted" : ""}`; @@ -248,6 +256,19 @@ function render() { kind.className = "kind"; kind.textContent = edit.kind; row.append(pip, label, kind); + if (edit.id && !locked && (feedbackOnly || (edit.cid && state.undoable.has(edit.cid)))) { + const undo = document.createElement("button"); + undo.type = "button"; + undo.className = "edit-undo"; + undo.title = "Undo this change"; + undo.setAttribute("aria-label", `Undo the ${edit.kind} change to ${edit.label}`); + undo.textContent = "↩"; + undo.addEventListener("click", (event) => { + event.stopPropagation(); + undoEdit(edit); + }); + row.append(undo); + } rows.append(row); } if (edits.length > LIMIT) { @@ -407,12 +428,77 @@ function editComment(card, body, comment) { input.setSelectionRange(input.value.length, input.value.length); } -function toast(message) { +function toast(message, action) { const el = document.createElement("div"); el.className = "toast"; el.textContent = message; + if (action) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "toast-action"; + btn.textContent = action.label; + btn.addEventListener("click", () => { + el.remove(); + action.run(); + }); + el.append(btn); + } document.body.append(el); - setTimeout(() => el.remove(), 3200); + setTimeout(() => el.remove(), action ? 6000 : 3200); +} + +// --------------------------------------------------------------------- undo + +/** In-flight eh:undo transactions, resolved by eh:undone (or a timeout). */ +const undoWaiters = new Map(); +function undoInFrame(cid, domRevert) { + return new Promise((resolve) => { + undoWaiters.set(cid, resolve); + toFrame({ type: "eh:undo", cid, domRevert }); + setTimeout(() => { + if (undoWaiters.get(cid) === resolve) { + undoWaiters.delete(cid); + resolve({ ok: false }); + } + }, 800); + }); +} + +/** A pending batch freezes what the agent will apply; undo must wait for it. */ +const feedbackLocked = () => state.agent === "working" || state.agent === "stranded" || state.sent; + +/** + * Undo one edit row. The SDK owns the transaction (purge queue → revert DOM → + * save); the row is dropped server-side only after the frame confirms, so a + * debounced flush can never resurrect it. + */ +async function undoEdit(edit) { + if (feedbackLocked()) { + toast("Feedback already sent — wait for the agent before undoing"); + return; + } + const page = state.page; + // File-backed HTML must revert on disk; rendered sources (markdown, + // localhost, self-rendering pages) never wrote a file, so removing the row + // is the whole undo there. + const fileBacked = page.kind !== "url" && !page.markdown && !state.dynamic; + const domRevert = page.kind === "url" ? false : page.markdown ? true : !state.dynamic; + const result = edit.cid ? await undoInFrame(edit.cid, domRevert) : { ok: false }; + if (!result.ok && fileBacked) { + toast("Can't undo this one — the page reloaded since the change was made"); + render(); + return; + } + try { + state.page = (await api(`/api/page/${state.key}/edit/${edit.id}`, { method: "DELETE" })).page; + } catch (err) { + toast(err.message); + return; + } + if (!fileBacked && !result.ok) toast("Removed from feedback"); + render(); + // A restore rebuilt the block's inner markup; re-anchor any comment marks it held. + if (result.ok && domRevert) toFrame({ type: "eh:anchors", comments: state.page.comments || [] }); } function setActive(id, scroll) { @@ -556,6 +642,8 @@ window.addEventListener("message", async (event) => { state.page = (await api(`/api/page/${state.key}/edit`, { method: "POST", body: JSON.stringify({ + cid: msg.cid, + boot: msg.boot, label: msg.label, kind: msg.kind, before: msg.before, @@ -568,7 +656,25 @@ window.addEventListener("message", async (event) => { })).page; state.sent = false; render(); + // Deleting a block is the easiest change to make by accident; offer the + // way back the moment it happens. + if (msg.kind === "deleted" && msg.cid) { + const row = (state.page.edits || []).find((e) => e.cid === msg.cid); + if (row) toast(`Deleted ${row.label}`, { label: "Undo", run: () => undoEdit(row) }); + } + break; + case "eh:undoable": + state.undoable = new Set(msg.cids || []); + render(); + break; + case "eh:undone": { + const waiter = undoWaiters.get(msg.cid); + if (waiter) { + undoWaiters.delete(msg.cid); + waiter({ ok: !!msg.ok }); + } break; + } case "eh:asset": try { const saved = await fetch(`/api/page/${state.key}/asset?type=${encodeURIComponent(msg.assetType || "")}`, { @@ -791,6 +897,8 @@ function connect() { const hadEdits = state.page ? state.page.edits.length : 0; state.reloading = true; state.dynamic = false; + // The frame is about to reboot: its undo registry dies with it. + state.undoable = new Set(); // The file on disk changed: queued saves are based on the old version. state.baseHash = null; clearTimeout(retryTimer); diff --git a/src/chrome.css b/src/chrome.css index 6440d14..a581618 100644 --- a/src/chrome.css +++ b/src/chrome.css @@ -350,6 +350,20 @@ body.collapsed .handle { right: 0; } .edit-row.deleted .pip { background: var(--danger); } .edit-row .label { font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .edit-row .kind { margin-left: auto; color: var(--faint-2); font-size: 11px; white-space: nowrap; } +.edit-undo { + flex: none; + width: 21px; + height: 21px; + padding: 0; + border: 0; + border-radius: 50%; + background: none; + color: var(--faint-2); + font-size: 13px; + line-height: 1; + cursor: pointer; +} +.edit-undo:hover { background: var(--soft); color: var(--strong-txt); } .edit-more { width: 100%; @@ -445,6 +459,17 @@ body.collapsed .handle { right: 0; } box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); animation: rise 140ms ease-out; } +.toast-action { + margin-left: 12px; + padding: 2px 8px; + border: 1px solid currentColor; + border-radius: 6px; + background: none; + color: inherit; + font-size: 11.5px; + font-weight: 600; + cursor: pointer; +} @keyframes rise { from { opacity: 0; transform: translateY(4px); } diff --git a/src/sdk.js b/src/sdk.js index 9876add..b59e49c 100644 --- a/src/sdk.js +++ b/src/sdk.js @@ -541,19 +541,58 @@ function flushSave() { const editQueue = new Map(); let editTimer = null; +/** When this document booted — lets the server tell a reloaded frame's fresh + * cids apart from two live blocks that happen to share a label. */ +const BOOT_AT = Date.now(); + function flushEdits() { clearTimeout(editTimer); editTimer = null; - for (const payload of editQueue.values()) post("eh:edit", payload); + for (const payload of editQueue.values()) post("eh:edit", { boot: BOOT_AT, ...payload }); editQueue.clear(); } function queueEdit(payload) { - editQueue.set(`${payload.label}\u0000${payload.kind}`, payload); + editQueue.set(payload.cid || `${payload.label}\u0000${payload.kind}`, payload); clearTimeout(editTimer); editTimer = setTimeout(flushEdits, EDIT_FLUSH_MS); } +// --------------------------------------------------------------- undo registry + +/** + * What it takes to undo one edit row, keyed by the row's cid. Lives only as + * long as this document: element references cannot survive a reload, so after + * one the chrome simply stops offering undo for these rows. Entries hold the + * exact nodes involved — a removed element is kept detached and reinserted as + * itself, so labels, captured originals, and comment anchors keep their + * identity. + */ +const undoRegistry = new Map(); // cid -> { kind, el, parent?, prev?, next? } +const blockIds = new WeakMap(); +let cidSeq = 0; + +/** A stable per-document id for a block; one row per block per kind. */ +function cidFor(el, kind) { + if (!blockIds.has(el)) { + cidSeq += 1; + blockIds.set(el, `b${cidSeq}_${Math.random().toString(36).slice(2, 8)}`); + } + return `${blockIds.get(el)}:${kind}`; +} + +function postUndoable() { + post("eh:undoable", { cids: [...undoRegistry.keys()] }); +} + +function registerUndo(cid, entry) { + // A move's home is where the block sat before its FIRST move; a re-move + // must not overwrite it with an intermediate spot. + if (entry.kind === "moved" && undoRegistry.has(cid)) return; + undoRegistry.set(cid, entry); + postUndoable(); +} + // ------------------------------------------------------------- interactions function clearPending() { @@ -806,6 +845,7 @@ function boot() { userEdited = true; const target = targetFor(hoverMedia); const blockEl = target ? target.el : hoverMedia; + captureOriginal(blockEl); resizing = { el: hoverMedia, startX: event.clientX, @@ -833,7 +873,10 @@ function boot() { const { blockEl, label, beforeText, beforeHtml } = resizing; resizing = null; suppressUntil = Date.now() + 250; + const cid = cidFor(blockEl, "edited"); + registerUndo(cid, { kind: "edited", el: blockEl }); queueEdit({ + cid, label, kind: "edited", before: beforeText, @@ -852,11 +895,22 @@ function boot() { const target = targetFor(hoverTarget); const label = target ? target.label : "Element"; const before = hoverTarget.textContent; + const cid = cidFor(hoverTarget, "deleted"); + // Keep the removed node itself plus both neighbors: reinsertion as the + // same element preserves every identity (label, originals, comment marks), + // and either neighbor can die before the undo without stranding it. + registerUndo(cid, { + kind: "deleted", + el: hoverTarget, + parent: hoverTarget.parentNode, + prev: hoverTarget.previousSibling, + next: hoverTarget.nextSibling, + }); hoverTarget.remove(); hoverTarget = null; place(els.outline, null); showChip(null); - queueEdit({ label, kind: "deleted", before, after: "" }); + queueEdit({ cid, label, kind: "deleted", before, after: "" }); flushSave(); }); @@ -875,7 +929,10 @@ function boot() { const emitBlockEdit = (blockEl, fallbackLabel) => { const connected = blockEl.isConnected; const target = connected ? targetFor(blockEl) : null; + const cid = cidFor(blockEl, "edited"); + if (connected) registerUndo(cid, { kind: "edited", el: blockEl }); queueEdit({ + cid, label: (target && target.label) || fallbackLabel || "Document body", kind: "edited", before: originalText.get(blockEl), @@ -1265,10 +1322,13 @@ function boot() { const next = drop.before ? drop.ref : drop.ref.nextElementSibling; if (next === el || (drop.before ? drop.ref.previousElementSibling : drop.ref) === el) return; userEdited = true; + const cid = cidFor(el, "moved"); + registerUndo(cid, { kind: "moved", el, parent: el.parentNode, prev: el.previousSibling, next: el.nextSibling }); drop.ref.parentNode.insertBefore(el, next); const prev = el.previousElementSibling; const following = el.nextElementSibling; queueEdit({ + cid, label, kind: "moved", before: originalText.get(el), @@ -1346,9 +1406,12 @@ function boot() { const sel = document.getSelection(); const node = sel && sel.anchorNode ? sel.anchorNode : event.target; const target = targetFor(node); + const cid = target ? cidFor(target.el, "edited") : undefined; + if (target && originalHtml.has(target.el)) registerUndo(cid, { kind: "edited", el: target.el }); // Text alone loses formatting-only edits (bold, italic, underline change // markup, not textContent), so the block's cleaned HTML travels too. queueEdit({ + cid, label: target ? target.label : "Document body", kind: "edited", before: target ? originalText.get(target.el) : undefined, @@ -1395,6 +1458,56 @@ function boot() { { passive: true } ); + // ------------------------------------------------------------------- undo + + /** + * Put a block back to its captured original by syncing attributes and inner + * markup onto the SAME element. Replacing the node would orphan everything + * keyed on it (pinned label, captured originals, comment markers). + */ + const restoreOriginal = (el) => { + const html = originalHtml.get(el); + if (typeof html !== "string") return false; + const tpl = document.createElement("template"); + tpl.innerHTML = html; + const src = tpl.content.firstElementChild; + if (!src || src.tagName !== el.tagName) return false; + // blockHtml strips the element-comment marker from snapshots; keep the + // live one so "Jump to" still lands. + const commentMark = el.getAttribute("data-eh-el"); + for (const name of [...el.getAttributeNames()]) { + if (!src.hasAttribute(name)) el.removeAttribute(name); + } + for (const name of src.getAttributeNames()) el.setAttribute(name, src.getAttribute(name)); + if (commentMark) el.setAttribute("data-eh-el", commentMark); + if (!/^(img|br|hr|input|source|track|wbr|embed|area|col|base|link|meta|param)$/i.test(el.tagName)) { + el.innerHTML = src.innerHTML; + } + return true; + }; + + /** Reinsert at the remembered spot, tolerating one dead neighbor. */ + const reinsertAt = (el, parent, prev, next) => { + if (!parent || !parent.isConnected) return false; + if (next && next.parentNode === parent) parent.insertBefore(el, next); + else if (prev && prev.parentNode === parent) prev.after(el); + else parent.appendChild(el); + return true; + }; + + const revertEntry = (entry) => { + if (entry.kind === "edited") return entry.el.isConnected ? restoreOriginal(entry.el) : false; + if (entry.kind === "deleted") { + if (entry.el.isConnected) return true; + return reinsertAt(entry.el, entry.parent, entry.prev, entry.next); + } + if (entry.kind === "moved") { + if (!entry.el.isConnected) return false; + return reinsertAt(entry.el, entry.parent, entry.prev, entry.next); + } + return false; + }; + window.addEventListener("message", (event) => { // Only the chrome page may drive the SDK — not popups the artifact opened, // and not the artifact's own scripts. @@ -1434,7 +1547,32 @@ function boot() { clearTimeout(saveTimer); clearTimeout(editTimer); editQueue.clear(); + undoRegistry.clear(); + postUndoable(); break; + case "eh:undo": { + // Undo one edit row. Strict order: purge the queued payload first (a + // later flush would resurrect the row), revert the DOM, then save — + // only after eh:undone does the chrome drop the row server-side. + const entry = undoRegistry.get(msg.cid); + if (!entry) { + post("eh:undone", { cid: msg.cid, ok: false }); + break; + } + editQueue.delete(msg.cid); + undoRegistry.delete(msg.cid); + postUndoable(); + let ok = true; + if (msg.domRevert !== false) { + ok = revertEntry(entry); + if (ok) { + userEdited = true; + flushSave(); + } + } + post("eh:undone", { cid: msg.cid, ok }); + break; + } case "eh:raw": checkDynamic(String(msg.html || "")); break; diff --git a/src/server.js b/src/server.js index 75a5b84..6c4051b 100644 --- a/src/server.js +++ b/src/server.js @@ -655,9 +655,30 @@ export function createServer() { const label = String(body.label || "Document"); const kind = body.kind === "deleted" ? "deleted" : body.kind === "moved" ? "moved" : "edited"; const cap = (s) => (typeof s === "string" ? s.slice(0, 4000) : undefined); - const extra = - kind === "moved" ? { moved_after: cap(body.moved_after) || "", moved_before: cap(body.moved_before) || "" } : undefined; - store.addEdit(key, label, kind, cap(body.before), cap(body.after), cap(body.before_html), cap(body.after_html), extra); + const extra = { + ...(kind === "moved" ? { moved_after: cap(body.moved_after) || "", moved_before: cap(body.moved_before) || "" } : {}), + // The SDK's per-block id, so one block stays one row and the + // browser can undo exactly the row it reverted. + ...(typeof body.cid === "string" && body.cid ? { cid: body.cid.slice(0, 80) } : {}), + }; + store.addEdit( + key, + label, + kind, + cap(body.before), + cap(body.after), + cap(body.before_html), + cap(body.after_html), + extra, + Number(body.boot) || 0 + ); + return json(res, 200, { page: pageState(key) }); + } + + // Undo of one edit row: the SDK has already reverted the block (or the + // page is feedback-only and the row is the only artifact). + if (action === "edit" && req.method === "DELETE" && tail) { + if (!store.removeEdit(key, tail)) return json(res, 404, { error: "unknown edit" }); return json(res, 200, { page: pageState(key) }); } diff --git a/src/state.js b/src/state.js index 7fb3a0b..29ee307 100644 --- a/src/state.js +++ b/src/state.js @@ -203,24 +203,61 @@ export class Store { } /** - * Edits are deduped by label+kind so retyping one block stays one row, but - * the text is refreshed every time so `after` is always the latest wording. + * Edits are deduped by the block's client id (`cid`) when the SDK sends one, + * so retyping one block stays one row even when two blocks share a label. + * The label+kind fallback keeps continuity across frame reloads, where the + * same block boots with a fresh cid — but only for rows written before this + * frame booted (`bootAt`), so two live blocks that happen to share a label + * never collapse into one row. The text is refreshed every time so `after` + * is always the latest wording. Each row gets a durable server id, the + * handle for undoing that single row. */ - addEdit(key, label, kind, before, after, beforeHtml, afterHtml, extra) { + addEdit(key, label, kind, before, after, beforeHtml, afterHtml, extra, bootAt) { return this.update(key, (page) => { - const row = page.edits.find((e) => e.label === label && e.kind === kind); + const cid = extra && extra.cid; + const row = + (cid && page.edits.find((e) => e.cid === cid)) || + page.edits.find( + (e) => + e.label === label && + e.kind === kind && + (!cid || !e.cid || (e.updatedAt || 0) < (bootAt || Infinity)) + ); if (row) { if (after !== undefined) row.after = after; if (afterHtml !== undefined) row.after_html = afterHtml; - // A re-move of the same block replaces its landing spot. + // A re-move of the same block replaces its landing spot, and a reload + // hands the row to the block's fresh cid. if (extra) Object.assign(row, extra); row.updatedAt = Date.now(); return; } - page.edits.push({ label, kind, before, after, before_html: beforeHtml, after_html: afterHtml, ...(extra || {}), at: Date.now(), updatedAt: Date.now() }); + page.edits.push({ + id: `e_${crypto.randomBytes(6).toString("hex")}`, + label, + kind, + before, + after, + before_html: beforeHtml, + after_html: afterHtml, + ...(extra || {}), + at: Date.now(), + updatedAt: Date.now(), + }); }); } + /** Undo of a single row, by its server id. Returns null for an unknown id. */ + removeEdit(key, id) { + let found = false; + const page = this.update(key, (p) => { + const next = p.edits.filter((e) => e.id !== id); + found = next.length !== p.edits.length; + p.edits = next; + }); + return found ? page : null; + } + clearEdits(key) { return this.update(key, (page) => { page.edits = []; diff --git a/test/undo.test.js b/test/undo.test.js new file mode 100644 index 0000000..1613ec1 --- /dev/null +++ b/test/undo.test.js @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "human-review-undo-")); +process.env.HUMAN_REVIEW_STATE_DIR = path.join(tmp, "state"); + +const { start } = await import("../src/server.js"); +const { Store } = await import("../src/state.js"); + +function request(port, token, { method = "GET", route = "/", body = null } = {}) { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: "127.0.0.1", + port, + method, + path: route, + headers: { + "x-human-review-token": token, + ...(body ? { "content-type": "application/json" } : {}), + }, + }, + (res) => { + let raw = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + raw += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode, raw })); + } + ); + req.on("error", reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +const j = (res) => JSON.parse(res.raw); + +// ------------------------------------------------------------------- store + +test("every edit row gets a durable id", () => { + const store = new Store(); + const file = path.join(tmp, "ids.html"); + fs.writeFileSync(file, "

x

"); + const { key } = store.openPage(file, "

x

"); + store.addEdit(key, "Body", "edited", "a", "b"); + const [row] = store.page(key).edits; + assert.match(row.id, /^e_[0-9a-f]{12}$/); +}); + +test("edits dedupe by cid first, so live twin labels stay separate rows", () => { + const store = new Store(); + const file = path.join(tmp, "cid.html"); + fs.writeFileSync(file, "

x

"); + const { key } = store.openPage(file, "

x

"); + const boot = Date.now() - 1000; // both blocks live in the same frame + store.addEdit(key, "Intro · p 2", "edited", "a", "b", undefined, undefined, { cid: "b1:edited" }, boot); + store.addEdit(key, "Intro · p 2", "edited", "a", "c", undefined, undefined, { cid: "b1:edited" }, boot); + store.addEdit(key, "Intro · p 2", "edited", "q", "r", undefined, undefined, { cid: "b2:edited" }, boot); + const edits = store.page(key).edits; + assert.equal(edits.length, 2, "same cid merges, a different live cid does not"); + assert.equal(edits[0].after, "c", "the merged row carries the latest wording"); +}); + +test("label+kind fallback keeps one row across a frame reload", () => { + const store = new Store(); + const file = path.join(tmp, "reload.html"); + fs.writeFileSync(file, "

x

"); + const { key } = store.openPage(file, "

x

"); + store.addEdit(key, "Body", "edited", "a", "b", undefined, undefined, { cid: "b1:edited" }, Date.now() - 1000); + // After a reload the same block boots with a fresh cid; the old row predates + // the new frame's boot, so it is the same block and merges. + store.addEdit(key, "Body", "edited", "a", "c", undefined, undefined, { cid: "b9:edited" }, Date.now() + 1000); + const edits = store.page(key).edits; + assert.equal(edits.length, 1); + assert.equal(edits[0].after, "c"); + assert.equal(edits[0].cid, "b9:edited", "the row now belongs to the fresh cid"); +}); + +test("removeEdit drops exactly one row by id", () => { + const store = new Store(); + const file = path.join(tmp, "remove.html"); + fs.writeFileSync(file, "

x

"); + const { key } = store.openPage(file, "

x

"); + store.addEdit(key, "Body", "edited", "a", "b"); + store.addEdit(key, "Body", "deleted", "gone", ""); + const [edited, deleted] = store.page(key).edits; + assert.ok(store.removeEdit(key, deleted.id)); + assert.deepEqual(store.page(key).edits.map((e) => e.id), [edited.id]); + assert.equal(store.removeEdit(key, "e_nope"), null, "an unknown id is a miss, not a crash"); +}); + +// ------------------------------------------------------------------ server + +let running; +let sessionKey; + +test("DELETE /api/page/:key/edit/:id undoes one row over the wire", async () => { + const file = path.join(tmp, "wire.html"); + fs.writeFileSync(file, "

Original

"); + running = await start(0); + const { port, token } = running; + + const opened = j(await request(port, token, { method: "POST", route: "/api/session", body: { file } })); + sessionKey = opened.key; + await request(port, token, { + method: "POST", + route: `/api/page/${sessionKey}/edit`, + body: { cid: "b1:edited", label: "Body", kind: "edited", before: "Original", after: "Changed" }, + }); + await request(port, token, { + method: "POST", + route: `/api/page/${sessionKey}/edit`, + body: { cid: "b2:deleted", label: "Aside", kind: "deleted", before: "Gone", after: "" }, + }); + + const page = j(await request(port, token, { route: `/api/page/${sessionKey}` })); + assert.equal(page.edits.length, 2); + const deleted = page.edits.find((e) => e.kind === "deleted"); + assert.ok(deleted.id, "rows carry their id to the browser"); + assert.equal(deleted.cid, "b2:deleted", "rows carry their cid to the browser"); + + const undone = await request(port, token, { method: "DELETE", route: `/api/page/${sessionKey}/edit/${deleted.id}` }); + assert.equal(undone.status, 200); + assert.deepEqual(j(undone).page.edits.map((e) => e.kind), ["edited"]); + + const missing = await request(port, token, { method: "DELETE", route: `/api/page/${sessionKey}/edit/${deleted.id}` }); + assert.equal(missing.status, 404, "undoing the same row twice is a clean 404"); +}); + +test("the agent batch is unchanged: no id or cid fields ship to the agent", async () => { + const { port, token } = running; + const sessions = j(await request(port, token, { route: `/api/page/${sessionKey}` })); + assert.ok(sessions.edits.length >= 1); + + // Send what is left and read the batch the agent would receive. + const opened = j( + await request(port, token, { method: "POST", route: "/api/session", body: { file: path.join(tmp, "wire.html") } }) + ); + await request(port, token, { + method: "POST", + route: `/api/page/${sessionKey}/send`, + body: { sessionId: opened.sessionId, note: "" }, + }); + const poll = j(await request(port, token, { route: `/api/poll?target=${encodeURIComponent(path.join(tmp, "wire.html"))}` })); + assert.equal(poll.status, "feedback"); + const keys = Object.keys(poll.pages[0].edits[0]); + assert.ok(!keys.includes("id") && !keys.includes("cid"), `agent-facing edit keys stay clean: ${keys}`); +}); + +test.after(async () => { + if (running) running.dispose(); + fs.rmSync(tmp, { recursive: true, force: true }); +});