Files
sw-ads-agent/packages/harness/tests/reports.test.ts
T

85 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" });
});
});