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
+11 -2
View File
@@ -1,9 +1,10 @@
import { mkdtemp, mkdir } from "node:fs/promises";
import { mkdtemp, mkdir, copyFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildApp, type AppServices } from "../src/app.js";
import type { AgentSessionFactory, AgentSessionPort } from "@agent-studio/harness";
import { sampleReport } from "../../../packages/harness/tests/report-fixture.js";
import type { FastifyInstance } from "fastify";
class ServerFakePort implements AgentSessionPort {
@@ -20,7 +21,7 @@ afterEach(async () => { while (apps.length) await apps.pop()?.close(); });
async function fixture() {
const root = await mkdtemp(path.join(tmpdir(), "studio-server-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); await mkdir(path.join(root, "fixtures"));
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, services: services! };
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, root, services: services! };
}
async function createSession(app: FastifyInstance) { const response = await app.inject({ method: "POST", url: "/api/sessions" }); return (response.json() as { sessionId: string }).sessionId; }
async function configure(app: FastifyInstance, sessionId: string, supportsImages = true) { return app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake-model", apiKey: "sk-test-never-return", supportsImages } }); }
@@ -36,6 +37,14 @@ describe("Fastify API", () => {
it("supports approve and deny endpoints with conflict detection", async () => { const { app, services } = await fixture(); const sessionId = await createSession(app); const controller = new AbortController(); const pending = services.sessions.approvals.request({ sessionId, runId: "run_test", toolCallId: "tool_test", toolName: "save_report_draft", arguments: {}, riskLevel: "medium" }, controller.signal); const event = services.events.listAfter(sessionId).events.find((item) => item.type === "approval.required"); if (!event || event.type !== "approval.required") throw new Error("missing approval"); const approved = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/approve` }); expect(approved.statusCode).toBe(200); expect((await pending).status).toBe("approved"); const conflict = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/deny` }); expect(conflict.statusCode).toBe(409); });
it("accepts a valid multipart PNG and rejects SVG", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); const accepted = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "image.png", "image/png", png) }); expect(accepted.statusCode).toBe(201); expect(accepted.json()).toMatchObject({ mediaType: "image/png", width: 1, status: "ready" }); const rejected = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "x.svg", "image/svg+xml", Buffer.from("<svg/>")) }); expect(rejected.statusCode).toBe(415); });
it("serves SSE events and heartbeat comments", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const address = await app.listen({ host: "127.0.0.1", port: 0 }); const controller = new AbortController(); const response = await fetch(`${address}/api/sessions/${sessionId}/events`, { signal: controller.signal }); expect(response.headers.get("content-type")).toContain("text/event-stream"); const reader = response.body!.getReader(); let text = ""; for (let count = 0; count < 4 && !text.includes(": heartbeat"); count++) { const chunk = await reader.read(); text += new TextDecoder().decode(chunk.value); } controller.abort(); expect(text).toContain("event: session.started"); expect(text).toContain(": heartbeat"); });
it("keeps reports downloadable after session deletion and guards artifact routes", async () => {
const { app, root, services } = await fixture(); const assets = path.join(root, ".agents/skills/swads-daily-report/assets"); await mkdir(assets, { recursive: true }); await copyFile(path.resolve(import.meta.dirname, "../../../.agents/skills/swads-daily-report/assets/report.css"), path.join(assets, "report.css"));
const artifact = await services.reports.create(sampleReport(), new AbortController().signal); const session = await createSession(app); await app.inject({ method: "DELETE", url: `/api/sessions/${session}` });
for (const [format, url] of Object.entries(artifact.downloads)) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(200); expect(response.headers["content-disposition"]).toContain(format === "png" ? "inline" : "attachment"); expect(response.headers["x-content-type-options"]).toBe("nosniff"); expect(response.headers["content-security-policy"]).toContain("default-src 'none'"); expect(response.body).not.toContain(root); expect(response.headers["content-type"]).toContain(format === "png" ? "image/png" : format === "json" ? "application/json" : "text/html"); }
const base = artifact.downloads.json.slice(0, -4);
for (const url of [base + "secret", base.replace(artifact.reportId, "00000000-0000-4000-8000-000000000000") + "json", "/api/reports/%2e%2e/x/y/json", "/api/reports/", base + "%2e%2e%2fsecret"]) { const response = await app.inject({ method: "GET", url }); expect(response.statusCode).toBe(404); expect(response.body).not.toContain(root); }
}, 60_000);
});
function multipart(boundary: string, filename: string, mime: string, data: Buffer): Buffer { return Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); }