// Shared fakes for the offline suite. No network, no token, no model. import { mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after } from "node:test"; import { validateBinding } from "../src/binding.mjs"; import { RestOutcome } from "../src/rest.mjs"; const roots = []; export function makeRoot() { const root = mkdtempSync(join(tmpdir(), "mosaic-discord-")); roots.push(root); return root; } after(() => { for (const r of roots) rmSync(r, { recursive: true, force: true }); }); export const IDS = Object.freeze({ guild: "100000000000000001", bot: "100000000000000002", admin: "100000000000000010", general: "100000000000000011", other: "100000000000000012", owner: "100000000000000100", stranger: "100000000000000101", threadOfAdmin: "100000000000000020", threadOfOther: "100000000000000021", }); export function rawBinding(overrides = {}) { return { bindingVersion: 1, name: "test-seat", seat: "sage", guildId: IDS.guild, guildName: "Test Server", botUserId: IDS.bot, tokenFile: "/nonexistent/token", channels: [ { id: IDS.admin, name: "seat-admin", mode: "open" }, { id: IDS.general, name: "general", mode: "mention" }, ], users: [{ id: IDS.owner, name: "owner" }], engine: { provider: "zai", model: "glm-5.3", thinking: "high" }, limits: { turnsPerDay: 200, turnTimeoutSeconds: 180, replyChunkChars: 1900, inboundMaxChars: 4000 }, context: { files: ["contracts/CONSTITUTION.md"] }, ...overrides, }; } export function binding(overrides = {}) { return validateBinding(rawBinding(overrides)); } // A fake repository with the files prepare() looks for. export function makeRepo(root) { const repo = join(root, "repo"); mkdirSync(join(repo, "node_modules", ".bin"), { recursive: true }); mkdirSync(join(repo, "contracts"), { recursive: true }); writeFileSync(join(repo, "node_modules", ".bin", "pi"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); writeFileSync(join(repo, "contracts", "CONSTITUTION.md"), "# constitution\nbe good\n"); return repo; } // A data root with config and one binding on disk. Returns paths. export function makeDeployment(root, overrides = {}, { tokenMode = 0o600, tokenContent = "MTAw.abcdefghijklmnopqrstuvwxyz0123456789" } = {}) { const dataRoot = join(root, "data"); const secrets = join(root, "secrets"); mkdirSync(join(dataRoot, "discord"), { recursive: true, mode: 0o700 }); mkdirSync(secrets, { recursive: true, mode: 0o700 }); const tokenFile = join(secrets, "bot.token"); writeFileSync(tokenFile, tokenContent + "\n", { mode: 0o600 }); chmodSync(tokenFile, tokenMode); const raw = rawBinding({ tokenFile, ...overrides }); const bindingFile = join(dataRoot, "discord", `${raw.name}.json`); writeFileSync(bindingFile, JSON.stringify(raw, null, 2), { mode: 0o600 }); const config = join(root, "config.json"); writeFileSync(config, JSON.stringify({ dataRoot })); return { dataRoot, config, bindingFile, tokenFile, raw, journalDir: join(dataRoot, "discord", raw.name) }; } export function message(overrides = {}) { return { id: overrides.id || "200000000000000001", channel_id: IDS.admin, guild_id: IDS.guild, author: { id: IDS.owner, username: "owner" }, content: "hello", mentions: [], mention_everyone: false, ...overrides, }; } // Fake REST: scripted outcomes for createMessage, records every call. export function fakeRest({ outcomes = [] } = {}) { const calls = []; let n = 0; return { calls, typingCalls: [], reactions: [], // When false, react resolves false (Discord refused the reaction). reactOk: true, channels: new Map(), lookups: 0, // When set, getChannel waits on this promise before answering (held lookup). holdLookup: null, push(outcome) { outcomes.push(outcome); }, async createMessage(channelId, body) { calls.push({ channelId, ...body }); const o = outcomes.length > 0 ? outcomes.shift() : { ok: true }; if (o.ok) return { messageId: o.messageId || `m${++n}`, status: 200 }; throw new RestOutcome(o.kind, o.message || `fake ${o.kind}`); }, async typing(channelId) { this.typingCalls.push(channelId); }, async react(channelId, messageId, emoji) { this.reactions.push({ channelId, messageId, emoji }); return this.reactOk; }, async getChannel(id) { this.lookups += 1; if (this.holdLookup) await this.holdLookup; const c = this.channels.get(id); if (!c) throw new RestOutcome("refused", "fake 404"); return c; }, }; } export function fakeGateway() { const listeners = new Map(); return { connected: false, closed: null, on(name, fn) { if (!listeners.has(name)) listeners.set(name, new Set()); listeners.get(name).add(fn); }, emit(name, payload) { for (const fn of listeners.get(name) || []) fn(payload); }, connect() { this.connected = true; }, close(code, reason) { this.closed = { code, reason }; }, }; } // Fake engine: each prompt resolves with a scripted reply, or rejects. // Replies come back in prompt order, as pi's turn_end events do. // hold: true keeps every prompt pending until release() is called, so tests // can observe the connector's state while turns are in flight. export function fakeEngine({ replies = [], delayMs = 0, hold = false } = {}) { const prompts = []; let chain = Promise.resolve(); let release = () => {}; const gate = hold ? new Promise((resolve) => { release = resolve; }) : Promise.resolve(); return { prompts, started: false, stopped: false, release() { release(); }, start() { this.started = true; }, prompt(text, opts) { prompts.push({ text, opts }); const r = replies.length > 0 ? replies.shift() : { text: "ok" }; const run = () => gate.then(() => new Promise((resolve, reject) => { setTimeout(() => { if (r.error) reject(Object.assign(new Error(r.error), { details: { code: r.code || "fake" } })); else resolve({ text: r.text, message: null, usage: r.usage || { input: 1, output: 1 }, model: null, provider: null }); }, r.delayMs ?? delayMs); })); const p = chain.then(run, run); chain = p.catch(() => {}); return p; }, async stop() { this.stopped = true; }, }; } // Manual timers for gateway and engine tests. export function fakeTimers() { let now = 0; let seq = 0; const timers = new Map(); return { setTimeout(fn, ms) { const id = ++seq; timers.set(id, { at: now + ms, fn }); return id; }, clearTimeout(id) { timers.delete(id); }, get pending() { return timers.size; }, // Advance the clock, running timers in order. advance(ms) { const target = now + ms; for (;;) { let next = null; for (const [id, t] of timers) if (t.at <= target && (next === null || t.at < next.t.at)) next = { id, t }; if (!next) break; timers.delete(next.id); now = next.t.at; next.t.fn(); } now = target; }, }; } export function flush() { return new Promise((r) => setTimeout(r, 0)); }