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]>
231 lines
7.2 KiB
JavaScript
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));
|
|
}
|