mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
d04b714ccb
* feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294)
PLAN-2290 Phase 4, PR A. The mock's tabbed pane, built on the hard rule:
panels are CSS-hidden (.tab-hidden, display:none), NEVER {#if}-unmounted —
the collab editor, ChildItems/ItemTimeline SSE subscriptions, and
BacklinksPanel's count callback all carry mount side effects that must
survive tab switches.
- ItemDetail: pane-tabs tablist after the action bar; Details wraps Code
Context + .item-body (fields+editor, layout-{layout} preserved);
Relationships wraps relationships/add/children/backlinks (inside the
existing {#key itemSlug} block); ONE ItemTimeline instance serves both
Activity and Versions via the new visibleKinds render-filter. Tabs reset
to Details on item switch (guarded plain-let effect, no read-write loop).
Jump buttons switch-tab-then-scroll. Print shows all panels, no tab bar.
Tab clicks stay interactive while peeking and activate the side per the
focus-follows-editing model (deliberately NOT an exempt surface).
- ItemTimeline: visibleKinds?: ('comment'|'activity'|'version')[] —
filter-only over the one merged feed (no refetch on switch); composer
renders only when comments are visible.
- E2E: five specs updated — tab-click preludes where interactions target
tabbed sections; four frozen-master tests reworked to assert per-tab
visuals BEFORE the peek and DOM-based freeze proxies during it (the
per-surface freeze audit lives in masterFreeze/mutationGate unit suites).
Gates: svelte-check 0 errors, 488 unit tests, the five affected e2e specs
27/27 locally; runtime-verified collab badge synced across a full tab
round-trip, version filter (4 real cards), composer placement, editor DOM
alive throughout.
* fix(web): pane-tabs review fixes — title-Enter surfaces Details before editor focus; ARIA ids/roving-tabindex/arrow nav; block-drag hover integration restored via re-peek
Codex findings on #1027: (1) Enter-after-title-edit now sets
activeTab='details' + tick before focusing the editor (was focusing a
display:none node from other tabs); (2) tablist gains arrow-key roving
focus, per-instance aria-controls/id pairing ($props.id() — two ItemDetail
instances mount on the full-page host), tabindex discipline; (3) host
test 3 regains the end-to-end hover assertion: re-surface master Details
(activates), re-peek via the pane, hover the frozen editor, assert the
handle stays display:none — the reactive-editable choke verified in
integration again, not just by contenteditable proxy.
* fix(web): pane tabs use automatic activation on arrow nav (Codex — roving tabindex must follow focus; activation is free on display-toggled panels)
375 lines
16 KiB
TypeScript
375 lines
16 KiB
TypeScript
import { test, expect } from './fixtures';
|
|
import type { Request as PWRequest } from '@playwright/test';
|
|
import { browserLogin, EDITOR_SELECTOR, SYNCED_BADGE_SELECTOR } from './lib/collab-helpers';
|
|
|
|
/**
|
|
* Version-restore under live collab (BUG-2264).
|
|
*
|
|
* The version-restore handler used to write items.content directly,
|
|
* bypassing the Yjs collab applier. Under a live co-editing session the
|
|
* editor's Y.Doc still held the PRE-restore document, so the next
|
|
* collab-snapshot flush PATCHed that stale markdown back over the
|
|
* restored content — silently undoing the restore.
|
|
*
|
|
* The fix routes the restore's content through the same applier path as
|
|
* a normal content PATCH (server/handlers_item_versions.go): a connected
|
|
* client applies the restored markdown into the shared Y.Doc (propagating
|
|
* to all peers + the op-log), with a plain items.content write only when
|
|
* no collab room is live.
|
|
*
|
|
* This spec drives the real chain through the embedded UI:
|
|
*
|
|
* seed v1 → open live editor → type v2 (creates a version) → restore v1
|
|
* while the room is live → type a further edit → assert items.content
|
|
* reflects the restored v1 (+ the new edit) and was NOT reverted to v2.
|
|
*
|
|
* Under the bug, the restore leaves the live Y.Doc holding v2, so both
|
|
* the immediate editor assertion (v2 still present) and the final
|
|
* persisted-content assertion (v2 leaked back) go red.
|
|
*
|
|
* Determinism notes (mirrors collab-persistence.spec.ts):
|
|
* - We wait for the "Synced" collab badge before typing so the Y.Doc
|
|
* binding is live.
|
|
* - We register the collab-snapshot flush wait (the 5s idle PATCH that
|
|
* writes items.content) BEFORE each edit and scope it to a response
|
|
* body carrying the marker, so assertions can't race the write.
|
|
* - The restore POST blocks server-side until the connected client acks
|
|
* the applier request, so by the time it resolves the live Y.Doc has
|
|
* already been reconciled.
|
|
*
|
|
* Why an in-page login (browserLogin): the collab WebSocket handshake
|
|
* can't carry an Authorization header, so the server authenticates it
|
|
* from a same-UA session cookie. See collab-helpers.ts.
|
|
*/
|
|
|
|
test('restoring a version under live collab is not clobbered by the next flush (BUG-2264)', async ({
|
|
page,
|
|
fixture,
|
|
request
|
|
}, testInfo) => {
|
|
// The collab chain is viewport-agnostic; one project is enough and
|
|
// running both doubles WS load on the single test instance for no
|
|
// added coverage.
|
|
test.skip(
|
|
testInfo.project.name !== 'desktop-chromium',
|
|
'version-restore collab is viewport-agnostic; one project is enough'
|
|
);
|
|
// Two 5s idle flushes + the WS handshake + the applier round-trip need
|
|
// well over the 30s default on a busy CI runner.
|
|
test.setTimeout(90_000);
|
|
|
|
const ws = fixture.workspaceSlug;
|
|
const headers = {
|
|
Authorization: `Bearer ${fixture.apiToken}`,
|
|
'Content-Type': 'application/json'
|
|
};
|
|
const ts = Date.now();
|
|
const alpha = `alpha-restore-${ts}`;
|
|
const beta = `beta-super-${ts}`;
|
|
const gamma = `gamma-after-${ts}`;
|
|
|
|
// Seed a doc whose initial (v1) body is `alpha` — the state we later
|
|
// restore back to after typing over it.
|
|
const createResp = await request.post(`/api/v1/workspaces/${ws}/collections/docs/items`, {
|
|
headers,
|
|
data: { title: `Version restore collab ${ts}`, content: alpha }
|
|
});
|
|
if (!createResp.ok())
|
|
throw new Error(`doc create failed (${createResp.status()}): ${await createResp.text()}`);
|
|
const { slug } = (await createResp.json()) as { id: string; slug: string };
|
|
|
|
await browserLogin(page);
|
|
await page.goto(`/${fixture.adminUsername}/${ws}/docs/${slug}`);
|
|
|
|
const editor = page.locator(EDITOR_SELECTOR);
|
|
await expect(editor).toBeVisible();
|
|
// Y.Doc hydrated from items.content (the seeded v1).
|
|
await expect(editor).toContainText(alpha);
|
|
// Schema-version handshake complete — typing before this races the binding.
|
|
await expect(page.locator(SYNCED_BADGE_SELECTOR)).toBeVisible();
|
|
|
|
// Type `beta` — the v2 edit that supersedes v1. Its collab-snapshot
|
|
// flush persists "alpha beta" and (first content change → no throttle)
|
|
// creates a version whose resolved content is the pre-edit v1.
|
|
const betaFlushed = page.waitForResponse(
|
|
async (r) =>
|
|
r.url().includes('source=collab-snapshot') &&
|
|
r.request().method() === 'PATCH' &&
|
|
r.ok() &&
|
|
(await r.text()).includes(beta),
|
|
{ timeout: 30_000 }
|
|
);
|
|
await editor.click();
|
|
await page.keyboard.press('Control+End');
|
|
await page.keyboard.type(' ' + beta);
|
|
await expect(editor).toContainText(beta);
|
|
await betaFlushed;
|
|
|
|
// Find the version snapshot holding the pre-beta (v1) state: resolved
|
|
// content contains alpha but not beta.
|
|
const versionsResp = await request.get(
|
|
`/api/v1/workspaces/${ws}/items/${slug}/versions`,
|
|
{ headers }
|
|
);
|
|
expect(versionsResp.ok()).toBeTruthy();
|
|
const versions = (await versionsResp.json()) as Array<{ id: string; content: string }>;
|
|
const v1 = versions.find((v) => v.content.includes(alpha) && !v.content.includes(beta));
|
|
expect(v1, 'expected a version snapshot holding the pre-edit (v1) content').toBeTruthy();
|
|
|
|
// Restore v1 while the collab room is LIVE. The POST blocks until the
|
|
// connected client applies the restored markdown into its Y.Doc via the
|
|
// applier protocol (the BUG-2264 fix). Under the bug this wrote
|
|
// items.content directly and the still-live Y.Doc (holding beta) would
|
|
// overwrite it on the next flush.
|
|
const restoreResp = await request.post(
|
|
`/api/v1/workspaces/${ws}/items/${slug}/versions/${v1!.id}/restore`,
|
|
{ headers }
|
|
);
|
|
expect(restoreResp.ok()).toBeTruthy();
|
|
|
|
// The restore endpoint must return the RESTORED body synchronously — the
|
|
// UI adopts this response immediately. A metadata-only write on the
|
|
// applier-ack path would echo the still-pre-restore content here.
|
|
const restored = (await restoreResp.json()) as { content: string };
|
|
expect(restored.content).toContain(alpha);
|
|
expect(restored.content).not.toContain(beta);
|
|
|
|
// The applier reconciled the live editor: v1 is back, v2 is gone. Under
|
|
// the bug the editor's Y.Doc would still hold beta here.
|
|
await expect(editor).toContainText(alpha);
|
|
await expect(editor).not.toContainText(beta, { timeout: 15_000 });
|
|
|
|
// Subsequent edit + flush. If the restore had NOT reconciled the Y.Doc,
|
|
// this flush would persist the stale beta back into items.content.
|
|
const gammaFlushed = page.waitForResponse(
|
|
async (r) =>
|
|
r.url().includes('source=collab-snapshot') &&
|
|
r.request().method() === 'PATCH' &&
|
|
r.ok() &&
|
|
(await r.text()).includes(gamma),
|
|
{ timeout: 30_000 }
|
|
);
|
|
await editor.click();
|
|
await page.keyboard.press('Control+End');
|
|
await page.keyboard.type(' ' + gamma);
|
|
await expect(editor).toContainText(gamma);
|
|
await gammaFlushed;
|
|
|
|
// The persisted body reflects the restored v1 (+ the new edit) and was
|
|
// NOT reverted to v2.
|
|
const finalResp = await request.get(`/api/v1/workspaces/${ws}/items/${slug}`, { headers });
|
|
expect(finalResp.ok()).toBeTruthy();
|
|
const finalItem = (await finalResp.json()) as { content: string };
|
|
expect(finalItem.content).toContain(alpha);
|
|
expect(finalItem.content).toContain(gamma);
|
|
expect(finalItem.content).not.toContain(beta);
|
|
});
|
|
|
|
/**
|
|
* Undo-point captures an UNFLUSHED live edit (BUG-2271).
|
|
*
|
|
* A version restore creates a "Restored from…" undo-point version from the
|
|
* CURRENT persisted items.content, server-side, inside the restore tx. If a
|
|
* live collab editor holds edits still sitting in the Y.Doc/op-log within the
|
|
* ~5s flush-debounce window (not yet PATCHed to items.content), those edits are
|
|
* NOT in the undo-point — and, because the restore also prunes the op-log +
|
|
* reseeds every peer (BUG-2264), they are permanently lost.
|
|
*
|
|
* The collab server is a dumb relay with no Yjs decoder, so it can't render the
|
|
* live Y.Doc to markdown. The fix is client-side: the initiating client flushes
|
|
* its live editor markdown into items.content BEFORE issuing the restore, so the
|
|
* undo-point reflects the true pre-restore live state. ItemDetail threads a
|
|
* `flushBeforeRestore` callback down through ItemTimeline → TimelineVersionCard,
|
|
* which awaits it immediately before the restore POST.
|
|
*
|
|
* This spec drives the restore through the REAL UI button (not the API) so the
|
|
* flush-before-restore callback is exercised:
|
|
*
|
|
* seed alpha → type+flush beta (creates a version) → reload (so the timeline
|
|
* shows the version card) → type gamma but do NOT wait for its flush → click
|
|
* Restore in the UI → assert a version now holds gamma (the undo-point).
|
|
*
|
|
* Under the bug, clicking Restore issues no pre-flush, so the undo-point is
|
|
* captured without gamma and gamma is lost when the op-log is pruned — no
|
|
* version ever contains gamma and the poll goes red.
|
|
*
|
|
* Determinism / no false-pass (Codex P3 on #994): the poll alone could pass for
|
|
* the WRONG reason if the ordinary 5s idle flush happened to land gamma before
|
|
* the restore. So we deterministically SUPPRESS the idle flush — a route gate
|
|
* ABORTS every `source=collab-snapshot` PATCH until we explicitly allow it,
|
|
* immediately before the Confirm-Restore click — so the ONLY gamma collab-snapshot
|
|
* PATCH that can land is the pre-restore one. That makes both assertions airtight:
|
|
* (1) items.content can only acquire gamma via the pre-restore flush, so the
|
|
* undo-point holding gamma proves the fix; and (2) a monotonic event sequence
|
|
* (array index, not ms timestamps) shows the successful gamma flush response is
|
|
* dispatched STRICTLY before the restore POST is issued (the client awaited it).
|
|
* Under the bug the restore POST fires with no preceding gamma flush, so both go
|
|
* red.
|
|
*/
|
|
test('an unflushed live edit is captured in the restore undo-point via the UI (BUG-2271)', async ({
|
|
page,
|
|
fixture,
|
|
request
|
|
}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== 'desktop-chromium',
|
|
'version-restore collab is viewport-agnostic; one project is enough'
|
|
);
|
|
// A flush + reload + WS re-handshake + a second flush + restore need well
|
|
// over the 30s default on a busy CI runner.
|
|
test.setTimeout(90_000);
|
|
|
|
const ws = fixture.workspaceSlug;
|
|
const headers = {
|
|
Authorization: `Bearer ${fixture.apiToken}`,
|
|
'Content-Type': 'application/json'
|
|
};
|
|
const ts = Date.now();
|
|
const alpha = `alpha-base-${ts}`;
|
|
const beta = `beta-mid-${ts}`;
|
|
const gamma = `gamma-unflushed-${ts}`;
|
|
|
|
// Seed a doc whose v1 body is `alpha` — the state we restore back to.
|
|
const createResp = await request.post(`/api/v1/workspaces/${ws}/collections/docs/items`, {
|
|
headers,
|
|
data: { title: `Restore undo-point ${ts}`, content: alpha }
|
|
});
|
|
if (!createResp.ok())
|
|
throw new Error(`doc create failed (${createResp.status()}): ${await createResp.text()}`);
|
|
const { slug } = (await createResp.json()) as { id: string; slug: string };
|
|
|
|
await browserLogin(page);
|
|
await page.goto(`/${fixture.adminUsername}/${ws}/docs/${slug}`);
|
|
|
|
let editor = page.locator(EDITOR_SELECTOR);
|
|
await expect(editor).toBeVisible();
|
|
await expect(editor).toContainText(alpha);
|
|
await expect(page.locator(SYNCED_BADGE_SELECTOR)).toBeVisible();
|
|
|
|
// Type `beta` and let it flush — persists "alpha beta" and (first content
|
|
// change → no throttle) creates the version snapshot we later restore to.
|
|
const betaFlushed = page.waitForResponse(
|
|
async (r) =>
|
|
r.url().includes('source=collab-snapshot') &&
|
|
r.request().method() === 'PATCH' &&
|
|
r.ok() &&
|
|
(await r.text()).includes(beta),
|
|
{ timeout: 30_000 }
|
|
);
|
|
await editor.click();
|
|
await page.keyboard.press('Control+End');
|
|
await page.keyboard.type(' ' + beta);
|
|
await expect(editor).toContainText(beta);
|
|
await betaFlushed;
|
|
|
|
// Reload so the timeline renders the version card. Content saves deliberately
|
|
// do NOT auto-refresh the timeline (ItemTimeline only refreshes on
|
|
// comment/reaction events); a natural reload surfaces the new version entry.
|
|
await page.reload();
|
|
editor = page.locator(EDITOR_SELECTOR);
|
|
await expect(editor).toBeVisible();
|
|
await expect(editor).toContainText(beta);
|
|
await expect(page.locator(SYNCED_BADGE_SELECTOR)).toBeVisible();
|
|
|
|
// P3 (Codex): deterministically SUPPRESS the ordinary 5s idle collab-snapshot
|
|
// flush so the ONLY gamma-carrying collab-snapshot PATCH that can land is the
|
|
// pre-restore one — making both the outcome and the ordering assertions
|
|
// airtight (no reliance on "reach the click under 5s"). A route gate ABORTS
|
|
// collab-snapshot PATCHes until we explicitly allow them, immediately before
|
|
// the Confirm-Restore click. The earlier beta flush already completed before
|
|
// this gate is installed, so it's unaffected; hydration after the reload does
|
|
// no edit, so it issues no flush to abort.
|
|
let allowSnapshotFlush = false;
|
|
await page.route(/source=collab-snapshot/, async (route) => {
|
|
if (route.request().method() === 'PATCH' && !allowSnapshotFlush) {
|
|
await route.abort();
|
|
return;
|
|
}
|
|
await route.continue();
|
|
});
|
|
|
|
// Record a MONOTONIC event sequence (array index, NOT ms Date.now()) so the
|
|
// ordering compare is exact and tie-free. Only a SUCCESSFUL (2xx) gamma
|
|
// collab-snapshot response and the restore POST request enter the sequence;
|
|
// aborted idle attempts fire 'requestfailed', never 'response', so they can't
|
|
// pollute it. The gamma filter also ignores the earlier beta flush.
|
|
const seq: string[] = [];
|
|
const isGammaFlush = (r: PWRequest) =>
|
|
r.url().includes('source=collab-snapshot') &&
|
|
r.method() === 'PATCH' &&
|
|
(r.postData() ?? '').includes(gamma);
|
|
const isRestorePost = (r: PWRequest) =>
|
|
/\/versions\/[^/]+\/restore$/.test(r.url()) && r.method() === 'POST';
|
|
page.on('response', (resp) => {
|
|
if (isGammaFlush(resp.request()) && resp.ok()) seq.push('gamma-flush-ok');
|
|
});
|
|
page.on('request', (r) => {
|
|
if (isRestorePost(r)) seq.push('restore-start');
|
|
});
|
|
|
|
// Type `gamma` — its idle flush is now blocked by the gate, so it stays in the
|
|
// live Y.Doc, unpersisted to items.content (the BUG-2271 window).
|
|
await editor.click();
|
|
await page.keyboard.press('Control+End');
|
|
await page.keyboard.type(' ' + gamma);
|
|
await expect(editor).toContainText(gamma);
|
|
|
|
// Sanity: items.content does NOT hold gamma. With the gate aborting every
|
|
// collab-snapshot PATCH, gamma can reach items.content ONLY via the pre-restore
|
|
// flush we allow below — so the undo-point holding gamma is airtight proof that
|
|
// the pre-restore flush (not an idle flush) captured it.
|
|
const preRestore = await request.get(`/api/v1/workspaces/${ws}/items/${slug}`, { headers });
|
|
expect(preRestore.ok()).toBeTruthy();
|
|
expect((await preRestore.json()).content as string).not.toContain(gamma);
|
|
|
|
// Drive the restore through the UI. confirmRestore must FIRST flush the live
|
|
// editor (gamma) into items.content, THEN POST the restore — so the server's
|
|
// undo-point (captured from items.content in the restore tx) includes gamma.
|
|
const restoreDone = page.waitForResponse(
|
|
(r) =>
|
|
/\/versions\/[^/]+\/restore$/.test(r.url()) &&
|
|
r.request().method() === 'POST' &&
|
|
r.ok(),
|
|
{ timeout: 30_000 }
|
|
);
|
|
// Version cards moved under the Versions tab (PLAN-2290 Phase 4).
|
|
await page.getByRole('tab', { name: 'Versions' }).click();
|
|
const card = page.locator('#item-timeline .version-card').first();
|
|
await card.locator('.card-header').click(); // expand
|
|
await card.getByRole('button', { name: 'Restore this version' }).click();
|
|
// Allow collab-snapshot PATCHes ONLY from here, so the first (and only) gamma
|
|
// PATCH that lands is the pre-restore flush the confirm click triggers.
|
|
allowSnapshotFlush = true;
|
|
await card.getByRole('button', { name: 'Confirm Restore' }).click();
|
|
await restoreDone;
|
|
|
|
// Ordering proof (Codex P3): a SUCCESSFUL gamma collab-snapshot flush occurred
|
|
// AND its response was dispatched STRICTLY BEFORE the restore POST was issued
|
|
// (monotonic index — the client awaited the flush before POSTing the restore).
|
|
// Under the bug the restore POST fires with no preceding gamma flush, so
|
|
// 'gamma-flush-ok' is absent (or after) → red.
|
|
const gammaIdx = seq.indexOf('gamma-flush-ok');
|
|
const restoreIdx = seq.indexOf('restore-start');
|
|
expect(gammaIdx, 'a successful pre-restore gamma flush must occur').toBeGreaterThanOrEqual(0);
|
|
expect(restoreIdx, 'the restore POST must be issued').toBeGreaterThanOrEqual(0);
|
|
expect(gammaIdx).toBeLessThan(restoreIdx);
|
|
|
|
// And the outcome: the undo-point version created by the restore holds the
|
|
// pre-restore items.content — which, thanks to the pre-restore flush, includes
|
|
// gamma. Under the bug no version ever holds gamma (it was lost with the op-log).
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const vr = await request.get(
|
|
`/api/v1/workspaces/${ws}/items/${slug}/versions`,
|
|
{ headers }
|
|
);
|
|
if (!vr.ok()) return false;
|
|
const versions = (await vr.json()) as Array<{ content: string }>;
|
|
return versions.some((v) => v.content.includes(gamma));
|
|
},
|
|
{ timeout: 15_000 }
|
|
)
|
|
.toBe(true);
|
|
});
|