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>
134 lines
6 KiB
JavaScript
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);
|
|
});
|
|
});
|