feat(swads): add daily image report workflow
This commit is contained in:
@@ -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("<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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user