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