MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
- Server admins manage organizations (one per user); new admin section, organization on user creation and invites, setup creates the first one - Sharing modes private / organizations / open, enforced centrally (CanSee, CanShareWith) for directory, all shares and groups; unknown and invisible targets give the same error - Losing visibility (org change, mode change, leaving a group) prunes shares, group memberships and task assignments; dry run warns before tightening - Only owners share; editors can no longer reshare lists or remove others' shares; legacy task_lists.team_id and old /organizations endpoints removed (FLAW-06, FLAW-10, FLAW-24) - Schema version: outdated databases are wiped and recreated on start - Fix setup sample data (createSampleData was ignored); setup and admin use a shared ToggleSwitch instead of checkboxes - Docs: sharing-and-permissions rewritten, ui-patterns, REST, DB, flaws Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
9.2 KiB
JavaScript
172 lines
9.2 KiB
JavaScript
// Locks, presence, undo and conflict copies with two real browsers. Needs a server in demo mode
|
|
// (accounts admin/admin and user/user); see e2e/README.md.
|
|
import { chromium } from 'playwright';
|
|
|
|
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
let failures = 0;
|
|
const check = (cond, msg) => {
|
|
console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`);
|
|
if (!cond) failures++;
|
|
};
|
|
|
|
async function api(token, method, path, body) {
|
|
const res = await fetch(BASE + path, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text();
|
|
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text}`);
|
|
return text ? JSON.parse(text) : null;
|
|
}
|
|
|
|
async function waitFor(fn, ms) {
|
|
const end = Date.now() + ms;
|
|
while (Date.now() < end) {
|
|
try {
|
|
const v = await fn();
|
|
if (v) return v;
|
|
} catch {}
|
|
await sleep(250);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const serverBlocks = async (token, docId) =>
|
|
(await api(token, 'GET', `/api/v1/docs/${docId}/snapshot`)).blocks.filter((b) => !b.deleted);
|
|
const editorValues = (page) =>
|
|
page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value));
|
|
|
|
// Puts the cursor at the end of the text field showing `text` (works whether or not it is focused already)
|
|
async function focusField(page, text) {
|
|
const handle = await page.waitForFunction(
|
|
(t) => Array.from(document.querySelectorAll('textarea')).find((e) => e.value === t) || null,
|
|
text,
|
|
{ timeout: 5000, polling: 100 } // rAF polling stalls in background pages
|
|
);
|
|
const el = handle.asElement();
|
|
await el.click();
|
|
await page.keyboard.press('End');
|
|
}
|
|
|
|
async function openDoc(browser, token, title) {
|
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
|
|
const page = await ctx.newPage();
|
|
await page.goto(BASE);
|
|
await page.getByRole('button', { name: /Workspace/ }).first().click();
|
|
await page.getByText(title, { exact: true }).first().click();
|
|
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 1, null, { timeout: 10000 });
|
|
return { ctx, page };
|
|
}
|
|
|
|
const TITLE = `Sperr-Test ${Date.now()}`;
|
|
const id = (n) => `lk_${n}_${Date.now()}`;
|
|
|
|
const main = async () => {
|
|
const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token;
|
|
const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token;
|
|
const dir = await api(admin, 'GET', '/api/v1/users/directory');
|
|
const userId = dir.find((u) => u.username === 'user' || u.email?.startsWith('user')).id;
|
|
const ids = { a: id('a'), b: id('b') };
|
|
const doc = await api(admin, 'POST', '/api/v1/documents', {
|
|
title: TITLE,
|
|
blocks: [
|
|
{ id: ids.a, type: 'bullet', content: 'Admin schreibt hier', level: 0 },
|
|
{ id: ids.b, type: 'bullet', content: 'Darunter', level: 0 },
|
|
],
|
|
});
|
|
await api(admin, 'POST', `/api/v1/documents/${doc.id}/shares`, { userId, permission: 'write' });
|
|
|
|
const browser = await chromium.launch();
|
|
const A = await openDoc(browser, admin, TITLE);
|
|
const B = await openDoc(browser, user, TITLE);
|
|
|
|
// Presence
|
|
// Presence is an avatar stack; its label lists who is online
|
|
const presence = await waitFor(async () => B.page.getByLabel(/^Gerade online:/).getAttribute('aria-label'), 8000);
|
|
check(!!presence && /Admin/i.test(presence), `Bearbeiter sieht, wer noch in der Notiz ist: ${presence}`);
|
|
|
|
// Admin clicks into the first bullet -> user sees the lock
|
|
await A.page.getByText('Admin schreibt hier', { exact: true }).first().click();
|
|
const lockBadge = await waitFor(async () => (await B.page.getByTitle(/bearbeitet diesen Punkt gerade/).count()) > 0, 10000);
|
|
check(!!lockBadge, 'Bearbeiter sieht das Schloss am gesperrten Bullet');
|
|
const readOnly = await B.page.$$eval('textarea', (els) => els.find((e) => e.value === 'Admin schreibt hier')?.readOnly);
|
|
check(readOnly === true, 'Gesperrter Bullet ist beim Bearbeiter schreibgeschützt');
|
|
|
|
// User tries to merge "Darunter" into the locked bullet
|
|
await B.page.getByText('Darunter', { exact: true }).first().click();
|
|
await B.page.keyboard.press('Home');
|
|
await B.page.keyboard.press('Backspace');
|
|
const notice = await waitFor(async () => B.page.getByRole('status').innerText(), 3000);
|
|
check(!!notice && /bearbeitet diesen Punkt gerade/.test(notice), `Hinweis statt halber Aktion: ${notice}`);
|
|
await sleep(1500);
|
|
const afterMerge = (await serverBlocks(admin, doc.id)).map((b) => b.content).sort();
|
|
check(JSON.stringify(afterMerge) === JSON.stringify(['Admin schreibt hier', 'Darunter']), `Nichts wurde teilweise ausgeführt: ${JSON.stringify(afterMerge)}`);
|
|
|
|
// Undo on the admin side
|
|
await focusField(A.page, 'Admin schreibt hier');
|
|
await A.page.keyboard.type(' und noch mehr');
|
|
await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000);
|
|
await A.page.keyboard.press('Control+z');
|
|
const undone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier'), 5000);
|
|
check(!!undone, 'Strg+Z nimmt den getippten Text zurück (auch auf dem Server)');
|
|
const fieldUndone = await waitFor(async () => (await editorValues(A.page)).includes('Admin schreibt hier'), 3000);
|
|
check(!!fieldUndone, 'Das fokussierte Feld zeigt den zurückgenommenen Text');
|
|
await A.page.keyboard.press('Control+Shift+z');
|
|
const redone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000);
|
|
check(!!redone, 'Strg+Umschalt+Z stellt ihn wieder her');
|
|
|
|
// Typing right after Enter lands in the new bullet (no pause)
|
|
await A.page.keyboard.press('End');
|
|
await A.page.keyboard.press('Enter');
|
|
await A.page.keyboard.type('Direkt nach Enter');
|
|
const newBullet = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Direkt nach Enter'), 5000);
|
|
check(!!newBullet, 'Sofort nach Enter Getipptes landet im neuen Bullet');
|
|
|
|
// Conflict copy: two writes on the same base version via the API
|
|
const before = (await serverBlocks(admin, doc.id)).find((b) => b.id === ids.b);
|
|
await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-1', lastSeq: 0, ops: [{ opId: `${ids.b}-x`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Gewinner-Text' }] });
|
|
await api(user, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-2', lastSeq: 0, ops: [{ opId: `${ids.b}-y`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Verlierer-Text' }] });
|
|
const conflictShown = await waitFor(async () => (await A.page.getByText(/Version von/).count()) > 0, 8000);
|
|
check(!!conflictShown, 'Konfliktkopie wird markiert angezeigt');
|
|
// The user's cursor is still in the original bullet (it holds the lock), so the admin may not overwrite it
|
|
await A.page.getByRole('button', { name: 'Übernehmen' }).first().click();
|
|
const refused = await waitFor(async () => A.page.getByRole('status').innerText(), 3000);
|
|
check(!!refused && /bearbeitet/.test(refused), `Übernehmen in einen fremd gesperrten Punkt wird verweigert: ${refused}`);
|
|
// The user holding the original can accept once the copy's author (here a separate API client that never
|
|
// renews its lease) no longer holds the copy, i.e. after at most 20 s
|
|
await waitFor(async () => (await B.page.getByText(/Version von/).count()) > 0, 8000);
|
|
const copyId = (await serverBlocks(admin, doc.id)).find((b) => b.props?.conflictOf)?.id;
|
|
const copyFree = await waitFor(async () => {
|
|
const probe = await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'probe', lastSeq: 0 });
|
|
return !probe.leases.others.some((l) => l.blockId === copyId);
|
|
}, 25000);
|
|
check(!!copyFree, 'Sperre der Konfliktkopie läuft ab');
|
|
await sleep(3500); // the user's browser learns about it with its next sync
|
|
// Both API writes became conflict copies (the user's cursor locked the original); accept the first one
|
|
const copiesBefore = (await serverBlocks(admin, doc.id)).filter((b) => b.props?.conflictOf === ids.b);
|
|
const firstCopyText = await B.page.$$eval(
|
|
'textarea',
|
|
(els, texts) => els.map((e) => e.value).find((v) => texts.includes(v)),
|
|
copiesBefore.map((c) => c.content)
|
|
);
|
|
await B.page.getByRole('button', { name: 'Übernehmen' }).first().click();
|
|
const accepted = await waitFor(async () => {
|
|
const blocks = await serverBlocks(admin, doc.id);
|
|
const copiesNow = blocks.filter((b) => b.props?.conflictOf === ids.b);
|
|
return blocks.find((b) => b.id === ids.b)?.content === firstCopyText && copiesNow.length === copiesBefore.length - 1;
|
|
}, 6000);
|
|
check(!!accepted, `„Übernehmen“ ersetzt den ursprünglichen Text durch „${firstCopyText}“ und entfernt diese Kopie`);
|
|
|
|
await browser.close();
|
|
console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN');
|
|
process.exit(failures ? 1 : 0);
|
|
};
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(2);
|
|
});
|