@wabot-dev/framework 0.9.80 → 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.
Files changed (77) hide show
  1. package/bin/skills.mjs +151 -0
  2. package/bin/wabot-skills.mjs +120 -0
  3. package/dist/build/build.js +1031 -8
  4. package/dist/src/addon/chat-bot/in-memory/InMemoryChatMemory.js +1 -3
  5. package/dist/src/addon/chat-bot/xai/XAIChatAdapter.js +180 -0
  6. package/dist/src/addon/chat-controller/cmd/cmdChannelSocketPath.js +1 -5
  7. package/dist/src/addon/chat-controller/hubspot/@hubspot.js +28 -0
  8. package/dist/src/addon/chat-controller/hubspot/HubSpotChannel.js +81 -0
  9. package/dist/src/addon/chat-controller/hubspot/HubSpotChannelConfig.js +20 -0
  10. package/dist/src/addon/chat-controller/hubspot/HubSpotReceiver.js +42 -0
  11. package/dist/src/addon/chat-controller/hubspot/HubSpotSender.js +118 -0
  12. package/dist/src/addon/chat-controller/hubspot/HubSpotWebhookController.js +122 -0
  13. package/dist/src/addon/chat-controller/hubspot/downloadHubSpotAttachments.js +45 -0
  14. package/dist/src/addon/chat-controller/hubspot/hubspotChannelName.js +3 -0
  15. package/dist/src/addon/chat-controller/hubspot/verifyHubSpotSignatureV3.js +28 -0
  16. package/dist/src/addon/chat-controller/{telegram/markdownToTelegramHtml.js → markdown/markdownToChatHtml.js} +5 -8
  17. package/dist/src/addon/chat-controller/slack/@slack.js +22 -0
  18. package/dist/src/addon/chat-controller/slack/SlackChannel.js +187 -0
  19. package/dist/src/addon/chat-controller/slack/SlackChannelConfig.js +12 -0
  20. package/dist/src/addon/chat-controller/slack/markdownToSlackMrkdwn.js +38 -0
  21. package/dist/src/addon/chat-controller/slack/slackChannelName.js +3 -0
  22. package/dist/src/addon/chat-controller/telegram/TelegramChannel.js +2 -2
  23. package/dist/src/addon/ui/preact/PreactRenderer.js +86 -0
  24. package/dist/src/addon/ui/preact/outlet.js +22 -0
  25. package/dist/src/addon/ui/preact/preactClientRuntime.js +67 -0
  26. package/dist/src/core/repository/CrudRepository.js +7 -7
  27. package/dist/src/feature/async/computeDedupKey.js +1 -1
  28. package/dist/src/feature/pg/@pgExtension.js +2 -4
  29. package/dist/src/feature/project-runner/ProjectRunner.js +62 -10
  30. package/dist/src/feature/project-runner/scanner.js +1 -1
  31. package/dist/src/feature/repository/@memExtension.js +1 -2
  32. package/dist/src/feature/ui-controller/actions.js +35 -0
  33. package/dist/src/feature/ui-controller/bundler/UiBundler.js +191 -0
  34. package/dist/src/feature/ui-controller/bundler/devMiddleware.js +41 -0
  35. package/dist/src/feature/ui-controller/bundler/index.js +4 -0
  36. package/dist/src/feature/ui-controller/bundler/manifest.js +34 -0
  37. package/dist/src/feature/ui-controller/bundler/navRuntime.js +236 -0
  38. package/dist/src/feature/ui-controller/bundler/pageAssets.js +30 -0
  39. package/dist/src/feature/ui-controller/document/escape.js +17 -0
  40. package/dist/src/feature/ui-controller/document/helpers.js +13 -0
  41. package/dist/src/feature/ui-controller/document/renderDocument.js +43 -0
  42. package/dist/src/feature/ui-controller/island/IslandRegistry.js +68 -0
  43. package/dist/src/feature/ui-controller/island/island.js +40 -0
  44. package/dist/src/feature/ui-controller/island/serialize.js +35 -0
  45. package/dist/src/feature/ui-controller/metadata/@action.js +18 -0
  46. package/dist/src/feature/ui-controller/metadata/@uiController.js +19 -0
  47. package/dist/src/feature/ui-controller/metadata/@uiMiddleware.js +20 -0
  48. package/dist/src/feature/ui-controller/metadata/@view.js +18 -0
  49. package/dist/src/feature/ui-controller/metadata/UiControllerMetadataStore.js +107 -0
  50. package/dist/src/feature/ui-controller/renderer/UiRendererRegistry.js +42 -0
  51. package/dist/src/feature/ui-controller/runUiControllers.js +285 -0
  52. package/dist/src/index.d.ts +632 -3
  53. package/dist/src/index.js +30 -1
  54. package/dist/src/testing/index.d.ts +43 -1
  55. package/dist/src/testing/index.js +1 -0
  56. package/dist/src/testing/uiHarness.js +102 -0
  57. package/dist/src/ui/client.js +6 -0
  58. package/dist/src/ui/index.d.ts +427 -0
  59. package/dist/src/ui/index.js +29 -0
  60. package/dist/src/ui/jsx-dev-runtime.d.ts +1 -0
  61. package/dist/src/ui/jsx-dev-runtime.js +1 -0
  62. package/dist/src/ui/jsx-runtime.d.ts +1 -0
  63. package/dist/src/ui/jsx-runtime.js +1 -0
  64. package/package.json +33 -13
  65. package/skills/wabot-async/SKILL.md +143 -0
  66. package/skills/wabot-auth/SKILL.md +153 -0
  67. package/skills/wabot-chat/SKILL.md +140 -0
  68. package/skills/wabot-di-config/SKILL.md +117 -0
  69. package/skills/wabot-framework/SKILL.md +81 -0
  70. package/skills/wabot-framework/references/quickstart.md +85 -0
  71. package/skills/wabot-mindset/SKILL.md +159 -0
  72. package/skills/wabot-ops/SKILL.md +151 -0
  73. package/skills/wabot-persistence/SKILL.md +159 -0
  74. package/skills/wabot-rest-socket/SKILL.md +167 -0
  75. package/skills/wabot-testing/SKILL.md +214 -0
  76. package/skills/wabot-ui/SKILL.md +201 -0
  77. package/skills/wabot-validation/SKILL.md +108 -0
@@ -0,0 +1,159 @@
1
+ ---
2
+ name: wabot-persistence
3
+ description: Use when defining entities, repositories, queries, or per-adapter query extensions in Wabot. Covers Entity / IEntityData, @repository, the @query method-name DSL (find/findOne/count/exists/delete + And/Or + operators), @queryExtension, @memExtension / MemoryRepositoryExtension, @pgExtension / PgRepositoryExtension, and how the active adapter is chosen automatically by the project runner.
4
+ ---
5
+
6
+ # Persistence
7
+
8
+ Wabot ships a small ORM. You write a domain entity, a `@repository`-decorated class with method-name queries, and (optionally) an adapter-specific extension. The project runner picks the adapter automatically based on `DATABASE_URL`.
9
+
10
+ ## Entity
11
+
12
+ ```typescript
13
+ import { Entity, IEntityData } from '@wabot-dev/framework'
14
+
15
+ export type IGameStatus = 'backlog' | 'playing' | 'finished' | 'abandoned'
16
+
17
+ export interface IGameData extends IEntityData {
18
+ userId: string
19
+ title: string
20
+ status: IGameStatus
21
+ hoursPlayed: number
22
+ addedAt: number
23
+ }
24
+
25
+ export class Game extends Entity<IGameData> {}
26
+ ```
27
+
28
+ `IEntityData` adds `id?: string`, `createdAt?: number | null`, `discardedAt?: number | null`. The id/createdAt are filled in by the repository on `create`.
29
+
30
+ `Entity` exposes:
31
+ - `id` (throws if not yet created)
32
+ - `createdAt: Date`
33
+ - `update(partial)` — merges allowed fields and sets `updatedAt`; rejects writes to id/createdAt/discardedAt
34
+ - `wasCreated()`, `validate()`
35
+ - `lockerKey()` — returns the id, so any `Entity` can be passed to `Locker.withKey(...)`
36
+
37
+ Date values stored on entity data should be epoch milliseconds (`number`) — the `Mapper` and `Storable` deep-copy convert `Date` to ms automatically.
38
+
39
+ ## Repository
40
+
41
+ ```typescript
42
+ import { CrudRepository, query, queryExtension, repository } from '@wabot-dev/framework'
43
+ import { Game, IGameStatus } from './Game'
44
+ import { IGameRepositoryExtensions } from './IGameRepositoryExtensions'
45
+
46
+ @repository({ table: 'game', constructor: Game })
47
+ export class GameRepository
48
+ extends CrudRepository<Game, IGameRepositoryExtensions>
49
+ implements IGameRepositoryExtensions
50
+ {
51
+ @query() declare findByUserIdAndStatus: (userId: string, status: IGameStatus) => Promise<Game[]>
52
+ @query() declare countByUserIdAndStatus: (userId: string, status: IGameStatus) => Promise<number>
53
+ @queryExtension() declare findLongestInBacklog: (userId: string, limit: number) => Promise<Game[]>
54
+ }
55
+ ```
56
+
57
+ The decorator generates concrete implementations for `CrudRepository`'s methods (`find`, `findOrThrow`, `findByIds`, `findAll`, `create`, `update`, `delete`) and for every `@query()` declare. It also installs a lazy `.extension` accessor backed by the active adapter.
58
+
59
+ `@repository` applies `@singleton()` for you. Inject `GameRepository` directly anywhere.
60
+
61
+ ## `@query` method-name DSL
62
+
63
+ `@query()` reads the method name and turns it into a structured query. Supported grammar:
64
+
65
+ - Prefix: `find`, `findOne`, `count`, `exists`, `delete`.
66
+ - Conditions: PascalCase field names joined by `And` / `Or`.
67
+ - Operators (suffix on the field): `Equals` (default), `Not`, `Like`, `NotLike`, `In`, `NotIn`, `Gt`, `Gte`, `Lt`, `Lte`, `IsNull`, `IsNotNull`.
68
+ - Ordering: append `OrderByField[Asc|Desc]` (chain multiple with `And`).
69
+ - Limit: append `LimitN`.
70
+
71
+ Examples that work:
72
+
73
+ ```typescript
74
+ @query() declare findByUserId: (userId: string) => Promise<Game[]>
75
+ @query() declare findOneByUserIdAndStatus: (userId: string, status: IGameStatus) => Promise<Game | null>
76
+ @query() declare countByStatusInAndAddedAtGt: (statuses: IGameStatus[], addedAt: number) => Promise<number>
77
+ @query() declare existsByTitleLike: (title: string) => Promise<boolean>
78
+ @query() declare deleteByDiscardedAtIsNotNull: () => Promise<void>
79
+ @query() declare findByUserIdOrderByAddedAtAscLimit10: (userId: string) => Promise<Game[]>
80
+ ```
81
+
82
+ Argument count and order must match the conditions left-to-right. `IsNull`/`IsNotNull` take no argument; `In`/`NotIn` take an array.
83
+
84
+ If your query cannot be expressed by the DSL (joins, aggregates, JSON paths) use `@queryExtension()` instead — see below.
85
+
86
+ ## Adapter extensions
87
+
88
+ The runner picks one adapter per process:
89
+
90
+ - `MemoryRepositoryAdapter` when `DATABASE_URL` is missing or not a `postgres://` URL.
91
+ - `PgJsonRepositoryAdapter` when `DATABASE_URL` is a Postgres URL (data is stored as a JSON blob per row).
92
+
93
+ For each `@queryExtension()` method you must provide one implementation per adapter you support, in classes decorated with `@memExtension(Repo)` and/or `@pgExtension(Repo)`:
94
+
95
+ ```typescript
96
+ import { memExtension, MemoryRepositoryExtension, pgExtension, PgRepositoryExtension } from '@wabot-dev/framework'
97
+
98
+ @memExtension(GameRepository)
99
+ export class GameMemoryQueries
100
+ extends MemoryRepositoryExtension<Game>
101
+ implements IGameRepositoryExtensions
102
+ {
103
+ async findLongestInBacklog(userId: string, limit: number) {
104
+ return [...this.items.values()]
105
+ .filter((g) => g['data'].userId === userId && g['data'].status === 'backlog')
106
+ .sort((a, b) => a['data'].addedAt - b['data'].addedAt)
107
+ .slice(0, limit)
108
+ .map((g) => this.clone(g))
109
+ }
110
+ }
111
+
112
+ @pgExtension(GameRepository)
113
+ export class GamePgQueries
114
+ extends PgRepositoryExtension<Game>
115
+ implements IGameRepositoryExtensions
116
+ {
117
+ async findLongestInBacklog(userId: string, limit: number) {
118
+ const sql = `
119
+ SELECT ${this['columns']} FROM ${this['table']}
120
+ WHERE "data"->>'userId' = $1 AND "data"->>'status' = 'backlog'
121
+ ORDER BY ("data"->>'addedAt')::numeric ASC
122
+ LIMIT $2
123
+ `
124
+ return this['query'](sql, [userId, limit])
125
+ }
126
+ }
127
+ ```
128
+
129
+ - The extension class must extend `MemoryRepositoryExtension<T>` / `PgRepositoryExtension<T>`. The decorator throws otherwise.
130
+ - Inside a Pg extension, use `this['columns']` / `this['table']` / `this['query'](sql, params)` from `PgRepositoryBase`. The Pg JSON adapter stores fields under a `data` JSONB column.
131
+ - A repository may opt in to only one adapter — if you only ship `@memExtension`, the repository will throw when invoked under Postgres.
132
+
133
+ ## Transactions
134
+
135
+ Wrap business methods with `@transaction()` — it runs the call inside every registered transaction adapter:
136
+
137
+ ```typescript
138
+ import { transaction } from '@wabot-dev/framework'
139
+
140
+ class CheckoutService {
141
+ @transaction()
142
+ async checkout(orderId: string) {
143
+ // all repository writes here run inside one PG transaction
144
+ }
145
+ }
146
+ ```
147
+
148
+ The project runner registers a `PgTransactionAdapter` under the name `'default'` when `DATABASE_URL` is Postgres (so `@transaction(['default'])` also works); without Postgres no adapter is registered and `@transaction()` executes the method directly. Never write `@transaction(['pg'])` — naming an unregistered adapter throws at call time. See `wabot-async` for command-level transactions.
149
+
150
+ ## Rules
151
+
152
+ - Always declare the full type on `@query() declare` properties — the decorator uses the method name only, but TypeScript needs the signature.
153
+ - Don't write SQL or memory iteration inside `@query()` methods — that's what `@queryExtension` is for.
154
+ - Never mutate `entity['data']` directly outside the entity class. Use `entity.update(...)` or domain methods.
155
+ - Repository classes are singletons; do not store per-request state on them.
156
+
157
+ ## Testing
158
+
159
+ `useMemoryRepositories()` from `@wabot-dev/framework/testing` backs every `@repository` with an in-RAM adapter (no PostgreSQL, no `.wabot/` persistence) — call it once at the top of the test file, before anything resolves a repository. `entityFixture(Entity, data, { id? })` seeds already-created, validated entities. See the `wabot-testing` skill.
@@ -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.