* ACPX: keep plugin-local runtime installs out of dist * Gateway: harden ACP startup and service PATH * ACP: reinitialize error-state configured bindings * ACP: classify pre-turn runtime failures as session init failures * Plugins: move configured ACP routing behind channel seams * Telegram tests: align startup probe assertions after rebase * Discord: harden ACP configured binding recovery * ACP: recover Discord bindings after stale runtime exits * ACPX: replace dead sessions during ensure * Discord: harden ACP binding recovery * Discord: fix review follow-ups * ACP bindings: load channel snapshots across workspaces * ACP bindings: cache snapshot channel plugin resolution * Experiments: add ACP pluginification holy grail plan * Experiments: rename ACP pluginification plan doc * Experiments: drop old ACP pluginification doc path * ACP: move configured bindings behind plugin services * Experiments: update bindings capability architecture plan * Bindings: isolate configured binding routing and targets * Discord tests: fix runtime env helper path * Tests: fix channel binding CI regressions * Tests: normalize ACP workspace assertion on Windows * Bindings: isolate configured binding registry * Bindings: finish configured binding cleanup * Bindings: finish generic cleanup * Bindings: align runtime approval callbacks * ACP: delete residual bindings barrel * Bindings: restore legacy compatibility * Revert "Bindings: restore legacy compatibility" This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe. * Tests: drop ACP route legacy helper names * Discord/ACP: fix binding regressions --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
359 lines
10 KiB
TypeScript
359 lines
10 KiB
TypeScript
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
GatewayIntents,
|
|
baseRegisterClientSpy,
|
|
GatewayPlugin,
|
|
globalFetchMock,
|
|
HttpsProxyAgent,
|
|
getLastAgent,
|
|
restProxyAgentSpy,
|
|
undiciFetchMock,
|
|
undiciProxyAgentSpy,
|
|
resetLastAgent,
|
|
webSocketSpy,
|
|
wsProxyAgentSpy,
|
|
} = vi.hoisted(() => {
|
|
const wsProxyAgentSpy = vi.fn();
|
|
const undiciProxyAgentSpy = vi.fn();
|
|
const restProxyAgentSpy = vi.fn();
|
|
const undiciFetchMock = vi.fn();
|
|
const globalFetchMock = vi.fn();
|
|
const baseRegisterClientSpy = vi.fn();
|
|
const webSocketSpy = vi.fn();
|
|
|
|
const GatewayIntents = {
|
|
Guilds: 1 << 0,
|
|
GuildMessages: 1 << 1,
|
|
MessageContent: 1 << 2,
|
|
DirectMessages: 1 << 3,
|
|
GuildMessageReactions: 1 << 4,
|
|
DirectMessageReactions: 1 << 5,
|
|
GuildPresences: 1 << 6,
|
|
GuildMembers: 1 << 7,
|
|
} as const;
|
|
|
|
class GatewayPlugin {
|
|
options: unknown;
|
|
gatewayInfo: unknown;
|
|
constructor(options?: unknown, gatewayInfo?: unknown) {
|
|
this.options = options;
|
|
this.gatewayInfo = gatewayInfo;
|
|
}
|
|
async registerClient(client: unknown) {
|
|
baseRegisterClientSpy(client);
|
|
}
|
|
}
|
|
|
|
class HttpsProxyAgent {
|
|
static lastCreated: HttpsProxyAgent | undefined;
|
|
proxyUrl: string;
|
|
constructor(proxyUrl: string) {
|
|
if (proxyUrl === "bad-proxy") {
|
|
throw new Error("bad proxy");
|
|
}
|
|
this.proxyUrl = proxyUrl;
|
|
HttpsProxyAgent.lastCreated = this;
|
|
wsProxyAgentSpy(proxyUrl);
|
|
}
|
|
}
|
|
|
|
return {
|
|
baseRegisterClientSpy,
|
|
GatewayIntents,
|
|
GatewayPlugin,
|
|
globalFetchMock,
|
|
HttpsProxyAgent,
|
|
getLastAgent: () => HttpsProxyAgent.lastCreated,
|
|
restProxyAgentSpy,
|
|
undiciFetchMock,
|
|
undiciProxyAgentSpy,
|
|
resetLastAgent: () => {
|
|
HttpsProxyAgent.lastCreated = undefined;
|
|
},
|
|
webSocketSpy,
|
|
wsProxyAgentSpy,
|
|
};
|
|
});
|
|
|
|
// Unit test: don't import Carbon just to check the prototype chain.
|
|
vi.mock("@buape/carbon/gateway", () => ({
|
|
GatewayIntents,
|
|
GatewayPlugin,
|
|
}));
|
|
|
|
vi.mock("https-proxy-agent", () => ({
|
|
HttpsProxyAgent,
|
|
}));
|
|
|
|
vi.mock("undici", () => ({
|
|
ProxyAgent: class {
|
|
proxyUrl: string;
|
|
constructor(proxyUrl: string) {
|
|
this.proxyUrl = proxyUrl;
|
|
undiciProxyAgentSpy(proxyUrl);
|
|
restProxyAgentSpy(proxyUrl);
|
|
}
|
|
},
|
|
fetch: undiciFetchMock,
|
|
}));
|
|
|
|
vi.mock("ws", () => ({
|
|
default: class MockWebSocket {
|
|
constructor(url: string, options?: { agent?: unknown }) {
|
|
webSocketSpy(url, options);
|
|
}
|
|
},
|
|
}));
|
|
|
|
describe("createDiscordGatewayPlugin", () => {
|
|
let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin;
|
|
|
|
beforeAll(async () => {
|
|
({ createDiscordGatewayPlugin } = await import("./gateway-plugin.js"));
|
|
});
|
|
|
|
function createRuntime() {
|
|
return {
|
|
log: vi.fn(),
|
|
error: vi.fn(),
|
|
exit: vi.fn(() => {
|
|
throw new Error("exit");
|
|
}),
|
|
};
|
|
}
|
|
|
|
async function registerGatewayClient(plugin: unknown) {
|
|
await (
|
|
plugin as {
|
|
registerClient: (client: { options: { token: string } }) => Promise<void>;
|
|
}
|
|
).registerClient({
|
|
options: { token: "token-123" },
|
|
});
|
|
}
|
|
|
|
async function expectGatewayRegisterFetchFailure(response: Response) {
|
|
const runtime = createRuntime();
|
|
globalFetchMock.mockResolvedValue(response);
|
|
const plugin = createDiscordGatewayPlugin({
|
|
discordConfig: {},
|
|
runtime,
|
|
});
|
|
|
|
await expect(registerGatewayClient(plugin)).rejects.toThrow(
|
|
"Failed to get gateway information from Discord",
|
|
);
|
|
expect(baseRegisterClientSpy).not.toHaveBeenCalled();
|
|
}
|
|
|
|
async function expectGatewayRegisterFallback(response: Response) {
|
|
const runtime = createRuntime();
|
|
globalFetchMock.mockResolvedValue(response);
|
|
const plugin = createDiscordGatewayPlugin({
|
|
discordConfig: {},
|
|
runtime,
|
|
});
|
|
|
|
await registerGatewayClient(plugin);
|
|
|
|
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
|
|
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
|
|
"wss://gateway.discord.gg/",
|
|
);
|
|
expect(runtime.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("discord: gateway metadata lookup failed transiently"),
|
|
);
|
|
}
|
|
|
|
async function registerGatewayClientWithMetadata(params: {
|
|
plugin: unknown;
|
|
fetchMock: typeof globalFetchMock;
|
|
}) {
|
|
params.fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }),
|
|
} as Response);
|
|
await registerGatewayClient(params.plugin);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal("fetch", globalFetchMock);
|
|
vi.useRealTimers();
|
|
baseRegisterClientSpy.mockClear();
|
|
globalFetchMock.mockClear();
|
|
restProxyAgentSpy.mockClear();
|
|
undiciFetchMock.mockClear();
|
|
undiciProxyAgentSpy.mockClear();
|
|
wsProxyAgentSpy.mockClear();
|
|
webSocketSpy.mockClear();
|
|
resetLastAgent();
|
|
});
|
|
|
|
it("uses safe gateway metadata lookup without proxy", async () => {
|
|
const runtime = createRuntime();
|
|
const plugin = createDiscordGatewayPlugin({
|
|
discordConfig: {},
|
|
runtime,
|
|
});
|
|
|
|
await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock });
|
|
|
|
expect(globalFetchMock).toHaveBeenCalledWith(
|
|
"https://discord.com/api/v10/gateway/bot",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bot token-123" },
|
|
}),
|
|
);
|
|
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("maps plain-text Discord 503 responses to fetch failed", async () => {
|
|
await expectGatewayRegisterFallback({
|
|
ok: false,
|
|
status: 503,
|
|
text: async () =>
|
|
"upstream connect error or disconnect/reset before headers. reset reason: overflow",
|
|
} as Response);
|
|
});
|
|
|
|
it("keeps fatal Discord metadata failures fatal", async () => {
|
|
await expectGatewayRegisterFetchFailure({
|
|
ok: false,
|
|
status: 401,
|
|
text: async () => "401: Unauthorized",
|
|
} as Response);
|
|
});
|
|
|
|
it("uses proxy agent for gateway WebSocket when configured", async () => {
|
|
const runtime = createRuntime();
|
|
|
|
const plugin = createDiscordGatewayPlugin({
|
|
discordConfig: { proxy: "http://proxy.test:8080" },
|
|
runtime,
|
|
});
|
|
|
|
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
|
|
|
|
const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
|
|
.createWebSocket;
|
|
createWebSocket("wss://gateway.discord.gg");
|
|
|
|
expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080");
|
|
expect(webSocketSpy).toHaveBeenCalledWith(
|
|
"wss://gateway.discord.gg",
|
|
expect.objectContaining({ agent: getLastAgent() }),
|
|
);
|
|
expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
|
|
expect(runtime.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to the default gateway plugin when proxy is invalid", async () => {
|
|
const runtime = createRuntime();
|
|
|
|
const plugin = createDiscordGatewayPlugin({
|
|
discordConfig: { proxy: "bad-proxy" },
|
|
runtime,
|
|
});
|
|
|
|
expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
|
|
expect(runtime.error).toHaveBeenCalled();
|
|
expect(runtime.log).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses proxy fetch for gateway metadata lookup before registering", async () => {
|
|
const runtime = createRuntime();
|
|
const plugin = createDiscordGatewayPlugin({
|
|
discordConfig: { proxy: "http://proxy.test:8080" },
|
|
runtime,
|
|
});
|
|
|
|
await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock });
|
|
|
|
expect(restProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080");
|
|
expect(undiciFetchMock).toHaveBeenCalledWith(
|
|
"https://discord.com/api/v10/gateway/bot",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bot token-123" },
|
|
dispatcher: expect.objectContaining({ proxyUrl: "http://proxy.test:8080" }),
|
|
}),
|
|
);
|
|
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("maps body read failures to fetch failed", async () => {
|
|
await expectGatewayRegisterFallback({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () => {
|
|
throw new Error("body stream closed");
|
|
},
|
|
} as unknown as Response);
|
|
});
|
|
|
|
it("falls back to the default gateway url when metadata lookup times out", async () => {
|
|
vi.useFakeTimers();
|
|
const runtime = createRuntime();
|
|
globalFetchMock.mockImplementation(() => new Promise(() => {}));
|
|
const plugin = createDiscordGatewayPlugin({
|
|
discordConfig: {},
|
|
runtime,
|
|
});
|
|
|
|
const registerPromise = registerGatewayClient(plugin);
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
await registerPromise;
|
|
|
|
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
|
|
expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
|
|
"wss://gateway.discord.gg/",
|
|
);
|
|
expect(runtime.log).toHaveBeenCalledWith(
|
|
expect.stringContaining("discord: gateway metadata lookup failed transiently"),
|
|
);
|
|
});
|
|
|
|
it("refreshes fallback gateway metadata on the next register attempt", async () => {
|
|
const runtime = createRuntime();
|
|
globalFetchMock
|
|
.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 503,
|
|
text: async () =>
|
|
"upstream connect error or disconnect/reset before headers. reset reason: overflow",
|
|
} as Response)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
text: async () =>
|
|
JSON.stringify({
|
|
url: "wss://gateway.discord.gg/?v=10",
|
|
shards: 8,
|
|
session_start_limit: {
|
|
total: 1000,
|
|
remaining: 999,
|
|
reset_after: 120_000,
|
|
max_concurrency: 16,
|
|
},
|
|
}),
|
|
} as Response);
|
|
const plugin = createDiscordGatewayPlugin({
|
|
discordConfig: {},
|
|
runtime,
|
|
});
|
|
|
|
await registerGatewayClient(plugin);
|
|
await registerGatewayClient(plugin);
|
|
|
|
expect(globalFetchMock).toHaveBeenCalledTimes(2);
|
|
expect(baseRegisterClientSpy).toHaveBeenCalledTimes(2);
|
|
expect(
|
|
(plugin as unknown as { gatewayInfo?: { url?: string; shards?: number } }).gatewayInfo,
|
|
).toMatchObject({
|
|
url: "wss://gateway.discord.gg/?v=10",
|
|
shards: 8,
|
|
});
|
|
});
|
|
});
|