16 KiB
16 KiB
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_tokendownload, 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[*].agentIdecho→home-developer, per~/.openclaw/ openclaw.json—echowas 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 localMediaPaths→ the openclaw agent then invoked itsreadtool 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(alocalhostURL) tripped openclaw's SSRF guard — now only localMediaPaths/MediaTypesare 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
deliveris never called. Pre-existing local model/agent behavior, independent of the Fabric files/canvas/plugin code. discuss/workdiffer only in x_type label; turn semantics identical — test one, both covered.- Desktop / Android submodules are out of scope (untouched).