Files
stack/packages/discord/tests/helpers.mjs
T
jason.woltjeandClaude Fable 5.1 93d6b62457 feat(discord): eyes reaction as a read receipt on every admitted message (#1509)
QUEUE row 15, MVP iteration 1 after the Sage pilot. rest.react is best
effort (2xx true, anything else false, never throws); the connector reacts
at admission before the engine runs and records the outcome in the turn
record as receipt. Drops and refusals get no reaction. Suite 28/28, 90
node tests.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-13 14:20:30 -05:00

231 lines
7.2 KiB
JavaScript

// 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));
}