feat(swads): add daily image report workflow

This commit is contained in:
Jeffrey Wu
2026-09-07 11:34:00 +08:00
parent f39f6ac881
commit 5c61371d7e
38 changed files with 2537 additions and 52 deletions
+22
View File
@@ -40,6 +40,12 @@ describe("InMemoryEventStore", () => {
it("unsubscribes listeners", () => {
const store = new InMemoryEventStore(); const listener = vi.fn(); const unsubscribe = store.subscribe("s", listener); unsubscribe(); store.append("s", { type: "run.completed", payload: {} }); expect(listener).not.toHaveBeenCalled();
});
it("removes the configured server token from SSE payloads", () => {
vi.stubEnv("SWADS_MCP_TOKEN", "server-sentinel-secret");
try { const store = new InMemoryEventStore(); store.append("s", { type: "assistant.delta", payload: { messageId: "m", delta: "Value server-sentinel-secret" } }); expect(JSON.stringify(store.listAfter("s"))).not.toContain("server-sentinel-secret"); }
finally { vi.unstubAllEnvs(); }
});
});
describe("ApprovalBroker", () => {
@@ -106,6 +112,22 @@ describe("SkillCatalog", () => {
it("matches requested skill reads by canonical path", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-skills-")); await createSkill(root, ".agents/skills/one", "one", "First skill"); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent-dir")); await catalog.reload(); expect((await catalog.matchReadPath(path.join(root, ".agents/skills/one/SKILL.md"), root))?.name).toBe("one"); expect(await catalog.matchReadPath("SKILL.md", root)).toBeUndefined();
});
it("expands only the exact Slash command on demand and guards reference paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "studio-slash-")); const dir = path.join(root, ".agents/skills/swads-daily-report");
await createSkill(root, ".agents/skills/swads-daily-report", "swads-daily-report", "Daily report"); await mkdir(path.join(dir, "references")); await writeFile(path.join(dir, "references/schema.md"), "schema");
await mkdir(path.join(root, ".pi/skills"), { recursive: true }); const catalog = new SkillCatalog(root, path.join(root, "agent")); await catalog.reload();
expect(JSON.stringify(catalog.list())).not.toContain("SECRET BODY");
await writeFile(path.join(dir, "SKILL.md"), "---\nname: swads-daily-report\ndescription: Daily report\n---\nLATEST BODY");
const command = await catalog.expandCommand("/swads-daily-report account=abc date=2026-09-06"); expect(command.text).toContain("LATEST BODY"); expect(command.text).not.toContain("description:"); expect(command.text).toContain("account=abc date=2026-09-06");
expect((await catalog.expandCommand("/swads-daily-report-other")).skill).toBeUndefined(); expect((await catalog.expandCommand("mention /swads-daily-report")).skill).toBeUndefined();
expect((await catalog.matchReadPath(path.join(dir, "references/schema.md"), root))?.name).toBe("swads-daily-report");
expect(await catalog.matchReadPath(path.join(dir, "references"), root)).toBeUndefined();
expect(await catalog.matchReadPath(`${dir}/references/../SKILL.md`, root)).toBeUndefined();
await writeFile(path.join(root, ".agents/skills/outside.md"), "outside"); expect(await catalog.matchReadPath(path.join(root, ".agents/skills/outside.md"), root)).toBeUndefined();
await symlink(path.join(dir, "references/schema.md"), path.join(dir, "references/link.md")); expect(await catalog.matchReadPath(path.join(dir, "references/link.md"), root)).toBeUndefined();
await symlink(path.join(root, ".agents/skills/outside.md"), path.join(dir, "references/escape.md")); expect(await catalog.matchReadPath(path.join(dir, "references/escape.md"), root)).toBeUndefined();
});
});
describe("Base URL safety", () => {
+16
View File
@@ -0,0 +1,16 @@
import type { ReportData } from "../src/report-schema.js";
export function sampleReport(): ReportData {
const campaign = { id: "campaign-1", name: "Demo campaign", status: "ENABLE", spend: 100, revenue: 600, orders: 30, roas: 6, reason: "30 orders; compare sample size before scaling." };
const creative = { id: "creative-1", name: "Demo product", creator: "demo-creator", spend: 20, revenue: 120, orders: 6, roas: 6, clicks: 50, product_ctr_percent: 2, reason: "Only six orders; small sample." };
return {
meta: { report_date: "2026-09-06", account_name: "Demo Commerce", account_id: "demo-account", currency: "MYR", reporting_timezone: "Asia/Kuala_Lumpur", period_label: "2026-09-06", comparison_label: "2026-08-30 to 2026-09-05 · seven complete business days", freshness_label: "2026-09-07 08:00 MYT", attribution_label: "TikTok GMV Max native attribution", status_label: "稳健观察" },
summary: { spend: 100, revenue: 600, orders: 30, ctr_percent: null, cpi: null, roas: 6, simple_roi_percent: 500, cost_per_order: 3.33 },
monitoring_note: "示例数据仅用于测试,不代表真实账户。",
trend: Array.from({ length: 7 }, (_, i) => ({ label: `Day ${i + 1}`, roas: i === 2 ? null : 3 + i, partial: false })),
campaigns: { winners: [campaign], anomalies: [{ ...campaign, name: "Low sample campaign", orders: 2, roas: 1, reason: "Only two orders; uncertainty remains." }] },
creative: { row_count: 100, coverage_percent: 80, source_groups: [{ label: "Creator", spend: 20, revenue: 120, orders: 6, roas: 6 }], winners: [creative], anomalies: [], insights: ["Creator attribution is correlated with efficiency; not a causal claim."] },
recommendations: [{ title: "观察高效 Campaign", detail: "以 30 单样本为依据,先检查下一完整业务日。", confirmation: "none" }, { title: "检查异常素材", detail: "低样本不足以支持立即暂停,核对归因。", confirmation: "none" }, { title: "预算调整候选", detail: "预算调整前人工复核订单与成本。", confirmation: "required" }],
manual_confirmations: [{ action: "预算调整", required: true, reason: "Changes live delivery" }],
notes: ["简化广告 ROI 未扣商品、佣金、退款与履约成本。", "CTR 与 CPI 缺失为 N/A。"],
};
}
+84
View File
@@ -0,0 +1,84 @@
import { mkdtemp, readFile, readdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import sharp from "sharp";
import { reportDataSchema } from "../src/report-schema.js";
import { ReportStore } from "../src/report-store.js";
import { discoverChrome, renderReportHtml, renderReportPng, type ChromeRunner } from "../src/report-renderer.js";
import { sampleReport } from "./report-fixture.js";
const cssPath = path.resolve(import.meta.dirname, "../../../.agents/skills/swads-daily-report/assets/report.css");
const signal = () => new AbortController().signal;
const root = () => mkdtemp(path.join(tmpdir(), "swads-report-test-"));
describe("normalized report", () => {
it("keeps null, multiplier and percentage units and exactly three recommendations", () => {
const data = reportDataSchema.parse(sampleReport()); expect(data.summary).toMatchObject({ ctr_percent: null, roas: 6, simple_roi_percent: 500 });
for (const mutate of [(d: ReturnType<typeof sampleReport>) => { d.recommendations.pop(); }, (d: ReturnType<typeof sampleReport>) => { d.meta.report_date = "2026-02-30"; }, (d: ReturnType<typeof sampleReport>) => { d.summary.spend = 0; }, (d: ReturnType<typeof sampleReport>) => { d.manual_confirmations = []; }]) { const invalid = sampleReport(); mutate(invalid); expect(reportDataSchema.safeParse(invalid).success).toBe(false); }
expect(reportDataSchema.safeParse({ ...data, outputPath: "/tmp/x" }).success).toBe(false);
expect(reportDataSchema.safeParse({ ...data, summary: { ...data.summary, roas: "6x" } }).success).toBe(false);
});
it("escapes all dynamic HTML and excludes external resources", async () => {
const data = sampleReport(); data.meta.account_name = '<script src="https://evil">&'; data.creative.insights = ["</style><img src=x onerror=alert(1)>"];
const html = renderReportHtml(data, await readFile(cssPath, "utf8")); expect(html).toContain("&lt;script"); expect(html).not.toContain('<script'); expect(html).not.toContain('<img'); expect(html).not.toContain("fonts.googleapis"); expect(html).toContain("N/A"); expect(html).toContain("500%"); expect(html).toContain("6×");
});
});
describe("PNG renderer", () => {
it("degrades when Chrome is missing without invoking a process", async () => { const runner = vi.fn(); expect((await renderReportPng("/tmp/report.html", signal(), { discover: async () => undefined, runner })).warning?.code).toBe("CHROME_UNAVAILABLE"); expect(runner).not.toHaveBeenCalled(); });
it("uses fixed args, retries truncation, crops only a verified complete image", async () => {
const heights: number[] = [];
const runner: ChromeRunner = async (exe, args) => {
expect(exe).toBe("/trusted/chrome"); expect(args).toContain("--host-resolver-rules=MAP * ~NOTFOUND"); expect(args.at(-1)).toBe("file:///tmp/report%20$(x).html");
const height = Number(args.find((s) => s.startsWith("--window-size="))!.split(",")[1]); heights.push(height);
const output = args.find((s) => s.startsWith("--screenshot="))!.slice(13);
const overlays = height > 5200 ? [{ input: await sharp({ create: { width: 1440, height: 8, channels: 3, background: "#1234ab" } }).png().toBuffer(), top: 6000, left: 0 }] : [];
await sharp({ create: { width: 1440, height, channels: 3, background: "#e8ebe6" } }).composite([{ input: await sharp({ create: { width: 1200, height: 2000, channels: 3, background: "white" } }).png().toBuffer(), top: 24, left: 120 }, ...overlays]).png().toFile(output);
};
const result = await renderReportPng('/tmp/report $(x).html', signal(), { discover: async () => "/trusted/chrome", runner });
expect(heights).toEqual([5200, 9000]); expect(result.warning).toBeUndefined(); expect((await sharp(result.png!).metadata()).height).toBe(2048);
});
it("returns safe timeout/failure warnings and honors abort", async () => {
const options = { discover: async () => "/trusted/chrome", runner: async () => { throw Object.assign(new Error("/private/token-secret"), { killed: true }); } };
expect(await renderReportPng("/tmp/x", signal(), options)).toMatchObject({ warning: { code: "PNG_TIMEOUT" } });
const controller = new AbortController(); controller.abort(); await expect(renderReportPng("/tmp/x", controller.signal, options)).rejects.toThrow();
});
it("renders a real sample and longest allowed report when Chrome is installed", async () => {
const chrome = await discoverChrome(); if (!chrome) return;
const folder = await root(); const css = await readFile(cssPath, "utf8");
const longest = sampleReport(); const long = "最长允许内容,检查换行与画布完整性。".repeat(30).slice(0, 500);
longest.campaigns.winners = Array.from({ length: 5 }, () => ({ ...longest.campaigns.winners[0]!, name: long.slice(0, 160), id: long.slice(0, 160), reason: long })); longest.campaigns.anomalies = structuredClone(longest.campaigns.winners);
longest.creative.winners = Array.from({ length: 5 }, () => ({ ...longest.creative.winners[0]!, name: long.slice(0, 160), id: long.slice(0, 160), reason: long })); longest.creative.anomalies = structuredClone(longest.creative.winners);
longest.creative.winners.forEach((row) => { row.creator = long.slice(0, 160); }); longest.creative.anomalies = structuredClone(longest.creative.winners);
longest.creative.source_groups = Array.from({ length: 6 }, () => ({ label: long.slice(0, 160), spend: 100, revenue: 600, orders: 30, roas: 6 }));
longest.meta.account_name = long.slice(0, 160); longest.monitoring_note = long;
longest.creative.insights = Array(4).fill(long); longest.notes = Array(10).fill(long); longest.manual_confirmations = Array.from({ length: 10 }, () => ({ action: long.slice(0, 160), required: true, reason: long })); longest.recommendations.forEach((r) => { r.detail = long; r.title = long.slice(0, 160); });
for (const [name, data] of [["sample", sampleReport()], ["longest", longest]] as const) {
const htmlPath = path.join(folder, `${name}.html`); await writeFile(htmlPath, renderReportHtml(data, css)); const result = await renderReportPng(htmlPath, signal());
expect(result.warning, JSON.stringify(result.warning)).toBeUndefined(); expect(result.png).toBeDefined(); const metadata = await sharp(result.png!).metadata(); expect(metadata.width).toBeGreaterThan(1200); expect(metadata.height).toBeGreaterThan(1500); expect(metadata.height).toBeLessThan(24000); await writeFile(path.join(folder, `${name}.png`), result.png!);
}
}, 120_000);
});
describe("ReportStore", () => {
it("atomically persists independent reports and allows only strict artifact paths", async () => {
const folder = await root(); const store = new ReportStore(folder, cssPath, async () => ({ warning: { code: "CHROME_UNAVAILABLE", message: "HTML/JSON available" } }));
const reports = await Promise.all([store.create(sampleReport(), signal()), store.create(sampleReport(), signal())]); expect(reports[0]!.reportId).not.toBe(reports[1]!.reportId);
const { accountKey, reportDate, reportId } = reports[0]!; const segments = { accountKey, reportDate, reportId };
expect(JSON.stringify(reports)).not.toContain(folder); expect((await store.read(segments, "json")).bytes.toString()).toContain('"roas": 6'); await expect(store.read(segments, "png")).rejects.toMatchObject({ code: "REPORT_NOT_FOUND" });
await expect(store.read({ ...segments, accountKey: ".." }, "json")).rejects.toMatchObject({ code: "REPORT_NOT_FOUND" }); await expect(store.read(segments, "../../x")).rejects.toThrow();
const external = path.join(await root(), "secret"); await writeFile(external, "secret"); await symlink(external, path.join(folder, accountKey, reportDate, reportId, "report.png")); await expect(store.read(segments, "png")).rejects.toThrow();
expect((await readdir(path.join(folder, accountKey, reportDate))).some((name) => name.startsWith(".tmp"))).toBe(false);
});
it("does not persist the configured server token in JSON or HTML", async () => {
vi.stubEnv("SWADS_MCP_TOKEN", "server-sentinel-secret");
try {
const store = new ReportStore(await root(), cssPath, async () => ({ warning: { code: "CHROME_UNAVAILABLE", message: "HTML/JSON available" } }));
const data = sampleReport(); data.notes.push("server-sentinel-secret"); const { accountKey, reportDate, reportId } = await store.create(data, signal());
for (const format of ["html", "json"]) expect((await store.read({ accountKey, reportDate, reportId }, format)).bytes.toString()).not.toContain("server-sentinel-secret");
} finally { vi.unstubAllEnvs(); }
});
it("cleans temporary files on failure and blocks symlink storage", async () => {
const folder = await root(); const store = new ReportStore(folder, cssPath, async () => { throw new Error("failed"); }); await expect(store.create(sampleReport(), signal())).rejects.toThrow();
const account = (await readdir(folder))[0]!; const date = path.join(folder, account, "2026-09-06"); expect(await readdir(date)).toEqual([]);
const linked = path.join(await root(), "reports"); await symlink(folder, linked); await expect(new ReportStore(linked, cssPath).create(sampleReport(), signal())).rejects.toMatchObject({ code: "REPORT_STORE_UNSAFE" });
});
});
+22 -2
View File
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, readFile, stat } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
@@ -45,7 +45,7 @@ async function setup(supportsImages = true) {
const events = new InMemoryEventStore(); const attachments = new FileAttachmentStore(sessionsRoot); const models = new InMemoryModelConnectionStore({}); const factory = new FakeFactory(supportsImages);
const registry = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, catalog, factory, 500, 5_000);
const session = await registry.create(); await registry.configureModel(session.sessionId, { providerId: "fake", api: "openai-responses", apiKey: "test-secret", modelId: "fake-model", supportsImages });
return { root, sessionsRoot, events, attachments, models, factory, registry, session };
return { root, sessionsRoot, events, attachments, models, catalog, factory, registry, session };
}
describe("session lifecycle", () => {
@@ -80,6 +80,26 @@ describe("session lifecycle", () => {
it("derives skill requested/loaded callbacks with the same toolCallId", async () => {
const context = await setup(); await context.registry.send(context.session.sessionId, { text: "hello", attachmentIds: [] }); const skill = { name: "ads-analysis", description: "x", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", canonicalPath: "/tmp/SKILL.md", diagnostics: [] }; context.factory.input?.skillRequested(skill, "read_1"); context.factory.input?.skillLoaded(skill, "read_1"); const skillEvents = context.events.listAfter(context.session.sessionId).events.filter((event) => event.type.startsWith("skill.")); expect(skillEvents.map((event) => event.type)).toEqual(["skill.requested", "skill.loaded"]); context.factory.port.complete();
});
it("preserves Slash user text and emits command activation without a fake toolCallId", async () => {
const context = await setup(); const dir = path.join(context.root, ".agents/skills/swads-daily-report"); await mkdir(dir); await writeFile(path.join(dir, "SKILL.md"), "---\nname: swads-daily-report\ndescription: report\ndisable-model-invocation: true\n---\nINVOKED BODY");
await context.catalog.reload();
const prompt = vi.spyOn(context.factory.port, "prompt"); const original = "/swads-daily-report account=demo date=2026-09-06";
await context.registry.send(context.session.sessionId, { text: original, attachmentIds: [] });
await vi.waitFor(() => expect(prompt).toHaveBeenCalled()); expect(prompt.mock.calls[0]![0]).toContain("<skill name="); expect(prompt.mock.calls[0]![0]).toContain("account=demo date=2026-09-06");
const events = context.events.listAfter(context.session.sessionId).events; expect(events.find((e) => e.type === "user.message")?.payload).toMatchObject({ text: original });
const activations = events.filter((e) => e.type === "skill.loaded" || e.type === "skill.requested"); expect(activations).toHaveLength(2); for (const e of activations) { expect(e.payload.source).toBe("command"); expect(e.payload.toolCallId).toBeUndefined(); }
context.factory.port.complete();
});
it("emits only report metadata on completion and preserves classified tool failures", async () => {
const context = await setup(false); await context.registry.send(context.session.sessionId, { text: "report", attachmentIds: [] });
const accountKey = "acct_0123456789abcdef01234567", reportDate = "2026-09-06", reportId = "12345678-1234-4234-8234-123456789abc"; const url = `/api/reports/${accountKey}/${reportDate}/${reportId}`;
const artifact = { accountKey, reportDate, reportId, previewUrl: `${url}/png`, downloads: { png: `${url}/png`, html: `${url}/html`, json: `${url}/json` } };
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "render", toolName: "render_swads_daily_report", isError: false, result: { content: [{ type: "text", text: "/private/SHOULD_NOT_LEAK full report body" }], details: artifact } });
context.factory.port.emit({ type: "tool_execution_end", toolCallId: "whoami", toolName: "swads_cli", isError: true, result: { content: [{ type: "text", text: "SWADS_AUTH_FAILED" }] } });
const events = context.events.listAfter(context.session.sessionId).events; const generated = events.find((e) => e.type === "report.generated"); expect(generated?.payload).toMatchObject({ assistantMessageId: context.session.currentRun!.assistantMessageId, artifact }); expect(JSON.stringify(events)).not.toContain("SHOULD_NOT_LEAK"); expect(events.find((e) => e.type === "tool.failed")?.payload).toMatchObject({ error: "SWADS_AUTH_FAILED" }); context.factory.port.complete();
});
});
describe("multimodal session path", () => {
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import { SwadsGateway, gatewayConfig, mcpConnector, MAX_GATEWAY_BYTES, type GatewayConnection } from "../src/swads-gateway.js";
import { SwadsReportTools, commands, validateCli } from "../src/swads-tools.js";
import type { ReportStore } from "../src/report-store.js";
import { sampleReport } from "./report-fixture.js";
const signal = () => new AbortController().signal;
const token = "private-sentinel-swads-value";
const config = () => gatewayConfig({ SWADS_MCP_TOKEN: token, SWADS_MCP_TIMEOUT_MS: "1000" });
const capabilities = (): Tool[] => Object.values(commands).filter((name) => name !== "tools/list").map((name) => ({ name, inputSchema: { type: "object" }, annotations: { readOnlyHint: true, destructiveHint: false } }));
function fixture(tools = capabilities()) {
const connection: GatewayConnection = { list: vi.fn(async () => tools), call: vi.fn(async (name) => ({ content: [{ type: "text", text: JSON.stringify(name === "metrics_semantic_query" ? { columns: [{ name: "spend" }], rows: [[100]] } : { platform: "tiktok", account_id: "demo", metrics: ["spend"], echo: token }) }] })), close: vi.fn(async () => undefined) };
const gateway = new SwadsGateway(config(), async () => connection); const create = vi.fn(async () => ({ ok: true }));
let runId = "run_1";
const toolsFacade = new SwadsReportTools(gateway, { create } as unknown as ReportStore, () => ({ runId, signal: signal() }));
return { connection, gateway, tools: toolsFacade, create, nextRun: () => { runId = "run_2"; } };
}
async function preflight(tools: SwadsReportTools) { await tools.cli({ command: "capabilities", arguments: {} }, signal()); await tools.cli({ command: "whoami", arguments: {} }, signal()); await tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal()); await tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: { metrics: ["spend"] } } }, signal()); }
describe("swads CLI boundary", () => {
it("rejects unknown commands, arbitrary control parameters and oversized/deep inputs", () => {
for (const input of [{ command: "tools/call", arguments: {} }, { command: "whoami", arguments: { url: "https://evil" } }, { command: "metrics.catalog", arguments: { platform: "vk" } }, { command: "metrics.query", arguments: { platform: "tiktok", query: { platform: "vk" } } }, { command: "metrics.query", arguments: { platform: "tiktok", query: { headers: { x: "y" } } } }, { command: "whoami", arguments: { name: "x" } }]) expect(() => validateCli(input)).toThrow();
let nested: unknown = {}; for (let i = 0; i < 12; i++) nested = { child: nested }; expect(() => validateCli({ command: "metrics.query", arguments: { platform: "tiktok", query: nested } })).toThrow();
expect(() => validateCli({ command: "metrics.query", arguments: { platform: "tiktok", query: { large: "x".repeat(70_000) } } })).toThrow();
});
it("maps only fixed tools, unwraps JSON, caches capabilities and redacts token before model delivery", async () => {
const f = fixture(); const result = await f.tools.cli({ command: "whoami", arguments: {} }, signal()); expect(result).toMatchObject({ echo: "[REDACTED]" });
await f.tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal()); expect(f.connection.list).toHaveBeenCalledTimes(1); expect(f.connection.call).toHaveBeenCalledWith("metrics_catalog", { platform: "tiktok" }, expect.any(AbortSignal));
expect(JSON.stringify(f.tools.definitions())).not.toContain(token);
});
it("refuses missing tools, non-read-only annotations and remote input drift", async () => {
const f = fixture(capabilities().filter((tool) => tool.name !== "metrics_semantic_query")); await expect(f.tools.cli({ command: "capabilities", arguments: {} }, signal())).rejects.toMatchObject({ code: "SWADS_TOOL_MISSING" }); await expect(f.tools.render(sampleReport(), signal())).rejects.toMatchObject({ code: "SWADS_PREFLIGHT_REQUIRED" }); expect(f.create).not.toHaveBeenCalled();
const list = capabilities(); list.find((tool) => tool.name === "runtime_findings")!.annotations = { readOnlyHint: false, destructiveHint: true }; const unsafe = fixture(list); await expect(unsafe.tools.cli({ command: "runtime.findings", arguments: { platform: "tiktok", external_account_id: "demo" } }, signal())).rejects.toMatchObject({ code: "SWADS_NOT_READ_ONLY" }); expect(unsafe.connection.call).not.toHaveBeenCalled();
const drift = capabilities(); drift.find((tool) => tool.name === "metrics_catalog")!.inputSchema = { type: "object", properties: { platform: { enum: ["vk", "yandex"] } } }; await expect(fixture(drift).tools.cli({ command: "metrics.catalog", arguments: { platform: "tiktok" } }, signal())).rejects.toMatchObject({ code: "SWADS_TOOL_DRIFT" });
});
it("requires successful real preflight and rows in this run to render", async () => {
const f = fixture(); await expect(f.tools.render(sampleReport(), signal())).rejects.toThrow(); await preflight(f.tools); const report = sampleReport(); report.notes.push(token); await f.tools.render(report, signal()); expect(JSON.stringify(vi.mocked(f.create).mock.calls)).not.toContain(token); f.nextRun(); await expect(f.tools.render(report, signal())).rejects.toThrow();
});
it("invalidates render evidence after query failure and rejects empty data", async () => {
const f = fixture(); await preflight(f.tools); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { columns: [{ name: "spend" }], rows: [] } }); await expect(f.tools.cli({ command: "metrics.query", arguments: { platform: "tiktok", query: {} } }, signal())).rejects.toMatchObject({ code: "SWADS_NO_DATA" }); await expect(f.tools.render(sampleReport(), signal())).rejects.toThrow();
});
it("classifies upstream permission envelopes and response limits without their contents", async () => {
for (const [status, code] of [[401, "SWADS_AUTH_FAILED"], [403, "SWADS_FORBIDDEN"]] as const) { const f = fixture(); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { error_code: "upstream", upstream_status: status, detail: token } }); await expect(f.tools.cli({ command: "whoami", arguments: {} }, signal())).rejects.toMatchObject({ code, message: code }); }
const f = fixture(); vi.mocked(f.connection.call).mockResolvedValue({ structuredContent: { data: "x".repeat(MAX_GATEWAY_BYTES + 1) } }); await expect(f.tools.cli({ command: "whoami", arguments: {} }, signal())).rejects.toMatchObject({ code: "SWADS_RESPONSE_TOO_LARGE" });
});
it("validates startup URL but defers missing credentials to first call", async () => {
expect(() => gatewayConfig({ SWADS_MCP_URL: "file:///tmp/x" })).toThrow(); expect(() => gatewayConfig({ SWADS_MCP_URL: "https://u:p@example.test" })).toThrow(); const gateway = new SwadsGateway(gatewayConfig({})); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code: "SWADS_NOT_CONFIGURED" });
});
it("cancels and times out blocked transport calls, closing connections", async () => {
const connection: GatewayConnection = { list: async () => [], call: async () => new Promise(() => undefined), close: vi.fn(async () => undefined) };
const gateway = new SwadsGateway({ ...config(), timeoutMs: 100 }, async () => connection); await expect(gateway.execute((conn, s) => conn.call("x", {}, s), signal())).rejects.toMatchObject({ code: "SWADS_TIMEOUT" });
const controller = new AbortController(); const pending = gateway.execute((conn, s) => conn.call("x", {}, s), controller.signal); controller.abort(); await expect(pending).rejects.toMatchObject({ code: "SWADS_ABORTED" }); expect(connection.close).toHaveBeenCalled();
});
});
describe("official MCP Streamable HTTP transport with fake fetch", () => {
it("initializes, lists and calls over MCP with server-only Authorization", async () => {
const methods: string[] = [];
const fetcher: typeof fetch = async (_url, init) => {
expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${token}`);
if (init?.method === "GET") return new Response(null, { status: 405 });
const message = JSON.parse(String(init?.body)); methods.push(message.method);
if (message.id === undefined) return new Response(null, { status: 202 });
const result = message.method === "initialize" ? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } } : message.method === "tools/list" ? { tools: capabilities() } : { content: [{ type: "text", text: '{"ok":true}' }] };
return Response.json({ jsonrpc: "2.0", id: message.id, result });
};
const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher));
await gateway.execute(async (conn, s) => { expect(await conn.list(s)).toHaveLength(8); await conn.call("swads_whoami", {}, s); }, signal());
expect(methods).toEqual(expect.arrayContaining(["initialize", "notifications/initialized", "tools/list", "tools/call"]));
});
it.each([[401, "SWADS_AUTH_FAILED"], [403, "SWADS_FORBIDDEN"], [503, "SWADS_UNAVAILABLE"]])("classifies HTTP %s without leaking body", async (status, code) => {
const fetcher: typeof fetch = async () => new Response(token, { status: Number(status) }); const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher)); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code, message: code });
});
it("bounds response bytes before JSON parsing", async () => { const fetcher: typeof fetch = async () => new Response("x".repeat(MAX_GATEWAY_BYTES + 1), { headers: { "content-type": "application/json", "content-length": String(MAX_GATEWAY_BYTES + 1) } }); const gateway = new SwadsGateway(config(), mcpConnector(config(), fetcher)); await expect(gateway.execute(async () => true, signal())).rejects.toMatchObject({ code: "SWADS_RESPONSE_TOO_LARGE" }); });
});