flora is a fast and secure runtime that lets you write discord bots for your servers, with a rich TypeScript SDK, without worrying about running infrastructure. [mirror]
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(sdk): isolated testing harness

+1104 -91
+2 -91
sdk/src/runtime/index.ts
··· 14 14 ReactionContext, 15 15 ReactionRemoveAllContext 16 16 } from '../sdk/types' 17 + import { normalizeEdit, normalizeReply } from './normalize' 18 + import type { AnyPayload } from './normalize' 17 19 18 20 export interface FloraEventMap { 19 21 ready: BaseContext<EventReady> ··· 73 75 op_secret_placeholder(name: string): string | undefined 74 76 } 75 77 } 76 - } 77 - 78 - type AnyPayload = { 79 - id?: string 80 - messageId?: string 81 - channelId?: string 82 - interactionId?: string 83 - interactionToken?: string 84 - token?: string 85 - [key: string]: unknown 86 78 } 87 79 88 80 const core = Deno.core ··· 173 165 skipIfRunning: options?.skipIfRunning ?? false 174 166 }) 175 167 } 176 - 177 - function normalizeReply( 178 - message: string | MessageReplyOptions, 179 - payload: AnyPayload 180 - ): Record<string, unknown> { 181 - if (payload?.interactionToken) { 182 - return normalizeInteractionReply(message, payload) 183 - } 184 - 185 - const base = { channelId: payload.channelId } 186 - const replyId = payload.id ?? payload.messageId 187 - 188 - if (typeof message === 'string') { 189 - return { ...base, messageId: replyId, content: message } 190 - } 191 - 192 - if (message && typeof message === 'object') { 193 - const normalized: Record<string, unknown> = { ...base, ...message } 194 - const explicitReplyTo = message.replyTo ?? (message as Record<string, unknown>).replyTo 195 - 196 - if (explicitReplyTo === null) { 197 - delete normalized.messageId 198 - } else if (explicitReplyTo !== undefined) { 199 - normalized.messageId = explicitReplyTo 200 - } else if (replyId) { 201 - normalized.messageId = replyId 202 - } 203 - 204 - delete normalized.replyTo 205 - delete normalized.reply_to 206 - return normalized 207 - } 208 - 209 - return { ...base, messageId: replyId, content: String(message) } 210 - } 211 - 212 - function normalizeEdit( 213 - message: string | MessageEditOptions, 214 - payload: AnyPayload 215 - ): Record<string, unknown> { 216 - const messageId = payload.id ?? payload.messageId 217 - if (!messageId || !payload?.channelId) { 218 - throw new Error('Message edit requires a message payload') 219 - } 220 - 221 - const base = { channelId: payload.channelId, messageId } 222 - 223 - if (typeof message === 'string') { 224 - return { ...base, content: message } 225 - } 226 - 227 - if (message && typeof message === 'object') { 228 - return { ...base, ...message } 229 - } 230 - 231 - return { ...base, content: String(message) } 232 - } 233 - 234 - function normalizeInteractionReply( 235 - message: string | MessageReplyOptions, 236 - payload: AnyPayload 237 - ): Record<string, unknown> { 238 - const base = { 239 - interactionId: payload.interactionId ?? payload.id, 240 - token: payload.interactionToken 241 - } 242 - 243 - if (typeof message === 'string') { 244 - return { ...base, content: message } 245 - } 246 - 247 - if (message && typeof message === 'object') { 248 - const normalized: Record<string, unknown> = { ...base, ...message } 249 - if (message.ephemeral !== undefined) { 250 - normalized.ephemeral = message.ephemeral 251 - } 252 - return normalized 253 - } 254 - 255 - return { ...base, content: String(message) } 256 - }
+92
sdk/src/runtime/normalize.ts
··· 1 + import type { MessageEditOptions, MessageReplyOptions } from '../sdk/types' 2 + 3 + export type AnyPayload = { 4 + id?: string 5 + messageId?: string 6 + channelId?: string 7 + interactionId?: string 8 + interactionToken?: string 9 + token?: string 10 + [key: string]: unknown 11 + } 12 + 13 + export function normalizeReply( 14 + message: string | MessageReplyOptions, 15 + payload: AnyPayload 16 + ): Record<string, unknown> { 17 + if (payload?.interactionToken) { 18 + return normalizeInteractionReply(message, payload) 19 + } 20 + 21 + const base = { channelId: payload.channelId } 22 + const replyId = payload.id ?? payload.messageId 23 + 24 + if (typeof message === 'string') { 25 + return { ...base, messageId: replyId, content: message } 26 + } 27 + 28 + if (message && typeof message === 'object') { 29 + const normalized: Record<string, unknown> = { ...base, ...message } 30 + const explicitReplyTo = message.replyTo ?? (message as Record<string, unknown>).replyTo 31 + 32 + if (explicitReplyTo === null) { 33 + delete normalized.messageId 34 + } else if (explicitReplyTo !== undefined) { 35 + normalized.messageId = explicitReplyTo 36 + } else if (replyId) { 37 + normalized.messageId = replyId 38 + } 39 + 40 + delete normalized.replyTo 41 + delete normalized.reply_to 42 + return normalized 43 + } 44 + 45 + return { ...base, messageId: replyId, content: String(message) } 46 + } 47 + 48 + export function normalizeEdit( 49 + message: string | MessageEditOptions, 50 + payload: AnyPayload 51 + ): Record<string, unknown> { 52 + const messageId = payload.id ?? payload.messageId 53 + if (!messageId || !payload?.channelId) { 54 + throw new Error('Message edit requires a message payload') 55 + } 56 + 57 + const base = { channelId: payload.channelId, messageId } 58 + 59 + if (typeof message === 'string') { 60 + return { ...base, content: message } 61 + } 62 + 63 + if (message && typeof message === 'object') { 64 + return { ...base, ...message } 65 + } 66 + 67 + return { ...base, content: String(message) } 68 + } 69 + 70 + export function normalizeInteractionReply( 71 + message: string | MessageReplyOptions, 72 + payload: AnyPayload 73 + ): Record<string, unknown> { 74 + const base = { 75 + interactionId: payload.interactionId ?? payload.id, 76 + token: payload.interactionToken 77 + } 78 + 79 + if (typeof message === 'string') { 80 + return { ...base, content: message } 81 + } 82 + 83 + if (message && typeof message === 'object') { 84 + const normalized: Record<string, unknown> = { ...base, ...message } 85 + if (message.ephemeral !== undefined) { 86 + normalized.ephemeral = message.ephemeral 87 + } 88 + return normalized 89 + } 90 + 91 + return { ...base, content: String(message) } 92 + }
+178
sdk/src/testing/factories.ts
··· 1 + import type { 2 + EventComponentInteraction, 3 + EventInteractionCreate, 4 + EventMessage, 5 + EventModalSubmit, 6 + EventReaction, 7 + EventReady, 8 + EventUser 9 + } from '../generated' 10 + import { nextId } from './id' 11 + import type { 12 + ComponentInteractionPartial, 13 + DeepPartial, 14 + InteractionOptions, 15 + InteractionPartial, 16 + MessagePartial, 17 + ModalSubmitPartial, 18 + ReactionPartial, 19 + ReadyPartial 20 + } from './types' 21 + 22 + function deepMerge<T>(defaults: T, partial?: DeepPartial<T>): T { 23 + if (!partial) return defaults 24 + if (typeof defaults !== 'object' || defaults === null) return (partial ?? defaults) as T 25 + if (Array.isArray(defaults)) return (partial ?? defaults) as T 26 + 27 + const result = { ...defaults } as Record<string, unknown> 28 + for (const key of Object.keys(partial as object)) { 29 + const pVal = (partial as Record<string, unknown>)[key] 30 + const dVal = (defaults as Record<string, unknown>)[key] 31 + if ( 32 + pVal !== undefined && typeof pVal === 'object' && pVal !== null && !Array.isArray(pVal) && 33 + typeof dVal === 'object' && dVal !== null && !Array.isArray(dVal) 34 + ) { 35 + result[key] = deepMerge(dVal, pVal as DeepPartial<typeof dVal>) 36 + } else if (pVal !== undefined) { 37 + result[key] = pVal 38 + } 39 + } 40 + return result as T 41 + } 42 + 43 + export function makeUser(partial?: DeepPartial<EventUser>): EventUser { 44 + return deepMerge<EventUser>( 45 + { id: nextId(), username: 'testuser', bot: false }, 46 + partial 47 + ) 48 + } 49 + 50 + export function makeMember( 51 + partial?: DeepPartial<EventMessage['member']> 52 + ): NonNullable<EventMessage['member']> { 53 + const user = makeUser(partial?.user) 54 + return deepMerge( 55 + { 56 + user, 57 + roles: [] as string[], 58 + deaf: false, 59 + mute: false, 60 + flags: 0, 61 + pending: false 62 + }, 63 + { ...partial, user } as any 64 + ) 65 + } 66 + 67 + export function makeMessage(partial?: MessagePartial, guildId?: string): EventMessage { 68 + const author = makeUser(partial?.author) 69 + const defaults: EventMessage = { 70 + id: nextId(), 71 + channelId: nextId(), 72 + guildId: guildId ?? nextId(), 73 + content: '', 74 + author 75 + } 76 + return deepMerge(defaults, partial) 77 + } 78 + 79 + function optionToDiscord(name: string, value: string | number | boolean) { 80 + if (typeof value === 'string') return { name, value, type: 3 } 81 + if (typeof value === 'boolean') return { name, value, type: 5 } 82 + if (typeof value === 'number') { 83 + return Number.isInteger(value) 84 + ? { name, value, type: 4 } 85 + : { name, value, type: 10 } 86 + } 87 + return { name, value } 88 + } 89 + 90 + export function makeInteraction( 91 + name: string, 92 + opts?: InteractionOptions, 93 + partial?: InteractionPartial, 94 + guildId?: string 95 + ): EventInteractionCreate { 96 + const discordOptions = opts 97 + ? Object.entries(opts).map(([k, v]) => optionToDiscord(k, v)) 98 + : [] 99 + 100 + const user = makeUser(partial?.user) 101 + const defaults: EventInteractionCreate = { 102 + interactionId: nextId(), 103 + interactionToken: `test-token-${nextId()}`, 104 + applicationId: nextId(), 105 + guildId: guildId ?? nextId(), 106 + channelId: nextId(), 107 + user, 108 + commandName: name, 109 + data: { options: discordOptions }, 110 + locale: 'en-US' 111 + } 112 + return deepMerge(defaults, partial) 113 + } 114 + 115 + export function makeComponentInteraction( 116 + customId: string, 117 + partial?: ComponentInteractionPartial, 118 + guildId?: string 119 + ): EventComponentInteraction { 120 + const user = makeUser(partial?.user) 121 + const defaults: EventComponentInteraction = { 122 + interactionId: nextId(), 123 + interactionToken: `test-token-${nextId()}`, 124 + applicationId: nextId(), 125 + guildId: guildId ?? nextId(), 126 + channelId: nextId(), 127 + user, 128 + data: { custom_id: customId, component_type: 2 } 129 + } 130 + return deepMerge(defaults, partial) 131 + } 132 + 133 + export function makeModalSubmit( 134 + customId: string, 135 + fields?: Record<string, string>, 136 + partial?: ModalSubmitPartial, 137 + guildId?: string 138 + ): EventModalSubmit { 139 + const components = fields 140 + ? Object.entries(fields).map(([id, value]) => ({ 141 + type: 1, 142 + components: [{ type: 4, custom_id: id, value }] 143 + })) 144 + : [] 145 + 146 + const user = makeUser(partial?.user) 147 + const defaults: EventModalSubmit = { 148 + interactionId: nextId(), 149 + interactionToken: `test-token-${nextId()}`, 150 + applicationId: nextId(), 151 + guildId: guildId ?? nextId(), 152 + channelId: nextId(), 153 + user, 154 + data: { custom_id: customId, components } 155 + } 156 + return deepMerge(defaults, partial) 157 + } 158 + 159 + export function makeReaction(partial?: ReactionPartial, guildId?: string): EventReaction { 160 + const defaults: EventReaction = { 161 + userId: nextId(), 162 + channelId: nextId(), 163 + messageId: nextId(), 164 + guildId: guildId ?? nextId(), 165 + emoji: { name: '👍' }, 166 + burst: false 167 + } 168 + return deepMerge(defaults, partial) 169 + } 170 + 171 + export function makeReady(partial?: ReadyPartial): EventReady { 172 + const user = makeUser({ bot: true, ...partial?.user }) 173 + const defaults: EventReady = { 174 + user, 175 + guildIds: [] 176 + } 177 + return deepMerge(defaults, partial) 178 + }
+336
sdk/src/testing/harness.test.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it } from 'vitest' 2 + import { createBot, prefix, slash } from '../sdk/commands' 3 + import { store } from '../sdk/kv' 4 + import { TestHarness } from './harness' 5 + 6 + describe('TestHarness', () => { 7 + const t = new TestHarness() 8 + 9 + afterEach(() => { 10 + t.teardown() 11 + }) 12 + 13 + describe('prefix commands', () => { 14 + beforeEach(() => { 15 + t.setup(() => { 16 + createBot({ 17 + prefix: '!', 18 + commands: [ 19 + prefix({ 20 + name: 'ping', 21 + run: async (ctx) => { 22 + await ctx.reply('pong') 23 + } 24 + }), 25 + prefix({ 26 + name: 'echo', 27 + run: async (ctx) => { 28 + await ctx.reply(ctx.args.join(' ')) 29 + } 30 + }) 31 + ] 32 + }) 33 + }) 34 + }) 35 + 36 + it('dispatches prefix command and records reply', async () => { 37 + const r = await t.message({ content: '!ping' }) 38 + expect(r.replies).toHaveLength(1) 39 + expect(r.firstReply).toMatchObject({ content: 'pong' }) 40 + }) 41 + 42 + it('passes args to prefix command', async () => { 43 + const r = await t.message({ content: '!echo hello world' }) 44 + expect(r.replies).toHaveLength(1) 45 + expect(r.firstReply).toMatchObject({ content: 'hello world' }) 46 + }) 47 + 48 + it('ignores messages without prefix', async () => { 49 + const r = await t.message({ content: 'hello' }) 50 + expect(r.replies).toHaveLength(0) 51 + }) 52 + 53 + it('ignores bot messages', async () => { 54 + const r = await t.message({ content: '!ping', author: { bot: true } }) 55 + expect(r.replies).toHaveLength(0) 56 + }) 57 + }) 58 + 59 + describe('slash commands', () => { 60 + beforeEach(() => { 61 + t.setup(() => { 62 + createBot({ 63 + slashCommands: [ 64 + slash({ 65 + name: 'greet', 66 + description: 'Greet someone', 67 + run: async (ctx) => { 68 + await ctx.reply(`Hello, ${ctx.options.name}!`) 69 + } 70 + }) 71 + ] 72 + }) 73 + }) 74 + }) 75 + 76 + it('dispatches slash command with options', async () => { 77 + const r = await t.interaction('greet', { name: 'World' }) 78 + expect(r.replies).toHaveLength(1) 79 + expect(r.firstReply).toMatchObject({ content: 'Hello, World!' }) 80 + }) 81 + 82 + it('records interaction response op', async () => { 83 + const r = await t.interaction('greet', { name: 'Test' }) 84 + expect(r.replies[0]!.op).toBe('op_send_interaction_response') 85 + }) 86 + }) 87 + 88 + describe('component interactions', () => { 89 + beforeEach(() => { 90 + t.setup(() => { 91 + on('componentInteraction', async (ctx) => { 92 + const data = ctx.msg.data as any 93 + await ctx.reply(`clicked: ${data?.custom_id}`) 94 + }) 95 + }) 96 + }) 97 + 98 + it('dispatches component interaction', async () => { 99 + const r = await t.componentInteraction('btn-confirm') 100 + expect(r.replies).toHaveLength(1) 101 + expect(r.firstReply).toMatchObject({ content: 'clicked: btn-confirm' }) 102 + }) 103 + }) 104 + 105 + describe('modal submit', () => { 106 + beforeEach(() => { 107 + t.setup(() => { 108 + on('modalSubmit', async (ctx) => { 109 + const data = ctx.msg.data as any 110 + await ctx.reply(`modal: ${data?.custom_id}`) 111 + }) 112 + }) 113 + }) 114 + 115 + it('dispatches modal submit', async () => { 116 + const r = await t.modalSubmit('feedback-form', { message: 'great' }) 117 + expect(r.replies).toHaveLength(1) 118 + expect(r.firstReply).toMatchObject({ content: 'modal: feedback-form' }) 119 + }) 120 + }) 121 + 122 + describe('KV operations', () => { 123 + beforeEach(() => { 124 + t.setup(() => { 125 + createBot({ 126 + prefix: '!', 127 + commands: [ 128 + prefix({ 129 + name: 'save', 130 + run: async (ctx) => { 131 + const s = store('data') 132 + await s.set('key1', 'value1') 133 + await ctx.reply('saved') 134 + } 135 + }), 136 + prefix({ 137 + name: 'load', 138 + run: async (ctx) => { 139 + const s = store('data') 140 + const val = await s.get('key1') 141 + await ctx.reply(val ?? 'not found') 142 + } 143 + }) 144 + ] 145 + }) 146 + }) 147 + }) 148 + 149 + it('stores and retrieves KV data', async () => { 150 + await t.message({ content: '!save' }) 151 + const r = await t.message({ content: '!load' }) 152 + expect(r.firstReply).toMatchObject({ content: 'value1' }) 153 + }) 154 + 155 + it('returns null for missing keys', async () => { 156 + const r = await t.message({ content: '!load' }) 157 + expect(r.firstReply).toMatchObject({ content: 'not found' }) 158 + }) 159 + 160 + it('exposes kv store for direct assertions', async () => { 161 + await t.message({ content: '!save' }) 162 + expect(t.kv.get('data', 'key1')).toBe('value1') 163 + }) 164 + 165 + it('supports delete', async () => { 166 + await t.message({ content: '!save' }) 167 + t.kv.delete('data', 'key1') 168 + const r = await t.message({ content: '!load' }) 169 + expect(r.firstReply).toMatchObject({ content: 'not found' }) 170 + }) 171 + 172 + it('supports list keys', () => { 173 + t.kv.set('data', 'a', '1', {}) 174 + t.kv.set('data', 'b', '2', {}) 175 + t.kv.set('data', 'c', '3', {}) 176 + const result = t.kv.listKeys({ prefix: 'a' }, 'data') 177 + expect(result.keys).toHaveLength(1) 178 + expect(result.keys[0]!.name).toBe('a') 179 + expect(result.listComplete).toBe(true) 180 + }) 181 + }) 182 + 183 + describe('cron', () => { 184 + it('triggers cron handler', async () => { 185 + let called = false 186 + t.setup(() => { 187 + cron('cleanup', '0 * * * *', async () => { 188 + called = true 189 + }) 190 + }) 191 + await t.triggerCron('cleanup') 192 + expect(called).toBe(true) 193 + }) 194 + 195 + it('records cron registration op', () => { 196 + t.setup(() => { 197 + cron('daily', '0 0 * * *', () => {}) 198 + }) 199 + const calls = t.allCalls().filter((c) => c.op === 'op_register_cron') 200 + expect(calls).toHaveLength(1) 201 + expect(calls[0]!.args[0]).toMatchObject({ name: 'daily', expr: '0 0 * * *' }) 202 + }) 203 + }) 204 + 205 + describe('secrets', () => { 206 + it('returns configured secrets', () => { 207 + const h = new TestHarness({ secrets: { TOKEN: 'abc123' } }) 208 + h.setup(() => {}) 209 + expect(secrets.get('TOKEN')).toBe('abc123') 210 + h.teardown() 211 + }) 212 + 213 + it('supports setSecret at runtime', () => { 214 + t.setup(() => {}) 215 + t.setSecret('API_KEY', 'xyz') 216 + expect(secrets.get('API_KEY')).toBe('xyz') 217 + }) 218 + }) 219 + 220 + describe('rest calls', () => { 221 + beforeEach(() => { 222 + t.setup(() => { 223 + on('messageCreate', async (ctx) => { 224 + await ctx.reply('hi') 225 + await ctx.edit('edited') 226 + }) 227 + }) 228 + }) 229 + 230 + it('records send and edit ops', async () => { 231 + const r = await t.message({ content: 'test' }) 232 + expect(r.replies).toHaveLength(1) 233 + expect(r.edits).toHaveLength(1) 234 + }) 235 + }) 236 + 237 + describe('reset', () => { 238 + it('clears state and allows re-setup', async () => { 239 + t.setup(() => { 240 + createBot({ 241 + prefix: '!', 242 + commands: [prefix({ name: 'a', run: async (ctx) => ctx.reply('a') })] 243 + }) 244 + }) 245 + 246 + await t.message({ content: '!a' }) 247 + expect(t.allCalls().length).toBeGreaterThan(0) 248 + 249 + t.reset() 250 + 251 + t.setup(() => { 252 + createBot({ 253 + prefix: '!', 254 + commands: [prefix({ name: 'b', run: async (ctx) => ctx.reply('b') })] 255 + }) 256 + }) 257 + 258 + const r = await t.message({ content: '!b' }) 259 + expect(r.firstReply).toMatchObject({ content: 'b' }) 260 + 261 + // old command should not work 262 + const r2 = await t.message({ content: '!a' }) 263 + expect(r2.replies).toHaveLength(0) 264 + }) 265 + }) 266 + 267 + describe('multiple dispatches', () => { 268 + beforeEach(() => { 269 + t.setup(() => { 270 + createBot({ 271 + prefix: '!', 272 + commands: [prefix({ name: 'x', run: async (ctx) => ctx.reply('x') })] 273 + }) 274 + }) 275 + }) 276 + 277 + it('accumulates allCalls across dispatches', async () => { 278 + await t.message({ content: '!x' }) 279 + await t.message({ content: '!x' }) 280 + const all = t.allCalls().filter((c) => c.op === 'op_send_message') 281 + expect(all).toHaveLength(2) 282 + }) 283 + 284 + it('returns per-dispatch results', async () => { 285 + const r1 = await t.message({ content: '!x' }) 286 + const r2 = await t.message({ content: '!x' }) 287 + expect(r1.replies).toHaveLength(1) 288 + expect(r2.replies).toHaveLength(1) 289 + }) 290 + }) 291 + 292 + describe('mockResponse', () => { 293 + it('returns mocked value for fetch ops', async () => { 294 + t.setup(() => { 295 + on('messageCreate', async (ctx) => { 296 + const msg = await (globalThis as any).Deno.core.ops.op_fetch_message({ 297 + channelId: '123', 298 + messageId: '456' 299 + }) 300 + await ctx.reply(msg.content) 301 + }) 302 + }) 303 + 304 + t.mockResponse('op_fetch_message', { content: 'fetched content' }) 305 + const r = await t.message({ content: 'test' }) 306 + expect(r.firstReply).toMatchObject({ content: 'fetched content' }) 307 + }) 308 + 309 + it('supports function mocks', async () => { 310 + t.setup(() => { 311 + on('messageCreate', async (ctx) => { 312 + const msg = await (globalThis as any).Deno.core.ops.op_fetch_message({ messageId: '789' }) 313 + await ctx.reply(msg.id) 314 + }) 315 + }) 316 + 317 + t.mockResponse('op_fetch_message', (input: any) => ({ id: input.messageId, content: 'hi' })) 318 + const r = await t.message({ content: 'test' }) 319 + expect(r.firstReply).toMatchObject({ content: '789' }) 320 + }) 321 + }) 322 + 323 + describe('console.log capture', () => { 324 + it('captures logs in dispatch result', async () => { 325 + t.setup(() => { 326 + on('messageCreate', async () => { 327 + console.log('debug', 42) 328 + }) 329 + }) 330 + 331 + const r = await t.message({ content: 'test' }) 332 + expect(r.logs).toHaveLength(1) 333 + expect(r.logs[0]).toEqual(['debug', 42]) 334 + }) 335 + }) 336 + })
+323
sdk/src/testing/harness.ts
··· 1 + import { normalizeEdit, normalizeReply } from '../runtime/normalize' 2 + import type { AnyPayload } from '../runtime/normalize' 3 + import type { MessageEditOptions, MessageReplyOptions } from '../sdk/types' 4 + import { 5 + makeComponentInteraction, 6 + makeInteraction, 7 + makeMessage, 8 + makeModalSubmit, 9 + makeReaction, 10 + makeReady 11 + } from './factories' 12 + import { resetIdCounter } from './id' 13 + import { KvMock } from './kv_mock' 14 + import type { 15 + ComponentInteractionPartial, 16 + DispatchResult, 17 + HarnessOptions, 18 + InteractionOptions, 19 + InteractionPartial, 20 + MessagePartial, 21 + ModalSubmitPartial, 22 + OpCall, 23 + ReactionPartial, 24 + ReadyPartial 25 + } from './types' 26 + 27 + export class TestHarness { 28 + private guildId: string 29 + private secretsMap: Record<string, string> 30 + private opCalls: OpCall[] = [] 31 + private mockResponses = new Map<string, unknown | ((...args: unknown[]) => unknown)>() 32 + private savedGlobals: Record<string, unknown> = {} 33 + 34 + kv = new KvMock() 35 + 36 + constructor(options?: HarnessOptions) { 37 + this.guildId = options?.guildId ?? '999000000000000000' 38 + this.secretsMap = { ...(options?.secrets ?? {}) } 39 + } 40 + 41 + setup(fn: () => void): this { 42 + this.installGlobals() 43 + fn() 44 + return this 45 + } 46 + 47 + reset(): void { 48 + this.opCalls = [] 49 + this.kv.clear() 50 + this.mockResponses.clear() 51 + resetIdCounter() 52 + this.removeGlobals() 53 + this.installGlobals() 54 + } 55 + 56 + teardown(): void { 57 + this.opCalls = [] 58 + this.kv.clear() 59 + this.mockResponses.clear() 60 + this.removeGlobals() 61 + } 62 + 63 + private installGlobals(): void { 64 + this.savedGlobals = { 65 + Deno: (globalThis as any).Deno, 66 + __floraHandlers: (globalThis as any).__floraHandlers, 67 + __floraGuildId: (globalThis as any).__floraGuildId, 68 + __floraCreateBotState: (globalThis as any).__floraCreateBotState, 69 + __floraSubcommands: (globalThis as any).__floraSubcommands, 70 + on: (globalThis as any).on, 71 + __floraDispatch: (globalThis as any).__floraDispatch, 72 + registerSlashCommands: (globalThis as any).registerSlashCommands, 73 + cron: (globalThis as any).cron, 74 + secrets: (globalThis as any).secrets, 75 + console: (globalThis as any).console 76 + } 77 + 78 + const self = this 79 + 80 + const opsProxy = new Proxy({} as Record<string, Function>, { 81 + get(_target, prop: string) { 82 + return (...args: unknown[]): unknown => { 83 + self.opCalls.push({ op: prop, args, timestamp: Date.now() }) 84 + 85 + // KV ops 86 + if (prop === 'op_kv_get') { 87 + return Promise.resolve(self.kv.get(args[0] as string, args[1] as string)) 88 + } 89 + if (prop === 'op_kv_get_with_metadata') { 90 + return Promise.resolve(self.kv.getWithMetadata(args[0] as string, args[1] as string)) 91 + } 92 + if (prop === 'op_kv_set') { 93 + self.kv.set(args[0] as string, args[1] as string, args[2] as string, args[3] as any) 94 + return Promise.resolve() 95 + } 96 + if (prop === 'op_kv_update_metadata') { 97 + self.kv.updateMetadata(args[0] as string, args[1] as string, args[2] as any) 98 + return Promise.resolve() 99 + } 100 + if (prop === 'op_kv_delete') { 101 + self.kv.delete(args[0] as string, args[1] as string) 102 + return Promise.resolve() 103 + } 104 + if (prop === 'op_kv_list_keys') { 105 + return Promise.resolve(self.kv.listKeys(args[0] as any, args[1] as string)) 106 + } 107 + 108 + // Secrets 109 + if (prop === 'op_secret_placeholder') return self.secretsMap[args[0] as string] 110 + 111 + // Logging - no-op (captured via opCalls) 112 + if (prop === 'op_log') return undefined 113 + 114 + // Registration ops - no-op 115 + if (prop === 'op_register_cron') return undefined 116 + if (prop === 'op_upsert_guild_commands') return Promise.resolve() 117 + 118 + // Mock responses 119 + if (self.mockResponses.has(prop)) { 120 + const mock = self.mockResponses.get(prop) 121 + if (typeof mock === 'function') return Promise.resolve(mock(...args)) 122 + return Promise.resolve(mock) 123 + } 124 + 125 + // Default: resolve void for send/edit/defer/followup ops 126 + return Promise.resolve() 127 + } 128 + } 129 + }) 130 + ;(globalThis as any).Deno = { core: { ops: opsProxy } } 131 + ;(globalThis as any).__floraHandlers = {} 132 + ;(globalThis as any).__floraGuildId = this.guildId 133 + ;(globalThis as any).__floraCreateBotState = undefined 134 + ;(globalThis as any).__floraSubcommands = undefined 135 + ;(globalThis as any).secrets = { 136 + get: (name: string) => self.secretsMap[name] 137 + } 138 + ;(globalThis as any).on = function on(event: string, handler: Function): void { 139 + if (!(globalThis as any).__floraHandlers[event]) { 140 + ;(globalThis as any).__floraHandlers[event] = [] 141 + } 142 + ;(globalThis as any).__floraHandlers[event].push(handler) 143 + } 144 + 145 + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- proxy always returns a function 146 + const ops = opsProxy as { [k: string]: Function } & { 147 + op_send_interaction_response(o: unknown): Promise<void> 148 + op_send_message(o: unknown): Promise<void> 149 + op_edit_message(o: unknown): Promise<void> 150 + op_upsert_guild_commands(o: unknown): Promise<void> 151 + op_register_cron(o: unknown): void 152 + op_log(a: unknown[]): void 153 + } 154 + const core = { ops } 155 + ;(globalThis as any).__floraDispatch = async function __floraDispatch( 156 + event: string, 157 + payload: unknown 158 + ): Promise<void> { 159 + const handlers = (globalThis as any).__floraHandlers[event] || [] 160 + for (const handler of handlers) { 161 + const context = { 162 + msg: payload, 163 + reply(message: string | MessageReplyOptions) { 164 + const options = normalizeReply(message, payload as AnyPayload) 165 + if (options['interactionId'] && options['token']) { 166 + return core.ops.op_send_interaction_response(options) 167 + } 168 + return core.ops.op_send_message(options) 169 + }, 170 + edit(message: string | MessageEditOptions) { 171 + const options = normalizeEdit(message, payload as AnyPayload) 172 + return core.ops.op_edit_message(options) 173 + } 174 + } 175 + await handler(context) 176 + } 177 + } 178 + ;(globalThis as any).registerSlashCommands = function registerSlashCommands( 179 + commands: unknown[] 180 + ): Promise<void> | undefined { 181 + if (!(globalThis as any).__floraGuildId) return 182 + return core.ops.op_upsert_guild_commands({ 183 + guildId: (globalThis as any).__floraGuildId, 184 + commands 185 + }) as Promise<void> 186 + } 187 + 188 + const CRON_EVENT_PREFIX = '__cron:' 189 + ;(globalThis as any).cron = function cron( 190 + name: string, 191 + cronExpr: string, 192 + handler: Function, 193 + options?: { skipIfRunning?: boolean } 194 + ): void { 195 + if (typeof name !== 'string' || !name.length) { 196 + throw new TypeError('cron name must be a non-empty string') 197 + } 198 + if (typeof cronExpr !== 'string' || !cronExpr.length) { 199 + throw new TypeError('cron expression must be a non-empty string') 200 + } 201 + if (typeof handler !== 'function') throw new TypeError('cron handler must be a function') 202 + 203 + const eventName = CRON_EVENT_PREFIX + name 204 + if (!(globalThis as any).__floraHandlers[eventName]) { 205 + ;(globalThis as any).__floraHandlers[eventName] = [] 206 + } 207 + ;(globalThis as any).__floraHandlers[eventName].push(handler) 208 + core.ops.op_register_cron({ 209 + name, 210 + expr: cronExpr, 211 + skipIfRunning: options?.skipIfRunning ?? false 212 + }) 213 + } 214 + ;(globalThis as any).console = { 215 + log: (...args: unknown[]) => core.ops.op_log(args) 216 + } 217 + } 218 + 219 + private removeGlobals(): void { 220 + for (const [key, value] of Object.entries(this.savedGlobals)) { 221 + if (value === undefined) { 222 + delete (globalThis as any)[key] 223 + } else { 224 + ;(globalThis as any)[key] = value 225 + } 226 + } 227 + this.savedGlobals = {} 228 + } 229 + 230 + // --- Simulation methods --- 231 + 232 + async dispatch(event: string, payload: unknown): Promise<DispatchResult> { 233 + const startIdx = this.opCalls.length 234 + await (globalThis as any).__floraDispatch(event, payload) 235 + return this.buildResult(startIdx) 236 + } 237 + 238 + async message(partial?: MessagePartial): Promise<DispatchResult> { 239 + const payload = makeMessage(partial, this.guildId) 240 + return this.dispatch('messageCreate', payload) 241 + } 242 + 243 + async interaction( 244 + name: string, 245 + opts?: InteractionOptions, 246 + partial?: InteractionPartial 247 + ): Promise<DispatchResult> { 248 + const payload = makeInteraction(name, opts, partial, this.guildId) 249 + return this.dispatch('interactionCreate', payload) 250 + } 251 + 252 + async componentInteraction( 253 + customId: string, 254 + partial?: ComponentInteractionPartial 255 + ): Promise<DispatchResult> { 256 + const payload = makeComponentInteraction(customId, partial, this.guildId) 257 + return this.dispatch('componentInteraction', payload) 258 + } 259 + 260 + async modalSubmit( 261 + customId: string, 262 + fields?: Record<string, string>, 263 + partial?: ModalSubmitPartial 264 + ): Promise<DispatchResult> { 265 + const payload = makeModalSubmit(customId, fields, partial, this.guildId) 266 + return this.dispatch('modalSubmit', payload) 267 + } 268 + 269 + async reaction(partial?: ReactionPartial): Promise<DispatchResult> { 270 + const payload = makeReaction(partial, this.guildId) 271 + return this.dispatch('reactionAdd', payload) 272 + } 273 + 274 + async ready(partial?: ReadyPartial): Promise<DispatchResult> { 275 + const payload = makeReady(partial) 276 + return this.dispatch('ready', payload) 277 + } 278 + 279 + async triggerCron(name: string): Promise<DispatchResult> { 280 + const payload = { name, scheduledAt: new Date().toISOString() } 281 + return this.dispatch(`__cron:${name}`, payload) 282 + } 283 + 284 + // --- Configuration --- 285 + 286 + mockResponse(opName: string, value: unknown | ((...args: unknown[]) => unknown)): void { 287 + this.mockResponses.set(opName, value) 288 + } 289 + 290 + setSecret(name: string, value: string): void { 291 + this.secretsMap[name] = value 292 + } 293 + 294 + allCalls(): OpCall[] { 295 + return [...this.opCalls] 296 + } 297 + 298 + // --- Internal --- 299 + 300 + private buildResult(startIdx: number): DispatchResult { 301 + const calls = this.opCalls.slice(startIdx) 302 + 303 + const replies = calls.filter( 304 + (c) => c.op === 'op_send_message' || c.op === 'op_send_interaction_response' 305 + ) 306 + const edits = calls.filter((c) => c.op === 'op_edit_message') 307 + const defers = calls.filter((c) => c.op === 'op_defer_interaction_response') 308 + const followups = calls.filter((c) => c.op === 'op_create_followup_message') 309 + const logs = calls 310 + .filter((c) => c.op === 'op_log') 311 + .map((c) => c.args[0] as unknown[]) 312 + 313 + return { 314 + calls, 315 + replies, 316 + edits, 317 + defers, 318 + followups, 319 + logs, 320 + firstReply: replies[0]?.args[0] 321 + } 322 + } 323 + }
+10
sdk/src/testing/id.ts
··· 1 + let counter = 100000000000000000n 2 + 3 + export function nextId(): string { 4 + counter += 1n 5 + return counter.toString() 6 + } 7 + 8 + export function resetIdCounter(): void { 9 + counter = 100000000000000000n 10 + }
+26
sdk/src/testing/index.ts
··· 1 + export { 2 + makeComponentInteraction, 3 + makeInteraction, 4 + makeMember, 5 + makeMessage, 6 + makeModalSubmit, 7 + makeReaction, 8 + makeReady, 9 + makeUser 10 + } from './factories' 11 + export { TestHarness } from './harness' 12 + export { nextId, resetIdCounter } from './id' 13 + export { KvMock } from './kv_mock' 14 + export type { 15 + ComponentInteractionPartial, 16 + DeepPartial, 17 + DispatchResult, 18 + HarnessOptions, 19 + InteractionOptions, 20 + InteractionPartial, 21 + MessagePartial, 22 + ModalSubmitPartial, 23 + OpCall, 24 + ReactionPartial, 25 + ReadyPartial 26 + } from './types'
+100
sdk/src/testing/kv_mock.ts
··· 1 + import type { 2 + JsonValue, 3 + RawKvKeyMetadata, 4 + RawKvListKeysOptions, 5 + RawKvListKeysResult, 6 + RawKvSetOptions 7 + } from '../generated' 8 + 9 + type KvEntry = { 10 + value: string 11 + expiration?: bigint 12 + metadata?: JsonValue 13 + } 14 + 15 + export class KvMock { 16 + stores = new Map<string, Map<string, KvEntry>>() 17 + 18 + private getStore(name: string): Map<string, KvEntry> { 19 + let store = this.stores.get(name) 20 + if (!store) { 21 + store = new Map() 22 + this.stores.set(name, store) 23 + } 24 + return store 25 + } 26 + 27 + get(storeName: string, key: string): string | null { 28 + const entry = this.getStore(storeName).get(key) 29 + return entry?.value ?? null 30 + } 31 + 32 + getWithMetadata( 33 + storeName: string, 34 + key: string 35 + ): [string, RawKvKeyMetadata | null] | null { 36 + const entry = this.getStore(storeName).get(key) 37 + if (!entry) return null 38 + const meta: RawKvKeyMetadata | null = 39 + entry.expiration !== undefined || entry.metadata !== undefined 40 + ? { expiration: entry.expiration, metadata: entry.metadata } 41 + : null 42 + return [entry.value, meta] 43 + } 44 + 45 + set(storeName: string, key: string, value: string, options: RawKvSetOptions): void { 46 + this.getStore(storeName).set(key, { 47 + value, 48 + expiration: options.expiration ?? undefined, 49 + metadata: options.metadata ?? undefined 50 + }) 51 + } 52 + 53 + updateMetadata(storeName: string, key: string, metadata: JsonValue | undefined): void { 54 + const store = this.getStore(storeName) 55 + const entry = store.get(key) 56 + if (!entry) return 57 + entry.metadata = metadata 58 + } 59 + 60 + delete(storeName: string, key: string): void { 61 + this.getStore(storeName).delete(key) 62 + } 63 + 64 + listKeys(options: RawKvListKeysOptions, storeName: string): RawKvListKeysResult { 65 + const store = this.getStore(storeName) 66 + let keys = Array.from(store.entries()) 67 + .map(([name, entry]) => ({ 68 + name, 69 + expiration: entry.expiration, 70 + metadata: entry.metadata 71 + })) 72 + .sort((a, b) => a.name.localeCompare(b.name)) 73 + 74 + if (options.prefix) { 75 + keys = keys.filter((k) => k.name.startsWith(options.prefix!)) 76 + } 77 + 78 + let startIndex = 0 79 + if (options.cursor) { 80 + startIndex = keys.findIndex((k) => k.name > options.cursor!) 81 + if (startIndex === -1) { 82 + return { keys: [], listComplete: true, cursor: undefined } 83 + } 84 + } 85 + 86 + const limit = Number(options.limit ?? 100n) 87 + const slice = keys.slice(startIndex, startIndex + limit) 88 + const listComplete = startIndex + limit >= keys.length 89 + 90 + return { 91 + keys: slice, 92 + listComplete, 93 + cursor: listComplete ? undefined : slice[slice.length - 1]!.name 94 + } 95 + } 96 + 97 + clear(): void { 98 + this.stores.clear() 99 + } 100 + }
+37
sdk/src/testing/types.ts
··· 1 + import type { 2 + EventComponentInteraction, 3 + EventInteractionCreate, 4 + EventMessage, 5 + EventModalSubmit, 6 + EventReaction, 7 + EventReady 8 + } from '../generated' 9 + 10 + export type OpCall = { op: string; args: unknown[]; timestamp: number } 11 + 12 + export type DispatchResult = { 13 + calls: OpCall[] 14 + replies: OpCall[] 15 + edits: OpCall[] 16 + defers: OpCall[] 17 + followups: OpCall[] 18 + logs: unknown[][] 19 + firstReply: unknown | undefined 20 + } 21 + 22 + export type InteractionOptions = Record<string, string | number | boolean> 23 + 24 + export type HarnessOptions = { 25 + guildId?: string 26 + secrets?: Record<string, string> 27 + } 28 + 29 + export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } 30 + : T 31 + 32 + export type MessagePartial = DeepPartial<EventMessage> 33 + export type InteractionPartial = DeepPartial<EventInteractionCreate> 34 + export type ComponentInteractionPartial = DeepPartial<EventComponentInteraction> 35 + export type ModalSubmitPartial = DeepPartial<EventModalSubmit> 36 + export type ReactionPartial = DeepPartial<EventReaction> 37 + export type ReadyPartial = DeepPartial<EventReady>