ZDDC/tests/conflict.spec.js
ZDDC 8edbb81958 feat(browse): lost-update protection for editors + shared conflict dialog
Two users editing the same file online could silently clobber each other:
the editor's save did a bare PUT with no precondition, even though the master
already enforces optimistic concurrency (fileapi.go checkIfMatch → 412). Now
the editor sends a precondition and surfaces a conflict UI instead of
overwriting.

- util.js: saveFile(node, content, contentType, opts) sends `If-Match: <etag>`
  (or `If-Unmodified-Since` fallback) unless opts.force; returns {etag} from
  the PUT response (so save→edit→save adopts the new version and doesn't
  false-conflict); throws ConflictError (.status===412) on a precondition
  failure so callers branch cleanly. New saveCopy() parks a conflicting edit
  as `<stem>-conflict-<ts>.<ext>` (collision-probed) without losing either side.
- preview.js: getContentWithVersion(node) → {buf, etag, lastModified} captured
  from the content GET (the listing JSON carries no per-file etag); threaded
  into the editor ctx and exported. getArrayBuffer left untouched.
- conflict.js (new): shared, callback-driven dialog — mine-vs-theirs diff
  (reuses zddc.diff + css/history.css) + Overwrite / Reload-theirs /
  Save-a-copy / Cancel. Never calls saveFile/showFilePreview itself, so the
  deferred Phase 5 cache-outbox conflict UI can reuse it with its own callbacks.
- preview-markdown.js / preview-yaml.js: capture + forward the version token,
  adopt the returned etag on success, and on 412 open the dialog (Overwrite
  re-fetches the current etag then re-saves — re-conflicts on a third writer
  rather than blind-forcing; Reload clears dirty first so the renderInline
  guard skips its confirm). FS-Access mode sends no precondition (no
  concurrency) and never conflicts.
- build.sh: concat conflict.js after util.js.
- tests/conflict.spec.js (+ playwright project): If-Match sent, ConflictError
  on 412, new-etag returned, force omits the precondition, dialog renders the
  diff and each action resolves via its callback. Drives the fresh dist build
  over file:// with a stubbed fetch (the test binary embeds the committed
  browse.html, not dist, so a server-mode E2E would run stale code).

All browse + diff + conflict specs pass (18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:24:15 -05:00

134 lines
6 KiB
JavaScript

// 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);
});
});