Files
Fabric/docs/TEST_POINTS.md
2026-05-15 21:50:40 +01:00

211 lines
16 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` |
| F18 | composer file attach | 📎 button + multi file input; selected files shown as removable chips; sent after upload; image preview / download chip rendered (via `?access_token`) |
| F19 | pinned canvas panel | fixed below topbar, independent of message scroll; md→renderMarkdown, text→`<pre>`, html→sandboxed `<iframe sandbox srcdoc>`; collapse/expand |
| F20 | canvas share/edit | Share (no canvas) / Edit (sharer) modal: title, format, source; sharer-only Edit/Remove; live update via `canvas.updated`/`canvas.removed` sockets; channel & messages context menus |
## 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 |
| P14 | file delivery to agent | message attachments downloaded with the agent's guild token to a temp dir; **only local** `MediaPaths`/`MediaTypes` (+ singular) set on the finalized inbound context. No `MediaUrls` — the guild URL is a private host and openclaw's SSRF guard blocks re-fetching it (verified live: `fabric: fetched N attachment(s)`, SSRF WARN gone after the fix) |
## 8. Fabric.Backend.Guild — files & canvas
| # | Test point | Expected |
|---|---|---|
| FC1 | `POST /files` (multipart) | returns `{fileId,url:/api/files/:id,name,mimeType,size,expiresAt}`; `expiresAt ≈ now + TTL` (default 7d) |
| FC2 | size limit | `> FABRIC_BACKEND_GUILD_FILE_MAX_BYTES` (default 100MB, operator-configurable) → 400 |
| FC3 | `GET /files/:id` auth | reachable with Bearer **or** `?access_token=` (browser `<img>/<a>`); no token → 401; image/pdf/av inline, else attachment |
| FC4 | bytes round-trip | downloaded content byte-identical to upload |
| FC5 | retention sweep | rows past `expiresAt` purged with their blob on boot + hourly (`FilesService.cleanup`) |
| FC6 | message attachments | `attachments[]` persisted on the message and returned by `GET …/messages` |
| FC7 | canvas single-active | one row per channel (unique `channel_id`); `GET` null when none |
| FC8 | canvas share (PUT/POST) | caller becomes `sharerUserId`, `version=1`; re-share replaces (resets version, new sharer); emits `canvas.updated` |
| FC9 | canvas update (PATCH) | original sharer only (else 403); `version` increments; emits `canvas.updated` |
| FC10 | canvas delete | sharer only (else 403); emits `canvas.removed` |
| FC11 | canvas access scope | non-member of a non-public channel → 403 on get/share |
## 9. 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.
- **Files & canvas (§8)** backend is automated-e2e verified (upload,
`?access_token` download, 401, attachment persistence, canvas
share/update/replace/delete + sharer-only/access enforcement; retention
deadline asserted, sweep logic unit-level only — not waited out).
- **Plugin file delivery (P14)** verified live up to the agent boundary:
human posts a file in Fabric → `wakeup` → plugin admits → **downloads
the attachment with the agent's guild token** and sets local
`MediaPaths` → inbound turn runs through the kernel and returns cleanly.
The agent→Fabric **reply leg (P13) could not be exercised** in the local
openclaw instance: it emits **zero agent replies on any channel** (no
responder wired — `echo` agentId is undefined so it falls back to
`main`, whose model produces no deliverable output). That is a
pre-existing environment gap, independent of Fabric. Bug found & fixed
during this test: `MediaUrls` (a `localhost` URL) tripped openclaw's
SSRF guard — now only local `MediaPaths` are passed.
- `discuss`/`work` differ only in x_type label; turn semantics identical —
test one, both covered.
- Desktop / Android submodules are out of scope (untouched).