diff --git a/Fabric.Backend.Guild b/Fabric.Backend.Guild index 8c41d23..b3fcefb 160000 --- a/Fabric.Backend.Guild +++ b/Fabric.Backend.Guild @@ -1 +1 @@ -Subproject commit 8c41d23a9cf079030979b234cab41e9f7da2862d +Subproject commit b3fcefb5ec677f1709275bd46b436ca46c60a311 diff --git a/Fabric.Frontend b/Fabric.Frontend index 44c308b..372805c 160000 --- a/Fabric.Frontend +++ b/Fabric.Frontend @@ -1 +1 @@ -Subproject commit 44c308bd06d502da545212b5c250fa4b31869e6d +Subproject commit 372805c9fa4d3c318a4e1d9d25073352cf3c8770 diff --git a/docs/TEST_POINTS.md b/docs/TEST_POINTS.md new file mode 100644 index 0000000..1ab90a1 --- /dev/null +++ b/docs/TEST_POINTS.md @@ -0,0 +1,175 @@ +# 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 --password

` | `{ok:true,user}` | +| C2 | `user apikey` CLI | `node dist/cli.js user apikey --email [--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` | + +## 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..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).