refactor(tests): share harnesses for cli and monitor fixtures

This commit is contained in:
Peter Steinberger
2026-02-16 17:06:20 +00:00
parent b991919755
commit c37f65a449
5 changed files with 119 additions and 255 deletions

View File

@@ -27,6 +27,25 @@ vi.mock("qrcode-terminal", () => ({
const { registerQrCli } = await import("./qr-cli.js");
function createRemoteQrConfig(params?: { withTailscale?: boolean }) {
return {
gateway: {
...(params?.withTailscale ? { tailscale: { mode: "serve" } } : {}),
remote: { url: "wss://remote.example.com:444", token: "remote-tok" },
auth: { mode: "token", token: "local-tok" },
},
plugins: {
entries: {
"device-pair": {
config: {
publicUrl: "wss://wrong.example.com:443",
},
},
},
},
};
}
describe("registerQrCli", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -124,21 +143,7 @@ describe("registerQrCli", () => {
});
it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => {
loadConfig.mockReturnValue({
gateway: {
remote: { url: "wss://remote.example.com:444", token: "remote-tok" },
auth: { mode: "token", token: "local-tok" },
},
plugins: {
entries: {
"device-pair": {
config: {
publicUrl: "wss://wrong.example.com:443",
},
},
},
},
});
loadConfig.mockReturnValue(createRemoteQrConfig());
const program = new Command();
registerQrCli(program);
@@ -152,21 +157,7 @@ describe("registerQrCli", () => {
});
it("reports gateway.remote.url as source in --remote json output", async () => {
loadConfig.mockReturnValue({
gateway: {
remote: { url: "wss://remote.example.com:444", token: "remote-tok" },
auth: { mode: "token", token: "local-tok" },
},
plugins: {
entries: {
"device-pair": {
config: {
publicUrl: "wss://wrong.example.com:443",
},
},
},
},
});
loadConfig.mockReturnValue(createRemoteQrConfig());
const program = new Command();
registerQrCli(program);
@@ -202,22 +193,7 @@ describe("registerQrCli", () => {
});
it("prefers gateway.remote.url over tailscale when --remote is set", async () => {
loadConfig.mockReturnValue({
gateway: {
tailscale: { mode: "serve" },
remote: { url: "wss://remote.example.com:444", token: "remote-tok" },
auth: { mode: "token", token: "local-tok" },
},
plugins: {
entries: {
"device-pair": {
config: {
publicUrl: "wss://wrong.example.com:443",
},
},
},
},
});
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: true }));
runCommandWithTimeout.mockResolvedValue({
code: 0,
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',

View File

@@ -38,6 +38,22 @@ const makeEntries = (
return out;
};
function createAutoThreadMentionContext() {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
return { guildInfo, channelConfig };
}
describe("registerDiscordListener", () => {
class FakeListener {}
@@ -402,18 +418,7 @@ describe("discord mention gating", () => {
});
it("does not require mention inside autoThread threads", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
const { guildInfo, channelConfig } = createAutoThreadMentionContext();
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
@@ -427,18 +432,7 @@ describe("discord mention gating", () => {
});
it("requires mention inside user-created threads with autoThread enabled", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
const { guildInfo, channelConfig } = createAutoThreadMentionContext();
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,
@@ -452,18 +446,7 @@ describe("discord mention gating", () => {
});
it("requires mention when thread owner is unknown", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
channels: {
general: { allow: true, autoThread: true },
},
};
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: "1",
channelName: "General",
channelSlug: "general",
});
const { guildInfo, channelConfig } = createAutoThreadMentionContext();
expect(
resolveDiscordShouldRequireMention({
isGuildMessage: true,

View File

@@ -1,6 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { resolveFetch, wrapFetchWithAbortSignal } from "./fetch.js";
function createForeignSignalHarness() {
let abortHandler: (() => void) | null = null;
const removeEventListener = vi.fn((event: string, handler: () => void) => {
if (event === "abort" && abortHandler === handler) {
abortHandler = null;
}
});
const fakeSignal = {
aborted: false,
addEventListener: (event: string, handler: () => void) => {
if (event === "abort") {
abortHandler = handler;
}
},
removeEventListener,
} as AbortSignal;
return {
fakeSignal,
removeEventListener,
triggerAbort: () => abortHandler?.(),
};
}
describe("wrapFetchWithAbortSignal", () => {
it("adds duplex for requests with a body", async () => {
let seenInit: RequestInit | undefined;
@@ -25,27 +50,14 @@ describe("wrapFetchWithAbortSignal", () => {
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
let abortHandler: (() => void) | null = null;
const fakeSignal = {
aborted: false,
addEventListener: (event: string, handler: () => void) => {
if (event === "abort") {
abortHandler = handler;
}
},
removeEventListener: (event: string, handler: () => void) => {
if (event === "abort" && abortHandler === handler) {
abortHandler = null;
}
},
} as AbortSignal;
const { fakeSignal, triggerAbort } = createForeignSignalHarness();
const promise = wrapped("https://example.com", { signal: fakeSignal });
expect(fetchImpl).toHaveBeenCalledOnce();
expect(seenSignal).toBeInstanceOf(AbortSignal);
expect(seenSignal).not.toBe(fakeSignal);
abortHandler?.();
triggerAbort();
expect(seenSignal?.aborted).toBe(true);
await promise;
@@ -64,22 +76,7 @@ describe("wrapFetchWithAbortSignal", () => {
);
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
let abortHandler: (() => void) | null = null;
const removeEventListener = vi.fn((event: string, handler: () => void) => {
if (event === "abort" && abortHandler === handler) {
abortHandler = null;
}
});
const fakeSignal = {
aborted: false,
addEventListener: (event: string, handler: () => void) => {
if (event === "abort") {
abortHandler = handler;
}
},
removeEventListener,
} as AbortSignal;
const { fakeSignal, removeEventListener } = createForeignSignalHarness();
try {
await expect(wrapped("https://example.com", { signal: fakeSignal })).rejects.toBe(fetchError);
@@ -100,22 +97,7 @@ describe("wrapFetchWithAbortSignal", () => {
});
const wrapped = wrapFetchWithAbortSignal(fetchImpl);
let abortHandler: (() => void) | null = null;
const removeEventListener = vi.fn((event: string, handler: () => void) => {
if (event === "abort" && abortHandler === handler) {
abortHandler = null;
}
});
const fakeSignal = {
aborted: false,
addEventListener: (event: string, handler: () => void) => {
if (event === "abort") {
abortHandler = handler;
}
},
removeEventListener,
} as AbortSignal;
const { fakeSignal, removeEventListener } = createForeignSignalHarness();
expect(() => wrapped("https://example.com", { signal: fakeSignal })).toThrow(syncError);
expect(removeEventListener).toHaveBeenCalledOnce();

View File

@@ -5,22 +5,26 @@ import {
shouldEnableWindowsGitBashPasteFallback,
} from "./tui.js";
function createSubmitHarness() {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine,
});
return { editor, handleCommand, sendMessage, handleBangLine, onSubmit };
}
describe("createEditorSubmitHandler", () => {
it("routes lines starting with ! to handleBangLine", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine,
});
const { handleCommand, sendMessage, handleBangLine, onSubmit } = createSubmitHarness();
onSubmit("!ls");
@@ -31,20 +35,7 @@ describe("createEditorSubmitHandler", () => {
});
it("treats a lone ! as a normal message", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine,
});
const { sendMessage, handleBangLine, onSubmit } = createSubmitHarness();
onSubmit("!");
@@ -54,20 +45,7 @@ describe("createEditorSubmitHandler", () => {
});
it("does not treat leading whitespace before ! as a bang command", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine,
});
const { editor, sendMessage, handleBangLine, onSubmit } = createSubmitHarness();
onSubmit(" !ls");
@@ -77,20 +55,7 @@ describe("createEditorSubmitHandler", () => {
});
it("trims normal messages before sending and adding to history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine,
});
const { editor, sendMessage, onSubmit } = createSubmitHarness();
onSubmit(" hello ");
@@ -99,20 +64,7 @@ describe("createEditorSubmitHandler", () => {
});
it("preserves internal newlines for multiline messages", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handleBangLine = vi.fn();
const onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
handleBangLine,
});
const { editor, handleCommand, sendMessage, handleBangLine, onSubmit } = createSubmitHarness();
onSubmit("Line 1\nLine 2\nLine 3");

View File

@@ -16,6 +16,27 @@ const DEFAULT_MESSAGES_CFG = {
responsePrefix: undefined,
} as const;
function createAllowListConfig(allowFrom: string[]) {
return {
channels: {
whatsapp: {
allowFrom,
},
},
messages: DEFAULT_MESSAGES_CFG,
};
}
async function openInboxMonitor(onMessage = vi.fn()) {
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
return { onMessage, listener, sock: getSock() };
}
async function expectOutboundDmSkipsPairing(params: {
selfChatMode: boolean;
messageId: string;
@@ -73,27 +94,9 @@ describe("web monitor inbox", () => {
installWebMonitorInboxUnitTestHooks();
it("allows messages from senders in allowFrom list", async () => {
mockLoadConfig.mockReturnValue({
channels: {
whatsapp: {
// Allow +999
allowFrom: ["+111", "+999"],
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
});
mockLoadConfig.mockReturnValue(createAllowListConfig(["+111", "+999"]));
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const { onMessage, listener, sock } = await openInboxMonitor();
const upsert = {
type: "notify",
@@ -124,27 +127,9 @@ describe("web monitor inbox", () => {
it("allows same-phone messages even if not in allowFrom", async () => {
// Same-phone mode: when from === selfJid, should always be allowed
// This allows users to message themselves even with restrictive allowFrom
mockLoadConfig.mockReturnValue({
channels: {
whatsapp: {
// Only allow +111, but self is +123
allowFrom: ["+111"],
},
},
messages: {
messagePrefix: undefined,
responsePrefix: undefined,
},
});
mockLoadConfig.mockReturnValue(createAllowListConfig(["+111"]));
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const { onMessage, listener, sock } = await openInboxMonitor();
// Message from self (sock.user.id is "123@s.whatsapp.net" in mock)
const upsert = {
@@ -176,14 +161,7 @@ describe("web monitor inbox", () => {
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const { onMessage, listener, sock } = await openInboxMonitor();
// Message from someone else should be blocked
const upsertBlocked = {
@@ -280,14 +258,7 @@ describe("web monitor inbox", () => {
});
it("handles append messages by marking them read but skipping auto-reply", async () => {
const onMessage = vi.fn();
const listener = await monitorWebInbox({
verbose: false,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
onMessage,
});
const sock = getSock();
const { onMessage, listener, sock } = await openInboxMonitor();
const upsert = {
type: "append",