diff --git a/browse/build.sh b/browse/build.sh index 1e7b226..4c56f72 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -64,6 +64,7 @@ concat_files \ "../shared/zddc-source.js" \ "js/init.js" \ "js/util.js" \ + "js/conflict.js" \ "js/loader.js" \ "js/tree.js" \ "js/preview.js" \ diff --git a/browse/js/conflict.js b/browse/js/conflict.js new file mode 100644 index 0000000..b9e6d91 --- /dev/null +++ b/browse/js/conflict.js @@ -0,0 +1,203 @@ +// conflict.js — shared conflict-resolution dialog for the browse tool. +// +// Surfaced when a save loses an optimistic-concurrency race: the file +// changed on the server since the user loaded it (the editor sends an +// If-Match precondition; the master replies 412). Rather than clobber the +// other writer, the editor opens this dialog showing a mine-vs-theirs diff +// and four choices. +// +// Deliberately CALLBACK-DRIVEN: it never calls saveFile / showFilePreview +// itself — the caller supplies onOverwrite / onReload / onSaveCopy. That +// keeps it reusable by a second consumer (the deferred Phase 5 cache-outbox +// conflict UI, which would resolve `.zddc-outbox/.conflict-/` entries +// against new server endpoints rather than the live file). +// +// Reuses the modal shell + diff markup conventions from history.js and the +// shared css/history.css classes (md-history-*, md-diff-*) — no new CSS. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + function toast(msg, level) { + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast(msg, level || 'info'); + } + } + + // Render a line diff of base→mine into `pane` (theirs treated as the + // base, so additions are what this save would introduce). Mirrors the + // history.js diff view. + function renderDiff(pane, theirsText, mineText) { + pane.innerHTML = ''; + var ops = (window.zddc && window.zddc.diff) + ? window.zddc.diff.lines(theirsText, mineText) + : null; + var diff = document.createElement('div'); + diff.className = 'md-diff'; + if (!ops) { + diff.textContent = 'Diff unavailable (diff module not loaded).'; + pane.appendChild(diff); + return; + } + var unchanged = true; + ops.forEach(function (op) { + if (op.type !== 'eq') unchanged = false; + var line = document.createElement('div'); + line.className = 'md-diff-line md-diff-' + op.type; + var g = document.createElement('span'); + g.className = 'md-diff-gutter'; + g.textContent = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' '); + var t = document.createElement('span'); + t.className = 'md-diff-text'; + t.textContent = op.text; + line.appendChild(g); + line.appendChild(t); + diff.appendChild(line); + }); + if (unchanged) { + var same = document.createElement('div'); + same.className = 'md-diff-line md-diff-eq'; + same.textContent = '(no differences — your copy matches the server)'; + diff.appendChild(same); + } + pane.appendChild(diff); + var s = window.zddc.diff.stats(ops); + var stat = document.createElement('p'); + stat.className = 'md-history-hint'; + stat.textContent = 'Your version vs. current server: +' + s.added + ' / −' + s.removed; + pane.appendChild(stat); + } + + // open(opts) → Promise<'overwrite' | 'reload' | 'savecopy' | 'cancel'> + // + // opts: + // filename — display name (e.g. node.name) + // mineText — the user's current (unsaved) content, for the diff + // theirsText — current server content (string), OR… + // fetchTheirs — async () => string — lazy fetch of current server content + // onOverwrite — async () => void — re-save, forcing past the conflict + // onReload — async () => void — discard mine, reload from server + // onSaveCopy — async () => void — write mine to a sibling path (optional) + // + // The matching callback runs when its button is clicked; on success the + // dialog closes and resolves with the action name. On callback error the + // dialog stays open (a toast explains) so the user can pick another path. + // Cancel / Esc / backdrop resolve 'cancel' and leave the editor untouched. + function open(opts) { + opts = opts || {}; + return new Promise(function (resolve) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay md-history-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + + var box = document.createElement('div'); + box.className = 'md-history-box'; + var title = document.createElement('h2'); + title.className = 'md-history-title'; + title.textContent = 'Conflict — ' + (opts.filename || 'file'); + var body = document.createElement('div'); + body.className = 'md-history-body'; + box.appendChild(title); + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + + var settled = false; + function close() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + document.removeEventListener('keydown', onKey); + } + function finish(result) { + if (settled) return; + settled = true; + close(); + resolve(result); + } + function onKey(e) { if (e.key === 'Escape') finish('cancel'); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('mousedown', function (e) { + if (e.target === overlay) finish('cancel'); + }); + + var hint = document.createElement('p'); + hint.className = 'md-history-hint'; + hint.textContent = '"' + (opts.filename || 'This file') + + '" was changed by someone else since you opened it. ' + + 'Pick how to resolve — nothing is saved until you choose.'; + body.appendChild(hint); + + var diffPane = document.createElement('div'); + diffPane.textContent = 'Loading current server version…'; + body.appendChild(diffPane); + + var footer = document.createElement('div'); + footer.className = 'md-history-footer'; + body.appendChild(footer); + + function makeBtn(label, primary) { + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + if (primary) b.className = 'btn-primary'; + footer.appendChild(b); + return b; + } + var overwriteBtn = makeBtn('Overwrite (keep mine)'); + var reloadBtn = makeBtn('Discard mine — reload theirs'); + var copyBtn = opts.onSaveCopy ? makeBtn('Save a copy') : null; + var cancelBtn = makeBtn('Cancel', true); + + function setBusy(busy) { + [overwriteBtn, reloadBtn, copyBtn, cancelBtn].forEach(function (b) { + if (b) b.disabled = busy; + }); + } + + // Each action runs its callback; on success close+resolve, on + // error toast and re-enable so the user can try another path. + function wire(btn, fn, result) { + if (!btn) return; + btn.addEventListener('click', function () { + setBusy(true); + Promise.resolve() + .then(function () { return fn ? fn() : undefined; }) + .then(function () { finish(result); }) + .catch(function (e) { + toast('Could not ' + result + ': ' + (e && e.message ? e.message : e), 'error'); + setBusy(false); + }); + }); + } + wire(overwriteBtn, opts.onOverwrite, 'overwrite'); + wire(reloadBtn, opts.onReload, 'reload'); + wire(copyBtn, opts.onSaveCopy, 'savecopy'); + cancelBtn.addEventListener('click', function () { finish('cancel'); }); + + // Resolve the "theirs" text (eagerly provided or lazily fetched) + // then render the diff. A fetch failure leaves the actions usable + // — the diff is an aid, not a gate. + Promise.resolve() + .then(function () { + if (typeof opts.theirsText === 'string') return opts.theirsText; + if (opts.fetchTheirs) return opts.fetchTheirs(); + return null; + }) + .then(function (theirs) { + if (settled) return; + if (theirs == null) { + diffPane.textContent = 'Could not load the current server version for comparison.'; + return; + } + renderDiff(diffPane, theirs, opts.mineText || ''); + }) + .catch(function (e) { + if (settled) return; + diffPane.textContent = 'Could not load the current server version: ' + + (e && e.message ? e.message : e); + }); + }); + } + + window.app.modules.conflict = { open: open }; +})(); diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index bf9c6af..778eacb 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -278,8 +278,8 @@ // ── Save ──────────────────────────────────────────────────────────────── - function saveContent(node, content) { - return util.saveFile(node, content, 'text/markdown; charset=utf-8'); + function saveContent(node, content, opts) { + return util.saveFile(node, content, 'text/markdown; charset=utf-8', opts); } var isZipMemberNode = util.isZipMemberNode; @@ -310,11 +310,21 @@ } dispose(); - // Read content. - var text; + // Read content + the server version token (etag/last-modified) so + // the save can send an If-Match precondition and detect a concurrent + // edit instead of clobbering it. Falls back to getArrayBuffer (and a + // null token → no precondition) for callers/sources without it. + var text, loadedEtag = null, loadedLastModified = null; try { - var buf = await ctx.getArrayBuffer(node); - text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + if (ctx.getContentWithVersion) { + var loaded = await ctx.getContentWithVersion(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf); + loadedEtag = loaded.etag; + loadedLastModified = loaded.lastModified; + } else { + var buf = await ctx.getArrayBuffer(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + } } catch (e) { container.innerHTML = '
' @@ -553,7 +563,11 @@ hash: initialHash, tocEl: tocBody, fmEl: fmTextarea, - ac: ac + ac: ac, + // Server version token captured at load — sent as If-Match on + // save and refreshed from each successful PUT's response ETag. + etag: loadedEtag, + lastModified: loadedLastModified }; currentInstance = instance; @@ -687,21 +701,81 @@ fmTextarea.addEventListener('input', onFmChange); // ── Save ─────────────────────────────────────────────────────────── + // Mark a successful write: adopt the new server ETag (so the next + // save's If-Match matches — no false conflict on save→edit→save), + // refresh the dirty baseline, clear dirty. + async function markSaved(content, res) { + if (currentInstance !== instance) return; + if (res && res.etag) instance.etag = res.etag; + instance.hash = await hashContent(content); + if (currentInstance !== instance) return; + markDirty(false); + statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Saved ' + node.name, 'success'); + } + } + + // 412 → the file changed on the server since we loaded it. Open the + // shared conflict dialog rather than clobbering. Dirty stays set + // until the user resolves. + async function resolveConflict(content) { + var conflict = window.app.modules.conflict; + var prev = window.app.modules.preview; + if (!conflict || !prev) return; // no UI available — leave dirty + await conflict.open({ + filename: node.name, + mineText: content, + fetchTheirs: function () { + return prev.getContentWithVersion(node).then(function (r) { + return new TextDecoder('utf-8', { fatal: false }).decode(r.buf); + }); + }, + // Overwrite: re-fetch the CURRENT version and save against it + // (still 412s on a third concurrent writer rather than blind- + // forcing). + onOverwrite: function () { + return prev.getContentWithVersion(node).then(function (cur) { + return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified }); + }).then(function (res) { return markSaved(content, res); }); + }, + // Reload theirs: discard local edits. Clear dirty first so the + // renderInline dirty-guard skips its confirm; the fresh render + // re-captures content + a new ETag. + onReload: function () { + markDirty(false); + instance.dirty = false; + return prev.showFilePreview(node); + }, + onSaveCopy: function () { + return util.saveCopy(node, content, 'text/markdown; charset=utf-8') + .then(function (name) { + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Saved your version as ' + name, 'success'); + } + }); + } + }); + if (currentInstance === instance) statusEl.textContent = ''; + } + async function save() { if (currentInstance !== instance) return; if (!instance.dirty || !canSave(node)) return; var content = assembleContent(fmTextarea.value, editor.getMarkdown()); try { statusEl.textContent = 'Saving…'; - await saveContent(node, content); - if (currentInstance !== instance) return; // switched away mid-save - instance.hash = await hashContent(content); - markDirty(false); - statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); - if (window.zddc && window.zddc.toast) { - window.zddc.toast('Saved ' + node.name, 'success'); - } + var res = await saveContent(node, content, { + etag: instance.etag, lastModified: instance.lastModified + }); + await markSaved(content, res); } catch (e) { + if (e && e.status === 412) { + if (currentInstance !== instance) return; + statusEl.textContent = 'Conflict — resolving…'; + await resolveConflict(content); + return; + } statusEl.textContent = 'Save failed: ' + (e.message || e); if (window.zddc && window.zddc.toast) { window.zddc.toast('Save failed: ' + (e.message || e), 'error'); diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index 21c2153..f45979d 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -45,11 +45,11 @@ // ── Save (mirrors preview-markdown.js) ───────────────────────────────── - function saveContent(node, content) { + function saveContent(node, content, opts) { // Via the shared saveFile so local (FS-Access) saves escalate to // readwrite the same as the markdown editor — previously this path // skipped ensureWritable and failed on read-only-picked folders. - return util.saveFile(node, content, 'application/x-yaml; charset=utf-8'); + return util.saveFile(node, content, 'application/x-yaml; charset=utf-8', opts); } var isZipMemberNode = util.isZipMemberNode; @@ -350,6 +350,10 @@ var currentEditor = null; var currentDirty = false; var currentNodeRef = null; + // Server version token for the loaded file — sent as If-Match on save + // and refreshed from each successful PUT's response ETag. + var currentEtag = null; + var currentLastModified = null; function dispose() { // CM doesn't have an explicit destroy(); GC handles it once @@ -358,6 +362,8 @@ currentEditor = null; currentDirty = false; currentNodeRef = null; + currentEtag = null; + currentLastModified = null; } function isDirty() { @@ -377,10 +383,17 @@ } dispose(); - var text; + var text, loadedEtag = null, loadedLastModified = null; try { - var buf = await ctx.getArrayBuffer(node); - text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + if (ctx.getContentWithVersion) { + var loaded = await ctx.getContentWithVersion(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf); + loadedEtag = loaded.etag; + loadedLastModified = loaded.lastModified; + } else { + var buf = await ctx.getArrayBuffer(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + } } catch (e) { container.innerHTML = '
' @@ -483,6 +496,8 @@ currentEditor = editor; currentNodeRef = node; currentDirty = false; + currentEtag = loadedEtag; + currentLastModified = loadedLastModified; if (!writable) { saveBtn.disabled = true; @@ -511,6 +526,56 @@ markDirty(h !== initialHash); }); + // Adopt the new server ETag + refresh the dirty baseline after a + // successful write so save→edit→save doesn't false-conflict. + async function markSaved(content, res) { + if (currentEditor !== editor) return; + if (res && res.etag) currentEtag = res.etag; + initialHash = await hashContent(content); + if (currentEditor !== editor) return; + markDirty(false); + statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Saved ' + node.name, 'success'); + } + } + + // 412 → file changed on the server since load. Open the shared + // conflict dialog instead of clobbering. + async function resolveConflict(content) { + var conflict = window.app.modules.conflict; + var prev = window.app.modules.preview; + if (!conflict || !prev) return; + await conflict.open({ + filename: node.name, + mineText: content, + fetchTheirs: function () { + return prev.getContentWithVersion(node).then(function (r) { + return new TextDecoder('utf-8', { fatal: false }).decode(r.buf); + }); + }, + onOverwrite: function () { + return prev.getContentWithVersion(node).then(function (cur) { + return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified }); + }).then(function (res) { return markSaved(content, res); }); + }, + onReload: function () { + markDirty(false); + currentDirty = false; + return prev.showFilePreview(node); + }, + onSaveCopy: function () { + return util.saveCopy(node, content, 'application/x-yaml; charset=utf-8') + .then(function (name) { + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Saved your version as ' + name, 'success'); + } + }); + } + }); + if (currentEditor === editor) statusEl.textContent = ''; + } + async function save() { if (saveBtn.disabled) return; // Re-check authority at click time, not via the mount-time @@ -520,14 +585,17 @@ var content = editor.getValue(); try { statusEl.textContent = 'Saving…'; - await saveContent(node, content); - initialHash = await hashContent(content); - markDirty(false); - statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); - if (window.zddc && window.zddc.toast) { - window.zddc.toast('Saved ' + node.name, 'success'); - } + var res = await saveContent(node, content, { + etag: currentEtag, lastModified: currentLastModified + }); + await markSaved(content, res); } catch (e) { + if (e && e.status === 412) { + if (currentEditor !== editor) return; + statusEl.textContent = 'Conflict — resolving…'; + await resolveConflict(content); + return; + } statusEl.textContent = 'Save failed: ' + (e.message || e); if (window.zddc && window.zddc.toast) { window.zddc.toast('Save failed: ' + (e.message || e), 'error'); diff --git a/browse/js/preview.js b/browse/js/preview.js index 9125a3e..64277a9 100644 --- a/browse/js/preview.js +++ b/browse/js/preview.js @@ -56,6 +56,30 @@ throw new Error('no source for file'); } + // Like getArrayBuffer, but also returns the server version token + // ({etag, lastModified}) captured from the content GET. The editors use + // it to send an If-Match precondition on save so a concurrent edit is + // rejected (412) instead of silently clobbered. FS-Access mode has no + // server version — etag/lastModified are null and the precondition is a + // clean no-op (a single locally-picked file has no concurrency). + async function getContentWithVersion(node) { + if (state.source === 'server' && node.url) { + var resp = await fetch(node.url, { credentials: 'same-origin' }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + var buf = await resp.arrayBuffer(); + return { + buf: buf, + etag: resp.headers.get('ETag') || null, + lastModified: resp.headers.get('Last-Modified') || null + }; + } + if (node.handle) { + var f = await node.handle.getFile(); + return { buf: await f.arrayBuffer(), etag: null, lastModified: null }; + } + throw new Error('no source for file'); + } + async function getBlobUrl(node) { // Server-served files (including zip members at "<…>.zip/" // URLs) load straight from the server — preserves Content-Type @@ -180,7 +204,7 @@ window.app.modules.markdown && typeof window.app.modules.markdown.render === 'function') { try { - await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer }); + await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); } catch (e) { renderError(container, 'Markdown render failed: ' + (e.message || e)); } @@ -193,7 +217,7 @@ var yamlMod = window.app.modules.yamledit; if (yamlMod && yamlMod.handles(node)) { try { - await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer }); + await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); } catch (e) { renderError(container, 'YAML render failed: ' + (e.message || e)); } @@ -443,6 +467,9 @@ // Tear down any live editor + blank the pane (rescope / popstate). clearPreview: clearPreview, // Expose for the markdown plugin so it can read file bytes. - getArrayBuffer: getArrayBuffer + getArrayBuffer: getArrayBuffer, + // Like getArrayBuffer but also returns the {etag, lastModified} + // version token — the editors use it for optimistic-concurrency saves. + getContentWithVersion: getContentWithVersion }; })(); diff --git a/browse/js/util.js b/browse/js/util.js index a4249e3..4b2dbdd 100644 --- a/browse/js/util.js +++ b/browse/js/util.js @@ -90,33 +90,100 @@ return false; } - // Write content back to a file's source. Local (FS-Access) folders are + // Thrown by saveFile when the server rejects a write with 412 + // Precondition Failed — the file changed under us since we loaded it. + // Callers branch on `.status === 412` to open the conflict UI instead + // of treating it as a generic save failure. + function ConflictError(message) { + var e = new Error(message || 'Conflict: file changed on server'); + e.name = 'ConflictError'; + e.status = 412; + return e; + } + + // Write content back to a file's source, returning { etag } (the new + // server ETag, or null in FS-Access mode). Local (FS-Access) folders are // picked read-only, so the first write escalates to readwrite via // upload.ensureWritable (one permission prompt, then granted for the // session). contentType sets the PUT Content-Type for server files. - // Throws when the source has no write target. - async function saveFile(node, content, contentType) { + // + // opts (server mode only): + // etag — send as `If-Match` so the master 412s if the file + // changed since we observed this version (optimistic + // concurrency; preferred — exact). + // lastModified — fallback precondition sent as `If-Unmodified-Since` + // (raw HTTP-date string) when no etag is available. + // force — skip the precondition entirely (deliberate overwrite). + // + // Throws ConflictError (.status===412) on a precondition failure, a + // plain Error('HTTP ') on any other non-2xx, or "no write + // target" when the source is read-only. + async function saveFile(node, content, contentType, opts) { + opts = opts || {}; if (node.handle && typeof node.handle.createWritable === 'function') { var up = window.app.modules.upload; if (up && up.ensureWritable) await up.ensureWritable(); var writable = await node.handle.createWritable(); await writable.write(content); await writable.close(); - return; + return { etag: null }; } if (node.url && window.app.state.source === 'server') { + var headers = { 'Content-Type': contentType }; + if (!opts.force) { + if (opts.etag) headers['If-Match'] = opts.etag; + else if (opts.lastModified) headers['If-Unmodified-Since'] = opts.lastModified; + } var resp = await fetch(node.url, { method: 'PUT', - headers: { 'Content-Type': contentType }, + headers: headers, body: content, credentials: 'same-origin' }); + if (resp.status === 412) throw ConflictError(); if (!resp.ok) throw new Error('HTTP ' + resp.status); - return; + return { etag: resp.headers.get('ETag') || null }; } throw new Error('No write target for this file (read-only source).'); } + // Write `content` to a NEW sibling of `node` named + // `-conflict-.` (server mode only), so a + // conflicting edit can be parked without losing either version. Probes + // for a free name (numeric-suffix bump, capped) so a same-second retry + // doesn't clobber a prior copy. Returns the created filename. The PUT + // uses no precondition — it's a brand-new path. + async function saveCopy(node, content, contentType) { + if (!(node.url && window.app.state.source === 'server')) { + throw new Error('Save a copy is only available for server files.'); + } + var split = window.zddc.splitExtension(node.name); + var stem = split.name || node.name; + var ext = split.extension; + var d = new Date(); + var stamp = d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) + + '-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds()); + var base = stem + '-conflict-' + stamp; + var slash = node.url.lastIndexOf('/'); + var dirUrl = slash >= 0 ? node.url.slice(0, slash + 1) : ''; + var name = '', candidateUrl = ''; + for (var i = 0; i < 20; i++) { + name = window.zddc.joinExtension(base + (i ? '-' + (i + 1) : ''), ext); + candidateUrl = dirUrl + encodeURIComponent(name); + var head; + try { + head = await fetch(candidateUrl, { method: 'HEAD', credentials: 'same-origin' }); + } catch (_e) { + break; // network unknown — attempt the write rather than spin + } + if (head.status === 404) break; // free slot + if (head.status !== 200) break; // HEAD unsupported / odd — attempt anyway + if (i === 19) throw new Error('Could not find a free filename for the copy.'); + } + await saveFile({ url: candidateUrl, name: name, ext: ext }, content, contentType, { force: true }); + return name; + } + window.app.modules.util = { escapeHtml: escapeHtml, hashContent: hashContent, @@ -126,6 +193,8 @@ fetchAccessEmails: fetchAccessEmails, fmtSize: fmtSize, isZipMemberNode: isZipMemberNode, - saveFile: saveFile + saveFile: saveFile, + saveCopy: saveCopy, + ConflictError: ConflictError }; })(); diff --git a/playwright.config.js b/playwright.config.js index e52dc4e..43510b9 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -55,6 +55,10 @@ export default defineConfig({ name: 'browse', testMatch: 'browse.spec.js', }, + { + name: 'conflict', + testMatch: 'conflict.spec.js', + }, { name: 'zddc-source', testMatch: 'zddc-source.spec.js', diff --git a/tests/conflict.spec.js b/tests/conflict.spec.js new file mode 100644 index 0000000..d05e4a9 --- /dev/null +++ b/tests/conflict.spec.js @@ -0,0 +1,134 @@ +// conflict.spec.js — optimistic-concurrency save (If-Match → 412) + the +// shared conflict-resolution dialog in the browse tool. +// +// These drive the client modules directly against a stubbed fetch rather +// than a real master: the test zddc-server embeds the COMMITTED +// internal/apps/embedded/browse.html, not browse/dist/browse.html, so a +// server-mode E2E would run stale code. Loading the fresh dist build over +// file:// and stubbing fetch exercises exactly the code under test. Full +// server-mode behavior (the master's checkIfMatch → 412) is covered +// manually / on the bitnest dev server. +import { test, expect } from '@playwright/test'; +import * as path from 'path'; + +const HTML_PATH = path.resolve('browse/dist/browse.html'); + +test.describe('Conflict / optimistic concurrency', () => { + test.beforeEach(async ({ page }) => { + await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' }); + // init.js + util.js + conflict.js run synchronously on load. + await page.waitForFunction( + () => window.app && window.app.modules + && window.app.modules.util && window.app.modules.conflict); + }); + + test('saveFile sends If-Match and throws ConflictError on 412', async ({ page }) => { + const result = await page.evaluate(async () => { + const calls = []; + window.fetch = async (url, opts) => { + calls.push({ url, opts }); + return { status: 412, ok: false, headers: { get: () => null } }; + }; + window.app.state.source = 'server'; + const node = { url: '/doc.md', name: 'doc.md' }; + let status = null, name = null; + try { + await window.app.modules.util.saveFile( + node, 'hi', 'text/markdown; charset=utf-8', { etag: '"v1"' }); + } catch (e) { + status = e.status; + name = e.name; + } + return { + sentIfMatch: calls[0] && calls[0].opts.headers['If-Match'], + method: calls[0] && calls[0].opts.method, + status, name + }; + }); + expect(result.method).toBe('PUT'); + expect(result.sentIfMatch).toBe('"v1"'); + expect(result.status).toBe(412); + expect(result.name).toBe('ConflictError'); + }); + + test('saveFile returns the new ETag on success (re-edit loop)', async ({ page }) => { + const result = await page.evaluate(async () => { + window.fetch = async () => ({ + status: 200, ok: true, + headers: { get: (h) => (h === 'ETag' ? '"v2"' : null) } + }); + window.app.state.source = 'server'; + const node = { url: '/doc.md', name: 'doc.md' }; + const res = await window.app.modules.util.saveFile( + node, 'hi', 'text/markdown; charset=utf-8', { etag: '"v1"' }); + return res; + }); + expect(result.etag).toBe('"v2"'); + }); + + test('saveFile omits the precondition when force is set', async ({ page }) => { + const sent = await page.evaluate(async () => { + let headers = null; + window.fetch = async (url, opts) => { + headers = opts.headers; + return { status: 200, ok: true, headers: { get: () => null } }; + }; + window.app.state.source = 'server'; + await window.app.modules.util.saveFile( + { url: '/doc.md', name: 'doc.md' }, 'hi', 'text/markdown', + { etag: '"v1"', force: true }); + return { hasIfMatch: 'If-Match' in headers }; + }); + expect(sent.hasIfMatch).toBe(false); + }); + + test('conflict dialog renders a diff and Overwrite resolves via the callback', async ({ page }) => { + // Kick off the dialog; stash the resolution + a flag the callback sets. + await page.evaluate(() => { + window.__conflict = { resolved: null, overwrote: false }; + window.app.modules.conflict.open({ + filename: 'doc.md', + mineText: 'line one\nMINE EDIT\nline three\n', + theirsText: 'line one\nTHEIR EDIT\nline three\n', + onOverwrite: async () => { window.__conflict.overwrote = true; }, + onReload: async () => {}, + onSaveCopy: async () => {} + }).then((r) => { window.__conflict.resolved = r; }); + }); + + // Modal + diff present. + const overlay = page.locator('.md-history-overlay'); + await expect(overlay).toBeVisible(); + await expect(overlay.locator('.md-diff-add').first()).toBeVisible(); + await expect(overlay.locator('.md-diff-del').first()).toBeVisible(); + + // Click "Overwrite (keep mine)". + await overlay.getByRole('button', { name: 'Overwrite (keep mine)' }).click(); + + await page.waitForFunction(() => window.__conflict.resolved !== null); + const outcome = await page.evaluate(() => window.__conflict); + expect(outcome.resolved).toBe('overwrite'); + expect(outcome.overwrote).toBe(true); + await expect(overlay).toBeHidden(); + }); + + test('conflict dialog Cancel resolves "cancel" and runs no callback', async ({ page }) => { + await page.evaluate(() => { + window.__c2 = { resolved: null, ran: false }; + window.app.modules.conflict.open({ + filename: 'doc.md', + mineText: 'a\n', + theirsText: 'b\n', + onOverwrite: async () => { window.__c2.ran = true; }, + onReload: async () => { window.__c2.ran = true; } + }).then((r) => { window.__c2.resolved = r; }); + }); + const overlay = page.locator('.md-history-overlay'); + await expect(overlay).toBeVisible(); + await overlay.getByRole('button', { name: 'Cancel' }).click(); + await page.waitForFunction(() => window.__c2.resolved !== null); + const outcome = await page.evaluate(() => window.__c2); + expect(outcome.resolved).toBe('cancel'); + expect(outcome.ran).toBe(false); + }); +});