experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(page): page-host FSM Phase 2 — drag/resize/drag-out-of-maximized states

Extends the FSM from Phase 1 (NORMAL ↔ MAXIMIZED) with the remaining
states from docs/page-host-state-machine.md:

* DRAGGING — entered on DRAG_START from NORMAL (navbar mousedown,
hold-to-drag, webview hold-drag). Exits on DRAG_END.
* RESIZING — entered on RESIZE_START (resize-handle pointerdown).
Exits on RESIZE_END. No SET_HANDLES_VISIBLE side effect — the
active handle stays visible through pointer capture.
* DRAGGING_OUT_OF_MAXIMIZED — special transition for the case where
the user mousedown's the navbar while maximized. The FSM emits
EXIT_MAXIMIZED_FOR_DRAG + SET_BODY_MAXIMIZED(false) +
SET_HANDLES_VISIBLE(true) + SET_URL_MAXIMIZED(false) +
SHOW_DRAG_OVERLAY in that order. Exit always lands in NORMAL —
drag-out never re-enters MAXIMIZED, matching today's behavior.

Self-loops on DRAG_MOVE / RESIZE_MOVE are no-op transitions (the
runtime adapter owns the screenBounds + delta math; FSM just
acknowledges). Duplicate DRAG_START / RESIZE_START / INIT_COMPLETE
inside the same state are tolerated no-ops — a console.warn flood from
a real-world input burst would be worse than silent.

Wiring in app/page/page.js:
* dispatchFsm at the start of startDrag() — handles both NORMAL→
DRAGGING and MAXIMIZED→DRAGGING_OUT_OF_MAXIMIZED via the same
event dispatch.
* dispatchFsm at every drag-end site (toggleMaximize cancel, webview
mouseup, document mousemove orphan-cleanup, document mouseup).
* dispatchFsm in resize handle pointerdown + cancelResize.
* applyFsmEffect handles SHOW_DRAG_OVERLAY / HIDE_DRAG_OVERLAY by
toggling dragOverlay's `active` class. EXIT_MAXIMIZED_FOR_DRAG
is currently a no-op in the adapter (page.js's startDrag still
owns the actual bounds restoration); promoting it to the runtime
adapter is part of Phase 3.

Tests:
* tests/unit/page-fsm.test.js: 15 → 33 tests (every legal edge for
the new states + illegal-transition warnings + end-to-end runs).
* tests/desktop/page-host-fsm.spec.ts (new, 5/5): drives real DOM
events through the page-host and asserts window.__pageFsmState
matches expectations after each round-trip. Covers init/drag/
resize/maximize/drag-out-of-maximize.

window.__pageFsmState is exposed for Playwright introspection only —
production code should never read it. The FSM's transition() API is
the only sanctioned interface.

Page.js still uses module-scoped `isDragging` / `isResizing` /
`isMaximized` flags as the source of truth — the FSM is shadow state,
kept in sync via dispatch + idempotent applyFsmEffect. Phase 3 will
flip the source of truth and delete the flags. Tracked in
docs/tasks.md.

Test results:
* unit: 628/628 (was 610 + 18 new page-fsm tests)
* page-host-fsm spec: 5/5 (new)
* session-restore-page-host: 2/2 (Phase 1 regression repro stays green)
* reopen-closed-window: 5/5
* page-load-failure: 5/5

+428 -10
+97 -7
app/page/page-fsm.js
··· 6 6 * adapter in `page.js` is the only thing that touches the DOM, calls 7 7 * setBounds, or writes URL params. 8 8 * 9 - * Today's scope: just the NORMAL ↔ MAXIMIZED lifecycle, because that's 10 - * the surface of the user-reported regression (restored maximized 11 - * windows losing their maximize state). DRAGGING / RESIZING / 12 - * DRAGGING_OUT_OF_MAXIMIZED states from the design doc are not yet 13 - * promoted into the FSM — page.js still owns those flags. Folding them 14 - * in is a follow-up; the test for that lives in 15 - * docs/page-host-state-machine.md. 9 + * Phase 1: NORMAL ↔ MAXIMIZED lifecycle (fixes the user-reported 10 + * session-restore regression). 11 + * Phase 2: DRAGGING / RESIZING / DRAGGING_OUT_OF_MAXIMIZED. page.js 12 + * still does the DOM mutations during drag/resize for now; the FSM 13 + * tracks state so `isDragging` / `isResizing` flags can be removed 14 + * incrementally. 16 15 * 17 16 * See: docs/page-host-state-machine.md, docs/cmd-state-machine.md 18 17 */ ··· 21 20 INITIALIZING: 'INITIALIZING', 22 21 NORMAL: 'NORMAL', 23 22 MAXIMIZED: 'MAXIMIZED', 23 + DRAGGING: 'DRAGGING', 24 + RESIZING: 'RESIZING', 25 + /** Drag started from MAXIMIZED — bounds restored, drag continues. */ 26 + DRAGGING_OUT_OF_MAXIMIZED: 'DRAGGING_OUT_OF_MAXIMIZED', 24 27 }); 25 28 26 29 export const EVENTS = Object.freeze({ ··· 28 31 INIT_COMPLETE: 'INIT_COMPLETE', 29 32 /** User asked to toggle maximize (cmd, navbar dblclick, pubsub). */ 30 33 TOGGLE_MAXIMIZE: 'TOGGLE_MAXIMIZE', 34 + /** User started dragging the window (navbar mousedown, hold-drag, etc). */ 35 + DRAG_START: 'DRAG_START', 36 + /** Mouse moved while DRAGGING; payload carries delta. Self-loop. */ 37 + DRAG_MOVE: 'DRAG_MOVE', 38 + /** Drag ended (mouseup, blur, cancel). */ 39 + DRAG_END: 'DRAG_END', 40 + /** User started resizing via a corner handle. */ 41 + RESIZE_START: 'RESIZE_START', 42 + /** Mouse moved while RESIZING; payload carries delta. Self-loop. */ 43 + RESIZE_MOVE: 'RESIZE_MOVE', 44 + /** Resize ended (mouseup, lostpointercapture). */ 45 + RESIZE_END: 'RESIZE_END', 31 46 }); 32 47 33 48 /** Initial FSM state. */ ··· 50 65 RESTORE_PRE_MAXIMIZE: 'RESTORE_PRE_MAXIMIZE', 51 66 /** Set screen bounds to the display work area (maximize). */ 52 67 ENTER_MAXIMIZED_LAYOUT: 'ENTER_MAXIMIZED_LAYOUT', 68 + /** Show the drag overlay element (covers webview during drag). */ 69 + SHOW_DRAG_OVERLAY: 'SHOW_DRAG_OVERLAY', 70 + /** Hide the drag overlay element. */ 71 + HIDE_DRAG_OVERLAY: 'HIDE_DRAG_OVERLAY', 72 + /** Restore pre-maximize bounds re-centered on the cursor — used when 73 + * drag starts from MAXIMIZED so the window pops out around the 74 + * cursor before the drag delta begins applying. */ 75 + EXIT_MAXIMIZED_FOR_DRAG: 'EXIT_MAXIMIZED_FOR_DRAG', 53 76 }); 54 77 55 78 const e = (type, payload = {}) => ({ type, ...payload }); ··· 104 127 ], 105 128 }; 106 129 } 130 + if (event.type === EVENTS.DRAG_START) { 131 + return { 132 + state: STATES.DRAGGING, 133 + effects: [e(EFFECTS.SHOW_DRAG_OVERLAY)], 134 + }; 135 + } 136 + if (event.type === EVENTS.RESIZE_START) { 137 + return { state: STATES.RESIZING, effects: [] }; 138 + } 107 139 if (event.type === EVENTS.INIT_COMPLETE) { 108 140 return { state, effects: [], warning: 'Duplicate INIT_COMPLETE in NORMAL' }; 109 141 } ··· 122 154 ], 123 155 }; 124 156 } 157 + if (event.type === EVENTS.DRAG_START) { 158 + // Drag-out-of-maximize: window pops out to pre-maximize bounds 159 + // re-centered on the cursor, then continues as a normal drag. 160 + return { 161 + state: STATES.DRAGGING_OUT_OF_MAXIMIZED, 162 + effects: [ 163 + e(EFFECTS.EXIT_MAXIMIZED_FOR_DRAG), 164 + e(EFFECTS.SET_BODY_MAXIMIZED, { value: false }), 165 + e(EFFECTS.SET_HANDLES_VISIBLE, { value: true }), 166 + e(EFFECTS.SET_URL_MAXIMIZED, { value: false }), 167 + e(EFFECTS.SHOW_DRAG_OVERLAY), 168 + ], 169 + }; 170 + } 125 171 if (event.type === EVENTS.INIT_COMPLETE) { 126 172 return { state, effects: [], warning: 'Duplicate INIT_COMPLETE in MAXIMIZED' }; 173 + } 174 + return { state, effects: [], warning: `Illegal event ${event.type} in ${state}` }; 175 + } 176 + 177 + case STATES.DRAGGING: { 178 + if (event.type === EVENTS.DRAG_MOVE) { 179 + // Self-loop. Bounds updates are owned by the runtime adapter 180 + // (it has the screenBounds + delta math); FSM just acknowledges. 181 + return { state, effects: [] }; 182 + } 183 + if (event.type === EVENTS.DRAG_END) { 184 + return { state: STATES.NORMAL, effects: [e(EFFECTS.HIDE_DRAG_OVERLAY)] }; 185 + } 186 + // Drag-during-drag should be a no-op, not a warning — input handlers 187 + // can race (e.g. two mousedown sources). Same for INIT_COMPLETE. 188 + if (event.type === EVENTS.DRAG_START || event.type === EVENTS.INIT_COMPLETE) { 189 + return { state, effects: [] }; 190 + } 191 + return { state, effects: [], warning: `Illegal event ${event.type} in ${state}` }; 192 + } 193 + 194 + case STATES.DRAGGING_OUT_OF_MAXIMIZED: { 195 + if (event.type === EVENTS.DRAG_MOVE) { 196 + return { state, effects: [] }; 197 + } 198 + if (event.type === EVENTS.DRAG_END) { 199 + // Drag-out exit always lands in NORMAL (never re-enters MAXIMIZED). 200 + return { state: STATES.NORMAL, effects: [e(EFFECTS.HIDE_DRAG_OVERLAY)] }; 201 + } 202 + if (event.type === EVENTS.DRAG_START || event.type === EVENTS.INIT_COMPLETE) { 203 + return { state, effects: [] }; 204 + } 205 + return { state, effects: [], warning: `Illegal event ${event.type} in ${state}` }; 206 + } 207 + 208 + case STATES.RESIZING: { 209 + if (event.type === EVENTS.RESIZE_MOVE) { 210 + return { state, effects: [] }; 211 + } 212 + if (event.type === EVENTS.RESIZE_END) { 213 + return { state: STATES.NORMAL, effects: [] }; 214 + } 215 + if (event.type === EVENTS.RESIZE_START || event.type === EVENTS.INIT_COMPLETE) { 216 + return { state, effects: [] }; 127 217 } 128 218 return { state, effects: [], warning: `Illegal event ${event.type} in ${state}` }; 129 219 }
+33 -2
app/page/page.js
··· 184 184 case FSM_EFFECTS.CAPTURE_PRE_MAXIMIZE: 185 185 case FSM_EFFECTS.RESTORE_PRE_MAXIMIZE: 186 186 case FSM_EFFECTS.ENTER_MAXIMIZED_LAYOUT: 187 - // toggleMaximize handles these directly today; the FSM emits them 188 - // for future migration. Ignore for now. 187 + case FSM_EFFECTS.EXIT_MAXIMIZED_FOR_DRAG: 188 + // toggleMaximize / startDrag handle these directly today; the 189 + // FSM emits them for future migration. Ignore for now. 190 + return; 191 + case FSM_EFFECTS.SHOW_DRAG_OVERLAY: 192 + // Some drag entry paths (navbar mousedown) add 'active' before 193 + // startDrag runs; this is idempotent. 194 + if (typeof dragOverlay !== 'undefined' && dragOverlay) { 195 + dragOverlay.classList.add('active'); 196 + } 197 + return; 198 + case FSM_EFFECTS.HIDE_DRAG_OVERLAY: 199 + if (typeof dragOverlay !== 'undefined' && dragOverlay) { 200 + dragOverlay.classList.remove('active'); 201 + } 189 202 return; 190 203 default: 191 204 console.warn('[page-fsm] Unknown effect:', effect); ··· 199 212 } 200 213 fsmState = next.state; 201 214 for (const eff of next.effects) applyFsmEffect(eff); 215 + // Expose for Playwright introspection / debugging only — production 216 + // code should never read this; use the FSM's transition() API. 217 + window.__pageFsmState = fsmState; 202 218 } 203 219 204 220 // NOTE: The window always includes navbar space (NAVBAR_HEIGHT) to avoid ··· 571 587 dragOverlay.classList.remove('active'); 572 588 document.body.style.cursor = ''; 573 589 navbar.style.cursor = 'grab'; 590 + dispatchFsm({ type: FSM_EVENTS.DRAG_END }); 574 591 } 575 592 cancelHoldDrag(); 576 593 cancelWebviewHold(); ··· 701 718 // Initial positioning 702 719 updatePositions(); 703 720 721 + // Expose initial fsmState even before the first dispatch (so tests can 722 + // observe INITIALIZING vs first-event transitions). 723 + window.__pageFsmState = fsmState; 724 + 704 725 // FSM init. Dispatch AFTER updatePositions so the runtime adapter's 705 726 // effects (which themselves call updatePositions/updateUrlParams) run 706 727 // against an initialized DOM. If `maximized=1` came in via URL params ··· 964 985 })(); 965 986 966 987 function startDrag(screenX, screenY) { 988 + // FSM: NORMAL → DRAGGING or MAXIMIZED → DRAGGING_OUT_OF_MAXIMIZED. 989 + // Effects (overlay, body class, URL param) are idempotent against 990 + // the inline mutations below. 991 + dispatchFsm({ type: FSM_EVENTS.DRAG_START }); 992 + 967 993 // Drag-out-of-maximize: restore original size, center on cursor 968 994 if (isMaximized && preMaximizeBounds) { 969 995 const restoreW = preMaximizeBounds.width; ··· 1179 1205 document.body.style.cursor = ''; 1180 1206 navbar.style.cursor = 'grab'; 1181 1207 updateUrlParams(); 1208 + dispatchFsm({ type: FSM_EVENTS.DRAG_END }); 1182 1209 } 1183 1210 return; 1184 1211 } ··· 1317 1344 activeResizeHandle = handle; 1318 1345 document.body.style.cursor = `${resizeDir}-resize`; 1319 1346 handle.setPointerCapture(e.pointerId); 1347 + dispatchFsm({ type: FSM_EVENTS.RESIZE_START }); 1320 1348 e.preventDefault(); 1321 1349 e.stopPropagation(); 1322 1350 }); ··· 1382 1410 document.body.style.cursor = ''; 1383 1411 if (wasResizing) { 1384 1412 updateUrlParams(); 1413 + dispatchFsm({ type: FSM_EVENTS.RESIZE_END }); 1385 1414 } 1386 1415 } 1387 1416 ··· 1395 1424 document.body.style.cursor = ''; 1396 1425 navbar.style.cursor = 'grab'; 1397 1426 updateUrlParams(); 1427 + dispatchFsm({ type: FSM_EVENTS.DRAG_END }); 1398 1428 return; 1399 1429 } 1400 1430 ··· 1423 1453 document.body.style.cursor = ''; 1424 1454 navbar.style.cursor = 'grab'; 1425 1455 updateUrlParams(); 1456 + dispatchFsm({ type: FSM_EVENTS.DRAG_END }); 1426 1457 } 1427 1458 // Clear the drag-out-of-maximize override once the drag ends. 1428 1459 dragOutOfMaximizeWindowSize = null;
+2 -1
docs/tasks.md
··· 10 10 11 11 ## State machines 12 12 13 - - [ ] **Page-host FSM — extend to drag/resize states.** Phase 1 shipped 2026-04-27 (see Pruned/completed log). Phase 2: fold the remaining ad-hoc state in `app/page/page.js` (`isDragging`, `isResizing`, `webviewHoldReady`, `pageMouseButtonDown`, `dragStart*`, `preMaximizeBounds`/`preMaximizeWindowBounds`) into the FSM as DRAGGING / RESIZING / DRAGGING_OUT_OF_MAXIMIZED states per [page-host-state-machine.md](page-host-state-machine.md). Each transition gets explicit effect descriptors; runtime adapter applies them. Add Playwright coverage for drag-out-of-maximized and resize-end (currently no specs). Pure-FSM unit tests already enforce the legal transition set. 13 + - [ ] **Page-host FSM — Phase 3: replace flag reads with FSM queries.** Phases 1 + 2 shipped 2026-04-27. The FSM now tracks all 6 states (INITIALIZING / NORMAL / MAXIMIZED / DRAGGING / RESIZING / DRAGGING_OUT_OF_MAXIMIZED) and page.js dispatches transition events at every entry/exit point. But `isDragging` / `isResizing` / `isMaximized` are still the source of truth — the FSM is shadow state, kept in sync via dispatch + idempotent applyFsmEffect. Phase 3: replace the flag reads (16 sites for `isDragging`, 5 for `isResizing`, 17 for `isMaximized`) with `fsmState === STATES.X` queries; delete the flags. Pre-maximize bounds (`preMaximizeBounds`, `preMaximizeWindowBounds`, `dragOutOfMaximizeWindowSize`) move into FSM context (the design doc's "small attribute bag"). The Playwright `Page Host FSM` spec already enforces the round-trips so any drift surfaces immediately. 14 14 15 15 --- 16 16 ··· 73 73 74 74 Keep short — for recent context only. Prune after a few weeks. 75 75 76 + - 2026-04-27 **Page-host FSM Phase 2 (drag/resize states).** Extended `app/page/page-fsm.js` with DRAGGING / RESIZING / DRAGGING_OUT_OF_MAXIMIZED states and DRAG_START / DRAG_MOVE / DRAG_END / RESIZE_START / RESIZE_MOVE / RESIZE_END events. Effect descriptors for SHOW_DRAG_OVERLAY / HIDE_DRAG_OVERLAY / EXIT_MAXIMIZED_FOR_DRAG. Transition table covers the drag-out-of-maximize flow (MAXIMIZED + DRAG_START → DRAGGING_OUT_OF_MAXIMIZED → NORMAL — never re-enters MAXIMIZED). Tolerated input-race no-ops on duplicate DRAG_START / RESIZE_START / INIT_COMPLETE so the unit warnings don't flood from a real-world burst. Unit tests grew from 15 to 33 (`tests/unit/page-fsm.test.js` 33/33). New `tests/desktop/page-host-fsm.spec.ts` (5/5) verifies the runtime adapter actually dispatches at each entry/exit point — round-trips for drag, resize, maximize, drag-out-of-maximize via real DOM event simulation; `window.__pageFsmState` exposes state for introspection. Page.js still uses its own `isDragging` / `isResizing` flags — the FSM is shadow state for now (Phase 3 replaces flag reads with state queries). 76 77 - 2026-04-27 **Page-host FSM Phase 1 (maximize lifecycle).** New `app/page/page-fsm.js` — pure module, no DOM/IPC; states `INITIALIZING / NORMAL / MAXIMIZED`; events `INIT_COMPLETE / TOGGLE_MAXIMIZE`; effect descriptors for body class / URL param / handle visibility / pre-maximize capture/restore. Unit tests in `tests/unit/page-fsm.test.js` (15/15) cover every legal edge + illegal-transition warnings + end-to-end runs. Wired into `page.js` via `dispatchFsm` / `applyFsmEffect` — init dispatches `INIT_COMPLETE` with the `maximized` URL param so a restored maximized page-host transitions straight into MAXIMIZED. `toggleMaximize` dispatches to keep `fsmState` in sync (DOM mutations stay in toggleMaximize for now; effects are idempotent). Drag/resize/etc still own their own flags — Phase 2 will fold them in. Maximize state propagated through the four pinch points: `updateUrlParams` (writes `maximized=1`), session-save reads the URL param into `WindowDescriptor.params.maximized`, undo-close `ClosedWindowEntry.maximized`, `window-open` reads `options.maximized` and (a) snaps the BrowserWindow to the active display's work area instead of adding canvas margins, (b) propagates `maximized=1` to the new page-host URL params. Test-bypass helpers added to `saveSessionSnapshot`/`restoreSessionSnapshot` (`_forceForTest` skips `isTestProfile()` + `isVisible()` guards) and exposed via `__peek_test.forceSaveSession`/`forceRestoreSession`. New `tests/desktop/session-restore-page-host.spec.ts` (2/2) — non-maximized round-trip + maximized round-trip (the regression). Reopen-closed-window 5/5, page-load-failure 5/5, unit 610/610. 77 78 - 2026-04-27 Three polish fixes from user testing (round 2): (1) **Undo-close window inflated each cycle.** `main.ts` `closed` handler subtracted only fixed canvas chrome from OS window bounds when computing saveBounds — side-panel `extraWidth` (entities/notes/etc) stayed in the saved width. On reopen that became the new WEBVIEW size, panels re-expanded the window, and each cycle compounded ("wayyy wider"). Fixed by reading the page-host URL params (`x/y/width/height` reflect screenBounds — webview-only) at close time. Special case: when the page-host is in maximized mode, screenBounds equals the work area (window == webview, no canvas chrome); `updateUrlParams` now sets `maximized=1` and main.ts falls back to fixed-margin subtraction so reopen doesn't overshoot the display. Fall back to the same subtraction for paths that never set URL params. New regression test in `tests/desktop/reopen-closed-window.spec.ts` (URL-param round-trip via `__peek_test.getHostUrlParamsByUrl`). (2) **Lists tag rows broken card.** `getItemDisplayInfo` had no branch for `itemType === 'tag'` — tag results rendered with undefined title and the question-mark fallback icon; the trash-can `onDelete` would have called `datastore.deleteItem` against a tag id (wrong primitive). Added `tag` branch (title from `item.title`, subtitle `Tag (used N×)`, `#` icon) and suppressed `onDelete` for tag rows in `lists/home.js`. New unit tests in `card-helpers-favicon.test.js`. (3) **Session-restore parity test made deterministic.** Added `window:removed` pubsub event in `main.ts` (published after `windowRegistry.delete` — the authoritative post-cleanup signal, distinct from `window:closed` which fires earlier and would gate too soon). Test subscribes via Promise pattern. 78 79 - 2026-04-27 `lists` bg+window consolidation: collapsed manifest to a single resident `home` tile, moved settings/command/shortcut wiring from `background.js` into `home.js`, deleted `background.{html,js}`. New `tests/desktop/lists-tile.spec.ts` 4/0 (manifest shape + keepLive, command registration, command-shows-window, keepLive cycle). `tags` deferred — see "Tile architecture cleanup" for rationale.
+147
tests/desktop/page-host-fsm.spec.ts
··· 1 + /** 2 + * Page-host FSM Phase 2 — drag/resize state tracking integration test. 3 + * 4 + * The unit tests in `tests/unit/page-fsm.test.js` cover the pure FSM in 5 + * isolation. This Playwright spec verifies the runtime adapter in 6 + * `app/page/page.js` actually dispatches DRAG_START/DRAG_END + 7 + * RESIZE_START/RESIZE_END at the right places, by reading 8 + * `window.__pageFsmState` after simulated input. 9 + * 10 + * If any of these break, page.js has lost a dispatch site and the FSM 11 + * is no longer the source of truth for the lifecycle. See 12 + * docs/page-host-state-machine.md. 13 + * 14 + * Run with: yarn test:grep "Page Host FSM" 15 + */ 16 + 17 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 18 + import { Page } from '@playwright/test'; 19 + import { createPerDescribeApp } from '../helpers/test-app'; 20 + import http from 'http'; 21 + 22 + let app: DesktopApp; 23 + let bgWindow: Page; 24 + let server: http.Server; 25 + let serverPort: number; 26 + 27 + test.describe('Page Host FSM @desktop', () => { 28 + test.beforeAll(async () => { 29 + ({ app, bgWindow } = await createPerDescribeApp('page-host-fsm')); 30 + 31 + await new Promise<void>((resolve) => { 32 + server = http.createServer((_req, res) => { 33 + res.writeHead(200, { 'Content-Type': 'text/html' }); 34 + res.end('<!DOCTYPE html><html><body><h1>fsm</h1></body></html>'); 35 + }); 36 + server.listen(0, '127.0.0.1', () => { 37 + const addr = server.address(); 38 + serverPort = typeof addr === 'object' && addr ? addr.port : 0; 39 + resolve(); 40 + }); 41 + }); 42 + }); 43 + 44 + test.afterAll(async () => { 45 + if (server) server.close(); 46 + if (app) await app.close(); 47 + }); 48 + 49 + async function openPageHost(slug: string): Promise<Page> { 50 + const url = `http://127.0.0.1:${serverPort}/${slug}`; 51 + const result = await bgWindow.evaluate(async (u: string) => { 52 + return await (window as any).app.window.open(u, { width: 800, height: 600 }); 53 + }, url); 54 + expect(result.success).toBe(true); 55 + const pageWindow = await app.getWindow(slug, 15000); 56 + await pageWindow.waitForFunction(() => (window as any).__pageModuleReady === true); 57 + return pageWindow; 58 + } 59 + 60 + async function fsmState(pageWindow: Page): Promise<string> { 61 + return await pageWindow.evaluate(() => (window as any).__pageFsmState); 62 + } 63 + 64 + test('initial state is NORMAL after init.complete (non-maximized url params)', async () => { 65 + const pageWindow = await openPageHost('fsm-initial'); 66 + expect(await fsmState(pageWindow)).toBe('NORMAL'); 67 + }); 68 + 69 + test('drag round-trip: NORMAL → DRAGGING → NORMAL', async () => { 70 + const pageWindow = await openPageHost('fsm-drag'); 71 + expect(await fsmState(pageWindow)).toBe('NORMAL'); 72 + 73 + // Simulate navbar mousedown → mousemove → mouseup. Use real DOM 74 + // events so the page.js handlers run end-to-end. 75 + await pageWindow.evaluate(() => { 76 + const nav = document.getElementById('navbar')!; 77 + nav.dispatchEvent(new MouseEvent('mousedown', { 78 + bubbles: true, button: 0, screenX: 100, screenY: 100, 79 + })); 80 + }); 81 + expect(await fsmState(pageWindow)).toBe('DRAGGING'); 82 + 83 + await pageWindow.evaluate(() => { 84 + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0 })); 85 + }); 86 + expect(await fsmState(pageWindow)).toBe('NORMAL'); 87 + }); 88 + 89 + test('resize round-trip: NORMAL → RESIZING → NORMAL', async () => { 90 + const pageWindow = await openPageHost('fsm-resize'); 91 + expect(await fsmState(pageWindow)).toBe('NORMAL'); 92 + 93 + // pointerdown on the SE resize handle → pointerup. 94 + await pageWindow.evaluate(() => { 95 + const handle = document.getElementById('resize-se')!; 96 + handle.dispatchEvent(new PointerEvent('pointerdown', { 97 + bubbles: true, button: 0, pointerId: 1, screenX: 100, screenY: 100, 98 + })); 99 + }); 100 + expect(await fsmState(pageWindow)).toBe('RESIZING'); 101 + 102 + await pageWindow.evaluate(() => { 103 + const handle = document.getElementById('resize-se')!; 104 + handle.dispatchEvent(new PointerEvent('pointerup', { 105 + bubbles: true, button: 0, pointerId: 1, 106 + })); 107 + }); 108 + expect(await fsmState(pageWindow)).toBe('NORMAL'); 109 + }); 110 + 111 + test('maximize via dblclick transitions NORMAL → MAXIMIZED', async () => { 112 + const pageWindow = await openPageHost('fsm-max'); 113 + expect(await fsmState(pageWindow)).toBe('NORMAL'); 114 + 115 + await pageWindow.evaluate(() => { 116 + const nav = document.getElementById('navbar')!; 117 + nav.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); 118 + }); 119 + await pageWindow.waitForFunction(() => (window as any).__pageFsmState === 'MAXIMIZED'); 120 + }); 121 + 122 + test('drag-out-of-maximize: MAXIMIZED → DRAGGING_OUT_OF_MAXIMIZED → NORMAL (never re-enters MAXIMIZED)', async () => { 123 + const pageWindow = await openPageHost('fsm-dragout'); 124 + 125 + // First maximize. 126 + await pageWindow.evaluate(() => { 127 + const nav = document.getElementById('navbar')!; 128 + nav.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); 129 + }); 130 + await pageWindow.waitForFunction(() => (window as any).__pageFsmState === 'MAXIMIZED'); 131 + 132 + // Now mousedown on navbar → drag-out path. 133 + await pageWindow.evaluate(() => { 134 + const nav = document.getElementById('navbar')!; 135 + nav.dispatchEvent(new MouseEvent('mousedown', { 136 + bubbles: true, button: 0, screenX: 200, screenY: 200, 137 + })); 138 + }); 139 + expect(await fsmState(pageWindow)).toBe('DRAGGING_OUT_OF_MAXIMIZED'); 140 + 141 + await pageWindow.evaluate(() => { 142 + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0 })); 143 + }); 144 + // Drag-out always lands in NORMAL — must NOT re-enter MAXIMIZED. 145 + expect(await fsmState(pageWindow)).toBe('NORMAL'); 146 + }); 147 + });
+149
tests/unit/page-fsm.test.js
··· 151 151 }); 152 152 }); 153 153 154 + // ──────────────────────────────────────────────────────────────────── 155 + // Phase 2: drag/resize states 156 + // ──────────────────────────────────────────────────────────────────── 157 + 158 + describe('page-fsm: NORMAL → DRAGGING', () => { 159 + it('drag.start from NORMAL transitions to DRAGGING and shows the overlay', () => { 160 + const { state, effects } = transition(STATES.NORMAL, { type: EVENTS.DRAG_START }); 161 + assert.equal(state, STATES.DRAGGING); 162 + assert.deepEqual(effectTypes(effects), [EFFECTS.SHOW_DRAG_OVERLAY]); 163 + }); 164 + }); 165 + 166 + describe('page-fsm: DRAGGING self-loops + exit', () => { 167 + it('drag.move in DRAGGING is a self-loop with no effects', () => { 168 + const { state, effects } = transition(STATES.DRAGGING, { type: EVENTS.DRAG_MOVE }); 169 + assert.equal(state, STATES.DRAGGING); 170 + assert.deepEqual(effects, []); 171 + }); 172 + 173 + it('drag.end from DRAGGING returns to NORMAL and hides the overlay', () => { 174 + const { state, effects } = transition(STATES.DRAGGING, { type: EVENTS.DRAG_END }); 175 + assert.equal(state, STATES.NORMAL); 176 + assert.deepEqual(effectTypes(effects), [EFFECTS.HIDE_DRAG_OVERLAY]); 177 + }); 178 + 179 + it('duplicate drag.start in DRAGGING is a tolerated no-op (input race)', () => { 180 + const { state, effects, warning } = transition(STATES.DRAGGING, { type: EVENTS.DRAG_START }); 181 + assert.equal(state, STATES.DRAGGING); 182 + assert.deepEqual(effects, []); 183 + assert.equal(warning, undefined); 184 + }); 185 + }); 186 + 187 + describe('page-fsm: MAXIMIZED → DRAGGING_OUT_OF_MAXIMIZED', () => { 188 + it('drag.start from MAXIMIZED exits maximized + transitions to DRAGGING_OUT_OF_MAXIMIZED', () => { 189 + const { state, effects } = transition(STATES.MAXIMIZED, { type: EVENTS.DRAG_START }); 190 + assert.equal(state, STATES.DRAGGING_OUT_OF_MAXIMIZED); 191 + // Order matters: exit maximized layout first (to set bounds + sync DOM), 192 + // then show drag overlay so the user sees the exit. 193 + assert.deepEqual(effectTypes(effects), [ 194 + EFFECTS.EXIT_MAXIMIZED_FOR_DRAG, 195 + EFFECTS.SET_BODY_MAXIMIZED, 196 + EFFECTS.SET_HANDLES_VISIBLE, 197 + EFFECTS.SET_URL_MAXIMIZED, 198 + EFFECTS.SHOW_DRAG_OVERLAY, 199 + ]); 200 + const setBody = effects.find(e => e.type === EFFECTS.SET_BODY_MAXIMIZED); 201 + assert.equal(setBody.value, false); 202 + }); 203 + 204 + it('drag.end from DRAGGING_OUT_OF_MAXIMIZED lands in NORMAL (never re-enters MAXIMIZED)', () => { 205 + const { state, effects } = transition(STATES.DRAGGING_OUT_OF_MAXIMIZED, { type: EVENTS.DRAG_END }); 206 + assert.equal(state, STATES.NORMAL); 207 + assert.deepEqual(effectTypes(effects), [EFFECTS.HIDE_DRAG_OVERLAY]); 208 + }); 209 + 210 + it('drag.move in DRAGGING_OUT_OF_MAXIMIZED is a self-loop', () => { 211 + const { state, effects } = transition(STATES.DRAGGING_OUT_OF_MAXIMIZED, { type: EVENTS.DRAG_MOVE }); 212 + assert.equal(state, STATES.DRAGGING_OUT_OF_MAXIMIZED); 213 + assert.deepEqual(effects, []); 214 + }); 215 + }); 216 + 217 + describe('page-fsm: NORMAL ↔ RESIZING', () => { 218 + it('resize.start from NORMAL transitions to RESIZING with no effects (handle visibility unchanged)', () => { 219 + const { state, effects } = transition(STATES.NORMAL, { type: EVENTS.RESIZE_START }); 220 + assert.equal(state, STATES.RESIZING); 221 + assert.deepEqual(effects, []); 222 + }); 223 + 224 + it('resize.move in RESIZING is a self-loop', () => { 225 + const { state, effects } = transition(STATES.RESIZING, { type: EVENTS.RESIZE_MOVE }); 226 + assert.equal(state, STATES.RESIZING); 227 + assert.deepEqual(effects, []); 228 + }); 229 + 230 + it('resize.end from RESIZING returns to NORMAL', () => { 231 + const { state, effects } = transition(STATES.RESIZING, { type: EVENTS.RESIZE_END }); 232 + assert.equal(state, STATES.NORMAL); 233 + assert.deepEqual(effects, []); 234 + }); 235 + }); 236 + 237 + describe('page-fsm: drag/resize illegal transitions', () => { 238 + it('drag.start in MAXIMIZED is the drag-out-of-max path, not illegal', () => { 239 + const { state, warning } = transition(STATES.MAXIMIZED, { type: EVENTS.DRAG_START }); 240 + assert.equal(state, STATES.DRAGGING_OUT_OF_MAXIMIZED); 241 + assert.equal(warning, undefined); 242 + }); 243 + 244 + it('toggle_maximize in DRAGGING is rejected (must end drag first)', () => { 245 + const { state, warning } = transition(STATES.DRAGGING, { type: EVENTS.TOGGLE_MAXIMIZE }); 246 + assert.equal(state, STATES.DRAGGING); 247 + assert.match(warning, /Illegal event/); 248 + }); 249 + 250 + it('resize.start in DRAGGING is rejected', () => { 251 + const { state, warning } = transition(STATES.DRAGGING, { type: EVENTS.RESIZE_START }); 252 + assert.equal(state, STATES.DRAGGING); 253 + assert.match(warning, /Illegal event/); 254 + }); 255 + 256 + it('drag.start in RESIZING is rejected', () => { 257 + const { state, warning } = transition(STATES.RESIZING, { type: EVENTS.DRAG_START }); 258 + assert.equal(state, STATES.RESIZING); 259 + assert.match(warning, /Illegal event/); 260 + }); 261 + 262 + it('drag.start in INITIALIZING is rejected', () => { 263 + const { state, warning } = transition(STATES.INITIALIZING, { type: EVENTS.DRAG_START }); 264 + assert.equal(state, STATES.INITIALIZING); 265 + assert.match(warning, /Illegal event/); 266 + }); 267 + }); 268 + 269 + describe('page-fsm: drag/resize end-to-end runs', () => { 270 + it('init→drag.start→drag.move→drag.end lands in NORMAL', () => { 271 + const { state } = run([ 272 + { type: EVENTS.INIT_COMPLETE, maximized: false }, 273 + { type: EVENTS.DRAG_START }, 274 + { type: EVENTS.DRAG_MOVE }, 275 + { type: EVENTS.DRAG_MOVE }, 276 + { type: EVENTS.DRAG_END }, 277 + ]); 278 + assert.equal(state, STATES.NORMAL); 279 + }); 280 + 281 + it('init→toggle_maximize→drag.start→drag.end lands in NORMAL (drag-out-of-max never re-enters MAXIMIZED)', () => { 282 + const { state } = run([ 283 + { type: EVENTS.INIT_COMPLETE, maximized: false }, 284 + { type: EVENTS.TOGGLE_MAXIMIZE }, 285 + { type: EVENTS.DRAG_START }, 286 + { type: EVENTS.DRAG_MOVE }, 287 + { type: EVENTS.DRAG_END }, 288 + ]); 289 + assert.equal(state, STATES.NORMAL); 290 + }); 291 + 292 + it('init→resize.start→resize.move→resize.end lands in NORMAL', () => { 293 + const { state } = run([ 294 + { type: EVENTS.INIT_COMPLETE, maximized: false }, 295 + { type: EVENTS.RESIZE_START }, 296 + { type: EVENTS.RESIZE_MOVE }, 297 + { type: EVENTS.RESIZE_END }, 298 + ]); 299 + assert.equal(state, STATES.NORMAL); 300 + }); 301 + }); 302 + 154 303 describe('page-fsm: end-to-end run() — round-trip via maximize toggle', () => { 155 304 it('init→maximize→unmaximize lands back in NORMAL with no leftover effects', () => { 156 305 const { state } = run([