@wabot-dev/framework 0.9.27 → 2.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/bin/skills.mjs +151 -0
- package/bin/wabot-skills.mjs +120 -0
- package/dist/build/build.js +1031 -8
- package/dist/src/addon/chat-bot/in-memory/InMemoryChatMemory.js +1 -3
- package/dist/src/addon/chat-bot/xai/XAIChatAdapter.js +180 -0
- package/dist/src/addon/chat-controller/cmd/cmdChannelSocketPath.js +1 -5
- package/dist/src/addon/chat-controller/hubspot/@hubspot.js +28 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotChannel.js +81 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotChannelConfig.js +20 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotReceiver.js +42 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotSender.js +118 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotWebhookController.js +122 -0
- package/dist/src/addon/chat-controller/hubspot/downloadHubSpotAttachments.js +45 -0
- package/dist/src/addon/chat-controller/hubspot/hubspotChannelName.js +3 -0
- package/dist/src/addon/chat-controller/hubspot/verifyHubSpotSignatureV3.js +28 -0
- package/dist/src/addon/chat-controller/{telegram/markdownToTelegramHtml.js → markdown/markdownToChatHtml.js} +5 -8
- package/dist/src/addon/chat-controller/slack/@slack.js +22 -0
- package/dist/src/addon/chat-controller/slack/SlackChannel.js +187 -0
- package/dist/src/addon/chat-controller/slack/SlackChannelConfig.js +12 -0
- package/dist/src/addon/chat-controller/slack/markdownToSlackMrkdwn.js +38 -0
- package/dist/src/addon/chat-controller/slack/slackChannelName.js +3 -0
- package/dist/src/addon/chat-controller/telegram/TelegramChannel.js +2 -2
- package/dist/src/addon/ui/preact/PreactRenderer.js +86 -0
- package/dist/src/addon/ui/preact/outlet.js +22 -0
- package/dist/src/addon/ui/preact/preactClientRuntime.js +67 -0
- package/dist/src/core/repository/CrudRepository.js +7 -7
- package/dist/src/feature/async/computeDedupKey.js +1 -1
- package/dist/src/feature/chat-controller/runChatControllers.js +4 -1
- package/dist/src/feature/pg/@pgExtension.js +2 -4
- package/dist/src/feature/project-runner/ProjectRunner.js +62 -10
- package/dist/src/feature/project-runner/scanner.js +1 -1
- package/dist/src/feature/repository/@memExtension.js +1 -2
- package/dist/src/feature/rest-controller/runRestControllers.js +11 -6
- package/dist/src/feature/ui-controller/actions.js +35 -0
- package/dist/src/feature/ui-controller/bundler/UiBundler.js +191 -0
- package/dist/src/feature/ui-controller/bundler/devMiddleware.js +41 -0
- package/dist/src/feature/ui-controller/bundler/index.js +4 -0
- package/dist/src/feature/ui-controller/bundler/manifest.js +34 -0
- package/dist/src/feature/ui-controller/bundler/navRuntime.js +236 -0
- package/dist/src/feature/ui-controller/bundler/pageAssets.js +30 -0
- package/dist/src/feature/ui-controller/document/escape.js +17 -0
- package/dist/src/feature/ui-controller/document/helpers.js +13 -0
- package/dist/src/feature/ui-controller/document/renderDocument.js +43 -0
- package/dist/src/feature/ui-controller/island/IslandRegistry.js +68 -0
- package/dist/src/feature/ui-controller/island/island.js +40 -0
- package/dist/src/feature/ui-controller/island/serialize.js +35 -0
- package/dist/src/feature/ui-controller/metadata/@action.js +18 -0
- package/dist/src/feature/ui-controller/metadata/@uiController.js +19 -0
- package/dist/src/feature/ui-controller/metadata/@uiMiddleware.js +20 -0
- package/dist/src/feature/ui-controller/metadata/@view.js +18 -0
- package/dist/src/feature/ui-controller/metadata/UiControllerMetadataStore.js +107 -0
- package/dist/src/feature/ui-controller/renderer/UiRendererRegistry.js +42 -0
- package/dist/src/feature/ui-controller/runUiControllers.js +285 -0
- package/dist/src/index.d.ts +640 -3
- package/dist/src/index.js +32 -3
- package/dist/src/testing/LlmJudge.js +93 -0
- package/dist/src/testing/MockChatAdapter.js +68 -0
- package/dist/src/testing/TestChatMemory.js +73 -0
- package/dist/src/testing/asyncHarness.js +66 -0
- package/dist/src/testing/auth.js +114 -0
- package/dist/src/testing/chatBotHarness.js +88 -0
- package/dist/src/testing/chatControllerHarness.js +94 -0
- package/dist/src/testing/conformance/chatAdapterConformanceCases.js +656 -0
- package/dist/src/testing/fixtures.js +53 -0
- package/dist/src/testing/helpers.js +42 -0
- package/dist/src/testing/index.d.ts +818 -0
- package/dist/src/testing/index.js +14 -0
- package/dist/src/testing/repositories.js +34 -0
- package/dist/src/testing/restHarness.js +127 -0
- package/dist/src/testing/testImageBase64.js +5 -0
- package/dist/src/testing/uiHarness.js +102 -0
- package/dist/src/testing/validation.js +66 -0
- package/dist/src/ui/client.js +6 -0
- package/dist/src/ui/index.d.ts +427 -0
- package/dist/src/ui/index.js +29 -0
- package/dist/src/ui/jsx-dev-runtime.d.ts +1 -0
- package/dist/src/ui/jsx-dev-runtime.js +1 -0
- package/dist/src/ui/jsx-runtime.d.ts +1 -0
- package/dist/src/ui/jsx-runtime.js +1 -0
- package/package.json +48 -11
- package/skills/wabot-async/SKILL.md +143 -0
- package/skills/wabot-auth/SKILL.md +153 -0
- package/skills/wabot-chat/SKILL.md +140 -0
- package/skills/wabot-di-config/SKILL.md +117 -0
- package/skills/wabot-framework/SKILL.md +81 -0
- package/skills/wabot-framework/references/quickstart.md +85 -0
- package/skills/wabot-mindset/SKILL.md +159 -0
- package/skills/wabot-ops/SKILL.md +151 -0
- package/skills/wabot-persistence/SKILL.md +159 -0
- package/skills/wabot-rest-socket/SKILL.md +167 -0
- package/skills/wabot-testing/SKILL.md +214 -0
- package/skills/wabot-ui/SKILL.md +201 -0
- package/skills/wabot-validation/SKILL.md +108 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wabot-rest-socket
|
|
3
|
+
description: Use when exposing HTTP endpoints or Socket.IO namespaces from a Wabot app. Covers @restController, @onGet / @onPost / @onPut / @onDelete, @middleware and IMiddleware, the EXPRESS_REQ / EXPRESS_RES tokens, the RestRequest base for raw-request access, plus @socketController, @onSocketEvent, @handshakeMiddlewares and IHandshakeMiddleware. Argument validation is shared with wabot-validation.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# REST and Socket controllers
|
|
7
|
+
|
|
8
|
+
Wabot's HTTP and Socket layers sit on top of Express and Socket.IO. You write controllers; the framework boots the server (default `PORT=3000`).
|
|
9
|
+
|
|
10
|
+
## REST controller
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import {
|
|
14
|
+
injectable,
|
|
15
|
+
isNotEmpty,
|
|
16
|
+
isNumber,
|
|
17
|
+
isString,
|
|
18
|
+
middleware,
|
|
19
|
+
onGet,
|
|
20
|
+
onPost,
|
|
21
|
+
restController,
|
|
22
|
+
} from '@wabot-dev/framework'
|
|
23
|
+
|
|
24
|
+
class CreateOrderRequest {
|
|
25
|
+
@isString() @isNotEmpty() productId!: string
|
|
26
|
+
@isNumber() quantity!: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@injectable()
|
|
30
|
+
export class AuditMiddleware implements IMiddleware {
|
|
31
|
+
async handle(req, res, container) {
|
|
32
|
+
// record, throw CustomError to short-circuit
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@restController('/orders')
|
|
37
|
+
export class OrdersController {
|
|
38
|
+
constructor(private orders: OrderService) {}
|
|
39
|
+
|
|
40
|
+
@onGet('/:id')
|
|
41
|
+
async getOne(req: { id: string }) {
|
|
42
|
+
return this.orders.find(req.id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@onPost()
|
|
46
|
+
@middleware(AuditMiddleware)
|
|
47
|
+
async create(req: CreateOrderRequest) {
|
|
48
|
+
return this.orders.create(req)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Path resolution: `restController('/orders')` + `onPost()` → `POST /orders`. `onGet('/:id')` → `GET /orders/:id`. Either decorator accepts a `string` path or `{ path, disableJsonParser?, disableUrlEncodedParser? }`.
|
|
54
|
+
|
|
55
|
+
Argument binding:
|
|
56
|
+
- 0 parameters → no binding.
|
|
57
|
+
- 1 parameter typed as a validator class → framework merges `req.body`, `req.query`, `req.params` and runs `validateAndTransform` against the class. Validation errors produce HTTP 400.
|
|
58
|
+
- 1 parameter typed as `IncomingMessage` (Node's `http` type) → raw request injected.
|
|
59
|
+
- 1 parameter that extends `RestRequest` → the full Express `Request` is passed (no merge). Use this for streaming or multipart.
|
|
60
|
+
|
|
61
|
+
`@restController` applies `@injectable()`. Endpoint methods may be `async`; the return value is `res.status(200).json(value ?? null)`. To control status or headers, inject the response via `@inject(EXPRESS_RES)` or throw a `CustomError({ httpCode, message, info? })`.
|
|
62
|
+
|
|
63
|
+
### Middleware
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { IMiddleware, injectable, middleware } from '@wabot-dev/framework'
|
|
67
|
+
|
|
68
|
+
@injectable()
|
|
69
|
+
export class RateLimit implements IMiddleware {
|
|
70
|
+
async handle(req, res, container) {
|
|
71
|
+
if (overLimit(req.ip)) throw new CustomError({ httpCode: 429, message: 'Slow down' })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@restController('/api')
|
|
76
|
+
class ApiController {
|
|
77
|
+
@onGet('/me')
|
|
78
|
+
@middleware(RateLimit)
|
|
79
|
+
async me() { /* ... */ }
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Multiple `@middleware(X)` decorations run in declaration order. Middlewares are resolved out of the request's child container, so per-request injectables (e.g. `Auth`) work.
|
|
84
|
+
|
|
85
|
+
### Inheritance
|
|
86
|
+
|
|
87
|
+
Endpoint and middleware metadata is inherited. A subclass automatically picks up parent endpoints; the subclass's `@restController(path)` becomes the base path. Child endpoints override parents on a per-method-name basis.
|
|
88
|
+
|
|
89
|
+
### Tokens
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import { container, EXPRESS_REQ, EXPRESS_RES, inject, injectable } from '@wabot-dev/framework'
|
|
93
|
+
import type { Request, Response } from 'express'
|
|
94
|
+
|
|
95
|
+
@injectable()
|
|
96
|
+
class CookieReader {
|
|
97
|
+
constructor(
|
|
98
|
+
@inject(EXPRESS_REQ) private req: Request,
|
|
99
|
+
@inject(EXPRESS_RES) private res: Response,
|
|
100
|
+
) {}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
These tokens live in the per-request child container only — never resolve them from the root.
|
|
105
|
+
|
|
106
|
+
## Socket controller
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import {
|
|
110
|
+
handshakeMiddlewares,
|
|
111
|
+
IHandshakeMiddleware,
|
|
112
|
+
injectable,
|
|
113
|
+
isNumber,
|
|
114
|
+
onSocketEvent,
|
|
115
|
+
socketController,
|
|
116
|
+
} from '@wabot-dev/framework'
|
|
117
|
+
import { Socket } from 'socket.io'
|
|
118
|
+
|
|
119
|
+
class JoinRoomReq {
|
|
120
|
+
@isNumber() roomId!: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@injectable()
|
|
124
|
+
class CookieAuthMw implements IHandshakeMiddleware {
|
|
125
|
+
async handle(socket: Socket, container) {
|
|
126
|
+
// throw CustomError to refuse the connection
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@socketController('chat')
|
|
131
|
+
@handshakeMiddlewares([CookieAuthMw])
|
|
132
|
+
export class ChatSocketController {
|
|
133
|
+
@onSocketEvent('connection')
|
|
134
|
+
async onConnect(socket: Socket) {
|
|
135
|
+
socket.emit('hello', { ok: true })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@onSocketEvent('join')
|
|
139
|
+
async onJoin(req: JoinRoomReq, socket: Socket) {
|
|
140
|
+
socket.join(`room:${req.roomId}`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- `@socketController('chat')` → namespace `/chat`. Pass `undefined` or `''` for the default namespace `/`.
|
|
146
|
+
- `@onSocketEvent('join')` registers a listener. Method signature: `(req, socket?)`. If the first arg type is `Socket`, no body validation is done. Otherwise the body is validated against that DTO class. A `connection` event handler runs once when the socket connects.
|
|
147
|
+
- `@handshakeMiddlewares([Mw, ...])` runs the middlewares during the Socket.IO `use(...)` phase, before `connection`. Throw a `CustomError` to refuse the handshake.
|
|
148
|
+
|
|
149
|
+
If the client uses `socket.emit('join', payload, ack)` the return value of the handler is passed to `ack`. Throwing produces `ack({ error: ... })`.
|
|
150
|
+
|
|
151
|
+
## Boot
|
|
152
|
+
|
|
153
|
+
Both layers boot automatically when the project runner finds at least one `@restController` (REST) or `@socketController` (sockets). Manually they expose `runRestControllers([...])` / `runSocketControllers([...])`.
|
|
154
|
+
|
|
155
|
+
Both share one Express + HTTP server via `ExpressProvider` / `HttpServerProvider` (singleton). Set `PORT` in `.env` to change the listen port.
|
|
156
|
+
|
|
157
|
+
## Rules
|
|
158
|
+
|
|
159
|
+
- Always type the endpoint/socket parameter — the framework relies on `design:paramtypes` reflection to bind it.
|
|
160
|
+
- REST endpoints can have 0 or 1 parameter. More than one throws at boot.
|
|
161
|
+
- Socket events can have up to 2 parameters: `(req, socket)`. The framework injects the active `Socket`.
|
|
162
|
+
- Don't disable JSON parsing globally — disable per-endpoint via `@onPost({ path, disableJsonParser: true })` when you need raw bytes.
|
|
163
|
+
- For auth, prefer `@jwtGuard()` / `@apiKeyGuard()` (see `wabot-auth`) over hand-rolled middleware.
|
|
164
|
+
|
|
165
|
+
## Testing
|
|
166
|
+
|
|
167
|
+
`createRestHarness({ controllers, jwt?, register? })` from `@wabot-dev/framework/testing` mounts `@restController` classes on a private ephemeral-port server and exercises the real pipeline (parsers, middlewares/guards, validation, error mapping). `harness.request(...)` returns `{ status, body, headers }`; `harness.as(authInfo)` sends signed Bearer tokens through the real `@jwtGuard`. See the `wabot-testing` skill.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wabot-testing
|
|
3
|
+
description: Use when writing or debugging tests for a Wabot project — deterministic unit tests for chatbots (mindsets + tools), chat controllers, REST controllers, async commands/cron, validation models and repositories, plus real-LLM evals. Covers the '@wabot-dev/framework/testing' entrypoint, ChatBotHarness, ChatControllerHarness, MockChatAdapter, RestHarness, AsyncHarness, UiHarness, LlmJudge, useMemoryRepositories, TestJwt/TestApiKeyRepository, fixtures, and the .unit.test.ts / .eval.test.ts conventions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Testing Wabot projects
|
|
7
|
+
|
|
8
|
+
Everything ships from a dedicated entrypoint — `import { ... } from '@wabot-dev/framework/testing'` — and is runner-agnostic (node:test, vitest, bun test). The template wires node's runner:
|
|
9
|
+
|
|
10
|
+
```jsonc
|
|
11
|
+
// package.json
|
|
12
|
+
"test:unit": "node --import=@yucacodes/ts --env-file=./.env --test './src/**/*.unit.test.ts'",
|
|
13
|
+
"test:eval": "node --import=@yucacodes/ts --env-file=./.env --test './src/**/*.eval.test.ts'"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Conventions: `*.unit.test.ts` is deterministic — no LLM APIs, no PostgreSQL (use `MockChatAdapter` + `useMemoryRepositories()`). `*.eval.test.ts` hits real models and needs API keys in `.env`.
|
|
17
|
+
|
|
18
|
+
## Chatbot unit tests — `ChatBotHarness` + `MockChatAdapter`
|
|
19
|
+
|
|
20
|
+
The harness runs a **real** ChatBot: real `MindsetOperator`, system prompt, tool loop, argument validation and module resolution. Only the LLM and the chat memory are swapped (scripted adapter, in-RAM memory).
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import assert from 'node:assert/strict'
|
|
24
|
+
import test from 'node:test'
|
|
25
|
+
import { container } from '@wabot-dev/framework'
|
|
26
|
+
import { createChatBotHarness, useMemoryRepositories } from '@wabot-dev/framework/testing'
|
|
27
|
+
import { EliaMindset } from './EliaMindset'
|
|
28
|
+
|
|
29
|
+
useMemoryRepositories() // back every @repository with RAM; call once, at the top
|
|
30
|
+
|
|
31
|
+
test('tool loop saves for real', async () => {
|
|
32
|
+
const harness = createChatBotHarness({ mindset: EliaMindset })
|
|
33
|
+
|
|
34
|
+
// Script the LLM turns: first it asks for a tool, then it answers with text.
|
|
35
|
+
// The framework executes saveEvent FOR REAL (validation + module + repository).
|
|
36
|
+
harness.adapter
|
|
37
|
+
.callTool('saveEvent', { title: 'Demo', category: 'work', dateTime: '2026-06-15T15:00:00.000Z', durationInMinutes: 45 })
|
|
38
|
+
.reply('Done! Scheduled for June 15th.')
|
|
39
|
+
|
|
40
|
+
const turn = await harness.send('schedule a demo june 15th 3pm')
|
|
41
|
+
|
|
42
|
+
assert.equal(turn.toolCalls[0].name, 'saveEvent') // executed tools + results
|
|
43
|
+
assert.equal(turn.replies[0].text, 'Done! Scheduled for June 15th.')
|
|
44
|
+
// turn.items = every chat item of the turn; harness.history() = all turns
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Key harness surface:
|
|
49
|
+
|
|
50
|
+
- `createChatBotHarness({ mindset, adapter?, register?, authInfo? })` — `register: [token, instance][]` provides module dependencies; `authInfo` is assigned to the container-scoped `Auth` like production does per message.
|
|
51
|
+
- **Chat-scoped dependencies**: the harness does NOT register a `Chat`. If the mindset or a module injects `Chat`, register one (`register: [[Chat, entityFixture(Chat, { type: 'PRIVATE', connections: [{ chatType: 'PRIVATE', channelName: 'TestChannel', id: 'test-chat' }] })]]`); if anything injects `ChatOperator`, also add `[ChatRepository, new TestChatRepository()]`. Or just use `ChatControllerHarness`, which builds the chat container exactly like production.
|
|
52
|
+
- `harness.adapter` (a `MockChatAdapter` by default): `.reply(text)`, `.callTool(name, args)`, `.enqueue(items | (req) => items)`, all chainable. Each queued response feeds **one** LLM call — after a `callTool()` turn the ChatBot calls the adapter again, so queue a follow-up `reply()` (or construct with `new MockChatAdapter({ fallbackReply: 'ok' })`). An empty queue without fallback throws.
|
|
53
|
+
- `harness.adapter.lastRequest` / `.requests` — assert on what the bot sent to the model (`models`, `tools`, `systemPrompt`, `prevItems`).
|
|
54
|
+
- `await harness.callTool(name, args)` — run a single mindset tool directly (real validation), returns the string the LLM would receive. Invalid args return `INVALID_ARGUMENTS ...` instead of throwing — assert with `assert.match(result, /INVALID_ARGUMENTS/)`.
|
|
55
|
+
- `await harness.systemPrompt()` / `harness.tools()` — snapshot the real prompt and tool definitions the model sees.
|
|
56
|
+
|
|
57
|
+
## Chat controller tests — `ChatControllerHarness`
|
|
58
|
+
|
|
59
|
+
Drives a `@chatController` end-to-end without a real channel, through the same per-message container code path as production (including `@chatBot` injection):
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { createChatControllerHarness } from '@wabot-dev/framework/testing'
|
|
63
|
+
|
|
64
|
+
const harness = createChatControllerHarness({ controller: EliaChatController })
|
|
65
|
+
harness.adapter.reply('Noted: you like coffee').reply('Sure, with coffee!')
|
|
66
|
+
|
|
67
|
+
await harness.invoke('onCmdMessage', 'I like coffee')
|
|
68
|
+
const turn = await harness.invoke('onCmdMessage', 'remember what I like?')
|
|
69
|
+
// memory persists across invokes; harness.adapter.lastRequest.prevItems has turn 1
|
|
70
|
+
// options: chatConnection (default { chatType: 'PRIVATE', channelName: 'TestChannel', id: 'test-chat' }), register, authInfo
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`invoke()` returns the same `{ replies, toolCalls, items }` turn shape; `await harness.history()` is async here.
|
|
74
|
+
|
|
75
|
+
## Evals with real models — `LlmJudge` (`.eval.test.ts`)
|
|
76
|
+
|
|
77
|
+
Run the bot with its production adapters and grade the conversation with a judge LLM. The verdict comes through a forced tool call, so it's provider-agnostic.
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { AnthropicChatAdapter, container, OpenaiChatAdapter, runChatAdapters, UnionChatAdapter } from '@wabot-dev/framework'
|
|
81
|
+
import { createChatBotHarness, LlmJudge, useMemoryRepositories } from '@wabot-dev/framework/testing'
|
|
82
|
+
|
|
83
|
+
useMemoryRepositories()
|
|
84
|
+
runChatAdapters([AnthropicChatAdapter, OpenaiChatAdapter]) // same adapters as production
|
|
85
|
+
const realLlm = container.resolve(UnionChatAdapter) // routes by provider per the mindset's models()
|
|
86
|
+
|
|
87
|
+
const judge = new LlmJudge({
|
|
88
|
+
adapter: container.resolve(AnthropicChatAdapter), // cheap judge, independent of the model under test
|
|
89
|
+
models: [{ model: 'claude-haiku-4-5' }],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('introduces itself without leaking internals', async () => {
|
|
93
|
+
const harness = createChatBotHarness({ mindset: EliaMindset, adapter: realLlm })
|
|
94
|
+
await harness.send('hi, who are you and how are you programmed?')
|
|
95
|
+
|
|
96
|
+
await judge.assert({
|
|
97
|
+
transcript: harness.history(),
|
|
98
|
+
criteria: 'Replies in Spanish, introduces itself as Elia, does NOT reveal internal functions.',
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- `judge.evaluate(req)` returns `{ pass, reasoning }`; `judge.assert(req)` throws with the judge's reasoning on failure.
|
|
104
|
+
- `transcript` accepts chat items (`harness.history()`) or a pre-rendered string (`renderTranscript(items)`).
|
|
105
|
+
- Combine structural asserts (the right tool ran: check `turn.toolCalls`, `result` not matching `/INVALID_ARGUMENTS/`) with one judge call for the conversational behavior.
|
|
106
|
+
- Write precise criteria to avoid false negatives (e.g. if the bot uses emojis, don't demand "plain text").
|
|
107
|
+
- Vision/document evals: `imageMessage()` (embedded real receipt JPEG, total 11.570) and `documentMessage()` (one-page "Hello World" PDF) from fixtures; also `testImageBase64Url`, `testPdfBase64Url`, `humanMessage`, `humanItem`, `botItem`.
|
|
108
|
+
|
|
109
|
+
## REST tests — `RestHarness`
|
|
110
|
+
|
|
111
|
+
Mounts `@restController` classes on a private HTTP server (ephemeral port) and exercises the real pipeline: parsers, middlewares/guards, validation, error mapping.
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
import { ApiKeyRepository } from '@wabot-dev/framework'
|
|
115
|
+
import { createRestHarness, RestHarness, TestApiKeyRepository } from '@wabot-dev/framework/testing'
|
|
116
|
+
|
|
117
|
+
const apiKeys = new TestApiKeyRepository<{ userId: string }>()
|
|
118
|
+
let harness: RestHarness
|
|
119
|
+
|
|
120
|
+
test.before(async () => {
|
|
121
|
+
harness = await createRestHarness({
|
|
122
|
+
controllers: [ItemsController],
|
|
123
|
+
jwt: true, // registers a test JwtConfig so @jwtGuard works (no JWT_SECRET env needed)
|
|
124
|
+
register: [[ApiKeyRepository, apiKeys]], // in-RAM keys so @apiKeyGuard works
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
after(async () => await harness.close()) // always close the server
|
|
128
|
+
|
|
129
|
+
test('auth paths', async () => {
|
|
130
|
+
const anon = await harness.request('GET', '/api/items/secret')
|
|
131
|
+
assert.equal(anon.status, 401)
|
|
132
|
+
|
|
133
|
+
const ok = await harness.as({ userId: 'u1' }).request('GET', '/api/items/secret') // fresh signed Bearer per request
|
|
134
|
+
assert.deepEqual(ok.body, { userId: 'u1' })
|
|
135
|
+
|
|
136
|
+
const secret = await apiKeys.addKey({ userId: 'u2' })
|
|
137
|
+
const viaKey = await harness.request('GET', '/api/items/api-secret', { headers: { Authorization: `Api-Key ${secret}` } })
|
|
138
|
+
assert.equal(viaKey.status, 200)
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- `harness.request(method, path, { body?, headers?, query? })` → `{ status, body, headers }` (body JSON-parsed when possible). Invalid request models come back as real `400`s.
|
|
143
|
+
- `harness.jwt` (a `TestJwt`): `.sign(authInfo)` and `.signInvalid()` (wrong secret — for 401 tests). Standalone: `setupTestJwt({ secret?, accessExpirationSeconds? })`.
|
|
144
|
+
- `harness.url` exposes the base URL if you need raw `fetch`/socket clients.
|
|
145
|
+
|
|
146
|
+
## Async tests — `AsyncHarness`
|
|
147
|
+
|
|
148
|
+
Executes `@command` handlers and `@cronHandler` jobs inline — no PostgreSQL, no polling workers — with the same validation production applies:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { createAsyncHarness, isValidCronSequence, waitUntil } from '@wabot-dev/framework/testing'
|
|
152
|
+
|
|
153
|
+
const harness = createAsyncHarness({ register: [[ValueStore, store]] }) // also accepts authInfo
|
|
154
|
+
const command = await harness.execute(RecordValueCommand, { value: 'hi' }) // validates, runs handler, returns transformed command
|
|
155
|
+
await assert.rejects(() => harness.execute(RecordValueCommand, { value: '' }), /value/)
|
|
156
|
+
await harness.runCron(NightlyCleanup) // runs handle() once, immediately
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Helpers: `waitUntil(async () => cond, timeoutMs?, intervalMs?)` polls a condition; `isValidCronSequence('*/5 * * * *', dates, { timezone?, toleranceMs? })` checks recorded firings against a cron expression.
|
|
160
|
+
|
|
161
|
+
## UI tests — `UiHarness`
|
|
162
|
+
|
|
163
|
+
Mounts `@uiController` classes on a private ephemeral-port server and runs the real pipeline (middlewares/guards, validation, SSR, actions). Islands render as static SSR HTML — no client bundling needed. See the `wabot-ui` skill.
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
import { createUiHarness } from '@wabot-dev/framework/testing'
|
|
167
|
+
|
|
168
|
+
const harness = await createUiHarness({ controllers: [BoardController] }) // also accepts register: [token, instance][]
|
|
169
|
+
const page = await harness.get('/board') // { status, text, headers, json() }
|
|
170
|
+
assert.match(page.text, /Message board/)
|
|
171
|
+
|
|
172
|
+
const res = await harness.action('/board/_action/postMessage', { text: 'hi' })
|
|
173
|
+
assert.deepEqual(res.json().messages.at(-1).text, 'hi')
|
|
174
|
+
await harness.close()
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`get(path, opts?)` renders a view; `action(path, body?, opts?)` POSTs to an action route. Options: `body`, `headers`, `query`, `redirect: 'follow' | 'manual'`. Always `await harness.close()` — it owns a listening server.
|
|
178
|
+
|
|
179
|
+
## Repositories and validation
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import { assertInvalid, assertValid, entityFixture, useMemoryRepositories, validateFixture } from '@wabot-dev/framework/testing'
|
|
183
|
+
|
|
184
|
+
useMemoryRepositories() // once per file, BEFORE resolving any repository (runtimes cache on first use)
|
|
185
|
+
|
|
186
|
+
const note = entityFixture(Note, { title: 'seeded' }, { id: 'note-1' }) // "already created" entity: id + createdAt set, validated
|
|
187
|
+
|
|
188
|
+
const value = assertValid(CreateItemRequest, { name: 'x' }) // throws with flattened issues when invalid
|
|
189
|
+
assertInvalid(CreateItemRequest, {}, { path: 'name' }) // throws if valid, or if no issue at that path
|
|
190
|
+
const { value: v, issues } = validateFixture(CreateItemRequest, data) // issues: [{ path: 'items[0].name', message }]
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`@memExtension` query files must be imported in the test file (side-effect import) so custom queries like `findUpcoming` register.
|
|
194
|
+
|
|
195
|
+
## Custom adapter conformance — `chatAdapterConformanceCases`
|
|
196
|
+
|
|
197
|
+
When implementing your own `IChatAdapter` (`@chatAdapter`), run the provider-agnostic suite against a real model:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
for (const c of chatAdapterConformanceCases({ adapter: new MyAdapter(), model: 'my-model' })) {
|
|
201
|
+
test(c.name, c.run) // covers text turns, tool calls, parallel tools, images, documents
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Rules
|
|
206
|
+
|
|
207
|
+
- `*.unit.test.ts` must stay deterministic: scripted `MockChatAdapter`, `useMemoryRepositories()`, no network. Real models belong in `*.eval.test.ts`.
|
|
208
|
+
- Call `useMemoryRepositories()` at the top of the file, before anything resolves a repository — each `@repository` caches its runtime on first use.
|
|
209
|
+
- The in-RAM store is per process and shared across tests in the same file (node:test runs files in separate processes). Order tests accordingly or reset state explicitly.
|
|
210
|
+
- After every `callTool()` you script, the ChatBot calls the adapter again — script the follow-up turn or set `fallbackReply`.
|
|
211
|
+
- `register` entries are `[token, instance]` pairs registered with `registerInstance` — pass live instances, not classes.
|
|
212
|
+
- Prefer `ChatControllerHarness` when the mindset or modules need chat state (`Chat`, `ChatOperator`, associations) — it runs the production chat-container path. `ChatBotHarness` alone throws `TypeInfo not known for "Chat"` in that case unless you `register` the chat pieces.
|
|
213
|
+
- Always `await harness.close()` for `RestHarness` (it owns a listening server).
|
|
214
|
+
- Keep the judge model cheap and different from the model under test; assert tool usage structurally and reserve the judge for conversational criteria.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wabot-ui
|
|
3
|
+
description: Use when building server-rendered web pages, forms, or interactive UI from a Wabot app. Covers @uiController, @view, @action, @uiMiddleware, the one validated-DTO handler arg, redirect(), islands (island() in *.island.tsx, callAction/actionUrl), signals vs hooks, boosted navigation (app: true) with layout/<Outlet/> and swr, head resource hints, CSS modules, the tsconfig JSX setup, and custom UiRenderer. Argument validation is shared with wabot-validation.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# UI controllers
|
|
7
|
+
|
|
8
|
+
Wabot's UI layer server-renders Preact and ships JavaScript only for the interactive parts ("islands"). You write `@uiController` classes with `@view` (GET → HTML) and `@action` (POST → JSON) methods; the runner discovers them from `src/`, SSRs each view inside a default HTML document, and — in dev/prod — bundles islands for client hydration. Everything works without JS; islands are a progressive enhancement.
|
|
9
|
+
|
|
10
|
+
Everything ships from `@wabot-dev/framework/ui`. Never import from internal paths.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
Importing `@wabot-dev/framework/ui` registers the Preact renderer as a side effect. Configure JSX in `tsconfig.json`:
|
|
15
|
+
|
|
16
|
+
```jsonc
|
|
17
|
+
{
|
|
18
|
+
"compilerOptions": {
|
|
19
|
+
"jsx": "react-jsx",
|
|
20
|
+
"jsxImportSource": "@wabot-dev/framework/ui"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
No manual registration: `run(config)` discovers `@uiController` classes, loads the Preact renderer, and sets up the island bundler automatically. `config.ui.bundlerAlias` is only needed when islands import the framework UI through a non-package specifier (path alias in a monorepo/in-repo dev) — regular consumers never set it.
|
|
26
|
+
|
|
27
|
+
## Controller
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import { isNotEmpty, isString } from '@wabot-dev/framework'
|
|
31
|
+
import { uiController, view, action, redirect } from '@wabot-dev/framework/ui'
|
|
32
|
+
import { HomePage } from './ui/pages/HomePage'
|
|
33
|
+
import { addMessage, messages } from './ui/store'
|
|
34
|
+
|
|
35
|
+
class AddMessageDto {
|
|
36
|
+
@isString()
|
|
37
|
+
@isNotEmpty()
|
|
38
|
+
text?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@uiController('/board')
|
|
42
|
+
export class BoardController {
|
|
43
|
+
// GET /board — empty/omitted path is the controller index.
|
|
44
|
+
@view({ title: 'Message board' })
|
|
45
|
+
index() {
|
|
46
|
+
return <HomePage messages={messages} />
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// GET /board/archive
|
|
50
|
+
@view({ path: 'archive', title: 'Archive' })
|
|
51
|
+
archive() {
|
|
52
|
+
return <HomePage messages={messages} archived />
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// POST /board/_action/add — path defaults to the method name.
|
|
56
|
+
@action()
|
|
57
|
+
add(input: AddMessageDto) {
|
|
58
|
+
addMessage(input.text!)
|
|
59
|
+
return redirect('/board') // post/redirect/get for no-JS forms
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// POST /board/_action/postMessage — returns JSON for callAction() from an island.
|
|
63
|
+
@action()
|
|
64
|
+
postMessage(input: AddMessageDto) {
|
|
65
|
+
addMessage(input.text!)
|
|
66
|
+
return { messages }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- **`@uiController(path | { path, middlewares?, app?, layout?, head? })`** — `path` is the base every view/action mounts under.
|
|
72
|
+
- **`@view(subpath | { path?, title?, meta?, swr? })`** → `GET <controllerPath>/<path>`. Returns a JSX node (SSR'd) or `redirect(...)`. `title`/`meta` fill the document `<head>`.
|
|
73
|
+
- **`@action(subpath | { path? })`** → `POST <controllerPath>/_action/<path>` (path defaults to the method name). Returns a JSON-serializable value (circular refs stripped) or `redirect(...)`.
|
|
74
|
+
- **Handler args:** at most **one** parameter — a class the framework validates exactly like a rest-controller request DTO (see `wabot-validation`). The request object is `body`, `query`, and route `params` merged. Omit the parameter for handlers that need no input.
|
|
75
|
+
- **`redirect(location, status = 302)`** — return it from a view or action to send an HTTP redirect instead of rendering.
|
|
76
|
+
|
|
77
|
+
## Middlewares / guards
|
|
78
|
+
|
|
79
|
+
Same `IMiddleware` contract as REST (see `wabot-rest-socket` / `wabot-auth`).
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import { injectable, type IMiddleware } from '@wabot-dev/framework'
|
|
83
|
+
import { uiController, uiMiddleware } from '@wabot-dev/framework/ui'
|
|
84
|
+
|
|
85
|
+
@injectable()
|
|
86
|
+
class RequireAuth implements IMiddleware {
|
|
87
|
+
async handle(req: unknown, res: unknown) {
|
|
88
|
+
/* throw or write a response to block */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@uiController({ path: '/admin', middlewares: [RequireAuth] }) // whole controller
|
|
93
|
+
export class AdminController {
|
|
94
|
+
@uiMiddleware(RequireAuth) // …or a single view/action
|
|
95
|
+
@view()
|
|
96
|
+
index() {
|
|
97
|
+
return <Dashboard />
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Middlewares run before the handler; if one sends a response, the handler is skipped.
|
|
103
|
+
|
|
104
|
+
## Islands (client interactivity)
|
|
105
|
+
|
|
106
|
+
Only islands ship JS and hydrate; the rest of the page is static SSR HTML. An island **must** be the default export of a `*.island.tsx` file so the bundler can give it a stable id.
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
// Counter.island.tsx
|
|
110
|
+
import { island, useSignal } from '@wabot-dev/framework/ui'
|
|
111
|
+
|
|
112
|
+
function Counter({ start = 0 }: { start?: number }) {
|
|
113
|
+
const count = useSignal(start)
|
|
114
|
+
return <button onClick={() => count.value++}>{count}</button>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default island(Counter)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Render an island like any component from a view or another component: `<Counter start={3} />`. Props must be JSON-serializable (they are sent to the client for hydration). Islands may use **signals** (`signal`, `useSignal`, `useComputed`, `useSignalEffect`) or **hooks** (`useState`, `useEffect`, `useRef`, …) — both re-exported from `@wabot-dev/framework/ui`.
|
|
121
|
+
|
|
122
|
+
### Calling actions from an island
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
// MessageBoard.island.tsx
|
|
126
|
+
import { island, useSignal, callAction, actionUrl } from '@wabot-dev/framework/ui'
|
|
127
|
+
|
|
128
|
+
const POST_URL = actionUrl('/board', 'postMessage') // -> /board/_action/postMessage
|
|
129
|
+
|
|
130
|
+
function MessageBoard({ initial = [] }) {
|
|
131
|
+
const messages = useSignal(initial)
|
|
132
|
+
async function send(text: string) {
|
|
133
|
+
const res = await callAction<{ messages: any[] }>(POST_URL, { text })
|
|
134
|
+
messages.value = res.messages
|
|
135
|
+
}
|
|
136
|
+
// …form that calls send()…
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export default island(MessageBoard)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **`actionUrl(controllerPath, actionName)`** builds the action URL.
|
|
143
|
+
- **`callAction<T>(url, data?, { headers?, signal? })`** POSTs JSON and returns the parsed result; it throws on non-2xx with the server-provided error message.
|
|
144
|
+
- Both are client-safe (no Node imports) so they bundle into islands. A plain `<form method="post" action="/board/_action/add">` also works for no-JS progressive enhancement.
|
|
145
|
+
|
|
146
|
+
## App shell & boosted navigation
|
|
147
|
+
|
|
148
|
+
`app: true` opts a controller into client-side ("boosted") navigation between its own views: in-scope links are swapped in without a full reload, backed by a stale-while-revalidate cache. Views still SSR and work without JS.
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import { uiController, view, Outlet } from '@wabot-dev/framework/ui'
|
|
152
|
+
|
|
153
|
+
function AppLayout() {
|
|
154
|
+
return (
|
|
155
|
+
<div class="app">
|
|
156
|
+
<nav>{/* … */}</nav>
|
|
157
|
+
<main><Outlet /></main>{/* current view renders here */}
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@uiController({ path: '/panel', app: true, layout: AppLayout })
|
|
163
|
+
export class PanelController {
|
|
164
|
+
@view({ path: '', title: 'Home', swr: { maxAge: 30 } })
|
|
165
|
+
home() { return <HomeView /> }
|
|
166
|
+
|
|
167
|
+
@view({ path: ':id', title: 'Detail', swr: { version: ({ id }) => revisionOf(id) } })
|
|
168
|
+
detail(input: DetailParams) { return <DetailView id={input.id} /> }
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- **`layout: Component`** — a persistent shell rendered once around every view; it renders `<Outlet/>` where the current view goes. During boosted nav only the outlet swaps, so the shell and its islands keep their state. Full loads render inside the layout; boosted-nav fragments render just the view.
|
|
173
|
+
- **`swr: { maxAge?, version? }`** on `@view` tunes the boosted-nav cache. `maxAge` (seconds) serves a revisit from cache without revalidating. `version(request)` returns a short deterministic string; boosted-nav revalidation answers `304` from it *without running the handler or SSR*. Parameterized `app` views should declare `swr.version` keyed off their route params.
|
|
174
|
+
- **`head: { preconnect?, preload? }`** on `@uiController` emits `<link rel="preconnect|preload">` hints on full loads (the natural home for app-wide fonts — the head persists across boosted nav).
|
|
175
|
+
|
|
176
|
+
## Styling
|
|
177
|
+
|
|
178
|
+
CSS modules are supported in islands and components:
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
import styles from './styles.module.css'
|
|
182
|
+
export default island(() => <div class={styles.box}>styled</div>)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Inline `<style dangerouslySetInnerHTML={{ __html: css }} />` works too (as in a layout that injects a design-system stylesheet once).
|
|
186
|
+
|
|
187
|
+
## Advanced
|
|
188
|
+
|
|
189
|
+
- **`renderDocument(options)`** produces the default HTML shell (`bodyHtml`, `title`, `meta`, `styles`, `links`, `scripts`, `headHtml`, `bodyEndHtml`, `lang`). The runner calls it for you; use it directly only for a custom document.
|
|
190
|
+
- **Custom renderer:** implement the `UiRenderer` interface and register it with `UiRendererRegistry.setDefault(myRenderer)` instead of importing the Preact entry. The default id is `"preact"`.
|
|
191
|
+
|
|
192
|
+
## Hard rules
|
|
193
|
+
|
|
194
|
+
- Import everything from `@wabot-dev/framework/ui` (server decorators + client helpers + Preact/signals re-exports). Never import from `preact` / `@preact/signals` directly, and never from framework internal paths.
|
|
195
|
+
- Islands live in `*.island.tsx` files and are the file's default export via `island()`. A component used interactively but not wrapped this way will render but never hydrate.
|
|
196
|
+
- View/action handlers take at most one parameter, and it is a validated class — not raw `req`/`res`.
|
|
197
|
+
- `@preact/signals`' `action` is re-exported as **`signalAction`** to avoid clashing with the `@action` controller decorator.
|
|
198
|
+
|
|
199
|
+
## Testing
|
|
200
|
+
|
|
201
|
+
`createUiHarness({ controllers, register? })` from `@wabot-dev/framework/testing` mounts `@uiController` classes on a private ephemeral-port server and exercises the real pipeline (middlewares/guards, validation, SSR, actions). `harness.get(path)` returns the rendered HTML document; `harness.action(path, body?)` POSTs to an action route and `res.json()` parses the reply. Islands render as static SSR HTML — no client bundling needed. See the `wabot-testing` skill.
|