























@@ -0,0 +1,318 @@
1+// Regression tests for the lazy-loading boundaries introduced for Slack
2+// startup-perf work (see PR #69317). Each test asserts both:
3+// - that the lazy module is reached (call mocks fire), and
4+// - that the inputs forwarded into the lazy module are correct, and
5+// - that the lazy module's return value is propagated back through the
6+// plugin surface unchanged.
7+//
8+// Together these guard against:
9+// - dynamic-import path/specifier drift on cold paths,
10+// - silent contract drift between the channel and its lazy modules,
11+// - and accidental loss of the perf intent (re-introducing eager imports
12+// without updating the seam).
13+14+import { beforeEach, describe, expect, it, vi } from "vitest";
15+import { slackPlugin } from "./channel.js";
16+import type { OpenClawConfig } from "./runtime-api.js";
17+import { setSlackRuntime } from "./runtime.js";
18+19+// --- Hoisted mocks for lazy seams ------------------------------------------------
20+21+const collectAuditFindingsMock = vi.hoisted(() => vi.fn());
22+const fetchSlackScopesMock = vi.hoisted(() => vi.fn());
23+const resolveTargetsWithOptionalTokenMock = vi.hoisted(() => vi.fn());
24+const buildPassiveProbedChannelStatusSummaryMock = vi.hoisted(() => vi.fn());
25+vi.mock("./security-audit.js", () => ({
26+collectSlackSecurityAuditFindings: collectAuditFindingsMock,
27+}));
28+29+vi.mock("./scopes.js", () => ({
30+fetchSlackScopes: fetchSlackScopesMock,
31+}));
32+33+vi.mock("openclaw/plugin-sdk/target-resolver-runtime", async (orig) => {
34+// Preserve any sibling exports so importers that touch unrelated helpers
35+// do not break; only override the function the channel actually calls.
36+const original = (await orig()) as Record<string, unknown>;
37+return {
38+ ...original,
39+resolveTargetsWithOptionalToken: resolveTargetsWithOptionalTokenMock,
40+};
41+});
42+43+vi.mock("openclaw/plugin-sdk/extension-shared", async (orig) => {
44+const original = (await orig()) as Record<string, unknown>;
45+return {
46+ ...original,
47+buildPassiveProbedChannelStatusSummary: buildPassiveProbedChannelStatusSummaryMock,
48+};
49+});
50+51+// --- Test setup -----------------------------------------------------------------
52+53+beforeEach(() => {
54+collectAuditFindingsMock.mockReset();
55+fetchSlackScopesMock.mockReset();
56+resolveTargetsWithOptionalTokenMock.mockReset();
57+buildPassiveProbedChannelStatusSummaryMock.mockReset();
58+setSlackRuntime({ channel: { slack: {} } } as never);
59+});
60+61+function makeMinimalSlackConfig(
62+opts: { botToken?: string; userToken?: string } = {},
63+): OpenClawConfig {
64+const slack: Record<string, unknown> = {};
65+if (opts.botToken !== undefined) {
66+slack.botToken = opts.botToken;
67+}
68+if (opts.userToken !== undefined) {
69+slack.userToken = opts.userToken;
70+}
71+return { channels: { slack } } as OpenClawConfig;
72+}
73+74+// --- Status: buildChannelSummary -------------------------------------------------
75+76+describe("slackPlugin.status.buildChannelSummary lazy SDK forwarding", () => {
77+it("calls the lazy extension-shared SDK helper with the snapshot and token sources, and returns its output unchanged", async () => {
78+const buildChannelSummary = slackPlugin.status?.buildChannelSummary;
79+if (!buildChannelSummary) {
80+throw new Error("slackPlugin.status.buildChannelSummary should be exposed");
81+}
82+83+const sentinelSummary = { sentinel: "passive-summary" };
84+buildPassiveProbedChannelStatusSummaryMock.mockReturnValue(sentinelSummary);
85+86+const snapshot = {
87+accountId: "default",
88+configured: true,
89+enabled: true,
90+botTokenSource: "config" as const,
91+appTokenSource: "config" as const,
92+extra: { custom: 1 },
93+};
94+95+const result = await buildChannelSummary({
96+account: { accountId: "default" } as never,
97+ snapshot,
98+cfg: makeMinimalSlackConfig({ botToken: "xoxb-test" }),
99+runtime: undefined,
100+} as never);
101+102+expect(buildPassiveProbedChannelStatusSummaryMock).toHaveBeenCalledTimes(1);
103+const [forwardedSnapshot, forwardedExtras] =
104+buildPassiveProbedChannelStatusSummaryMock.mock.calls[0] ?? [];
105+// Snapshot must be forwarded by reference / structurally intact.
106+expect(forwardedSnapshot).toBe(snapshot);
107+// The channel must forward the (possibly fallback'd) token sources.
108+expect(forwardedExtras).toEqual({ botTokenSource: "config", appTokenSource: "config" });
109+// The SDK return value must be propagated through unchanged.
110+expect(result).toBe(sentinelSummary);
111+});
112+113+it("falls back to 'none' for missing token sources before forwarding to the SDK helper", async () => {
114+const buildChannelSummary = slackPlugin.status?.buildChannelSummary;
115+if (!buildChannelSummary) {
116+throw new Error("slackPlugin.status.buildChannelSummary should be exposed");
117+}
118+119+buildPassiveProbedChannelStatusSummaryMock.mockReturnValue({ sentinel: true });
120+121+await buildChannelSummary({
122+account: { accountId: "default" } as never,
123+snapshot: { accountId: "default", configured: false, enabled: true } as never,
124+cfg: makeMinimalSlackConfig(),
125+runtime: undefined,
126+} as never);
127+128+const [, forwardedExtras] = buildPassiveProbedChannelStatusSummaryMock.mock.calls[0] ?? [];
129+expect(forwardedExtras).toEqual({ botTokenSource: "none", appTokenSource: "none" });
130+});
131+});
132+133+// --- Status: buildCapabilitiesDiagnostics ---------------------------------------
134+135+describe("slackPlugin.status.buildCapabilitiesDiagnostics lazy scopes loader", () => {
136+it("invokes fetchSlackScopes once when only a bot token is present", async () => {
137+const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics;
138+if (!buildDiagnostics) {
139+throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed");
140+}
141+142+fetchSlackScopesMock.mockResolvedValue({ ok: true, scopes: ["chat:write"] });
143+144+const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" });
145+const account = slackPlugin.config.resolveAccount(cfg, "default");
146+const result = await buildDiagnostics({ account, timeoutMs: 1234, cfg } as never);
147+148+expect(fetchSlackScopesMock).toHaveBeenCalledTimes(1);
149+expect(fetchSlackScopesMock).toHaveBeenCalledWith("xoxb-bot", 1234);
150+expect(result?.details).toMatchObject({
151+botScopes: { ok: true, scopes: ["chat:write"] },
152+});
153+expect(result?.lines?.length ?? 0).toBeGreaterThan(0);
154+});
155+156+it("invokes fetchSlackScopes twice (bot and user) when both tokens are present", async () => {
157+const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics;
158+if (!buildDiagnostics) {
159+throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed");
160+}
161+162+fetchSlackScopesMock
163+.mockResolvedValueOnce({ ok: true, scopes: ["chat:write"] })
164+.mockResolvedValueOnce({ ok: true, scopes: ["users:read"] });
165+166+const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot", userToken: "xoxp-user" });
167+const account = slackPlugin.config.resolveAccount(cfg, "default");
168+const result = await buildDiagnostics({ account, timeoutMs: 5000, cfg } as never);
169+170+expect(fetchSlackScopesMock).toHaveBeenCalledTimes(2);
171+expect(fetchSlackScopesMock.mock.calls[0]).toEqual(["xoxb-bot", 5000]);
172+expect(fetchSlackScopesMock.mock.calls[1]).toEqual(["xoxp-user", 5000]);
173+expect(result?.details).toMatchObject({
174+botScopes: { ok: true, scopes: ["chat:write"] },
175+userScopes: { ok: true, scopes: ["users:read"] },
176+});
177+});
178+179+it("does not invoke fetchSlackScopes when no bot token is present and reports a missing-token diagnostic", async () => {
180+const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics;
181+if (!buildDiagnostics) {
182+throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed");
183+}
184+185+const cfg = makeMinimalSlackConfig();
186+const account = slackPlugin.config.resolveAccount(cfg, "default");
187+const result = await buildDiagnostics({ account, timeoutMs: 1000, cfg } as never);
188+189+expect(fetchSlackScopesMock).not.toHaveBeenCalled();
190+expect(result?.details).toMatchObject({
191+botScopes: { ok: false, error: "Slack bot token missing." },
192+});
193+});
194+});
195+196+// --- Security: collectAuditFindings ---------------------------------------------
197+198+describe("slackPlugin.security.collectAuditFindings lazy module forwarding", () => {
199+it("delegates to the lazy security-audit module with the original params and returns its output", async () => {
200+const collectAuditFindings = slackPlugin.security?.collectAuditFindings;
201+if (!collectAuditFindings) {
202+throw new Error("slackPlugin.security.collectAuditFindings should be exposed");
203+}
204+205+const sentinel = [
206+{
207+checkId: "test-check",
208+severity: "info" as const,
209+title: "t",
210+detail: "d",
211+},
212+];
213+collectAuditFindingsMock.mockResolvedValue(sentinel);
214+215+const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" });
216+const account = slackPlugin.config.resolveAccount(cfg, "default");
217+const result = await collectAuditFindings({ cfg, accountId: "default", account } as never);
218+219+expect(collectAuditFindingsMock).toHaveBeenCalledTimes(1);
220+expect(collectAuditFindingsMock.mock.calls[0]?.[0]).toEqual({
221+ cfg,
222+accountId: "default",
223+ account,
224+});
225+expect(result).toBe(sentinel);
226+});
227+228+it("propagates an empty findings array unchanged", async () => {
229+const collectAuditFindings = slackPlugin.security?.collectAuditFindings;
230+if (!collectAuditFindings) {
231+throw new Error("slackPlugin.security.collectAuditFindings should be exposed");
232+}
233+234+collectAuditFindingsMock.mockResolvedValue([]);
235+236+const cfg = makeMinimalSlackConfig();
237+const account = slackPlugin.config.resolveAccount(cfg, "default");
238+const result = await collectAuditFindings({ cfg, account } as never);
239+240+expect(result).toEqual([]);
241+});
242+});
243+244+// --- Resolver: resolveTargets ---------------------------------------------------
245+246+describe("slackPlugin.resolver.resolveTargets lazy SDK forwarding", () => {
247+it("forwards user inputs and the configured token to the lazy SDK helper and returns its output", async () => {
248+const resolveTargets = slackPlugin.resolver?.resolveTargets;
249+if (!resolveTargets) {
250+throw new Error("slackPlugin.resolver.resolveTargets should be exposed");
251+}
252+253+const sentinelOutput = [{ input: "U123", resolved: true, id: "U123", note: undefined }];
254+resolveTargetsWithOptionalTokenMock.mockResolvedValue(sentinelOutput);
255+256+const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" });
257+const result = await resolveTargets({
258+ cfg,
259+accountId: "default",
260+inputs: ["U123"],
261+kind: "user",
262+} as never);
263+264+expect(resolveTargetsWithOptionalTokenMock).toHaveBeenCalledTimes(1);
265+const [params] = resolveTargetsWithOptionalTokenMock.mock.calls[0] ?? [];
266+expect(params).toMatchObject({
267+token: "xoxb-bot",
268+inputs: ["U123"],
269+missingTokenNote: "missing Slack token",
270+});
271+expect(typeof params.resolveWithToken).toBe("function");
272+expect(typeof params.mapResolved).toBe("function");
273+expect(result).toBe(sentinelOutput);
274+});
275+276+it("prefers the user token over the bot token when both are configured", async () => {
277+const resolveTargets = slackPlugin.resolver?.resolveTargets;
278+if (!resolveTargets) {
279+throw new Error("slackPlugin.resolver.resolveTargets should be exposed");
280+}
281+282+resolveTargetsWithOptionalTokenMock.mockResolvedValue([]);
283+284+await resolveTargets({
285+cfg: makeMinimalSlackConfig({ botToken: "xoxb-bot", userToken: "xoxp-user" }),
286+accountId: "default",
287+inputs: ["U1"],
288+kind: "user",
289+} as never);
290+291+const [params] = resolveTargetsWithOptionalTokenMock.mock.calls[0] ?? [];
292+expect(params).toMatchObject({ token: "xoxp-user" });
293+});
294+295+it("uses the same lazy SDK helper for kind='group'", async () => {
296+const resolveTargets = slackPlugin.resolver?.resolveTargets;
297+if (!resolveTargets) {
298+throw new Error("slackPlugin.resolver.resolveTargets should be exposed");
299+}
300+301+resolveTargetsWithOptionalTokenMock.mockResolvedValue([]);
302+303+await resolveTargets({
304+cfg: makeMinimalSlackConfig({ botToken: "xoxb-bot" }),
305+accountId: "default",
306+inputs: ["C1"],
307+kind: "group",
308+} as never);
309+310+expect(resolveTargetsWithOptionalTokenMock).toHaveBeenCalledTimes(1);
311+const [params] = resolveTargetsWithOptionalTokenMock.mock.calls[0] ?? [];
312+expect(params).toMatchObject({ token: "xoxb-bot", inputs: ["C1"] });
313+});
314+});
315+316+// Setup-wizard proxy delegation is unit-tested directly in
317+// setup-core.lazy-proxy.test.ts so it can be type-safe against the wider
318+// ChannelSetupWizard contract returned by createSlackSetupWizardProxy.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。