217 lines
16 KiB
Markdown
217 lines
16 KiB
Markdown
# 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 = mentions−sender ∩ 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 (model runs, reply delivered). `wakeup:false` → `recordInboundSession` only: message enters the agent's OpenClaw session as history/context, **model NOT run, nothing sent back** (no `/no-reply` — turn engine expects silence from non-woken agents). Verified: log `recorded (no wakeup, history only)`, 0 dispatch/deliver/posted |
|
||
| 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) — agent file receipt PROVEN live.**
|
||
After repointing the fabric binding to a real agent
|
||
(`bindings[*].agentId` `echo`→`home-developer`, per `~/.openclaw/
|
||
openclaw.json` — `echo` was never a defined agent), a human posted a
|
||
file in Fabric → `wakeup` → plugin admitted → **downloaded the
|
||
attachment with the agent's guild token** to a temp dir → set local
|
||
`MediaPaths` → the openclaw agent then invoked its `read` tool on
|
||
exactly that file (`/tmp/fabric-media-echo-<msgId>/0-<name>` in the
|
||
log). i.e. the uploaded file demonstrably reaches and is opened by the
|
||
agent. Bug found & fixed during this test: `MediaUrls` (a `localhost`
|
||
URL) tripped openclaw's SSRF guard — now only local `MediaPaths`/
|
||
`MediaTypes` are passed.
|
||
- The agent→Fabric **final-reply leg (P13) still not observed**: the
|
||
local kimi-backed agent ends its turn after the tool call without
|
||
emitting final text (no agent reply on any channel all day), so the
|
||
plugin's `deliver` is never called. Pre-existing local model/agent
|
||
behavior, independent of the Fabric files/canvas/plugin code.
|
||
- `discuss`/`work` differ only in x_type label; turn semantics identical —
|
||
test one, both covered.
|
||
- Desktop / Android submodules are out of scope (untouched).
|