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