mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
dbbbefda75d4fc4aaeac7bdaecd65eab772f00a9
26 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a0c7d68aa9 |
build(web): SvelteKit 3 + adapter-static 4 (TASK-3423 PR B) (#1835)
* wip(web): SvelteKit 3.0.1 + adapter-static 4 with the $lib alias (TASK-3423 PR B, WIP) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * wip(web): kit 3 migration fixes - goto reset, page.url.href, $app/env, $app/state, cookie v2 (TASK-3423 PR B) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): pane Back restores scroll through kit 3's snapshot() (TASK-3423 PR B) PaneHost read page.state one task after popstate; kit 3 applies a popped entry after an await, so the saved paneScrollTop was never seen. Kit's own snapshot() keeps the position per history entry, captures it when the entry is left, drops it on a replace, and restores only on a traversal after the entry is applied. The controller's replaceState(paneScrollTop) write, the getPaneScrollTop dep and readPaneScrollTop are gone. The 9 bug-2182 legs pass. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): the back-settle latch opens at the Back's popstate, where kit 3 arms it (TASK-3423 PR B) Kit 3's popstate handler is async, so afterNavigate (where the settle is armed) now runs after every popstate listener; measured 2-30ms after the helper's listener. Not yet run green: the box is held. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): the pane back-settle waits for an in-flight drill instead of minting the popped-to item (TASK-3423 PR B) Kit 3 resolves a goto to afterNavigate across tasks, so a drill fired inside the 140ms window could still be in flight when the settle fired; it minted the popped-to item first (measured: URL still ?item=A at fire time, 2 of 4 runs). The settle now re-arms while navigating.current is set. Unit tests cover the defer and the cancelled-navigation fallback; both die without the defer. e2e not yet re-run on this commit. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): defer the back-settle on navigating.type, not a nonexistent navigating.current (TASK-3423 PR B) 79175a8bc read navigating.current, which kit 3's $app/state navigating does not have: undefined !== null held always, so the settle deferred forever. The R14 drill legs passed for that wrong reason and 432 (a drill AFTER the window still sees A minted) failed 5/5. navigating.type is null exactly when no navigation is in flight. Measured at this commit: pane-async-race 373 and 432 and capstone 645, 5 repeats each, 15/15 pass. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): the list's restore-skip is released by the next navigation, not a microtask (TASK-3423 PR B) Closing the pane from a scrolled column jumped the list (bug-3165 e2e): the handoff wrote the anchored position at 52ms, then the scroll restore applied the list entry's saved offset (top 600) from 82ms on (measured with a scroll-write probe after Escape). Kit 3 calls snapshot.restore after an await in its navigate, so the queueMicrotask release ran before the restore it was meant to skip. The skip now belongs to the navigation that armed it: its own restore consumes it and the next beforeNavigate releases any remainder. bug-3165 spec: 6/6 desktop legs pass. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): editor menus unsubscribe from the editor they subscribed to, not the live prop (TASK-3423 PR B) version-restore-collab (BUG-2264's e2e) blanked the item page after a restore under live collab, 3/3 on kit 3: the force-refresh remount ran EditorBubbleMenu's and EditorLinkPopover's effect cleanups, which read the live editor prop. That read re-entered Svelte's flush and destroyed the editor mid-transaction (state_unsafe_mutation from ProseMirror's blur handler), and a null prop then threw on .off, killing the page (stacks captured with Error.stackTraceLimit=100). The cleanups now close over the editor their run subscribed to. Evidence is the e2e: red 3/3 at 347942e17, green 3/3 (2 tests x 3) with this change alone. A jsdom unit test could not reproduce it: there Svelte hands a teardown the pre-teardown prop value, so the old cleanup passed too, and a test that passes either way was not kept. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix(web): kit 3 in code main added since PR B: $app/env, goto reset in the library tabs (TASK-3423) * test(web): re-review the identity-gate rows the rebase onto main re-hashed (TASK-3423) collection page: quickCreateInColumn, the two pane-follow continuations, installPaneTestHook, loadUrlFilters and updateUrlFilters. Only kit 3 API swaps reach them (page.url.href, goto reset:false); no await or fence moved. library page: the tab-switch goto now restores focus to the tab after the navigation (kit 3 has no keepFocus); its continuation writes no state and is tabled. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): quick capture's Open lands in the title editor under kit 3 (TASK-3423) The captured item's URL carries ?new=1, which opens it with its title in the editor. Under kit 2 the follow-up goto's focus reset closed the editor at once, so the title read as text; kit 3's reset:false keeps it open (site 3 of the day-87 ruling). Assert the editor's value instead. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF * test(e2e): login honours ?redirect= and still refuses off-site targets (TASK-3423) The login page now reads ?redirect= through $app/state's page, and that value is validateRedirect's input. Nothing exercised the read: the share and invite specs check only the link that points at /login. Covers the form path from a signed-out context, the signed-in forward, and three off-site shapes (absolute, protocol-relative, encoded slash). Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF * test(e2e): name the encoded-slash case by what the guard receives; off-site on the form path too (TASK-3423 r2) Codex r2: the encoded-slash case is a value that still holds %2f after searchParams decodes it once; the label and a comment now say so. Hunter r2 L1: the form sign-in also gets an off-site target. Hunter r2 L2: the off-site cases wait on one poll for "this origin, past /login", so an intermediate URL cannot satisfy half of it. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF * fix(web): the activity page's URL clear uses kit 3's goto options (TASK-3423) #2226 landed dropFromUrl with goto's noScroll/keepFocus, which kit 3 removed (they throw in dev and are ignored in prod), and new URL(page.url) on the $app/state proxy. reset: false keeps scroll and focus as both options did; page.url.href gives URL a string. Claude-Session: https://claude.ai/code/session_018oQ5EErQKHtBJK8yLpcDNF |
||
|
|
d8a44bb5ee |
test(e2e): record the geometry when a board-card click fails (BUG-3553 probe) (#2049)
* test(e2e): record the geometry when a board-card click fails (BUG-3553 probe) pane-controller :583 and pane-content-link-anchors :290 fail on CI with another card intercepting the hit point, and then "not stable" until the timeout. It has not reproduced locally (a CI-shaped desktop shard 1/2 at 2 workers passed clean), so the mechanism has to be read from a failing CI run. clickBoardCard clicks as before; on failure it records the target's box over ten frames, its lane index, the column's scroll, neighbours' content-visibility state, transforms, and the element under the hit point, attaches it, and rethrows. No wait is added. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * test(e2e): BUG-2871 spec opens its own parent on a retry; its click is probed (BUG-3553) On #2049's first run the spec's first attempt clicked the parent card and no pane opened (the BUG-3553 family), and its retry then opened the FIRST attempt's parent: the locator matched the title prefix and took .first(). It now selects the card by the seeded slug. With --repeat-each=3 on one server the old locator fails exactly as CI did; the new one passes 3/3. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ |
||
|
|
416aea2d14 |
fix(collab): stop appending the whole document to the op-log on every reconnect (BUG-3523) (#1965)
* fix(collab): reconnect catch-up only when this tab's frames can be missing (BUG-3523)
The provider sent the whole document as one update on every reconnect,
appending a full copy to the op-log on each laptop wake or network blip.
It now sends it only when an edit was made with no socket open, or a
local update went out on a socket that then closed and no barrier ack
cleared it.
The barrier: once local sends go quiet (2s), the client sends one
`barrier {n}`; the server answers `barrier_ack {n, ok}` in readLoop
order, after every earlier frame was persisted, with ok=false when an
op-log append on that conn failed since the last barrier. An ok ack
covering every local send clears the debt; ok=false keeps it and logs
the item; a server that never answers leaves the tab owed.
Also counts materializer give-ups (pad_materialize_giveup_total{kind}).
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(collab): a SyncStep2 answer is stored but not counted as content (BUG-3523)
A step2 is y-protocols' answer to a peer's step1 and repeats content other
rows carry: the answering tab's own edits went out as updates (and are
repaired with a full catch-up if they may be missing), and everything
else it holds came through the relay. Counting it kept items pending and
made the materializer replay a full copy of the document per joining tab.
Measured first: recorded multi-tab op-logs, across a dormancy prune,
materialize to identical text with and without their step2 rows.
Applied at append only. The startup backfill keeps the older rule for
legacy rows, written by clients with no failed-append repair.
Also: a barrier_ack with ok=false now resends the document on the live
socket at once (lead ruling), instead of only owing the next connection.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(collab): say what the barrier ack's write deadline bounds (BUG-3523)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* test(e2e): take the server's data dir from one module (BUG-3523)
The step2 spec resolved .pad-e2e one level too shallow: it passed where
PAD_E2E_DATA_DIR was set and could not open the database in CI.
playwright.config.ts and the spec now read e2e/lib/data-dir.ts.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
|
||
|
|
439bda2278 |
feat(web): the auth pages follow the theme, with a toggle that survives sign-in, the Pad grid mark and a slim self-hosted header (TASK-3509) (#1924)
- AuthThemeToggle on every auth page writes 'pad-theme' and data-theme, the preference the root layout restores, so a choice made on /login is the theme the app opens in. - AuthBrand shows the 3x3 grid mark (static/pad-mark.svg, colours sampled from the mobile app icon); favicon and app icons unchanged. - Self-hosted gets a slim header: the mark, the host being signed in to, and the toggle. No marketing links. - Visible field labels; placeholders are examples (you@example.com, Ada Lovelace, ada, 123456) or gone, and tests select by label. - Auth submit buttons use --accent-primary-strong: white on --accent-blue failed AA contrast in dark. |
||
|
|
8c8deeeb89 |
fix: an invite asks an existing user instead of adding them; decline; CLI invitations (BUG-2136 U1) (#1831)
* test(server): an invite asks an existing user; re-invite replaces; decline by id and by code (BUG-2136, red)
* fix(server): an invite asks an existing user instead of adding them; a re-invite replaces the pending invitation; decline by id and by code (BUG-2136)
* feat(cli,mcp): pad workspace invitations/accept/decline; invite output says pending; ToolSurfaceVersion 0.66 (BUG-2136)
* test(e2e): invitees accept their invitation before the specs use the membership (BUG-2136)
* test(store): concurrent re-invites of one address leave one pending invitation (BUG-2136)
* test(store): an accept that loses to a decline adds no membership (BUG-2136, codex r1, red)
* test(server): an unverified invitee can decline by code, as they can accept (BUG-2136, codex r1, red)
* fix: an accept that loses to a decline or replace adds no membership; decline by code passes the verified-email gate; honest oracle comment (BUG-2136, codex r1)
* fix(server): the verified-email exemption matches exactly /invitations/{code}/accept|decline (BUG-2136, codex r2)
* test(server): drop the plan-limit harness's now-unused newUser (BUG-2136)
|
||
|
|
e62188ed15 | docs: fresh 2x product screenshots (dark and light) and a tightened README (TASK-3426) (#1816) | ||
|
|
78f3c08240 |
test(e2e): a real installed app, end to end over https (TASK-3415) (#1804)
* feat(e2e): a test-build-only door for the app origin's CA (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): a real install end to end over an https front and app origin (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): the install review waits for a real manifest fetch (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * chore: ignore the e2etest pad binary (TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): discovered https endpoints, awaited teardown, OS-assigned ports, an explicit timeout (codex r1 on TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test(e2e): teardown survives a spawn failure, pad's port retries, the front drops hop-by-hop headers, cert setup cleans up (codex r2 on TASK-3415) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS |
||
|
|
2bd0862af2 |
fix(auth): platform administration refuses an API token (BUG-3361) (#1742)
* fix(auth): platform administration refuses an API token (BUG-3361)
BREAKING for anyone driving /api/v1/admin/* or admin account creation with
a personal access token: those now answer 403 session_required. Use a
`pad auth login` CLI session, or on self-host set PAD_ADMIN_API_TOKENS=allow.
An admin's PAT carried full platform authority. codex reproduced the chain
during BUG-3336's review: register an account with a chosen password,
promote it to admin, sign in as it, and reset any account's password. A
leaked admin token therefore escalated to interactive sessions for any
account.
- refuseAPITokenForAdmin guards the whole /api/v1/admin route group, so an
admin route added later is refused by construction.
TestBUG3361_EveryAdminRouteRefusesAnAdminPAT walks the router to hold it.
- handleRegister refuses admin-create signup with an API token.
- PAD_ADMIN_API_TOKENS=allow is the self-host escape hatch. It is read at
startup, logs a WARN, and is never honoured in cloud mode. Documented in
docs/deployment.md, Security.
- A browser session and a CLI session (padsess_) are unaffected. So is the
Pad Cloud sidecar, which authenticates with the cloud secret, never a
PAT. /audit-log already refused a PAT (BUG-3334).
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test: drive platform administration with the admin's session, not its API token (BUG-3361)
CI on the first push: 70 E2E specs and TestMCPCapability_UpgradeChangesNothingWhileOff
called /api/v1/admin/* or admin account creation with an admin PAT, exactly
the use this change refuses. Our own harness was the first operator script
the breaking change broke.
- E2E: global setup keeps the session token login already returns as
fixture.adminSessionToken (padsess_ is not User-Agent bound when sent as
a Bearer). The seven admin call sites use it: account creation in
permission-walk, workspace-tabs, workspace-access-live, no-commerce and
account-delete; the admin user search; and PUT /admin/mcp. Every other
call keeps the PAT, including the MCP client config in plan-2310.
- mcp_capability_test: the admin drives /admin with a CLI session and keeps
its PAT for /mcp, which takes one.
Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
* test(e2e): the admin console specs use the admin's session (BUG-3361)
CI on
|
||
|
|
552dee8ded |
feat(web): workspace tabs feel like Chrome's (TASK-3312) (#1676)
* feat(web): workspace tabs feel like Chrome's (TASK-3312) Dave approved all six of the lead's proposals (day 83): - Motion: a tab grows in and collapses out (CSS classes, not Svelte transitions, since the strip is a dndzone). Chrome's close-freeze holds the other widths until the pointer leaves, the window resizes, or the tab set changes from elsewhere. Reduced motion drops the animation but keeps the freeze. - The arrow cursor instead of grab. - The active tab flares into the page. The list now reaches over the bar's border, which overflow-x had kept a tab's own negative margin from doing. - Dividers between inactive tabs, Chrome's rule. Tabs are 38px. - A favicon-like filled square icon in both states. - Middle-click closes a tab, so it no longer opens the workspace in a new browser tab. The × shows on every tab of 150px or wider. The tab-bar e2e helpers move to e2e/lib/workspace-tabs.ts. The TopBar close tests now wait out the 140ms collapse. Two of them kept passing with the delay only because they asserted before the close ran, so they now also assert that the close was called. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL * fix(web): no line under the active tab, room for the edge flares, readable tab initials (TASK-3312) The lead's review of #1676: - The 1px line left under the active tab was the connect banner's (and the verify banner's) top border, sitting flush against the bar and doubling its border. On desktop they now sit 8px below the bar as cards; phones are unchanged. Pixel-sampled: the page colour now runs straight down under the active tab in both themes. - The list clipped a first or last active tab's outer flare. It gets 9px of inline padding. - White initials measured 1.39-2.75:1 on the palette. An inactive icon now takes --text-primary, and an active one a fixed dark ink: 4.19:1 at worst. Two e2e legs pin the flare room and the missing line. Both were red on c1661c69 and are green here. Claude-Session: https://claude.ai/code/session_01WiJ1kQCbNUdFRyq2FEi4tL |
||
|
|
973e6eaafc |
test(e2e): the permission walk's readiness wait allows 20s, sized from measured settle times, not the 5s expect default (BUG-3267 follow-up) (#1629)
waitForAccessSettled inherited the 5s expect default. Under 8 workers two walk legs timed out on it with the page rendered. Measured across 952 waits (five walk specs, 8 workers x5): p50 1771 ms, p99 4416 ms, max 5222 ms, 3 above 5 s. 20 s is about 3.8x the max. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
b4cca2c408 |
test(e2e): the permission walk waits for access to settle before pressing or asserting absence (BUG-3267) (#1627)
Every permission-walk leg navigated, waited for content, then pressed Cmd-N or asserted chrome absent. Content is not the signal: the create decision needs the caller's membership (GET /me) and the workspace's collection list, and both can land after content. Before both, Cmd-N is consumed and ignored (BUG-3258's ruled behaviour) and every absence holds for the owner too. Measured at 8 workers: every failure caught pressed with membership known and no sidebar collections listed, so a membership-only wait still failed 3/420. - Sidebar publishes the store's own flag as data-membership (known|pending). Nothing in the app reads it. - waitForAccessSettled(page) waits for data-membership="known" and a listed sidebar collection. All five walk specs call it after their one goto. - Two owner control legs hold GET /me or GET /collections, and show a press made then is dropped (not deferred) and a press after settling opens quick-add. Claude-Session: https://claude.ai/code/session_01NidgPePY7UvHcEJCMkD2Qz |
||
|
|
cbbfb93ff7 |
test(e2e): seed a multi-account permission walk and check each account's sidebar (TASK-2866 U1) (#1613)
HT-1157 is BUG-984's quality gate, and it had never run. This adds the
fixture its walk needs: a workspace of its own, plus one account per
permission shape (owner, editor, viewer, viewer with Tasks collection-edit,
guest with one item-edit, guest with collection-edit plus item-view as the
precedence case, and editor restricted to Tasks). Each account holds its
own API token. The first checklist row is also here: the sidebar lists
exactly the collections each account may see.
The owner leg is the positive control. Every restricted leg also requires
its own Tasks entry, so an absence is measured beside a presence on the
same surface. Negative control: seeding the restricted editor with
collection_access=all turns its leg red with 3 extra collections.
The walk found one leak. GET /workspaces/{ws} omits is_guest, so a guest
loading the workspace renders member chrome (BUG-3254). That leg is marked
test.fail with the ref until the fix lands.
Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
|
||
|
|
225205052b |
test(e2e): the lightbox scale settles when its transform transition ends, not when two reads happen to agree (BUG-3213) (#1536)
* test(e2e): the lightbox scale settles when its transform transition ends, not when two reads happen to agree (BUG-3213) settleScale accepted two equal reads of the rendered matrix as "settled". Under an 8-worker load the renderer can produce no frame between two polls, so a mid-transition scale read as final: the touch spec's pre-zoom stopped at 1.19-2.20 when it needed more than 2.3, on 11/120 runs on main and on c0819dd4^ alike. "Settled" now means no live transform CSSTransition on the viewer image, checked after flushing style. Two animation frames are awaited first: Chromium delivers touch moves aligned to frames, and without the flush the pinch oracle read one move step early (exactly 6.25px = 50px/8 in X, 17/120). The old fixpoint's polling delay had been hiding that. The zoom spec's private copy of the same fixpoint now uses the shared helper. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * test(e2e): settleScale awaits each transform transition's finished in the page instead of polling (BUG-3213) expect.poll's 100/250/500ms backoff, paid once per zoom step, stretched the zoom spec's 20-step zoomToMax toward its 30s budget under load. A cancelled (retargeted) transition rejects finished, so the wait loops until no transform transition is live. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ * test(e2e): zoomToMax presses in bursts and settles once per burst, so an honest settle does not stretch its callers toward the timeout (BUG-3213) Each '+' computes from the viewer's zoom state, so a burst of four lands exactly four steps; settleScale waits for the transition to end, so a burst that does not climb is the clamp. Measured x40 at 8 workers (p50/max): controls-at-max 14.1/15.6 on main -> 9.7/12.9, pan-clamp 17.2/19.6 -> 14.2/18.7, resize-clamp 15.2/19.5 -> 11.6/17.2. Claude-Session: https://claude.ai/code/session_01UcaMsmoCt8u7VpJhCNxiJJ |
||
|
|
4eb5b9ef19 |
test(BUG-3152): bug-2602 waits for its event stream; the collab editor first mount gets a 20s budget (#1494)
* test(BUG-3152): bug-2602's move waits until the page's event stream is subscribed
The move is observable only through this page's event stream. A fresh
EventSource has no Last-Event-ID, so a move committed before the page
subscribed was never delivered, and the refetch oracle timed out at 15s.
Under full-suite load the stream's headers took 532ms, and the move landed
inside that window (full run 3 on
|
||
|
|
ca004cf221 |
test(BUG-3166): the drill-after-Back race legs time the drill in the page from the settle's arming; a drill after the window mints the popped item first (#1476)
* test(BUG-3166): drill-after-Back legs time the drill in the page from the settle's arming; add the after-window leg Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX * test(BUG-3166): the settle helper latches only a 140ms timer armed inside the Back's popstate dispatch (codex r1: the pane-follow debounce is also 140ms) Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX * test(BUG-3166): the settle helper clears its safety timer and popstate listener on either outcome (codex r2) Claude-Session: https://claude.ai/code/session_01Vf8uZmYdsrQqJtbAmjUFSX |
||
|
|
6cc2c7eb95 |
fix(items): create refuses reserved metadata keys; convention metadata goes through a typed create member; a full fields update may only carry them (BUG-3163, ToolSurfaceVersion 0.48) (#1470)
Item CREATE was the last door that stored a hand-written reserved metadata key (implementation_notes, decision_log, github_pr, convention). A `--field` or MCP `field` value landed as a string no extractor reads. For notes and decisions, that also made `note` / `decide` refuse on the item until the row was repaired. Create's `fields` now refuses all four keys with 400 validation_error. The check runs on the caller's map. Library activation, the only system writer that used this door, moves to a new typed `convention` create member, which is validated (an empty object is refused), normalized and lowered into the blob. Every convention writer switches to it: the CLI and remote MCP library activate, the web library activate, the web Conventions page create, and the e2e seed helper. The CLI checks the STORED `convention` key to confirm the member landed. It cannot use item.Convention for that, because that value is derived from the sibling keys and is non-nil even when an older server has dropped the member. A FULL `fields` update may CARRY stored reserved values unchanged. A differing value, or an OMITTED stored key (which the whole-blob replace would delete), is refused with 400. The comparison uses decoded JSON, never bytes, and runs in the update transaction against the row re-read under the write lock (composed into the precheck, like the BUG-3133 guard). A note appended concurrently therefore cannot be erased by a stale blob. A stored blob that is not a JSON object has no readable reserved metadata, so a full write can still repair it. Artifact import shares createItemChecked but needs no gate, because artifact.Decode fills Fields from a fixed frontmatter key list. Existing test fixtures that put reserved keys in through the two closed doors now seed at the store level. The stdio refusal texts are pinned to the classifier's validation_failed table. Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N |
||
|
|
84eef5dd38 |
test(viewer): CDP mobile touch-gesture proof + device checklist (TASK-2519)
PLAN-2392 phase 3d V3 (final task of the plan) — the mobile browser proof for the attachment viewer's touch gestures shipped in V2 (TASK-2518). Real touch is driven through the compositor via CDP Input.dispatchTouchEvent (a CdpTouch helper in the e2e lib), since Playwright's touchscreen is single-tap only. New web/e2e/attachment-viewer-touch.spec.ts (mobile-chromium), 9 legs: - two-finger spread/converge zoom in/out - off-centre affine anchor oracle: a moving-midpoint translate+spread keeps a known image-local point under the midpoint (sub-pixel residual; a zoom-around-centre mutant misses by ~75px, TOL=6px) - double-tap fit<->actual + single-image-tap-inert + backdrop-tap-close - 2->1 lift degrade: jump-free hand-off + survivor pan arms - '+' mid-pinch rebase (no stale-baseline snap; sampled on a tiny post-+ move) - touchCancel all-cancel teardown + next gesture arms fresh - letterbox touch never pans + letterbox tap closes - image/stage touch-action:none, backdrop auto - tap-to-load first-tap priority CDP semantics empirically pinned (not assumed): touchStart/Move carry the full active set; touchEnd names the ending point (pointerup#<id> observed); touchCancel is all-or-nothing. Emulation boundary recorded honestly in DOC-2521 (device-proof checklist): the 2->1 survivor-pan CONTINUATION can't be expressed in CDP — synthetic touch releases the survivor's implicit pointer-capture on the next move, tearing the fresh pan down (a real digitiser keeps it), so the leg proves arm+no-jump and the continuation is device-verified. Also checklisted: gesture feel/arbitration, momentum, real touchCancel, iOS Safari (no WebKit CI project), off-root release. Mutation-verified (build web+go at worktree root, fresh CI server per run): pinch handler disabled -> spread/oracle/rebase red; anchor->stage-centre -> oracle red while spread stays green (discrimination); double-tap disabled -> toggle red; degrade disabled -> 2->1 red; rebase disabled -> rebase red. Codex: 3 rounds, final CLEAN (r1 flagged a stage-settle race -> fixed, and a docs-based touchEnd objection -> refuted empirically; r2 flagged the rebase test wasn't discriminating -> sampled on a tiny move + mutation-proved). Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
885871a638 |
test(web): browser-prove attachment lifecycle completeness (TASK-2514)
The falsifiable subset jsdom can't see for PLAN-2392 phase 3c-iii — one Playwright leg per lifecycle mechanism the U1-U3 chain built, plus the fix for the U3 count test that was authored but never run. - Navigation-step (U3): a 3-image set whose viewer order is DERIVED at runtime (created_at-DESC ties are the DB's, not the upload order); the middle-navigated "arrival" is deleted via a SEPARATE API context so the process-local bus never tombstones it, its metadata is primed with a cacheable 200 in the PAGE context, and arrowing onto it after a reopen forces a no-store HEAD that 404s DESPITE the primed 200 (armed waitForResponse, causally the arrow's probe) → tombstone-advance to a distinct survivor. - Restore-revalidate (U2): on an ISOLATED workspace (the shared suite's SSE stream starves the delta-sync cursor), archive via per-item event then RESTORE via the BULK endpoint (items_bulk_updated, no item_id) — proving the prop-driven strip revalidation covers what a per-item SSE subscription would miss. Asserts no attachments.list on archive, and a one-shot route HOLDS the restore's revalidation list in flight to prove the tiles never blank DURING the fetch, not just after. - Timeline (U1): a strip-UI delete (so announceAttachmentDeleted runs on the process-local bus) reconciles a comment thumbnail img→missing live, with the document + timeline element stamped to prove no reload or remount. Also fixes attachment-surface-chrome.spec.ts's U3 count barrier: a bodyless HEAD is reported as net::ERR_ABORTED after its headers arrive, so it fires requestfailed, never requestfinished — the completion barrier now keys on the response. New e2e/lib/attachment-viewer.ts helpers: createWorkspace, createDoc, archiveItem, restoreItem, bulkItems (workspace-slug-aware), STRIP_DELETE. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
9c9107176c |
test(e2e): reconcile attachment e2e with the converged surface (TASK-2493)
PLAN-2392 phase 3c-ii T7 — the e2e half of the convergence (one host, one Lightbox for ANY attachment; the options-panel + image-viewer channels retired). Falsified + rewritten (the convergence changed the premise, so these were rewritten to assert the new behaviour, not deleted): - strip file-tile / editor file-chip open the role=dialog surface (no-bytes fallback arm), not a role=menu options panel - modal two-stacked-viewers -> the SUPERSEDE invariant (one host mounts at most one Lightbox by construction) - owner-4 BottomSheet source moved from the retired file-panel to the surviving strip delete-confirm menu - parity/two-host exact dialog-name -> anchored RegExp (T2b grew the accessible name to "name, type · size"); enforced in the hostile-name leg - zoom thumb->original timeline, switch-safety, and mobile deferred-load counts: filter to GET (the T6 always-revalidate-on-open no-store HEAD hits the same variant-less URL and polluted the counts) New legs: PDF/ZIP fallback integration (Open for PDF, none for ZIP); T6 no-store HEAD count (one per open, none on arrow, one on reopen); DR-14 archived-parent probe-gate + archive-while-open close; dual-host peeked addressing + un-peek; Pixel-7 sheet geometry / dock contiguity / backdrop-vs-chrome dismissal / shortened-stage zoom / file route / overlay-centring / DR-18 label reveal / native-pinch touch-action / forced-colors Canvas plate; desktop-unchanged contrast. Each of the four load-bearing behaviours was MUTATION-verified (break in source, rebuild the worktree ./pad, confirm the targeted leg FAILS, restore, confirm green): fallback admission, host event addressing, archive-close transition, and the T6 forced no-store probe. Codex-reviewed to CLEAN over five rounds. New selectors live in web/e2e/lib/attachment-viewer.ts, addressed by class or accessible name (never a bare [role="dialog"]); assertions are item-scoped / by-id / by-anchored-name to avoid the BUG-2504 unscoped-list pagination trap. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
a593407a21 |
test(attachments): browser proof for the 3c-i surface chrome (TASK-2484)
DR-9's rule — the a11y and interaction work of the A-E chain is verified in a
browser or it is not verified. Desktop-chromium legs for the viewer's toolbar,
metadata header, delete flow, permission gate and gesture seams (the sheet
layout has no mobile e2e until 3c-ii).
New spec web/e2e/attachment-surface-chrome.spec.ts:
- Toolbar renders on all THREE origins (strip, timeline, body NodeView), with
Open/Download as real anchors carrying the EXACT canonical variant-less URL
(^/api/v1/workspaces/{ws}/attachments/{id}$) and the exact download filename.
- The permission gate: a peeked side withholds the delete affordance
(mutationsEnabled=false reaches it) and the active side's viewer toolbar
offers Delete.
- The delete flow: toolbar Delete → drill-down reached BY KEYBOARD with the
roving tabindex asserted (0/-1 ↔ -1/0), confirmed with Enter, the viewer
ADVANCES to the survivor (not the retired C1 close), and the deleted strip
tile disappears (bus reconciliation).
- The metadata header: name/type/size visible, a 180-char filename clipped with
a resolved text-overflow:ellipsis and the full value in title (DR-13), and the
inert-label contract proven by a DRAG on the header that does not pan a
zoomed BIG_PNG image nor dismiss the viewer.
- The gesture seams: a wheel over the toolbar zooms neither the image nor the
inert page behind it, with a control wheel over the stage that DOES zoom.
Every leg was mutation-checked against this worktree's built binary (revert the
impl line, rebuild, confirm the test fails, restore) — wheel exclusion,
peek-permission (mutationGate canEdit && !peeking), header name, delete advance,
toolbar-render, and the header pointer-exclusion. The wheel and header
mutation-checks each surfaced a false-pass that was fixed (a zoom-out clamped to
fit; a too-small image with no pan bound).
The FALLBACK arm + no-bytes invariant is a documented test.fixme: it is not
reachable through the real producers (they snapshot the viewer set at open and
filter unsafe MIME before it reaches the viewer), so it is jsdom-proven
(TASK-2476, via direct prop mutation). The peeked-side no-Delete VIEWER is
similarly jsdom-proven (TASK-2474): the content click that opens a viewer
re-activates (un-peeks) that side under the invisible-freeze model.
Two existing modal-spec trap tests were updated: the toolbar added focusable
controls, so the "last control" is derived (focusViewerLastControl) rather than
named, and the wrap is still asserted by name at both edges.
https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu
|
||
|
|
872612360a |
test(attachments): browser proof for viewer zoom — desktop and mobile (TASK-2461)
Phase 3b's final task: the browser-level proof of the attachment viewer's zoom/pan/loading behaviour that jsdom structurally cannot give (no layout, no CSS, no gestures). New Playwright spec e2e/attachment-viewer-zoom.spec.ts runs on desktop-chromium and the Pixel 7 (mobile-chromium) project, with shared fixtures/helpers added to e2e/lib/attachment-viewer.ts. Desktop legs prove the RENDERED transform moves under wheel / ctrl-wheel / keyboard / double-click; the anchored point stays under the cursor; pan clamps in two legs (an in-bounds drag moves by the delta, an over-drag stops at the edge with no further movement); a press-drag-return-to-start over the backdrop does NOT dismiss while a plain backdrop click does; a click on blank stage space inside the stage box but outside the image dismisses (the letterbox is pointer-transparent); close/nav stay hit- testable and Tab still cycles at maximum zoom; enlarging the window clamps the stranded scale down to the new maximum (still zoomed, not reset); reduced-motion suppresses the animation while normal mode keeps it; and forced-colors keeps the image boundary visible. Loading legs prove the thumb->original swap (the thumb response finishes before the original is requested) and that a rapid A->B->A with a slow original leaves the live image correct (the switch-safety end-state; Chromium aborts detached img loads, so the detached-late-error fence itself stays unit-covered). The mobile leg proves the DR-5b deferred cell issues no automatic request until a real tap, then loads exactly one original. Every assertion is mutation-checked against the binary the Playwright webServer launches: each test fails when its implementation line is reverted. Selectors are class-qualified or by accessible name, never a bare [role=dialog]. Controls are addressed by accessible name; shared constants live in the lib. Also lands the DR-4 forced-colors CSS in Lightbox.svelte (deferred in the zoom tasks): a system-colour border on the image boundary (its box-shadow is stripped under forced-colors) plus explicit ButtonText borders on the controls — the contract the browser proof verifies. Claude-Session: https://claude.ai/code/session_01WFBYxdBuSZs2tjipATxAZu |
||
|
|
228f99318b |
test(e2e): prove the viewer's modal contract in a browser (TASK-2436)
Phase 3a deleted a native `<dialog>` that `showModal()` was giving five
guarantees for free — top-layer stacking, background inertness, a focus trap,
focus restore and Escape — and hand-wrote each one. jsdom's `<dialog>` polyfill
(`src/test/setup-jsdom.ts`) only toggles attributes, so the phase's unit suites
cannot see ANY of those five: no inertness, no top layer, no `:modal`, no real
Tab traversal, no stacking. DR-9 says this is verified in a real browser or it
is not verified.
Three specs, 32 tests, each written against "what mutation would this catch
that a jsdom-equivalent implementation would survive":
attachment-viewer-modal.spec.ts — portal + MEASURED viewport geometry (a
`transform`/`contain` ancestor changes the rect, not the declaration); focus
entry; background inertness proven by injecting a focusable probe into every
body child AND by the REAL top-bar control, which can only go inert by
cascade; focus restore asserted as an ORDERING (the invoker is verified
UNFOCUSABLE while the viewer is up, so a restore-before-release could not
pass) AND on its DECLINE path, with a detached invoker — the ordinary case,
since the NodeView that opens the viewer is re-rendered on any document
change; the focus trap in BOTH directions, including the backward-wrap branch
(`nextTrapTarget` returns `last` only for Shift+first) and the single-control
viewer where first === last; `showModal()` vs `show()` vs a dialog mounted
closed and shown later, plus a native modal opened OVER the viewer winning
both Escape and Tab outright; paint order hit-tested against a 99999
body-portaled rival, with a raised-z-index control so the measurement is
provably sensitive to stacking (Chromium excludes inert subtrees from hit
testing, which would otherwise make it vacuous); Escape through BOTH real
route guards, asserting which layer closed; two stacked viewers; and the
mobile pane integration, where the pane's nested `inert` writes and the
backdrop's body-child writes are shown to be disjoint at every transition.
attachment-viewer-owners.spec.ts — all seven TASK-2430 owners, each with a
viewer-frontmost case AND an empty-stack regression: the six root shortcuts,
the collection route's navigation half, DockedSheet, BottomSheet, the TopBar
overflow menu, the sidebar edge swipe (including a gesture that STRADDLES the
viewer opening) and the co-mounted item graph.
attachment-viewer-parity.spec.ts — the finite parity matrix, four producers ×
{open, ←/→, Escape, backdrop click, close}; Enter/Space activation of inline
images including explicit `repeat: true` keydowns; Cmd/Ctrl+Enter still being
the comment editor's SUBMIT; hostile/long/bidi accessible names and RTL
geometry; the host lifecycle, driven through CLIENT-SIDE navigation with the
document verifiably still mounted (a `page.goto()` version would prove only
that unloading a document removes its DOM); and two-host isolation.
TWO KNOWN DEFECTS ARE RECORDED AS `test.fail()`, not papered over — BUG-2441.
One Escape over a DockedSheet or a BottomSheet closes BOTH that sheet and the
viewer. The sheets' `isBlockedByModal()` guards are correct; they are READ too
late. Both they and the route's escape driver are `window` keydown listeners,
and Svelte flushes the viewer's teardown synchronously inside the driver's
handler, so a sheet listener running later in the SAME dispatch sees an
already-empty lease stack. Invisible to the unit suites (one component's
handler, nothing releasing a lease mid-dispatch) and invisible to a
click-driven test — closing the same viewer with its Close button leaves the
sheet open, which is how it was isolated. The annotations are applied AFTER
setup, so a login/seed/navigation failure cannot hide behind them. The tests
assert the CONTRACT, so the day it is fixed they go red and the annotations
must come off. The TopBar overflow menu, checked the same way, is unaffected.
Documented gaps, stated rather than papered over: the paint-order rival is a
synthetic overlay at the picker's declared z-index (the real picker cannot be
co-present — opening the viewer by pointer dismisses it) and must be de-inerted
to be hit-testable; `expectBackgroundInert`'s floor is one behaviourally-proven
background child; the pane test's inert-set comparison identifies elements by
tag plus first class; and gestures under a frontmost viewer are dispatched
rather than delivered, since a real wheel or touch cannot reach a covered
element (the graph's baseline leg does use real input).
The shared fixture builds a real 200x150 PNG rather than reusing the 1x1 the
older attachment specs share: that one has a bad IDAT checksum, so thumbnail
decoding skips and the rendered `<img>` has no box — unclickable, and "not
visible" to Playwright. It also has to out-size the editor's image toolbar,
which is absolutely positioned over a small image's whole area.
Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
|
||
|
|
53252ee5d0 |
fix(web): close BUG-2129 stale-fields gap in the collection-edit switch fence (#961)
* fix(web): close BUG-2129 stale-fields gap in the collection-edit switch fence
The EditCollectionModal onupdated handler's switch fence used
`{@const keyedSlug = itemSlug}` to detect a superseded save, but Svelte 5
compiles that as a lazily-pulled derived signal — since keyedSlug was only
read inside the async callback, it always evaluated the CURRENT itemSlug,
not the value at modal-open time, making the fence a no-op. Replace it with
a genuine gen+id snapshot (pendingCollectionEditGen/-ItemId) captured
synchronously in the onmanage click handler, and use it to (a) skip
navigation/archive/close for a genuinely superseded save and (b) still
refresh the currently-shown item's fields when it's in the collection that
just migrated, closing the stale-fields clobber gap BUG-2129 describes.
Adds two Playwright regression tests: a same-collection pane-switch repro
(BUG-2129's literal scenario) and a cross-collection full-page navigation
test that mutation-testing confirms discriminates the fix (fails on the
pre-fix code with an observable wrong-page hijack).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): thread editedCollectionId through EditCollectionModal to fix overlap + rename/archive gaps
Codex review of the initial fix found two real gaps: (1) the
pendingCollectionEditGen/-ItemId snapshot was a single shared mutable slot,
so opening a second collection-edit (for a different item) while an earlier
save was still in flight would overwrite it, letting the earlier save's
completion misapply the wrong item's context; (2) the superseded-but-same-
collection branch only handled the reload case, not a pending rename
(still uses the stale collSlug -> 404) or archive (silently did nothing).
Replace the parent-side snapshot with a value EditCollectionModal itself
captures synchronously (before its API call) and echoes back through
onupdated(updated, editedCollectionId). ItemDetail's fence collapses to one
check — does the currently-shown item still belong to editedCollectionId —
applied uniformly to the reload, rename-redirect, and archive-redirect
paths, so all three now behave correctly whether or not the pane switched
items mid-save.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): gate the collection-edit fence on route/load settlement + fix a PATCH-timing test race
Codex round 2 found a narrower race: between a route change (collSlug/
itemSlug updating) and loadData()'s async resolution, item/collection can
transiently still hold the PREVIOUS item's data while collSlug/itemSlug
already reflect the new one. A mixed read across that window could pass
the collection-id fence using the stale `item` but build a navigation URL
from the already-updated collSlug/itemSlug, hijacking to a mismatched URL.
Gate the whole onupdated body on the existing `itemMatchesRef` invariant
(already used elsewhere in this file for the same "has loadData() caught
up" check) — bailing there is always safe since the route's own in-flight
loadData() will fetch fresh state regardless.
Also fixes a regression-test-only flake Codex flagged: the field-update
PATCH readback used waitForRequest (resolves on dispatch) instead of
waitForResponse (resolves on commit), so the immediate belt-and-suspenders
GET could race an in-flight write.
Deferred (documented in the PR, not fixed here): a further compound race
where a still-open second collection-edit modal on a different item could
be closed by an earlier, unrelated same-collection save's completion —
narrow, pre-existing-adjacent, and squarely in PLAN-2154 Phase 1's
dedicated R14 fence-sweep scope (TASK-2167) rather than this Phase 0 fix.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): allow a safe corrective reload during route transitions; sync test on PATCH response
Codex round 3: gating the ENTIRE onupdated body on itemMatchesRef (added
last round) was too broad — it also suppressed the corrective void
loadData() call during a route-transition window, reopening BUG-2129's
core symptom (an in-flight load that raced the migration and lost gets no
second chance). loadData() is idempotent and gen-fenced against any
in-flight load, so calling it again is always safe, even mid-transition.
Split the behavior: the relevance + reload decision runs unconditionally
(using whatever `item` is currently known, stale or not — false positives
just cause a harmless redundant reload), while only the RISKY navigation
(rename-redirect / archive-redirect, which builds a URL from collSlug/
itemSlug) stays gated on itemMatchesRef, since those are what a mixed
stale-item/fresh-route read could misdirect (Codex round 2's finding).
collectionStore.loadCollections(wsSlug) now runs unconditionally too,
keeping the global sidebar in sync regardless of pane relevance.
Also tightens the cross-collection test: replaces a fixed 1.5s sleep
after releasing the migration with an explicit wait for the PATCH
response, so the assertion can't false-pass by running before the
(possibly buggy) navigation had a chance to fire.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): fence the collection-edit callback on collSlug instead of item state
Codex round 4: gating rename/archive redirects on itemMatchesRef (added
last round) fixed the cross-collection hijack but broke the SAME-collection
case — during a same-pane item switch, collSlug never changes, so a
pending rename/archive that resolves mid-transition would skip the needed
redirect (itemMatchesRef false) and then loadData() 404s fetching the
now-stale/gone collSlug, with nothing to correct it afterward.
Replace the item-state-based fence entirely with a comparison against
`collSlug` — a plain reactive prop derived straight from the route params,
with no async lag (item/collection require a loadData() round-trip to
catch up; collSlug updates synchronously with navigation). EditCollectionModal
now also echoes back `editedCollectionSlug` (captured the same way as
editedCollectionId). `collSlug === editedCollectionSlug` is correct in
every case with no separate itemMatchesRef gate needed: unchanged for a
same-pane switch (so rename/archive/reload all still fire correctly),
and updated immediately for a cross-collection navigation (so a stale
edit's redirect correctly no-ops). This resolves the tension between
rounds 2-4 of Codex's findings with one simpler, more robust check.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
* fix(web): guard the collection-edit callback against a destroyed instance + workspace reuse
PR-level Codex review found two more real gaps in the fence:
1. The onupdated closure can still fire after this ItemDetail instance is
torn down entirely (pane closed, or the whole page navigated away)
while a save was pending — JS doesn't cancel a lingering promise on
unmount. Unguarded, that stale closure's goto()/collectionStore writes
would visibly affect whatever the user has since navigated to. Added a
one-way `destroyed` flag (set in onDestroy, alongside the existing
loadGeneration bump) checked first in the callback.
2. Collection slugs are workspace-scoped, so if this component instance
is ever reused across a workspace switch (no remount, same as the
existing collSlug/itemSlug reuse this fence already relies on), a
collSlug match alone can't tell two different workspaces' same-named
collections apart. EditCollectionModal now also echoes back
`editedWsSlug`, compared against the live `wsSlug` prop.
Also documents (not fixed here) a narrow pre-existing-adjacent edge case
Codex flagged: an embedded pane driven by a hand-crafted `?item=` whose
item lives in a different collection than the host page's collSlug won't
match this fence when its real collection is edited. This doesn't regress
anything (the prior fence was dead code and refreshed unconditionally
regardless of relevance) and is deferred to PLAN-2154 Phase 1 (TASK-2167).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
5033bf6ef8 |
fix(web): harden split-pane collab teardown flush + regression e2e (TASK-2117) (#947)
Verify + harden provider/doc/flusher teardown across BOTH pane teardown
paths PLAN-2105's no-{#key} persistent pane introduced, plus the raw-mode
data-loss fix. The task anticipated a real defect ("if the child Editor
tears down before the parent collab $effect cleanup, capture the markdown
synchronously"); an independent Codex pass + an empirical Svelte probe
confirmed that IS the case.
Product hardening (ItemDetail.svelte):
- On unmount Svelte destroys the child <Editor> BEFORE this component's
top-level collab $effect cleanup (top-level $effects are deferred to
component pop(), so they tear down AFTER the template render effect that
owns <Editor> — proven by src/lib/collab/teardownOrder/order.svelte.test.ts).
So at teardown-flush time the editor is already destroyed; persistence
survived only because Tiptap happens to still serve storage after
editor.destroy() — a fragile implementation detail.
- Add `lastEditorMarkdown`, a shadow captured on every edit
(handleContentUpdate), reset per provider instance so it can't cross
items. readEditorMarkdown now prefers live storage only while the editor
is alive (!isDestroyed) and falls back to the shadow otherwise — making
the teardown flush correct-by-construction regardless of destroy order
or Tiptap's post-destroy behavior. The earlier comment (which claimed
the reverse ordering) is corrected.
Verified findings (unchanged, correct):
- Item-switch: collab $effect cleanup flushes → destroys provider+doc →
nulls slots; loadData reset + editorInstance-nulling fire.
- Raw-mode: loadData flushes the raw saver (keepalive) before
clearPending() when dirty.
- CollabProvider: per-itemID sessionStorage cursor isolates A/B; no change.
Regression e2e (pane-collab-teardown.spec.ts) — each proven to fail when
its target fix is reverted (mutation-tested), so they can't false-pass on
a timer-based backup (the risk the Codex pass flagged):
- close pane → edit reaches items.content; the collab-snapshot PATCH must
DISPATCH within 3s of close (the teardown flush), not ~5s later (the
idle backup).
- switch A->B in-pane → outgoing edit flushed (loadData cancels the idle,
so the cleanup flush is the sole path), exactly one collab WS at a time,
close → 0 WS, no A->B cross-write.
- switch mid raw-debounce → outgoing raw edit flushed (loadData cancels
the debounce, so the keepalive flush is the sole path).
Explicit 20s waits on "Synced" cover a slow handshake + reconnect backoff.
Extracts shared login/seed scaffolding into e2e/lib/collab-helpers.ts
(reused by collab-persistence.spec.ts).
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
954c84d0bf |
refactor(e2e): extract demo data into shared module (TASK-1201) (#431)
Lift the static "realistic workspace" data out of seedRealisticContent
into web/e2e/lib/demo-data.ts so it can be consumed by pad-remotion (a
sibling repo) without dragging in Playwright as a dependency. Single
source of truth for what a real-feeling Pad demo looks like.
Wire-shape compatibility is preserved exactly:
- demoPlan: same title, status, content
- demoTasks: same 7 tasks in same order, same status/priority/effort,
same parent-to-plan linkage (now expressed via parentToPlan: boolean
rather than carrying the plan id inline — the seeder maps it back
to the freshly-created plan id at post time)
- demoIdeas: same 2 ideas, same fields
Behavior verified by running the gated screenshot spec:
PAD_SCREENSHOTS=1 npx playwright test e2e/screenshots.spec.ts \
--project=desktop-chromium
seedRealisticContent runs to completion and the dashboard / board /
list / table screenshots regenerate identically (reverted; not part
of this PR's diff).
demoConventions is also exported (4 representative entries) for the
pad-remotion ContextScene to render ghost-cards. demo-seed.ts itself
doesn't consume it — seedConventions takes caller-supplied input — but
the shape lives here so the shared data module is complete.
The companion pad-remotion file (src/data/demoItems.ts) lands as a
separate PR in PerpetualSoftware/pad-remotion. We chose copy-with-
manual-sync over a path import / symlink because pad-remotion is a
distinct git repo; a CI drift check is a possible follow-up if this
duplication starts to bite.
Parent: PLAN-1198.
|
||
|
|
cf04e16b5e |
chore(e2e): blog screenshot capture spec + shared seed helpers (TASK-1031) (#374)
Adds infrastructure for capturing Pad UI screenshots that ship inside
blog posts on getpad.dev.
* web/e2e/lib/demo-seed.ts (new) — extracts the realistic-content seed
(1 active plan + 7 tasks + 2 ideas) from screenshots.spec.ts into a
shared module, plus two new helpers:
- seedConventions(fixture, request, [...])
- activateLibraryConventions(fixture, request, titles)
Both consumers now share the same source of truth.
* web/e2e/blog-screenshots.spec.ts (new) — gated on
PAD_BLOG_SCREENSHOTS=1. One test.describe per blog post; each owns
its post-specific seed and captures into ../../pad-web/static/blog/
<slug>/. First consumer is BLOG-1007 (Conventions and Playbooks);
subsequent posts add a describe block per shot.
* web/e2e/screenshots.spec.ts — refactored to import seedRealisticContent
from the shared lib. No behavior change; PAD_SCREENSHOTS=1 README
capture still passes.
Companion publish helper lives in pad-web at scripts/blog-publish.mjs.
Capture command:
make build-go && cd web && PAD_BLOG_SCREENSHOTS=1 \
npx playwright test blog-screenshots --project=desktop-chromium
Refs TASK-1031, unblocks BLOG-1022 / BLOG-1004 / BLOG-1003 backfill
which all want screenshots.
|