Files
Fabric/docs/TEST_POINTS.md
hzhang 77be66e26b chore: bump Guild+Frontend (bypass-list); add docs/TEST_POINTS.md
bypass-list feature for discuss/work channels (order/bypass disjoint
partition, mention nesting cap, mid-rotation move-to-bypass) + the
stack-wide test-point reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:35 +01:00

176 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Fabric — Test Points
Reference checklist of everything that should be verified across the stack.
Organized by component. Re-run the **whole list** after any infra-level change
(ESM migration, dependency bump, Docker/compose change) since those can
regress any path.
Legend: **Verified** = exercised on the live local stack at least once.
Local stack = `docker compose -f docker-compose.local.yml` (Center :7001,
Guild1 :7002 = `test-guild1`, Guild2 :7003 = `test-guild2`, Frontend :8088).
---
## 1. Fabric.Backend.Center (auth / identity)
| # | Test point | How to verify | Expected |
|---|---|---|---|
| C1 | `user create` CLI | `node dist/cli.js user create --email <e> --password <p>` | `{ok:true,user}` |
| C2 | `user apikey` CLI | `node dist/cli.js user apikey --email <e> [--label l]` | `{ok:true,apiKey:"fak_…"}` once |
| C3 | `POST /auth/login` | valid creds | session: accessToken, refreshToken, user{id,email,name}, guilds, guildAccessTokens |
| C4 | login email validation | email with 1-char TLD (`a@b.c`) | 400 (`@IsEmail`) — login & register both reject |
| C5 | `POST /auth/refresh` / `logout` | with refreshToken | new tokens / `{status:ok}` |
| C6 | `GET /auth/me` | bearer token | `{id,email,name}` |
| C7 | `PATCH /auth/me {name}` | bearer; then re-login | name updated & persists; empty → 4xx; default name = email |
| C8 | `POST /auth/agent/login {apiKey}` | valid key | same shape as login (guilds+tokens) |
| C9 | agent/login bad key | `{apiKey:"nope"}` | 401 |
| C10 | api-key-guard exemptions | login, agent/login, refresh, logout, GET/PATCH me, me/guilds, join, members | reachable without `x-api-key` |
| C11 | `POST /auth/resolve-names {guildNodeId,names}` | api-key auth (guild node key) | name/email → userId, scoped to guild members; unknown omitted |
| C12 | `POST /auth/me/guilds/join` | bearer | membership added |
| C13 | `GET /auth/me/guilds` | bearer | guilds + fresh guildAccessTokens |
| C14 | `GET /auth/guilds/:nodeId/members` | member bearer | members incl. `name` |
| C15 | guild node register | CLI / `POST /api/nodes/register` (localhost) | node + apiKey |
| C16 | `user.name` defaults to email | new user | login/members responses carry name=email until changed |
## 2. Fabric.Backend.Guild — channels
| # | Test point | Expected |
|---|---|---|
| G1 | create channel: `xType` required | missing → 400 |
| G2 | `xType` enum | not in {general,work,report,discuss,triage,custom} → 400 |
| G3 | creator auto-added as channel member | always in `channel_members` |
| G4 | `memberUserIds` added | listed users become members |
| G5 | triage `onDuty` required | missing → 400 |
| G6 | triage onDuty | auto-added member + 1 `wake_mapping` row |
| G7 | custom `listeners` | one `wake_mapping` row per listener (optional/empty ok) |
| G8 | `POST /channels/:id/close` | member (or public) → ok; non-member non-public → 403 |
| G9 | post to closed channel | 409 `{error:"channel_closed"}` (normal + command) |
| G10 | closed channel history | `GET …/messages` still 200 |
| G11 | `listForUser` carries `closed`/`isPublic`/`isMember` | present & correct |
| G12 | `POST /channels/:id/join` | public → member; non-public non-member → 403; idempotent |
| G13 | `POST /channels/:id/leave` | removes `channel_members` + `wake_mapping` rows + turn order entry |
| G14 | `GET /channels/:id/members` | explicit member userIds |
| G15 | `listForUser` visibility | public OR explicit member only |
| G16 | create discuss/work with `bypassUserIds` | order = members bypass (∩ members), sorted; bypass stored; disjoint partition |
| G17 | `POST /channels/:id/bypass {userId}` | any channel member actor; target must be a member; discuss/work only (else 400); moves target order→bypass |
| G18 | `GET /channels/:id/members` carries `bypass` | each row `{userId, bypass:boolean}` from turn state |
## 3. Fabric.Backend.Guild — messaging / wakeup
| # | Test point | Expected |
|---|---|---|
| W1 | one message-id; metadata at push only | `message.created` socket payload = view + `channelId` + per-recipient `wakeup` |
| W2 | author rule (precedence) | author's own message → `wakeup=false` (overrides all) |
| W3 | general (no mention) | all recipients true except author |
| W4 | general + `<@id>` | only at'd (mentioned author) true; others false |
| W5 | report | all false |
| W6 | triage / custom | only `wake_mapping` users true |
| W7 | mention parse | `<@id>` counts only outside backtick spans |
| W8 | name-mention translation | `<@user.name:NAME>``<@userId>` (Center resolve), outside backticks |
| W9 | name-mention unresolved | left literal |
| W10 | name-mention in backticks | left literal (not translated) |
## 4. Fabric.Backend.Guild — slash commands
| # | Test point | Expected |
|---|---|---|
| S1 | command registry | only registered (`/no-reply`,`/force-proceed`) intercepted |
| S2 | unknown `/x` (e.g. `/etc/passwd`) | delivered as a normal message |
| S3 | command never delivered | no message row; response `{status:command}` |
| S4 | `/no-reply` outside discuss/work | swallowed, no effect |
## 5. Fabric.Backend.Guild — discuss/work turn engine
| # | Test point | Expected |
|---|---|---|
| T1 | init state | `currentSpeaker = NULL`, order = members sorted by id, frames `[]` |
| T2 | single-member channel | `currentSpeaker` stays `NULL` forever |
| T3 | activation (from NULL) | speaker X → moved to order[0], `currentSpeaker = order[1]` |
| T4 | advance (current speaker normal) | `currentSpeaker → successor`; wakeup only successor |
| T5 | queue-jump (non-current normal) | no advance; all wakeup false; resets cross-round no-reply streak |
| T6 | queue-jump `/no-reply` | swallowed; does NOT reset streak |
| T7 | current `/no-reply` | guild `/ack` emitted; advance; streak += sender |
| T8 | all-members consecutive `/no-reply` (cross-round) | pause → `currentSpeaker=NULL`; resumes on next proactive normal msg |
| T9 | end-of-round shuffle | trailing `/no-reply` run → tail; anchor = last "prev not /nr & self /nr"; head shuffled; head[0] ≠ last normal speaker (D) |
| T10 | shuffle infeasible (head empty / only D) | pause instead of shuffle |
| T11 | `/force-proceed` | skip current (not recorded, streak untouched), advance; at order[-1] triggers shuffle |
| T12 | member join mid-rotation | appended to order tail |
| T13 | member leave | removed from order/streak/frames; if current → successor takes over |
| T14 | mention sub-frame push | current-speaker mention → push `{order:atList, idx:0}`; atList = mentionssender ∩ members; effective current = atList[0] |
| T15 | sub-frame single pass | each member acts once (real / `/no-reply` / `/force-proceed`) → pop |
| T16 | sub-frame pop | parent restored at saved pointer (the pusher); root `current_speaker` column preserved during sub-frame |
| T17 | nested sub-frames | mention inside a sub-frame pushes deeper; pops in order |
| T18 | backtick mention | `` `<@id>` `` does NOT push a sub-frame |
| T19 | `/ack` message | author=`guild`, content `/ack`, persisted (own message-id+seq), wakeup=true only for new currentSpeaker (else all false) |
| T20 | bypass excluded from rotation | bypass member never becomes currentSpeaker / never woken by normal rotation |
| T21 | mentioned bypass member | current-speaker mention of a bypass user → pushed into a sub-frame (transient), woken; on pop returns to bypass (not added to root order) |
| T22 | `moveToBypass` mid-rotation | target removed from order/streak/frames, added to bypass; if target was currentSpeaker → successor takes over (null if order empties) |
| T23 | mention nesting cap | max 4 sub-frames (5 levels incl. root); 5th push evicts bottom-most: root→A→B→C→D + E ⇒ root→B→C→D→E |
| T24 | member-leave strips bypass | leaver removed from `bypassUserIds` too (no orphan) |
## 6. Fabric.Frontend
| # | Test point | Expected |
|---|---|---|
| F1 | login screen | dark card; default Center base; no API key field |
| F2 | channels list | only member/public; per-x_type color; xType badge |
| F3 | create-channel modal | name; required Type select; Public checkbox default off; member list **excludes self**; creator auto-added note |
| F4 | triage in modal | required On-duty single-select, default = current user |
| F5 | custom in modal | optional Listeners multi-select |
| F6 | members sidebar | non-public channel selected → split "In channel" / "Guild"; public/no-channel → single list; shows name + (you) |
| F7 | join/leave buttons | topbar shows Join (non-member, public) / Leave (member) |
| F8 | closed channel | composer replaced by read-only banner; history still rendered |
| F9 | markdown render | per-message; HTML-escaped (XSS-safe); unclosed code fence contained to that message; no cross-message bleed |
| F10 | `<@id>` mention chip | `@DisplayName` pill (resolved via members; short-id fallback); backtick-wrapped literal; untranslated `<@user.name:>` literal |
| F11 | name edit (Settings) | PATCH /auth/me; reflected after save |
| F12 | dev mode toggle | shows guild `/ack` + per-message `wakeup` metadata; hidden when off; persists in localStorage |
| F13 | guild-token refresh on mount | no stale-token "Failed to load channels" after >15 min / reload |
| F14 | message not split | long agent reply renders/stored as one message (no Discord-style chunking) |
| F15 | Discord-style dark theme | server rail / channel sidebar / messages / members layout |
| F16 | create-modal bypass select | discuss/work only: optional multi-select of guild members; sent as `bypassUserIds`; reset on open/close |
| F17 | members panel bypass UI | discuss/work + in-channel list only: bypass members tagged `bypass`; others show "→ bypass" action calling `POST :id/bypass` |
## 7. Fabric.OpenclawPlugin (channel plugin)
| # | Test point | Expected |
|---|---|---|
| P1 | build vs real openclaw SDK | `node install.mjs --build-only` clean |
| P2 | install script | `--install` / `--uninstall` / `--openclaw-profile-path`; copies to `~/.openclaw/plugins/fabric` + `openclaw config` |
| P3 | plugin loads & registers | `openclaw channels list` → `Fabric … installed, configured, enabled` |
| P4 | independence | no openclaw source modified (plugin dir + config only) |
| P5 | agent auth | `channels.fabric.accounts.<agentId>.fabricApiKey` → `agent/login` session |
| P6 | inbound transport | one socket per agent; joins channel rooms; logs connect/join |
| P7 | wakeup → admission | `wakeup:true` → dispatch (agent runs); else drop (kept as history) |
| P8 | account → agent routing | requires `cfg.bindings` `{agentId,match:{channel:"fabric",accountId}}`; else falls back to default agent |
| P9 | dispatch | `runtime.channel.turn` path: `resolveAgentRoute` + `finalizeInboundContext` + `dispatchInboundReplyWithBase` |
| P10 | outbound | agent reply posted back to Fabric **as the agent**, exactly **one** message (no chunking; `disableBlockStreaming`) |
| P11 | tools | `fabric-register`; `create-{chat,work,report,discussion}-channel` (→ x_type); `discussion-complete` (summary + close) |
| P12 | gateway lifecycle | starts inbound on `gateway_start`, stops on `gateway_stop`; no separate sidecar |
| P13 | full round-trip | human posts in Fabric → wakeup → agent runs → reply lands in channel as agent |
## 8. Cross-cutting / infra
| # | Test point | Expected |
|---|---|---|
| X1 | ESM everywhere | all subprojects build & run as ES modules (NodeNext, explicit `.js` imports, CJS deps default-imported) |
| X2 | backends boot under ESM | no `ERR_MODULE` / `jwt.sign is not a function` / interop 500s |
| X3 | local stack bring-up | 2 mysql + Center + 2 Guilds + Frontend healthy; guild nodes registered; users creatable |
| X4 | DB_SYNC schema add | new entities/columns auto-create without data loss (additive) |
| X5 | metadata/message separation | one message-id; metadata only at push; frontend/desktop metadata-agnostic; `author=guild` hidden unless debug |
| X6 | submodule pointers | parent `Fabric` repo bumped after each submodule change; pushed to `origin/main` |
---
## Known coverage gaps / notes
- **Frontend (§6)** is build-verified (tsc) and was manually checked when
built; not browser-exercised in automated runs. ESM migration did not touch
the frontend.
- **Plugin polish (not yet hardened):** per-agent 15-min token refresh for
long-lived sockets (currently re-auth only on gateway restart); messages
posted before an agent socket finishes joining are missed (no backfill);
guild-token reconnect handling is minimal. Phase 2 (B2 firehose) not built.
- `discuss`/`work` differ only in x_type label; turn semantics identical —
test one, both covered.
- Desktop / Android submodules are out of scope (untouched).