ZDDC/tests/conflict.spec.js
2026-06-11 13:32:31 -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);
});
});