mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
test(web): Phase-2 CAPSTONE e2e for the full-page pane host (TASK-2175) (#981)
Capstone runtime verification for PLAN-2154 Phase 2 (the full-page pane host, TASK-2170–2174), mirroring the Phase-1 R14 async-race capstone (TASK-2167) but exercising the properties the full-page host alone introduces: - Option-A mutation-SILENCE (the D2/HT-2176 freeze acceptance): while a pane peeks beside the retain-alive master, the key NEW-edit-INITIATION surfaces are disabled/absent (title click-to-edit, field inputs, comment composer, contenteditable editor, star/Share/Quick-actions/Move/Delete/ relationship add+remove); un-peeking restores them. Asserts initiation surfaces, NOT zero writes (a pre-pane pending save + remote collab sync are expected under Option A). Exhaustive per-path freeze stays unit-tested. - Bounded two-WS cost while peeking: opening the pane yields master + pane = 2 collab WS to distinct rooms (total-live ceiling asserted), the master's own room never gets a second provider, a drill re-targets the one pane provider (not N). Plus a WS-instrumented self-collision test: a cold-loaded ?item=<master> mints NO second provider on the master room. - Host-side R14 late-async continuations: drill-right-after-Back (stale back-settle can't revert), close-then-reopen (stale loadData can't clobber — sub-resource GET proof by loaded slug), a held Back burst (coalesces to exactly one mint), and a rapid double-close (one history.go, never overshoots the master route). Adds a localStorage-gated `__padPaneController` test hook to the host route (mirroring the collection page's), so the R14 drill/close continuations are synchronously drivable under adversarial timing. Zero production surface. Gates: svelte-check 0 errors; vitest 444 green; full pane e2e green at --workers=1 (existing 54 + 7 new). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
This commit is contained in:
@@ -0,0 +1,820 @@
|
|||||||
|
import { test, expect } from './fixtures';
|
||||||
|
import { browserLogin, seedDoc, EDITOR_SELECTOR, SYNCED_BADGE_SELECTOR } from './lib/collab-helpers';
|
||||||
|
import type { APIRequestContext, Page } from '@playwright/test';
|
||||||
|
import type { SuiteFixture } from './fixtures';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-page pane HOST — Phase-2 CAPSTONE runtime verification
|
||||||
|
* (PLAN-2154 Phase 2 / Architecture E / TASK-2175).
|
||||||
|
*
|
||||||
|
* The host tests (pane-full-page-host.spec.ts, TASK-2174) prove the pane
|
||||||
|
* opens/drills/closes and the `?item==master` self-collision is stripped.
|
||||||
|
* The collection-page R14 async-race capstone (pane-async-race.spec.ts,
|
||||||
|
* TASK-2167) proves the controller's late-async-continuation fences on the
|
||||||
|
* COLLECTION page. This suite is the last piece: it exercises, on the
|
||||||
|
* FULL-PAGE host specifically, the three properties that host alone
|
||||||
|
* introduces or that only it can now demonstrate:
|
||||||
|
*
|
||||||
|
* 1. Option-A mutation-SILENCE (the D2 / HT-2176 freeze acceptance). While
|
||||||
|
* a pane is peeking beside the retain-alive master, NO NEW user edit can
|
||||||
|
* be INITIATED on the master — the title click-to-edit is gone, field
|
||||||
|
* inputs are read-only, the comment composer / compose surfaces are
|
||||||
|
* unmounted, the rich editor is contenteditable=false, and the star /
|
||||||
|
* Share / Quick-actions affordances are gated. This is Option A — "no NEW
|
||||||
|
* edit can be INITIATED while peeking", NOT "zero network writes": a
|
||||||
|
* pre-pane pending save legitimately completes and remote collab sync is
|
||||||
|
* expected, so we assert the INITIATION surfaces are disabled/absent, not
|
||||||
|
* the absence of REST/WS traffic. Un-peeking (close) restores every one.
|
||||||
|
*
|
||||||
|
* 2. The bounded TWO-WS cost while peeking (D2's "cost model" note). Opening
|
||||||
|
* the pane yields at most the master's provider + the pane's provider = 2
|
||||||
|
* collab WS to DISTINCT item rooms, and NEVER a second provider on the
|
||||||
|
* MASTER's own room (the `?item==master` guard's whole reason to exist). A
|
||||||
|
* drill re-targets the ONE pane provider (master + drilled = still 2), it
|
||||||
|
* does not accumulate N.
|
||||||
|
*
|
||||||
|
* 3. Host-side R14 late-async continuations — the same fence family the
|
||||||
|
* collection-page async-race suite covers, re-exercised against the
|
||||||
|
* full-page host's persistent no-`{#key}` master + pane: a drill fired
|
||||||
|
* right after a browser Back (the stale back-settle must not revert it), a
|
||||||
|
* close-then-reopen (a stale loadData must not clobber the new pane), a
|
||||||
|
* held Back burst across `?item=` entries (coalesces to one teardown/mint),
|
||||||
|
* and a rapid double-close (cannot stack a second history.go and overshoot
|
||||||
|
* the master route).
|
||||||
|
*
|
||||||
|
* DEFERRED / out of scope (do NOT assert): BUG-2177 (an editor-bound
|
||||||
|
* upload/crop orphaned by the peeking editor remount — accepted) and BUG-2178
|
||||||
|
* (collection-rename/move-from-pane URL shaping on this host — deferred).
|
||||||
|
*
|
||||||
|
* The pane is a desktop-split concern, so the desktop project alone covers it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DESKTOP = { width: 1200, height: 900 };
|
||||||
|
const FOLLOW_DEBOUNCE_MS = 140; // == PANE_MINT_SETTLE_MS
|
||||||
|
const SYNC_TIMEOUT = 20_000; // slow collab handshake + reconnect backoff
|
||||||
|
|
||||||
|
function authHeaders(fixture: SuiteFixture) {
|
||||||
|
return { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openItemParam(page: Page): string | null {
|
||||||
|
return new URL(page.url()).searchParams.get('item');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathname(page: Page): string {
|
||||||
|
return new URL(page.url()).pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullPageUrl(fixture: SuiteFixture, collSlug: string, slug: string, query = ''): string {
|
||||||
|
return `/${fixture.adminUsername}/${fixture.workspaceSlug}/${collSlug}/${slug}${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The route column that holds the MASTER (route-owned `.item-page-host >
|
||||||
|
* .item-page` direct child). The pane is a SEPARATE direct child
|
||||||
|
* (`aside.item-pane`), and ItemDetail's OWN inner content wrapper reuses the
|
||||||
|
* `.item-page` class — so scoping master-freeze assertions to the DIRECT-CHILD
|
||||||
|
* column excludes the pane's copy of every surface. */
|
||||||
|
function masterCol(page: Page) {
|
||||||
|
return page.locator('.item-page-host > .item-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SeededItem {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A `related` link FROM `sourceSlug` TO `targetId` — surfaces under the master
|
||||||
|
* page's "Related" relationship group as an `a.link-target` (the first-open
|
||||||
|
* affordance this host uses; mirrors pane-full-page-host.spec.ts). */
|
||||||
|
async function seedRelatedLink(
|
||||||
|
fixture: SuiteFixture,
|
||||||
|
request: APIRequestContext,
|
||||||
|
sourceSlug: string,
|
||||||
|
targetId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const resp = await request.post(
|
||||||
|
`/api/v1/workspaces/${fixture.workspaceSlug}/items/${sourceSlug}/links`,
|
||||||
|
{ headers: authHeaders(fixture), data: { target_id: targetId, link_type: 'related' } },
|
||||||
|
);
|
||||||
|
if (!resp.ok()) throw new Error(`link create failed (${resp.status()}): ${await resp.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A doc whose `fields.parent` makes it a child of `parentId` — the parent's
|
||||||
|
* pane renders a `.child-row` to drill into. */
|
||||||
|
async function seedChildDoc(
|
||||||
|
fixture: SuiteFixture,
|
||||||
|
request: APIRequestContext,
|
||||||
|
titlePrefix: string,
|
||||||
|
parentId: string,
|
||||||
|
): Promise<SeededItem> {
|
||||||
|
const resp = await request.post(
|
||||||
|
`/api/v1/workspaces/${fixture.workspaceSlug}/collections/docs/items`,
|
||||||
|
{
|
||||||
|
headers: authHeaders(fixture),
|
||||||
|
data: { title: `${titlePrefix} ${Date.now()}`, fields: JSON.stringify({ parent: parentId }), content: '' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!resp.ok()) throw new Error(`child doc create failed (${resp.status()}): ${await resp.text()}`);
|
||||||
|
return (await resp.json()) as SeededItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The PREFIX-NUMBER ref (e.g. "DOC-5") the app renders + puts in `?item=` after
|
||||||
|
* a relationship-link first-open. */
|
||||||
|
async function itemRef(fixture: SuiteFixture, request: APIRequestContext, slug: string): Promise<string> {
|
||||||
|
const resp = await request.get(`/api/v1/workspaces/${fixture.workspaceSlug}/items/${slug}`, {
|
||||||
|
headers: authHeaders(fixture),
|
||||||
|
});
|
||||||
|
if (!resp.ok()) throw new Error(`item get failed (${resp.status()}): ${await resp.text()}`);
|
||||||
|
const item = (await resp.json()) as { collection_prefix?: string; item_number?: number };
|
||||||
|
if (!item.collection_prefix || !item.item_number) throw new Error('item has no ref');
|
||||||
|
return `${item.collection_prefix}-${item.item_number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh, test-scoped collection with a plain `note` text field, so the
|
||||||
|
* master renders a real editable field input whose readonly transition proves
|
||||||
|
* the freeze. */
|
||||||
|
async function seedNoteCollection(
|
||||||
|
fixture: SuiteFixture,
|
||||||
|
request: APIRequestContext,
|
||||||
|
namePrefix: string,
|
||||||
|
itemPrefix: string,
|
||||||
|
): Promise<{ id: string; slug: string }> {
|
||||||
|
const name = `${namePrefix} ${Date.now()}`;
|
||||||
|
const schema = JSON.stringify({ fields: [{ key: 'note', label: 'Note', type: 'text' }] });
|
||||||
|
const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections`, {
|
||||||
|
headers: authHeaders(fixture),
|
||||||
|
data: { name, prefix: itemPrefix, schema },
|
||||||
|
});
|
||||||
|
if (!resp.ok()) throw new Error(`collection create failed (${resp.status()}): ${await resp.text()}`);
|
||||||
|
return (await resp.json()) as { id: string; slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedNoteItem(
|
||||||
|
fixture: SuiteFixture,
|
||||||
|
request: APIRequestContext,
|
||||||
|
collSlug: string,
|
||||||
|
title: string,
|
||||||
|
note: string,
|
||||||
|
body = '',
|
||||||
|
): Promise<SeededItem> {
|
||||||
|
const resp = await request.post(
|
||||||
|
`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${collSlug}/items`,
|
||||||
|
{ headers: authHeaders(fixture), data: { title, fields: JSON.stringify({ note }), content: body } },
|
||||||
|
);
|
||||||
|
if (!resp.ok()) throw new Error(`item create failed (${resp.status()}): ${await resp.text()}`);
|
||||||
|
return (await resp.json()) as SeededItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HookState {
|
||||||
|
paneDepth: number;
|
||||||
|
paneOwned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the host controller's live {paneDepth, paneOwned} via the test hook
|
||||||
|
* (installed by `[collection]/[slug]/+page.svelte` under the localStorage
|
||||||
|
* gate — TASK-2175). */
|
||||||
|
function paneState(page: Page): Promise<HookState | null> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const c = (window as unknown as { __padPaneController?: { getPaneState(): HookState } })
|
||||||
|
.__padPaneController;
|
||||||
|
return c ? c.getPaneState() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive an in-pane DRILL (`navigatePaneTo`) via the test hook. */
|
||||||
|
async function drillTo(page: Page, ref: string): Promise<void> {
|
||||||
|
await page.evaluate((r) => {
|
||||||
|
(
|
||||||
|
window as unknown as { __padPaneController?: { navigatePaneTo(ref: string): void } }
|
||||||
|
).__padPaneController?.navigatePaneTo(r);
|
||||||
|
}, ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable the controller test hook for all navigations in this context. */
|
||||||
|
async function enableHook(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('pad:pane-test-hook', '1');
|
||||||
|
} catch {
|
||||||
|
/* private mode / disabled storage — hook simply won't install */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every bare item-GET the browser issues (`/items/{seg}` — the plain item
|
||||||
|
* fetch `loadData` fires; `/items/{seg}/links` etc. carry an extra segment and
|
||||||
|
* are excluded). A live array of trailing segments so a test can prove a
|
||||||
|
* fenced-out continuation issued NO fetch, or a re-target fetched the RIGHT
|
||||||
|
* item, not the stale one (mirrors pane-async-race.spec.ts).
|
||||||
|
*/
|
||||||
|
function trackItemGets(page: Page): string[] {
|
||||||
|
const segs: string[] = [];
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (req.method() !== 'GET') return;
|
||||||
|
const m = /\/api\/v1\/workspaces\/[^/]+\/items\/([^/?]+)(?:\?|$)/.exec(req.url());
|
||||||
|
if (m) segs.push(decodeURIComponent(m[1]));
|
||||||
|
});
|
||||||
|
return segs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The collab-room segment (`item.id`) of a `/api/v1/collab/{itemID}` WS URL,
|
||||||
|
* or null for a non-collab socket. */
|
||||||
|
function collabRoom(url: string): string | null {
|
||||||
|
const m = /\/api\/v1\/collab\/([^/?]+)/.exec(url);
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the pane by clicking a MASTER relationship link (the host's real
|
||||||
|
* first-open path — `handleMasterOpenTarget` → `openItemPaneByRef`). */
|
||||||
|
async function openPaneViaRelated(page: Page, titlePrefix: string): Promise<void> {
|
||||||
|
await masterCol(page)
|
||||||
|
.locator('.relationship-group', { hasText: 'Related' })
|
||||||
|
.locator('a.link-target', { hasText: titlePrefix })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('full-page pane host CAPSTONE (PLAN-2154 Phase 2 / TASK-2175)', () => {
|
||||||
|
test.beforeEach(({}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name !== 'desktop-chromium',
|
||||||
|
'the pane is a desktop-split concern; one project is enough',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 1. Option-A mutation-SILENCE (the D2 / HT-2176 freeze acceptance) ────
|
||||||
|
// While a pane peeks beside the retain-alive master, NO NEW user edit can be
|
||||||
|
// INITIATED on the master. We assert the KEY initiation surfaces are
|
||||||
|
// disabled/absent — NOT the absence of network writes (Option A explicitly
|
||||||
|
// permits a pre-pane pending save to complete + remote collab sync). This is
|
||||||
|
// the RUNTIME smoke of the freeze; the exhaustive per-mutation-path audit
|
||||||
|
// (raw mode, tags, assignment, timeline reply/reaction/version, drag-reorder,
|
||||||
|
// etc.) is unit-tested in masterFreeze.svelte.test.ts + mutationGate.test.ts.
|
||||||
|
// Then un-peek (close) and assert the surfaces are restored.
|
||||||
|
test('peeking freezes the master NEW-edit-initiation surfaces; closing the pane restores them', async ({
|
||||||
|
page,
|
||||||
|
fixture,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await browserLogin(page);
|
||||||
|
|
||||||
|
// A note-collection MASTER (a real `note` text field whose readonly
|
||||||
|
// transition proves the field freeze) --related--> a pane target.
|
||||||
|
const coll = await seedNoteCollection(fixture, request, 'FP freeze', 'FPFZ');
|
||||||
|
const master = await seedNoteItem(
|
||||||
|
fixture,
|
||||||
|
request,
|
||||||
|
coll.slug,
|
||||||
|
`FP freeze master ${Date.now()}`,
|
||||||
|
'master-note-value',
|
||||||
|
'master editor body FREEZE-11',
|
||||||
|
);
|
||||||
|
const target = await seedNoteItem(fixture, request, coll.slug, `FP freeze target ${Date.now()}`, 'target-note', '');
|
||||||
|
await seedRelatedLink(fixture, request, master.slug, target.id);
|
||||||
|
|
||||||
|
await page.goto(fullPageUrl(fixture, coll.slug, master.slug));
|
||||||
|
const col = masterCol(page);
|
||||||
|
|
||||||
|
// ── Pre-peek: the master is fully EDITABLE (no pane yet). ──
|
||||||
|
await expect(page.locator('.item-page-host')).toBeVisible();
|
||||||
|
const editableTitle = col.locator('button.title', { hasText: 'FP freeze master' });
|
||||||
|
await expect(editableTitle).toBeVisible();
|
||||||
|
// The Note field is an editable input (FieldEditor, not readonly-display).
|
||||||
|
const noteInput = col.locator('.field-row', { hasText: 'Note' }).locator('input.field-input');
|
||||||
|
await expect(noteInput).toBeVisible();
|
||||||
|
await expect(noteInput).toBeEnabled();
|
||||||
|
// The comment composer is present.
|
||||||
|
await expect(col.locator('.compose')).toBeVisible();
|
||||||
|
// Star is enabled; Share + Quick-actions + Delete are present.
|
||||||
|
await expect(col.locator('button.star-btn')).toBeEnabled();
|
||||||
|
await expect(col.locator('button.action-btn', { hasText: 'Share' })).toBeVisible();
|
||||||
|
await expect(col.locator('button.trigger-btn[title="Quick actions"]')).toBeVisible();
|
||||||
|
await expect(col.locator('button.delete-btn')).toBeVisible();
|
||||||
|
await expect(col.locator('button.action-btn', { hasText: 'Move to' })).toBeVisible();
|
||||||
|
// Relationship mutation surfaces (the master HAS a `related` link): the
|
||||||
|
// per-link remove button (rendered but `display:none` until row-hover, so
|
||||||
|
// assert DOM presence, not visibility) and the "+ Add relationship" opener.
|
||||||
|
await expect(col.locator('button.link-delete-btn')).toHaveCount(1);
|
||||||
|
await expect(col.locator('button.add-relationship-btn')).toBeVisible();
|
||||||
|
// The rich editor is the live collab editor and is EDITABLE.
|
||||||
|
await expect(col.locator(SYNCED_BADGE_SELECTOR)).toBeVisible({ timeout: SYNC_TIMEOUT });
|
||||||
|
const masterEditor = col.locator(EDITOR_SELECTOR);
|
||||||
|
await expect(masterEditor).toBeVisible({ timeout: SYNC_TIMEOUT });
|
||||||
|
await expect(masterEditor).toHaveAttribute('contenteditable', 'true');
|
||||||
|
|
||||||
|
// ── Open the pane → the master goes PEEKING (retain-alive read-only). ──
|
||||||
|
await openPaneViaRelated(page, 'FP freeze target');
|
||||||
|
const pane = page.locator('.item-pane');
|
||||||
|
await expect(pane).toBeVisible();
|
||||||
|
await expect(pane.locator('.title', { hasText: 'FP freeze target' })).toBeVisible();
|
||||||
|
|
||||||
|
// ── Peeking: every NEW-edit-initiation surface is gone/disabled. ──
|
||||||
|
// Title: click-to-edit button replaced by a non-editable <h1>.
|
||||||
|
await expect(col.locator('h1.title.title-readonly', { hasText: 'FP freeze master' })).toBeVisible();
|
||||||
|
await expect(col.locator('button.title', { hasText: 'FP freeze master' })).toHaveCount(0);
|
||||||
|
// Field: the editable input is gone — rendered as a readonly-display.
|
||||||
|
await expect(col.locator('input.field-input')).toHaveCount(0);
|
||||||
|
await expect(col.locator('.field-row', { hasText: 'Note' }).locator('.readonly-display')).toBeVisible();
|
||||||
|
// Comment composer: unmounted entirely.
|
||||||
|
await expect(col.locator('.compose')).toHaveCount(0);
|
||||||
|
// Star: disabled (gated on `peeking`, not unmounted — it stays visible).
|
||||||
|
await expect(col.locator('button.star-btn')).toBeDisabled();
|
||||||
|
// Share + Quick-actions triggers: unmounted.
|
||||||
|
await expect(col.locator('button.action-btn', { hasText: 'Share' })).toHaveCount(0);
|
||||||
|
await expect(col.locator('button.trigger-btn[title="Quick actions"]')).toHaveCount(0);
|
||||||
|
// Move-to + Delete: unmounted (gated on mutationsEnabled).
|
||||||
|
await expect(col.locator('button.action-btn', { hasText: 'Move to' })).toHaveCount(0);
|
||||||
|
await expect(col.locator('button.delete-btn')).toHaveCount(0);
|
||||||
|
// Relationship mutation surfaces: the per-link remove + Add opener are gone.
|
||||||
|
await expect(col.locator('button.link-delete-btn')).toHaveCount(0);
|
||||||
|
await expect(col.locator('button.add-relationship-btn')).toHaveCount(0);
|
||||||
|
// Rich editor: retained (still visible — no teardown), but read-only.
|
||||||
|
await expect(masterEditor).toBeVisible();
|
||||||
|
await expect(masterEditor).toHaveAttribute('contenteditable', 'false');
|
||||||
|
|
||||||
|
// ── Close (un-peek) → every surface is EDITABLE again. ──
|
||||||
|
await pane.locator('button[aria-label="Close pane"]').click();
|
||||||
|
await expect(pane).toBeHidden();
|
||||||
|
await expect(col.locator('button.title', { hasText: 'FP freeze master' })).toBeVisible();
|
||||||
|
await expect(col.locator('h1.title.title-readonly')).toHaveCount(0);
|
||||||
|
await expect(col.locator('.field-row', { hasText: 'Note' }).locator('input.field-input')).toBeVisible();
|
||||||
|
await expect(col.locator('.compose')).toBeVisible();
|
||||||
|
await expect(col.locator('button.star-btn')).toBeEnabled();
|
||||||
|
await expect(col.locator('button.action-btn', { hasText: 'Share' })).toBeVisible();
|
||||||
|
await expect(col.locator('button.trigger-btn[title="Quick actions"]')).toBeVisible();
|
||||||
|
await expect(col.locator('button.delete-btn')).toBeVisible();
|
||||||
|
await expect(col.locator('button.action-btn', { hasText: 'Move to' })).toBeVisible();
|
||||||
|
await expect(col.locator('button.add-relationship-btn')).toBeVisible();
|
||||||
|
// The per-link remove is back in the DOM (display:none until row-hover).
|
||||||
|
await expect(col.locator('button.link-delete-btn')).toHaveCount(1);
|
||||||
|
await expect(masterEditor).toHaveAttribute('contenteditable', 'true', { timeout: SYNC_TIMEOUT });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2. Bounded TWO-WS cost while peeking (D2 cost model / the guard core) ─
|
||||||
|
// Opening the pane yields master-provider + pane-provider = 2 collab WS to
|
||||||
|
// DISTINCT rooms, and the master's OWN room is NEVER given a second provider.
|
||||||
|
// A drill RE-TARGETS the one pane provider (master + drilled = still 2), it
|
||||||
|
// does not accumulate N. Proven by counting LIVE sockets PER ROOM (so a
|
||||||
|
// double master provider shows as count 2, and accumulation shows as a 3rd
|
||||||
|
// room) plus the reconnect-safe max-concurrent-per-room ceiling.
|
||||||
|
test('opening + drilling the pane stays bounded to 2 collab WS to distinct rooms; the master room never gets a second provider', async ({
|
||||||
|
page,
|
||||||
|
fixture,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await browserLogin(page);
|
||||||
|
|
||||||
|
const master = await seedDoc(fixture, request, 'FP ws master');
|
||||||
|
const related = await seedDoc(fixture, request, 'FP ws related');
|
||||||
|
const grandchild = await seedChildDoc(fixture, request, 'FP ws grandchild', related.id);
|
||||||
|
await seedRelatedLink(fixture, request, master.slug, related.id);
|
||||||
|
|
||||||
|
// LIVE collab sockets per room + the running max concurrent per room. A
|
||||||
|
// reconnect closes-then-opens, so `maxByRoom[master] === 1` is the
|
||||||
|
// reconnect-SAFE encoding of "never two concurrent providers on the
|
||||||
|
// master's own room" — the `?item==master` guard's exact contract.
|
||||||
|
const liveByRoom = new Map<string, number>();
|
||||||
|
const maxByRoom = new Map<string, number>();
|
||||||
|
// The running peak of TOTAL concurrent collab sockets across ALL rooms —
|
||||||
|
// the real "at most 2 collab WS" ceiling. Per-room maxima alone would miss
|
||||||
|
// a transient master + old-pane + new-pane = 3 during a drill (Codex P1).
|
||||||
|
let maxTotalLive = 0;
|
||||||
|
const totalLive = () => [...liveByRoom.values()].reduce((a, b) => a + b, 0);
|
||||||
|
page.on('websocket', (ws) => {
|
||||||
|
const room = collabRoom(ws.url());
|
||||||
|
if (!room) return;
|
||||||
|
const n = (liveByRoom.get(room) ?? 0) + 1;
|
||||||
|
liveByRoom.set(room, n);
|
||||||
|
maxByRoom.set(room, Math.max(maxByRoom.get(room) ?? 0, n));
|
||||||
|
maxTotalLive = Math.max(maxTotalLive, totalLive());
|
||||||
|
ws.on('close', () => {
|
||||||
|
const c = (liveByRoom.get(room) ?? 0) - 1;
|
||||||
|
if (c <= 0) liveByRoom.delete(room);
|
||||||
|
else liveByRoom.set(room, c);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const liveRoomsObj = () => Object.fromEntries([...liveByRoom.entries()]);
|
||||||
|
|
||||||
|
await page.goto(fullPageUrl(fixture, 'docs', master.slug));
|
||||||
|
await expect(page.locator('.item-page-host')).toBeVisible();
|
||||||
|
// The master mounts its ONE collab provider (its own room, count 1).
|
||||||
|
await expect.poll(liveRoomsObj, { timeout: SYNC_TIMEOUT }).toEqual({ [master.id]: 1 });
|
||||||
|
|
||||||
|
// Open the pane on the related item → master + pane = exactly 2 distinct
|
||||||
|
// rooms, each with a single provider. A regressed guard that minted a
|
||||||
|
// SECOND provider on the master's room would show `{[master.id]: 2, ...}`.
|
||||||
|
await openPaneViaRelated(page, 'FP ws related');
|
||||||
|
const pane = page.locator('.item-pane');
|
||||||
|
await expect(pane).toBeVisible();
|
||||||
|
await expect(pane.locator('.title', { hasText: 'FP ws related' })).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(liveRoomsObj, { timeout: SYNC_TIMEOUT })
|
||||||
|
.toEqual({ [master.id]: 1, [related.id]: 1 });
|
||||||
|
|
||||||
|
// Drill related → grandchild INSIDE the pane (a real `.child-row` click).
|
||||||
|
// The pane provider RE-TARGETS: `related`'s socket is torn down, the
|
||||||
|
// grandchild's minted — master + grandchild = STILL 2 distinct rooms, not
|
||||||
|
// {master, related, grandchild}. This is "one pane provider that
|
||||||
|
// re-targets, not N".
|
||||||
|
await pane.locator('.child-row', { hasText: 'FP ws grandchild' }).click();
|
||||||
|
await expect(pane.locator('.title', { hasText: 'FP ws grandchild' })).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(liveRoomsObj, { timeout: SYNC_TIMEOUT })
|
||||||
|
.toEqual({ [master.id]: 1, [grandchild.id]: 1 });
|
||||||
|
|
||||||
|
// Close the pane → the pane provider is torn down; the master's retained
|
||||||
|
// provider remains its single, only-ever provider.
|
||||||
|
await pane.locator('button[aria-label="Close pane"]').click();
|
||||||
|
await expect(pane).toBeHidden();
|
||||||
|
await expect.poll(liveRoomsObj, { timeout: SYNC_TIMEOUT }).toEqual({ [master.id]: 1 });
|
||||||
|
|
||||||
|
// The load-bearing guard assertion: at NO point did the master's own room
|
||||||
|
// carry two concurrent providers (reconnect-safe max). The pane rooms
|
||||||
|
// (`related`, `grandchild`) likewise each peaked at a single provider.
|
||||||
|
expect(maxByRoom.get(master.id), 'master room must never have 2 concurrent providers').toBe(1);
|
||||||
|
expect(maxByRoom.get(related.id)).toBe(1);
|
||||||
|
expect(maxByRoom.get(grandchild.id)).toBe(1);
|
||||||
|
// The TOTAL ceiling: never more than master + ONE pane provider = 2 collab
|
||||||
|
// sockets alive at once (the drill's `collabKey→null` teardown closes the
|
||||||
|
// outgoing pane socket before the drilled one mints, so no transient 3).
|
||||||
|
expect(maxTotalLive, 'never more than master + one pane provider live at once').toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The self-collision case, WS-instrumented (the guard's raison d'être). The
|
||||||
|
// bounded-cost test above opens the pane on DIFFERENT items, so it can't catch
|
||||||
|
// a `?item==master` guard regression that briefly mints a SECOND provider on
|
||||||
|
// the master's OWN room. pane-full-page-host.spec.ts proves the URL is
|
||||||
|
// stripped + no pane mounts; THIS proves it at the WS layer: a cold-loaded
|
||||||
|
// `?item=<master>` must leave the master room at ONE provider and mint NO pane
|
||||||
|
// provider (a pane on `?item=master` would connect to the SAME room, so a
|
||||||
|
// regression shows as master-room concurrency 2 / total 2).
|
||||||
|
test('a cold-loaded ?item=<master> never mints a second provider on the master room (WS-instrumented)', async ({
|
||||||
|
page,
|
||||||
|
fixture,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await browserLogin(page);
|
||||||
|
|
||||||
|
const master = await seedDoc(fixture, request, 'FP selfref master');
|
||||||
|
const masterRef = await itemRef(fixture, request, master.slug);
|
||||||
|
|
||||||
|
const liveByRoom = new Map<string, number>();
|
||||||
|
const maxByRoom = new Map<string, number>();
|
||||||
|
let maxTotalLive = 0;
|
||||||
|
const openedRooms: string[] = [];
|
||||||
|
const totalLive = () => [...liveByRoom.values()].reduce((a, b) => a + b, 0);
|
||||||
|
page.on('websocket', (ws) => {
|
||||||
|
const room = collabRoom(ws.url());
|
||||||
|
if (!room) return;
|
||||||
|
openedRooms.push(room);
|
||||||
|
const n = (liveByRoom.get(room) ?? 0) + 1;
|
||||||
|
liveByRoom.set(room, n);
|
||||||
|
maxByRoom.set(room, Math.max(maxByRoom.get(room) ?? 0, n));
|
||||||
|
maxTotalLive = Math.max(maxTotalLive, totalLive());
|
||||||
|
ws.on('close', () => {
|
||||||
|
const c = (liveByRoom.get(room) ?? 0) - 1;
|
||||||
|
if (c <= 0) liveByRoom.delete(room);
|
||||||
|
else liveByRoom.set(room, c);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cold-load a hand-crafted `?item=<master ref>` — the forbidden self-alias.
|
||||||
|
await page.goto(fullPageUrl(fixture, 'docs', master.slug, `?item=${masterRef}`));
|
||||||
|
await expect(masterCol(page).locator('button.title', { hasText: 'FP selfref master' })).toBeVisible();
|
||||||
|
// The guard stripped `?item=` and never mounted a pane.
|
||||||
|
await expect(page.locator('.item-pane')).toHaveCount(0);
|
||||||
|
await expect.poll(() => openItemParam(page)).toBeNull();
|
||||||
|
// Let the master editor sync and settle any transient the mount-gate had to
|
||||||
|
// close, then read the WS ledger.
|
||||||
|
await expect(masterCol(page).locator(SYNCED_BADGE_SELECTOR)).toBeVisible({ timeout: SYNC_TIMEOUT });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
// Steady state: the master's own provider, and ONLY it.
|
||||||
|
await expect.poll(() => Object.fromEntries([...liveByRoom.entries()])).toEqual({ [master.id]: 1 });
|
||||||
|
// Only the master's room was ever opened (no pane provider for the self-ref).
|
||||||
|
expect(new Set(openedRooms)).toEqual(new Set([master.id]));
|
||||||
|
// The master room never carried two concurrent providers, and total collab
|
||||||
|
// sockets never exceeded 1 — the guard prevented the second-provider mint.
|
||||||
|
expect(maxByRoom.get(master.id), 'master room must never have 2 concurrent providers').toBe(1);
|
||||||
|
expect(maxTotalLive, 'no pane provider may be minted for a self-ref').toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3. Host-side R14 late-async continuations ────────────────────────────
|
||||||
|
|
||||||
|
// R14-a — a drill fired right after a browser Back lands on the new target;
|
||||||
|
// the stale back-settle never reverts it. The Back is a popstate that arms
|
||||||
|
// the host's ~140ms mint-settle for the popped-to ref; a drill inside that
|
||||||
|
// window is a deliberate `goto` that must CANCEL the pending settle and apply
|
||||||
|
// immediately, so the settle can't drag the pane back. Proven non-vacuously:
|
||||||
|
// the popped-to ref is never re-minted (fetched) in the post-Back window.
|
||||||
|
test('R14: a drill fired right after a browser Back lands on the new target; the stale back-settle never reverts it', async ({
|
||||||
|
page,
|
||||||
|
fixture,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await enableHook(page);
|
||||||
|
await browserLogin(page);
|
||||||
|
|
||||||
|
// MASTER --related--> A; B and C are drilled to by the hook.
|
||||||
|
const master = await seedDoc(fixture, request, 'FP drillback master');
|
||||||
|
const a = await seedDoc(fixture, request, 'FP drillback alpha');
|
||||||
|
const b = await seedDoc(fixture, request, 'FP drillback bravo');
|
||||||
|
const c = await seedDoc(fixture, request, 'FP drillback charlie');
|
||||||
|
await seedRelatedLink(fixture, request, master.slug, a.id);
|
||||||
|
|
||||||
|
await page.goto(fullPageUrl(fixture, 'docs', master.slug));
|
||||||
|
await expect(page.locator('.item-page-host')).toBeVisible();
|
||||||
|
|
||||||
|
const itemGets = trackItemGets(page);
|
||||||
|
// First-open the pane on A (owned depth 0), then drill A→B (depth 1).
|
||||||
|
await openPaneViaRelated(page, 'FP drillback alpha');
|
||||||
|
const pane = page.locator('.item-pane');
|
||||||
|
await expect(pane).toBeVisible();
|
||||||
|
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||||
|
const refA = openItemParam(page);
|
||||||
|
await drillTo(page, b.slug);
|
||||||
|
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
|
||||||
|
|
||||||
|
// Back to A (depth 0) — a popstate that arms the mint-settle for A — then
|
||||||
|
// IMMEDIATELY drill to C, inside the settle window.
|
||||||
|
const getsBeforeBack = itemGets.length;
|
||||||
|
await page.goBack();
|
||||||
|
await expect.poll(() => openItemParam(page)).toBe(refA);
|
||||||
|
await drillTo(page, c.slug);
|
||||||
|
|
||||||
|
await expect.poll(() => openItemParam(page)).toBe(c.slug);
|
||||||
|
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
|
||||||
|
await expect(pane.locator('.title', { hasText: /FP drillback charlie/ })).toBeVisible();
|
||||||
|
|
||||||
|
// The cancelled A-settle must NOT fire late and drag the pane back to A.
|
||||||
|
await page.waitForTimeout(FOLLOW_DEBOUNCE_MS + 120);
|
||||||
|
expect(openItemParam(page)).toBe(c.slug);
|
||||||
|
await expect(pane.locator('.title', { hasText: /FP drillback charlie/ })).toBeVisible();
|
||||||
|
// Non-vacuous: A's ref was never re-minted (fetched) after the Back — the
|
||||||
|
// drill's `goto` cancelled the settle before it could re-fetch A. Had the
|
||||||
|
// settle fired, A's ref would appear in the post-Back fetch sequence.
|
||||||
|
const postBack = itemGets.slice(getsBeforeBack);
|
||||||
|
expect(postBack).toContain(c.slug);
|
||||||
|
expect(postBack).not.toContain(refA);
|
||||||
|
});
|
||||||
|
|
||||||
|
// R14-b — a close then a re-open: a loadData GET still outstanding when the
|
||||||
|
// pane closed must write nothing over the newly-opened item. Closing unmounts
|
||||||
|
// PaneHost (its ItemDetail `onDestroy` bumps `loadGeneration`); re-opening
|
||||||
|
// mounts a fresh instance. The orphaned A GET, on resolving, must bail at the
|
||||||
|
// post-item-GET gen-check — so A's downstream sub-resource GETs never fire,
|
||||||
|
// and B (the visible item) is untouched.
|
||||||
|
test('R14: a loadData GET outstanding when the pane closed writes nothing over the re-opened item', async ({
|
||||||
|
page,
|
||||||
|
fixture,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await enableHook(page);
|
||||||
|
await browserLogin(page);
|
||||||
|
|
||||||
|
// MASTER --related--> A and --related--> B (two first-open targets).
|
||||||
|
const master = await seedDoc(fixture, request, 'FP closeload master');
|
||||||
|
const a = await seedDoc(fixture, request, 'FP closeload alpha', {});
|
||||||
|
const b = await seedDoc(fixture, request, 'FP closeload bravo', {});
|
||||||
|
await seedRelatedLink(fixture, request, master.slug, a.id);
|
||||||
|
await seedRelatedLink(fixture, request, master.slug, b.id);
|
||||||
|
// The relationship first-open puts A's REF (not slug) in `?item=`, and the
|
||||||
|
// pane fetches `/items/{aRef}` — gate THAT.
|
||||||
|
const aRef = await itemRef(fixture, request, a.slug);
|
||||||
|
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
page.on('pageerror', (err) => pageErrors.push(String(err)));
|
||||||
|
// A's DOWNSTREAM sub-resource GETs (progress/links) fire only if loadData
|
||||||
|
// proceeds PAST the post-item-GET gen-check — i.e. only if the fence failed.
|
||||||
|
// `loadData` fetches progress/links by the LOADED item's SLUG (ItemDetail
|
||||||
|
// ~L1211/1233 `itemData.slug`), NOT the `?item=` REF — so track the SLUG
|
||||||
|
// (plus the ref as a belt) or the fence proof is vacuous (Codex P1).
|
||||||
|
const aSubResourceGets: string[] = [];
|
||||||
|
page.on('request', (req) => {
|
||||||
|
if (req.method() !== 'GET') return;
|
||||||
|
const p = new URL(req.url()).pathname;
|
||||||
|
if (p.includes(`/items/${a.slug}/`) || p.includes(`/items/${aRef}/`)) aSubResourceGets.push(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
let releaseA: () => void = () => {};
|
||||||
|
const aGate = new Promise<void>((resolve) => {
|
||||||
|
releaseA = resolve;
|
||||||
|
});
|
||||||
|
await page.route(`**/api/v1/workspaces/*/items/${aRef}`, async (route) => {
|
||||||
|
if (route.request().method() !== 'GET') {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await aGate;
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(fullPageUrl(fixture, 'docs', master.slug));
|
||||||
|
await expect(page.locator('.item-page-host')).toBeVisible();
|
||||||
|
|
||||||
|
// Open the pane on A — A's loadData stalls on the gate (minimal header).
|
||||||
|
await openPaneViaRelated(page, 'FP closeload alpha');
|
||||||
|
const pane = page.locator('.item-pane');
|
||||||
|
await expect(pane).toBeVisible();
|
||||||
|
await expect(pane.locator('.pane-header--minimal')).toBeVisible();
|
||||||
|
|
||||||
|
// Close the pane while A's GET is still outstanding (the ItemDetail
|
||||||
|
// unmounts mid-load). Wait for the close to settle before re-opening.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(
|
||||||
|
window as unknown as { __padPaneController?: { closeItemPane(): void } }
|
||||||
|
).__padPaneController?.closeItemPane();
|
||||||
|
});
|
||||||
|
await expect.poll(() => openItemParam(page)).toBeNull();
|
||||||
|
await expect(pane).toBeHidden();
|
||||||
|
|
||||||
|
// Re-open the pane on B (B's GET is NOT gated) — B is now the live item and
|
||||||
|
// the evidence surface for any stale A write.
|
||||||
|
await openPaneViaRelated(page, 'FP closeload bravo');
|
||||||
|
await expect(pane).toBeVisible();
|
||||||
|
await expect(pane.locator('.title', { hasText: /FP closeload bravo/ })).toBeVisible();
|
||||||
|
const refB = openItemParam(page);
|
||||||
|
expect(refB).not.toBeNull();
|
||||||
|
|
||||||
|
// Release A's now-orphaned GET and deterministically drain its post-unmount
|
||||||
|
// continuation (`finished()` = full body delivered; a `setTimeout(0)`
|
||||||
|
// browser task turn runs after all the continuation's microtasks).
|
||||||
|
const aResponse = page.waitForResponse(
|
||||||
|
(r) => r.request().method() === 'GET' && new URL(r.url()).pathname.endsWith(`/items/${aRef}`),
|
||||||
|
);
|
||||||
|
releaseA();
|
||||||
|
await (await aResponse).finished();
|
||||||
|
await page.evaluate(() => new Promise<void>((r) => setTimeout(() => r(), 0)));
|
||||||
|
|
||||||
|
// B is untouched, `?item=` unmoved, nothing threw.
|
||||||
|
await expect(pane.locator('.title', { hasText: /FP closeload bravo/ })).toBeVisible();
|
||||||
|
expect(openItemParam(page)).toBe(refB);
|
||||||
|
await expect(pane.locator('.title', { hasText: /FP closeload alpha/ })).toHaveCount(0);
|
||||||
|
expect(pageErrors, `no page errors from the post-close load: ${pageErrors.join('; ')}`).toEqual([]);
|
||||||
|
// The load-bearing fence proof: A's continuation bailed at the gen-check
|
||||||
|
// immediately after its item GET resolved — it never proceeded to A's
|
||||||
|
// collection/progress/links, so no sub-resource GET for A was ever issued.
|
||||||
|
expect(
|
||||||
|
aSubResourceGets,
|
||||||
|
`A's post-close continuation must not proceed to sub-resource fetches: ${aSubResourceGets.join(', ')}`,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// R14-c — a held browser Back BURST through a drilled stack coalesces to ONE
|
||||||
|
// teardown/mint. The mint-settle's raison d'être is coalescing MULTIPLE
|
||||||
|
// popstates (a held Back firing one per entry) into a single teardown/mint.
|
||||||
|
// Fire three separate `history.back()` in one tick — a real burst — and
|
||||||
|
// assert the pane coalesces to the correct final item at depth 0, exactly one
|
||||||
|
// pane instance, with the intermediate stops NEVER minted.
|
||||||
|
test('R14: a held Back burst through the host drill stack coalesces to the base item with one mint', async ({
|
||||||
|
page,
|
||||||
|
fixture,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await enableHook(page);
|
||||||
|
await browserLogin(page);
|
||||||
|
|
||||||
|
const master = await seedDoc(fixture, request, 'FP burst master');
|
||||||
|
const a = await seedDoc(fixture, request, 'FP burst alpha');
|
||||||
|
const b = await seedDoc(fixture, request, 'FP burst bravo');
|
||||||
|
const c = await seedDoc(fixture, request, 'FP burst charlie');
|
||||||
|
const d = await seedDoc(fixture, request, 'FP burst delta');
|
||||||
|
await seedRelatedLink(fixture, request, master.slug, a.id);
|
||||||
|
|
||||||
|
await page.goto(fullPageUrl(fixture, 'docs', master.slug));
|
||||||
|
await expect(page.locator('.item-page-host')).toBeVisible();
|
||||||
|
|
||||||
|
const itemGets = trackItemGets(page);
|
||||||
|
// First-open A (depth 0), drill A→B→C→D (depth 3).
|
||||||
|
await openPaneViaRelated(page, 'FP burst alpha');
|
||||||
|
const pane = page.locator('.item-pane');
|
||||||
|
await expect(pane).toBeVisible();
|
||||||
|
const refA = openItemParam(page);
|
||||||
|
await drillTo(page, b.slug);
|
||||||
|
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
|
||||||
|
await drillTo(page, c.slug);
|
||||||
|
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 2, paneOwned: true });
|
||||||
|
await drillTo(page, d.slug);
|
||||||
|
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 3, paneOwned: true });
|
||||||
|
// D must be fully RENDERED before the burst, so the post-burst A-title
|
||||||
|
// assertion can't be satisfied by a stale, previously-rendered A (Codex).
|
||||||
|
await expect(pane.locator('.title', { hasText: /FP burst delta/ })).toBeVisible({ timeout: SYNC_TIMEOUT });
|
||||||
|
const getsBeforeBurst = itemGets.length;
|
||||||
|
|
||||||
|
// The burst: THREE genuine popstates, each `history.back()` fired from the
|
||||||
|
// PRECEDING popstate handler so the browser can't coalesce them at the
|
||||||
|
// event level — a real held-Back the pane's mint-settle (not the browser)
|
||||||
|
// must coalesce. `popstateCount` proves all three fired.
|
||||||
|
const popstateCount = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
new Promise<number>((resolve) => {
|
||||||
|
let count = 0;
|
||||||
|
const onPop = () => {
|
||||||
|
count += 1;
|
||||||
|
if (count < 3) {
|
||||||
|
history.back();
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('popstate', onPop);
|
||||||
|
resolve(count);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('popstate', onPop);
|
||||||
|
history.back();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(popstateCount).toBe(3);
|
||||||
|
|
||||||
|
// Coalesced to the base A at depth 0 — exactly one pane, showing A.
|
||||||
|
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||||
|
await expect.poll(() => openItemParam(page)).toBe(refA);
|
||||||
|
await expect(page.locator('.item-pane')).toHaveCount(1);
|
||||||
|
await expect(pane.locator('.title', { hasText: /FP burst alpha/ })).toBeVisible({ timeout: SYNC_TIMEOUT });
|
||||||
|
// Non-vacuous proof of COALESCING (not just the right destination): the
|
||||||
|
// intermediate stops (C, B) traversed mid-burst were NEVER minted — their
|
||||||
|
// item GETs are absent from the burst window. Had each popstate minted
|
||||||
|
// eagerly, C and B would appear here.
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const burstGets = itemGets.slice(getsBeforeBurst);
|
||||||
|
expect(burstGets).not.toContain(c.slug);
|
||||||
|
expect(burstGets).not.toContain(b.slug);
|
||||||
|
// ...and the base A was minted EXACTLY ONCE — the settle coalesced the
|
||||||
|
// three popstates into a SINGLE teardown/mint, not one per traversed entry
|
||||||
|
// (proves "one mint", not just "the right destination"; Codex P2).
|
||||||
|
expect(burstGets.filter((g) => g === refA)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// R14-d — a rapid double-close cannot stack a second history.go and overshoot
|
||||||
|
// the master route. The owned close is a one-phase `history.go(-1)`; without
|
||||||
|
// the in-flight fence a second synchronous close would read the still-stale
|
||||||
|
// state and stack a SECOND go(-1), overshooting PAST the master page (here,
|
||||||
|
// back to /login). We COUNT `history.go` calls across the burst: the fence
|
||||||
|
// must let EXACTLY ONE through, and the pane must land on the master route.
|
||||||
|
test('R14: a rapid double-close issues exactly one history.go and lands on the master route (never overshoots)', async ({
|
||||||
|
page,
|
||||||
|
fixture,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.setViewportSize(DESKTOP);
|
||||||
|
await enableHook(page);
|
||||||
|
await browserLogin(page);
|
||||||
|
|
||||||
|
const master = await seedDoc(fixture, request, 'FP dblclose master');
|
||||||
|
const a = await seedDoc(fixture, request, 'FP dblclose alpha');
|
||||||
|
await seedRelatedLink(fixture, request, master.slug, a.id);
|
||||||
|
|
||||||
|
await page.goto(fullPageUrl(fixture, 'docs', master.slug));
|
||||||
|
await expect(page.locator('.item-page-host')).toBeVisible();
|
||||||
|
const masterUrl = page.url();
|
||||||
|
const masterPath = pathname(page);
|
||||||
|
|
||||||
|
// First-open the pane on A (owned depth 0).
|
||||||
|
await openPaneViaRelated(page, 'FP dblclose alpha');
|
||||||
|
const pane = page.locator('.item-pane');
|
||||||
|
await expect(pane).toBeVisible();
|
||||||
|
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||||
|
|
||||||
|
// Fire closeItemPane TWICE synchronously, counting `history.go`. The owned
|
||||||
|
// close issues one go(-1); the second call hits the in-flight fence and
|
||||||
|
// no-ops (issues no go). Counting is the only timing-immune way to
|
||||||
|
// distinguish "second close fenced" from "second go(-1) browser-coalesced"
|
||||||
|
// — both leave the same end URL (Codex round 4 discipline).
|
||||||
|
const goCount = await page.evaluate(() => {
|
||||||
|
let count = 0;
|
||||||
|
const orig = history.go.bind(history);
|
||||||
|
history.go = ((delta?: number) => {
|
||||||
|
count += 1;
|
||||||
|
return orig(delta);
|
||||||
|
}) as typeof history.go;
|
||||||
|
const ctrl = (
|
||||||
|
window as unknown as { __padPaneController?: { closeItemPane(): void } }
|
||||||
|
).__padPaneController;
|
||||||
|
ctrl?.closeItemPane();
|
||||||
|
ctrl?.closeItemPane();
|
||||||
|
history.go = orig; // restore before the async traversal settles
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
expect(goCount).toBe(1);
|
||||||
|
|
||||||
|
// Landed EXACTLY on the master route (pane gone, `?item=` gone) — never
|
||||||
|
// overshot the master page back to /login.
|
||||||
|
await expect.poll(() => openItemParam(page)).toBeNull();
|
||||||
|
await expect(pane).toBeHidden();
|
||||||
|
await expect.poll(() => page.url()).toBe(masterUrl);
|
||||||
|
expect(pathname(page)).toBe(masterPath);
|
||||||
|
expect(page.url()).not.toContain('/login');
|
||||||
|
// The master is EDITABLE again (un-peeked) — a clean single unwind.
|
||||||
|
await expect(masterCol(page).locator('button.title', { hasText: 'FP dblclose master' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto, afterNavigate } from '$app/navigation';
|
import { goto, afterNavigate } from '$app/navigation';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import ItemDetail from '$lib/components/items/ItemDetail.svelte';
|
import ItemDetail from '$lib/components/items/ItemDetail.svelte';
|
||||||
import PaneHost from '$lib/components/collections/PaneHost.svelte';
|
import PaneHost from '$lib/components/collections/PaneHost.svelte';
|
||||||
import { createScrollRestoration } from '$lib/scroll/restore.svelte';
|
import { createScrollRestoration } from '$lib/scroll/restore.svelte';
|
||||||
import { createPaneController } from '$lib/collections/paneHostController';
|
import { createPaneController } from '$lib/collections/paneHostController';
|
||||||
|
import { type ResolvedPaneState } from '$lib/collections/paneController';
|
||||||
import { createPaneMintSettle, PANE_MINT_SETTLE_MS } from '$lib/collections/paneMintSettle';
|
import { createPaneMintSettle, PANE_MINT_SETTLE_MS } from '$lib/collections/paneMintSettle';
|
||||||
import { resolvePaneTarget, isSamePaneTarget, type PaneGuardItem } from '$lib/collections/paneTarget';
|
import { resolvePaneTarget, isSamePaneTarget, type PaneGuardItem } from '$lib/collections/paneTarget';
|
||||||
import { viewport } from '$lib/stores/breakpoint.svelte';
|
import { viewport } from '$lib/stores/breakpoint.svelte';
|
||||||
@@ -338,7 +339,43 @@
|
|||||||
if (runTopEscape()) e.preventDefault();
|
if (runTopEscape()) e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test-only hook (PLAN-2154 / TASK-2175): exposes the SAME pane-controller
|
||||||
|
// surface the collection page's hook does (`[collection]/+page.svelte`), so
|
||||||
|
// the full-page host's R14 async-race capstone can drive `navigatePaneTo`
|
||||||
|
// (in-pane drill), `closeItemPane` (three-way close), and read back the
|
||||||
|
// depth/ownership stamp — the drill/close continuations aren't otherwise
|
||||||
|
// synchronously drivable with the adversarial timing R14 needs. Gated on the
|
||||||
|
// same opt-in localStorage flag so it adds ZERO surface to production; the
|
||||||
|
// e2e harness sets `pad:pane-test-hook=1` before navigating.
|
||||||
|
interface PaneTestHook {
|
||||||
|
navigatePaneTo: (ref: string) => void;
|
||||||
|
closeItemPane: () => void;
|
||||||
|
getPaneState: () => ResolvedPaneState;
|
||||||
|
}
|
||||||
|
function installPaneTestHook() {
|
||||||
|
if (!browser) return;
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('pad:pane-test-hook') !== '1') return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(window as unknown as { __padPaneController?: PaneTestHook }).__padPaneController = {
|
||||||
|
navigatePaneTo: (r: string) => navigatePaneTo(r),
|
||||||
|
closeItemPane: () => closeItemPane(),
|
||||||
|
getPaneState: () => currentPaneState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function removePaneTestHook() {
|
||||||
|
if (!browser) return;
|
||||||
|
delete (window as unknown as { __padPaneController?: PaneTestHook }).__padPaneController;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
installPaneTestHook();
|
||||||
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
removePaneTestHook();
|
||||||
// Drop any in-flight controller `history.go` continuation, and cancel a
|
// Drop any in-flight controller `history.go` continuation, and cancel a
|
||||||
// settling paneMint so a late latch/settle can't write to the unmounted
|
// settling paneMint so a late latch/settle can't write to the unmounted
|
||||||
// page (PLAN-2154 / TASK-2166/2170).
|
// page (PLAN-2154 / TASK-2166/2170).
|
||||||
|
|||||||
Reference in New Issue
Block a user