Template-created pages (weekly report, project doc, meeting notes, to-do list, ...) were persisted without block ids. The editor assigned ids client-side, but the realtime room loaded the raw id-less content and sent it back on 'sync'; applySync then merged id-less server blocks with local blocks, producing data-bid='undefined' collisions and duplicated/shuffled lines as soon as the user edited. Editing an empty page was unaffected because the server state was empty. Fixes: - board.use_page_template: materialize unique block ids (recursively) when instantiating built-in or user templates. - realtime_server: unique block_id() (uuid) + ensure_block_ids() on room load and on insert ops. - editor: recursive ensureBlockIds() in init; gtTok() now restores [[fddate:...]] tokens (date chips survived as labels before). - realtime client: applySync() normalizes ids, no longer appends unknown local blocks (duplication), and keeps local content when server is empty. Tests: pytest (templates + realtime) and Playwright e2e covering to-do list, weekly report date chip, Enter ordering and legacy id-less repair.
158 lines
6.3 KiB
JavaScript
158 lines
6.3 KiB
JavaScript
const { test, expect } = require('@playwright/test');
|
|
|
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
|
const USER = process.env.FD_USER || '[email protected]';
|
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
|
|
|
async function login(page) {
|
|
await page.goto(`${FD_BASE}/auth/login?provider=local`);
|
|
await page.fill('#email', USER);
|
|
await page.fill('#password', PASS);
|
|
await page.click('.btn-primary');
|
|
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
|
}
|
|
|
|
async function setupWorkspace(page, name) {
|
|
return await page.evaluate(async (n) => {
|
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
|
const r = await fetch('/api/workspaces', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
|
body: JSON.stringify({ name: n }),
|
|
});
|
|
const ws = await r.json();
|
|
await fetch(`/api/workspaces/${ws.id}/select`, { method: 'POST' });
|
|
return ws;
|
|
}, name);
|
|
}
|
|
|
|
async function createFromTemplate(page, key, wsId) {
|
|
return await page.evaluate(async ({ k, id }) => {
|
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
|
const r = await fetch('/board/api/page-templates/0/use', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
|
body: JSON.stringify({ key: k, workspace_id: id }),
|
|
});
|
|
return await r.json();
|
|
}, { k: key, id: wsId });
|
|
}
|
|
|
|
test.describe('Template page editor', () => {
|
|
test('to-do list: block ids remain valid after realtime sync', async ({ page }) => {
|
|
await login(page);
|
|
const ws = await setupWorkspace(page, 'Edit Repro WS ' + Date.now());
|
|
const tpl = await createFromTemplate(page, 'todo_list', ws.id);
|
|
console.log('created page', tpl);
|
|
|
|
await page.goto(`${FD_BASE}/pages/${tpl.id}`);
|
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
|
await page.waitForTimeout(3000); // let WS sync land
|
|
|
|
const bids = await page.$$eval('#_blocksCt [data-bid]', (els) =>
|
|
els.map((e) => e.getAttribute('data-bid'))
|
|
);
|
|
console.log('BIDS:', JSON.stringify(bids));
|
|
expect(bids.length).toBeGreaterThan(0);
|
|
expect(bids).not.toContain('undefined');
|
|
expect(new Set(bids).size).toBe(bids.length);
|
|
});
|
|
|
|
test('weekly report: date chip survives editing', async ({ page }) => {
|
|
await login(page);
|
|
const ws = await setupWorkspace(page, 'Edit Repro WS2 ' + Date.now());
|
|
const tpl = await createFromTemplate(page, 'weekly_report', ws.id);
|
|
|
|
await page.goto(`${FD_BASE}/pages/${tpl.id}`);
|
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Type in the first heading then read back the saved content token
|
|
const h1 = page.locator('#_blocksCt .block-content').first();
|
|
await h1.click();
|
|
await page.keyboard.press('End');
|
|
await page.keyboard.type(' (edited)');
|
|
await page.waitForTimeout(2500); // autosave
|
|
|
|
const stored = await page.evaluate(async (pid) => {
|
|
const r = await fetch('/board/api/pages/' + pid);
|
|
return (await r.json()).content;
|
|
}, tpl.id);
|
|
console.log('STORED:', stored);
|
|
expect(stored).toContain('fddate');
|
|
});
|
|
|
|
test('to-do list: typing + Enter keeps order and does not duplicate', async ({ page }) => {
|
|
await login(page);
|
|
const ws = await setupWorkspace(page, 'Edit Repro WS3 ' + Date.now());
|
|
const tpl = await createFromTemplate(page, 'todo_list', ws.id);
|
|
|
|
await page.goto(`${FD_BASE}/pages/${tpl.id}`);
|
|
await page.waitForSelector('#_blocksCt .block-todo [contenteditable][data-bid]', { timeout: 15000 });
|
|
await page.waitForTimeout(2500); // let WS sync land
|
|
|
|
const first = page.locator('#_blocksCt .block-todo [contenteditable][data-bid]').first();
|
|
await first.click();
|
|
await page.keyboard.type('Task A');
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForTimeout(250); // editor refocuses the new block after render
|
|
await page.keyboard.type('Task B');
|
|
await page.waitForTimeout(2500); // autosave
|
|
|
|
const stored = await page.evaluate(async (pid) => {
|
|
const r = await fetch('/board/api/pages/' + pid);
|
|
return JSON.parse((await r.json()).content);
|
|
}, tpl.id);
|
|
|
|
const texts = stored.map((b) => b.content);
|
|
console.log('TODO STORED:', JSON.stringify(texts));
|
|
// heading + 3 to_do (template) + 1 inserted by Enter
|
|
expect(texts).toEqual(['To-do', 'Task A', 'Task B', '', '']);
|
|
const ids = stored.map((b) => b.id);
|
|
expect(new Set(ids).size).toBe(ids.length);
|
|
expect(ids).not.toContain(undefined);
|
|
});
|
|
|
|
test('legacy id-less page is repaired on load (no undefined ids / duplicates)', async ({ page }) => {
|
|
await login(page);
|
|
const ws = await setupWorkspace(page, 'Edit Repro Legacy ' + Date.now());
|
|
|
|
// create an empty page, then write id-less blocks directly to simulate a
|
|
// page created before block ids were persisted (old templates).
|
|
const pid = await page.evaluate(async (wsId) => {
|
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
|
const r = await fetch('/api/local-workspace/items', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
|
body: JSON.stringify({ name: 'Legacy', type: 'page', workspace_id: wsId }),
|
|
});
|
|
const d = await r.json();
|
|
await fetch('/board/api/pages/' + d.id + '/blocks', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
|
body: JSON.stringify({
|
|
title: 'Legacy',
|
|
blocks: [
|
|
{ type: 'heading_1', content: 'To-do' },
|
|
{ type: 'to_do', content: '', checked: false },
|
|
{ type: 'to_do', content: '', checked: false },
|
|
],
|
|
}),
|
|
});
|
|
return d.id;
|
|
}, ws.id);
|
|
|
|
await page.goto(`${FD_BASE}/pages/${pid}`);
|
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
|
await page.waitForTimeout(3000); // let realtime sync land
|
|
|
|
const bids = await page.$$eval('#_blocksCt [data-bid]', (els) =>
|
|
els.map((e) => e.getAttribute('data-bid'))
|
|
);
|
|
console.log('LEGACY BIDS:', JSON.stringify(bids));
|
|
expect(bids.length).toBe(3);
|
|
expect(bids).not.toContain('undefined');
|
|
expect(new Set(bids).size).toBe(bids.length);
|
|
});
|
|
});
|