cellajs/cella/two-tab-sync-test
Claude Code and agent skill by cellajs/cella
- community
Checking that the SKILL.md file is still in the repository.
Drive two signed-in browser tabs or two users with playwright to verify or diagnose realtime sync at runtime, for entity changes (CDC → SSE → fetch prioritizer → cache patch) and for collaborative descriptions (Yjs relay → saved row, outside writes, offline, a relay outage).
Install
npx vskill@latest install cellajs/cella/two-tab-sync-testSKILL.md
Two-tab realtime sync testing
Use when a live-sync symptom needs runtime evidence ("create doesn't show up in the other tab", "typing doesn't reach the other editor", "offline edits are gone") or to verify sync changes end-to-end. Two drivers, each a fixed matrix with per-tab console, network evidence and screenshots:
two-tab-driver.mjs: one user in two tabs. Rename control, create, delete-fresh, delete-preseeded, description typing and save, reload truth-check.description-driver.mjs: two users (and a third who may only read) on one description. Cursors, saves while typing, an outside write, offline from storage, a relay outage, sign-out with unsynced edits, two offline tabs, a delete under unsaved edits.
Preconditions
- A healthy dev stack in the checkout under test, its stdout captured (verify, Launch). The driver reads that checkout's URL and cookie name.
- The description experiment needs the relay (
appConfig.services.yjs.enabled, on by default). - Postgres probes:
psql <DATABASE_ADMIN_URL from backend/.env>(e.g. a row'sseq,deleted_atordescriptionafter an action). - Never act in the org a human is testing in (watch the request log for their active org); pick another seeded org.
Auth: a minted session
The driver mints a session for each user and signs it out when it ends (verify, Drive).
- Default, the bench user (needs
pnpm --filter bench db:seedonce):xbench-user-0000@xbench.localis admin ofxbench/xbench-org. The seed sets that tenant's attachment quota to unlimited, so every experiment runs there, creates included. - Any other seeded org: pick an admin and pass
EMAILandORG_PATH.
SELECT o.tenant_id, o.slug, m.role, e.email FROM organizations o JOIN memberships m ON m.organization_id = o.id JOIN emails e ON e.user_id = m.user_id WHERE o.slug <> 'xbench-org' ORDER BY o.slug, m.role LIMIT 20; An org at its tenant's attachment quota (appConfig.defaultRestrictions.quotas) answers creates with 403 `restrict_by_app`.
- The description driver's users:
EMAILandEMAIL_Bboth edit (bench users 0000 and 0001 by default),EMAIL_VIEWERmay read the row and not update it. With the template's permissions that is amemberwho did not create the row; every bench member is an admin, so the read-only experiment needs another org and is skipped withoutEMAIL_VIEWER.
Run
From the repo root:
Writes evidence.json (every console line / range fetch / assert, ms timestamps) and shots/*.png. Tab A acts, tab B observes. Asserts print PASS/FAIL live. The first line, target, names the URL and cookie in use: check it against the stack you mean to test.
Writes description-evidence.json and shots/*.png, about two minutes for the whole matrix. User A acts, user B keeps the description of ROW (the first row by default) open and observes; server truth is read through the API. It ends with the count of passed asserts and exits 1 when one failed.
A relay change in a worktree runs beside the main stack, with no stack of its own: start that checkout's relay alone, pointed at the main backend (BACKEND_INTERNAL_URL=<the main backend's internal URL> pnpm --filter yjs-worker dev; it listens on the worktree's own relay port and, with the same backend/.env values, shares the database and the token key), then run the driver from the main checkout with RELAY=ws://localhost:<that port>. Every editor socket of the run goes to that relay, and the main stack serves the rest.
Reading the evidence: entity changes
| Evidence | Meaning | |
|---|---|---|
[TabCoordinator] Acquired leader lock in one tab, [AppStream] Not leader, listening to broadcasts only in the other | election ran; only the leader opens SSE (sse-connect GET; the POST on the same path is catchup) | |
[handleProductChange] attachment:<action> viewing=<bool> in tab B console | the change crossed SSE + BroadcastChannel | |
Echo: patched stx, skipped data fetch in tab A | acting tab suppressed its own event (correct; sourceId is per-tab, uuidv7 at page load) | |
[CacheOps] Range fetch: <type> after a until b patched N entities + network GET …?afterSeq=a&untilSeq=b | prioritizer flushed and fetched | |
range response items (id, name, deletedAt, seq) | payload truth; a range read includes soft-deleted rows, as tombstones without a name | |
[CatchupProcessor] Away longer than the activity window → cursors reset, synced data refetched | the device was away longer than appConfig.retention.activityDays (caughtUpAt in syncStore). To test it, move caughtUpAt in the persisted sync store back and reload |
Decision rule: no console change line and the API's `cdc` component is not healthy (its reason says why) → the worker (read its log), not the client; no console change line otherwise → SSE/leader/broadcast layer; change but no fetch → fetch prioritizer/cursor (check the org and view seqs in syncStore, frontend/src/query/realtime/sync-store.ts); fetch contains the row but UI unchanged → cache-patch layer (fetchRangeAndPatch in frontend/src/query/realtime/cache-ops.ts).
Became leader, reconnecting... follows whenever a tab gains leadership with no open stream: at startup, or when the leader tab closed (Client). viewing=true marks a tab on the org route, which fetches at once. Chromium throttles a hidden tab's timers to about 1s, so use generous assert windows.
Creates not propagating: check the applyServerEntity applicator in cache-ops.ts first.
Reading the evidence: descriptions
Typing does not ride the entity path. Keystrokes go editor → relay socket (/yjs/<entityId>) → the other editors. The relay saves the row a few seconds after the last keystroke (Save), and the saved row then travels the entity path like a rename.
| Evidence | Meaning | |
|---|---|---|
relay-http yjs/token 200, then relay-connect ws://…/yjs/<id> in both tabs | each editor got a token and joined the document | |
| EXP5 live: the typed text in tab B's editor | the relay broadcasts | |
relay-http yjs/pull or yjs/push | the socket is out of reach; the editor syncs over HTTP through the API | |
EXP5 saved: a range-fetch item with hasWatchedText: true | the relay wrote the row and it crossed CDC → SSE → fetch | |
descriptionSaved after the reload | server truth |
Decision rule: no `relay-connect` → token or socket (the yjs/token status, the relay process, the /yjs proxy); connected but no live text → relay session and broadcast (yjs/src/sync/relay.ts); live text but no saved row → compaction and materialization (yjs/src/sync/compaction.ts, the backend's /internal/yjs/materialize); saved row fetched but the cell is stale → the cache rule for Yjs-owned fields (registerYjsOwnedFields).
The table cell of an editing tab proves no save: its own editor already patched the cache. Only the range fetch and the reload show the server row.
The description driver's experiments, by their ONLY name:
| Experiment | Passes when | A failure points at | |
|---|---|---|---|
live | typed text reaches the peer, its cursor follows a burst of arrow keys to where it ends, and leaves when the sheet closes right after a move | relay broadcast and the awareness rate window (yjs/src/sync/relay.ts) | |
typing | the server row holds the text while nonstop typing goes on (about 10s in) | the max-wait compaction (yjsCompactMaxWaitMs) and materialization | |
outside | a description written through the API shows in both open editors, with no reconnect and no toast | recordYjsOutsideWrite and the relay's log listener | |
viewer | a user without update rights gets the static only: no token request, no socket | canEdit in collaborative-blocknote.tsx | |
offline | a stored document opens editable offline, keeps its edit across a reopen, and syncs once online with no notice | yjs-store.ts, and the load before connect in yjs-connections.ts | |
outage | the limited-sync status after about 5s, edits crossing over HTTP both ways, the socket back with no reload | yjs-http.ts and the transport switch in yjs-connections.ts | |
signout | the sign-out page lists the unsynced document, "keep editing" stays signed in, the edit saves once a server is in reach | modules/auth/unsaved-edits-dialog.tsx, the resume of stored documents | |
tabs | two offline tabs of one user show each other's edits, and both reach the server row | yjs-tab-channel.ts | |
delete | a row deleted under unsaved edits brings the copy-or-discard notice and the deleted status | unsaveable-notices.tsx, the relay's 4410 close and the routes' 404 |
relay-http lines with route pull or push during outage are the HTTP transport at work; the same lines in any other experiment mean the socket failed there.
Gotchas
This is the start of the file. Read the full SKILL.md on GitHub.
Security
- Security scanPassed, 90 of 100Pattern checks on the skill's files.
- Model reviewPassed
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingcella/skills/two-tab-sync-test/SKILL.md
Popularity
- 7-day trend score
- 5
Not refreshed yet.
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/cellajs/cella/two-tab-sync-test)