Files
Fabric/docs/TEST_POINTS.md
hzhang 09acb5521c chore: slash-command full chain — bump Frontend/Plugin/Guild; TEST_POINTS §10
Backend.Guild registry + Plugin catalog sync + Frontend / autocomplete.
Verified: registry PUT/GET (401 unauth), 41 specs built+synced (incl.
dynamic choices), GET round-trip, frontend bundle+fetch wired.
Caveat: in-browser / panel interaction not automated; in-gateway auto
sync needs plugin reinstall + gateway restart.

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

18 KiB
Raw Permalink Blame History

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 listFabric … installed, configured, enabled
P4 independence no openclaw source modified (plugin dir + config only)
P5 agent auth channels.fabric.accounts.<agentId>.fabricApiKeyagent/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:falserecordInboundSession 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

10. Slash commands (registry / sync / autocomplete)

# Test point Expected
SC1 Guild registry API PUT /api/commands {commands} idempotent full replace; GET /api/commands{commands,updatedAt}; both authed (no token → 401). Verified
SC2 plugin builds catalog buildFabricCommandSpecs(cfg) via openclaw/plugin-sdk/native-command-registry (listNativeCommandSpecsForConfig+findCommandByNativeName); dynamic arg choices resolved to a static snapshot (resolveCommandArgChoices). Verified (41 specs, choices incl. dynamic)
SC3 plugin syncs on start syncFabricCommands runs after inbound on gateway_start; PUTs catalog to each connected guild (one per guild, idempotent). Verified via direct call (synced 41 -> test-guild1); in-gateway run needs plugin reinstall + gateway restart
SC4 no nativeCommands capability Fabric stays a TEXT-command surface; a /<cmd> message is delivered normally → plugin → OpenClaw command system. Only /no-reply,/force-proceed stay server-intercepted
SC5 frontend autocomplete / opens a command panel (filter, ↑↓/Enter/Esc); pick inserts /<nativeName> ; arg stage shows args + clickable choices. Built & deployed; browser interaction not automated — catalog fetch + bundle wiring verified
SC6 command execution a registered /<cmd> reuses the existing message→plugin→OpenClaw path (text-command + command session + auth); not re-verified end-to-end here (same path as P13/P14)

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 echohome-developer, per ~/.openclaw/ openclaw.jsonecho 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).