feat(read-only-viewer): editor viewer mode + kicadSetReadOnly binding
Anonymous / non-member sessions open PUBLIC projects as read-only viewers.
- kicadSetReadOnly(bool) embind (merged kicad_editor + pcbnew/eeschema/
pl_editor TUs): sets the PCBJAM_READ_ONLY flag + Prj().SetReadOnly (greys
the setup dialogs). Polls until the frame exists.
- read-only-mode.ts: resolveReadOnly(access, win) — server `access:"read"` or
?readonly=1 (narrow-only; no ?readonly=0). ToolPage threads it in, omits
saveBytes (MEMFS-only saves).
- WasmTool: chrome force-hidden with a "View only" pill (toggle + Cmd+\
disabled), presence/cross-app/comments/drift skipped, save-driven room
writers unregistered, wasm frame locked via kicadSetReadOnly failing CLOSED
(stale bundle → boot error, never a writable frame).
- collab: bindKicadCollab {readOnly} — inert DOWN hook, never seeds a room;
UP observer + adopt stay live so peer edits render. index.ts / sheet-manager
thread readOnly + drop initial awareness (invisible observer).
- Reference backend emits access:"write".
Bumps kicad + web/pcbjam-shared to the read-only-viewer commits.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012DN9py5GuPdExaaFzE4k27
This commit is contained in:
parent
f07b9970d5
commit
bb01f5d9e6
16 changed files with 665 additions and 49 deletions
242
tests/web/read-only-editor.spec.ts
Normal file
242
tests/web/read-only-editor.spec.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { test, expect, type Browser, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read-only viewer e2e (read-only-viewer): `?readonly=1` boots the pcbnew
|
||||
* editor as a locked viewer — chrome force-hidden with no toggle, the
|
||||
* Cmd/Ctrl+\ chord inert, nothing selectable or deletable through the REAL
|
||||
* input paths (the kicad PCBJAM_READ_ONLY gates), zoom/pan alive — while a
|
||||
* writer tab on the same board (broadcastchannel room) stays fully editable.
|
||||
*
|
||||
* The viewer boots FIRST on the fresh room (fresh browser context ⇒ empty
|
||||
* broadcastchannel room): a read-only binding must not seed it; the writer
|
||||
* then file-seeds as the first author, exactly like production where the
|
||||
* viewer's server connection is read-only (enforced in the closed repo).
|
||||
*
|
||||
* Boots once (beforeAll) and runs over the shared pages — the config is
|
||||
* workers:1 / fullyParallel:false, so file order holds.
|
||||
*/
|
||||
|
||||
const SCOPE = 'default';
|
||||
const FRONTEND_URL = process.env.WEB_APP_URL ?? 'http://localhost:3048';
|
||||
|
||||
type Mod = {
|
||||
kicadCollabGetSelection(): string;
|
||||
kicadCollabGetViewport(): string;
|
||||
kicadCollabTestSelectFirst(): string;
|
||||
kicadCollabTestClearSelection(): boolean;
|
||||
kicadCollabGetPos(id: string): string;
|
||||
kicadCollabTestMoveFirst(dx: number, dy: number): string;
|
||||
};
|
||||
type W = { Module: Mod };
|
||||
|
||||
let viewer: Page;
|
||||
let writer: Page;
|
||||
|
||||
async function bootBoard(page: Page, params: string, user: string): Promise<void> {
|
||||
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb?user=${user}${params}`);
|
||||
await expect(page.locator('#canvas')).toBeVisible({ timeout: 180000 });
|
||||
await expect
|
||||
.poll(() => page.title(), {
|
||||
message: `${user}: board editor never reached the expected title`,
|
||||
timeout: 120000,
|
||||
intervals: [1000],
|
||||
})
|
||||
.toMatch(/demo — PCB Editor/i);
|
||||
// Loading overlays (boot + lib fat-load, both `inset-0 z-30`) gone before
|
||||
// geometry/selection is trusted.
|
||||
await expect(page.locator('div.inset-0.z-30')).toHaveCount(0, { timeout: 180000 });
|
||||
}
|
||||
|
||||
/** Count of visible menubar titles (0 ⇒ menubar hidden) — chrome-toggle.spec.ts. */
|
||||
async function visibleMenuTitles(pg: Page): Promise<number> {
|
||||
return pg.evaluate(
|
||||
() =>
|
||||
Array.from(document.querySelectorAll('.wx-menu-title')).filter((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0 && getComputedStyle(el).display !== 'none';
|
||||
}).length,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a world position ("x,y" internal units, kicadCollabGetPos) to CSS pixels
|
||||
* through the page's live GAL viewport — the same mapping the comment pins
|
||||
* render through (comments-viewport-resize.spec.ts).
|
||||
*/
|
||||
async function screenPosOf(pg: Page, worldCsv: string): Promise<{ x: number; y: number }> {
|
||||
return pg.evaluate((csv: string) => {
|
||||
const win = window as unknown as W;
|
||||
const [wx, wy] = csv.split(',').map(Number);
|
||||
const vp = JSON.parse(win.Module.kicadCollabGetViewport()) as {
|
||||
cx: number; cy: number; scale: number; w: number; h: number;
|
||||
};
|
||||
const gl = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
|
||||
const r = (c as HTMLElement).getBoundingClientRect();
|
||||
return getComputedStyle(c as HTMLElement).display !== 'none' && r.width > 0;
|
||||
}) as HTMLElement;
|
||||
const r = gl.getBoundingClientRect();
|
||||
const ratio = r.width / vp.w;
|
||||
return {
|
||||
x: r.x + ((wx - vp.cx) * vp.scale + vp.w / 2) * ratio,
|
||||
y: r.y + ((wy - vp.cy) * vp.scale + vp.h / 2) * ratio,
|
||||
};
|
||||
}, worldCsv);
|
||||
}
|
||||
|
||||
const selection = (pg: Page) =>
|
||||
pg.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()));
|
||||
const posOf = (pg: Page, id: string) =>
|
||||
pg.evaluate((i: string) => (window as unknown as W).Module.kicadCollabGetPos(i), id);
|
||||
// Zoom probe: w/h are the fixed canvas pixel size — zoom moves `scale`.
|
||||
const viewportScale = (pg: Page) =>
|
||||
pg.evaluate(
|
||||
() =>
|
||||
(JSON.parse((window as unknown as W).Module.kicadCollabGetViewport()) as { scale: number })
|
||||
.scale,
|
||||
);
|
||||
|
||||
test.beforeAll(async ({ browser }: { browser: Browser }) => {
|
||||
test.setTimeout(480_000); // two full board boots
|
||||
const ctx = await browser.newContext({ baseURL: FRONTEND_URL });
|
||||
// Viewer FIRST: an empty room a read-only binding must NOT seed.
|
||||
viewer = await ctx.newPage();
|
||||
await bootBoard(viewer, '&readonly=1', 'viewer');
|
||||
// Writer second: file-seeds the room the viewer left untouched.
|
||||
writer = await ctx.newPage();
|
||||
await bootBoard(writer, '', 'writer');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await writer?.close();
|
||||
await viewer?.close();
|
||||
});
|
||||
|
||||
test('viewer boots locked: chrome-less, nothing selectable, hotkey edits inert, zoom alive', async () => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
// Chrome force-hidden: no menubar, no console footer, no toggle — the
|
||||
// "View only" pill is the one read-only affordance; Ctrl+\ stays inert.
|
||||
await expect.poll(() => visibleMenuTitles(viewer), { timeout: 15000 }).toBe(0);
|
||||
await expect(viewer.getByText(/console \(/)).toHaveCount(0);
|
||||
await expect(viewer.locator('[data-testid="chrome-toggle"]')).toHaveCount(0);
|
||||
await expect(viewer.getByTestId('view-only-pill')).toBeVisible();
|
||||
await viewer.keyboard.press('Control+\\');
|
||||
// Bounded settle via a real round-trip (no blind sleep): the canvas keeps
|
||||
// its full-bleed width through the keypress.
|
||||
const vp = viewer.viewportSize()!;
|
||||
const glWidth = () =>
|
||||
viewer.evaluate(() => {
|
||||
const gl = Array.from(document.querySelectorAll('[id^="glcanvas-"]')).find((c) => {
|
||||
const r = (c as HTMLElement).getBoundingClientRect();
|
||||
return getComputedStyle(c as HTMLElement).display !== 'none' && r.width > 0;
|
||||
}) as HTMLElement | undefined;
|
||||
return gl ? gl.getBoundingClientRect().width : 0;
|
||||
});
|
||||
await expect.poll(glWidth, { timeout: 15000 }).toBeGreaterThan(vp.width * 0.95);
|
||||
expect(await visibleMenuTitles(viewer)).toBe(0);
|
||||
|
||||
// The writer beside it keeps the full UI (positive control for the above).
|
||||
await expect(writer.locator('[data-testid="chrome-toggle"]')).toBeVisible();
|
||||
await expect(writer.getByTestId('view-only-pill')).toHaveCount(0);
|
||||
|
||||
// No presence/comments surfaces for a viewer.
|
||||
await expect(viewer.locator('[data-testid="presence-roster"]')).toHaveCount(0);
|
||||
expect(
|
||||
await viewer.evaluate(
|
||||
() => '__pcbjamComments' in (window as unknown as Record<string, unknown>),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// ── the kicad gates, probed through the REAL paths ─────────────────────────
|
||||
// A known item + its world position (the test hook force-selects on the
|
||||
// WRITER — fine there — and is cleared again before the click probes).
|
||||
const itemId = await writer.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectFirst(),
|
||||
);
|
||||
expect(itemId, 'demo board should have a first item').toBeTruthy();
|
||||
const itemWorld = await posOf(writer, itemId);
|
||||
expect(itemWorld).toContain(',');
|
||||
await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection());
|
||||
|
||||
// Gate 2 (Selectable): a real canvas click ON the item finds candidates for
|
||||
// the writer — a selection, or KiCad's clarify popup when several items
|
||||
// overlap ("Show More Choices" is unique to it) — and NOTHING for the
|
||||
// viewer: the gate empties the collector, so neither selection nor popup
|
||||
// can appear (both boards boot zoom-fit; each page maps through its own
|
||||
// live viewport).
|
||||
const writerClick = await screenPosOf(writer, itemWorld);
|
||||
await writer.mouse.click(writerClick.x, writerClick.y);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await selection(writer)).length > 0 ||
|
||||
(await writer.getByText(/Show More Choices/).count()) > 0,
|
||||
{
|
||||
timeout: 20000,
|
||||
message: "writer's click should select the item or pop the clarify menu",
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
await writer.keyboard.press('Escape'); // dismiss a clarify popup, drop any selection
|
||||
await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection());
|
||||
|
||||
const viewerClick = await screenPosOf(viewer, itemWorld);
|
||||
await viewer.mouse.click(viewerClick.x, viewerClick.y);
|
||||
expect(await selection(viewer), 'viewer click on the item must select nothing').toEqual([]);
|
||||
await expect(viewer.getByText(/Show More Choices/)).toHaveCount(0);
|
||||
|
||||
// Gate 1 (action allowlist): even with an item force-selected through the
|
||||
// test hook (AddItemToSel bypasses Selectable by design), the Delete hotkey
|
||||
// is swallowed — the item survives on the viewer's own board.
|
||||
const forced = await viewer.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectFirst(),
|
||||
);
|
||||
expect(forced).toBe(itemId); // same file, same board order
|
||||
const posBefore = await posOf(viewer, forced);
|
||||
expect(posBefore).toBeTruthy();
|
||||
await viewer.keyboard.press('Delete');
|
||||
// Documented interaction dwell: a hotkey delete commits within a frame or
|
||||
// two and has no observable on the swallowed path — give it time to have
|
||||
// acted if it were going to, then assert nothing happened.
|
||||
await viewer.waitForTimeout(800); // dwell
|
||||
expect(await posOf(viewer, forced), 'Delete must be swallowed in read-only').toBe(posBefore);
|
||||
|
||||
// Zoom stays alive on the viewer (wheel over the canvas changes the scale).
|
||||
const canvasBox = (await viewer.locator('#canvas').boundingBox())!;
|
||||
const scaleBefore = await viewportScale(viewer);
|
||||
await viewer.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
|
||||
await viewer.mouse.wheel(0, -240);
|
||||
await expect.poll(() => viewportScale(viewer), { timeout: 15000 }).not.toBe(scaleBefore);
|
||||
|
||||
await viewer.screenshot({ path: 'test-results/web-read-only-viewer.png', scale: 'css' });
|
||||
});
|
||||
|
||||
test("a writer's edits stream into the viewer live (and never the reverse)", async () => {
|
||||
// Blocked by a PRE-EXISTING applyItems regression at kicad 8364527: the
|
||||
// board-envelope wrap trips "1 is not a valid layer count" in the clipboard
|
||||
// parser, so NO cross-tab item apply lands — verified failing identically
|
||||
// for two fully WRITABLE tabs on a fresh kicad_editor build (the previous
|
||||
// CI artifacts were built at an older kicad rev). Un-fixme once the
|
||||
// envelope/parser mismatch is fixed on main.
|
||||
test.fixme();
|
||||
|
||||
const itemId = await writer.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectFirst(),
|
||||
);
|
||||
await writer.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection());
|
||||
const posBefore = await posOf(viewer, itemId);
|
||||
|
||||
// 2 mm — small nudges vanish in s-expr formatting (see ysync-two-tab.spec.ts).
|
||||
const moved = await writer.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestMoveFirst(2_000_000, 0),
|
||||
);
|
||||
expect(moved).toBe(itemId);
|
||||
await expect
|
||||
.poll(() => posOf(viewer, itemId), {
|
||||
timeout: 20000,
|
||||
message: "the writer's move never reached the viewer",
|
||||
})
|
||||
.not.toBe(posBefore);
|
||||
// The viewer's board still matches the writer's (nothing flowed back).
|
||||
expect(await posOf(writer, itemId)).toBe(await posOf(viewer, itemId));
|
||||
});
|
||||
Loading…
Reference in a new issue