FN-7764: add cross-type artifact create/list/view test coverage
Extend artifact test coverage to pin creation, listing, and viewing across every artifact type and payload variant on both the agent/dashboard-chat tool surface and the dashboard artifacts route. - Add a route-level integration test covering list/serve for all artifact types (document, image, video, audio, other) across inline content, uri reference, and binary data payloads, including task-scoped filtering, registry-level (task-less) artifacts, and 404 behavior for uri-only artifacts requested via /media. - Add an engine-level real-TaskStore test exercising fn_artifact_register/list/view (agent tools) and the dashboard-chat artifact tool for every artifact type and content/uri/dataBase64 variant, asserting list and view output correctness. - Factor out shared PNG_IMAGE_BYTES fixture and per-type MIME/binary fixtures to keep new assertions concise. Files changed: .../__tests__/artifacts-route-integration.test.ts | 150 ++++++++++++++++++++- .../src/__tests__/agent-artifact-tools.test.ts | 142 ++++++++++++++++++- 2 files changed, 287 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-7764 Fusion-Task-Lineage: 187b3f0f-d1b4-42fe-9658-1ee67870b524 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -6,10 +6,25 @@ import { mkdtempSync, rmSync } from "node:fs";
|
||||
import http from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore, type ArtifactWithTask } from "@fusion/core";
|
||||
import { TaskStore, type ArtifactType, type ArtifactWithTask } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
|
||||
const ARTIFACT_TYPES: ArtifactType[] = ["document", "image", "video", "audio", "other"];
|
||||
const PNG_IMAGE_BYTES = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64");
|
||||
const BINARY_MEDIA_BY_TYPE: Record<ArtifactType, { mimeType: string; bytes: Buffer }> = {
|
||||
document: { mimeType: "application/pdf", bytes: Buffer.from("%PDF-1.4\n% FN-7764 document bytes\n") },
|
||||
image: { mimeType: "image/png", bytes: PNG_IMAGE_BYTES },
|
||||
video: { mimeType: "video/mp4", bytes: Buffer.from("\x00\x00\x00\x18ftypmp42FN7764-video") },
|
||||
audio: { mimeType: "audio/mpeg", bytes: Buffer.from("ID3\x03\x00\x00\x00\x00\x00\x0fFN7764-audio") },
|
||||
other: { mimeType: "application/octet-stream", bytes: Buffer.from("FN-7764 other binary payload") },
|
||||
};
|
||||
|
||||
function contentMimeFor(type: ArtifactType): string {
|
||||
return type === "document" ? "text/markdown" : "text/plain";
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-06-27-00:00:
|
||||
* The mocked artifacts route tests skip the real listArtifacts LEFT JOIN and hand-write media files. This integration test uses a real TaskStore so the Documents Artifacts view contract is pinned end-to-end: registered image artifacts list with task metadata and stream from the real disk write path.
|
||||
@@ -42,7 +57,7 @@ describe("artifacts route integration", () => {
|
||||
title: "Render screenshot",
|
||||
description: "Capture dashboard artifact rendering evidence",
|
||||
});
|
||||
const imageBytes = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64");
|
||||
const imageBytes = PNG_IMAGE_BYTES;
|
||||
const artifact = await store.registerArtifact({
|
||||
type: "image",
|
||||
title: "Dashboard screenshot",
|
||||
@@ -116,7 +131,7 @@ describe("artifacts route integration", () => {
|
||||
});
|
||||
|
||||
it("a global image artifact still streams from the managed global artifacts directory", async () => {
|
||||
const imageBytes = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64");
|
||||
const imageBytes = PNG_IMAGE_BYTES;
|
||||
const artifact = await store.registerArtifact({
|
||||
type: "image",
|
||||
title: "Global screenshot",
|
||||
@@ -231,6 +246,135 @@ describe("artifacts route integration", () => {
|
||||
expect(ids).not.toContain(registryArtifact.id);
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-07-09-17:35:
|
||||
* FN-7764 pins the route/media side of the same operator-facing invariant as the agent tools: every artifact type and payload class must be discoverable through GET /api/artifacts with task context, and binary or inline media must be viewable without broken image-only assumptions.
|
||||
*/
|
||||
it("lists and serves every artifact type across inline, uri, binary, task, and registry-level states", async () => {
|
||||
const task = await store.createTask({
|
||||
title: "FN-7764 route artifact matrix",
|
||||
description: "Capture route-level artifact evidence",
|
||||
});
|
||||
const otherTask = await store.createTask({ title: "Other artifact task", description: "Must not leak through taskId filter" });
|
||||
const created: ArtifactWithTask[] = [];
|
||||
|
||||
for (const type of ARTIFACT_TYPES) {
|
||||
created.push(await store.registerArtifact({
|
||||
type,
|
||||
title: `route ${type} inline content`,
|
||||
description: `inline ${type} route evidence`,
|
||||
mimeType: contentMimeFor(type),
|
||||
content: `Inline ${type} route evidence for FN-7764`,
|
||||
authorId: "agent-route-content",
|
||||
authorType: "agent",
|
||||
taskId: task.id,
|
||||
}) as ArtifactWithTask);
|
||||
|
||||
created.push(await store.registerArtifact({
|
||||
type,
|
||||
title: `route ${type} uri reference`,
|
||||
description: `uri ${type} route evidence`,
|
||||
mimeType: BINARY_MEDIA_BY_TYPE[type].mimeType,
|
||||
uri: `artifacts/route-${type}-external.bin`,
|
||||
authorId: "agent-route-uri",
|
||||
authorType: "agent",
|
||||
taskId: task.id,
|
||||
}) as ArtifactWithTask);
|
||||
|
||||
created.push(await store.registerArtifact({
|
||||
type,
|
||||
title: `route ${type} binary media`,
|
||||
description: `binary ${type} route evidence`,
|
||||
mimeType: BINARY_MEDIA_BY_TYPE[type].mimeType,
|
||||
data: BINARY_MEDIA_BY_TYPE[type].bytes,
|
||||
authorId: "agent-route-binary",
|
||||
authorType: "agent",
|
||||
taskId: task.id,
|
||||
}) as ArtifactWithTask);
|
||||
}
|
||||
|
||||
const registryOnly = await store.registerArtifact({
|
||||
type: "other",
|
||||
title: "route registry-level other binary",
|
||||
description: "task-less registry evidence",
|
||||
mimeType: "application/octet-stream",
|
||||
data: Buffer.from("registry-level-other-bytes"),
|
||||
authorId: "agent-route-registry",
|
||||
authorType: "agent",
|
||||
});
|
||||
const otherTaskArtifact = await store.registerArtifact({
|
||||
type: "document",
|
||||
title: "other task hidden artifact",
|
||||
content: "This belongs to another task",
|
||||
authorId: "agent-route-content",
|
||||
authorType: "agent",
|
||||
taskId: otherTask.id,
|
||||
});
|
||||
|
||||
const allRes = await REQUEST(app, "GET", "/api/artifacts?limit=100");
|
||||
expect(allRes.status).toBe(200);
|
||||
const allArtifacts = allRes.body as ArtifactWithTask[];
|
||||
for (const artifact of created) {
|
||||
const listed = allArtifacts.find((candidate) => candidate.id === artifact.id);
|
||||
expect(listed).toMatchObject({
|
||||
id: artifact.id,
|
||||
type: artifact.type,
|
||||
title: artifact.title,
|
||||
taskId: task.id,
|
||||
taskTitle: "route artifact matrix",
|
||||
});
|
||||
expect(listed?.taskColumn).toBeTruthy();
|
||||
expect(listed?.content).toBeUndefined();
|
||||
}
|
||||
const registryListed = allArtifacts.find((candidate) => candidate.id === registryOnly.id);
|
||||
expect(registryListed?.id).toBe(registryOnly.id);
|
||||
expect(registryListed?.taskId).toBeUndefined();
|
||||
expect(registryListed?.taskTitle).toBeUndefined();
|
||||
expect(registryListed?.taskColumn).toBeUndefined();
|
||||
|
||||
const taskScopedRes = await REQUEST(app, "GET", `/api/artifacts?taskId=${encodeURIComponent(task.id)}&limit=100`);
|
||||
expect(taskScopedRes.status).toBe(200);
|
||||
const taskScopedIds = (taskScopedRes.body as ArtifactWithTask[]).map((artifact) => artifact.id);
|
||||
expect(taskScopedIds).toEqual(expect.arrayContaining(created.map((artifact) => artifact.id)));
|
||||
expect(taskScopedIds).not.toContain(registryOnly.id);
|
||||
expect(taskScopedIds).not.toContain(otherTaskArtifact.id);
|
||||
|
||||
const audioFilter = await REQUEST(app, "GET", `/api/artifacts?taskId=${encodeURIComponent(task.id)}&type=audio&authorId=agent-route-binary&q=binary&limit=2&offset=0`);
|
||||
expect(audioFilter.status).toBe(200);
|
||||
expect(audioFilter.body).toHaveLength(1);
|
||||
expect((audioFilter.body as ArtifactWithTask[])[0]).toMatchObject({ type: "audio", authorId: "agent-route-binary", title: "route audio binary media" });
|
||||
|
||||
const paged = await REQUEST(app, "GET", `/api/artifacts?taskId=${encodeURIComponent(task.id)}&limit=1&offset=1`);
|
||||
expect(paged.status).toBe(200);
|
||||
expect(paged.body).toHaveLength(1);
|
||||
|
||||
for (const type of ARTIFACT_TYPES) {
|
||||
const inline = created.find((artifact) => artifact.type === type && artifact.title.endsWith("inline content"));
|
||||
expect(inline).toBeDefined();
|
||||
const inlineMedia = await requestRawBuffer(app, `/api/artifacts/${inline!.id}/media`);
|
||||
expect(inlineMedia.status).toBe(200);
|
||||
expect(inlineMedia.headers["content-type"]).toContain(contentMimeFor(type));
|
||||
expect(inlineMedia.body.toString()).toBe(`Inline ${type} route evidence for FN-7764`);
|
||||
|
||||
const binary = created.find((artifact) => artifact.type === type && artifact.title.endsWith("binary media"));
|
||||
expect(binary).toBeDefined();
|
||||
const binaryMedia = await requestRawBuffer(app, `/api/artifacts/${binary!.id}/media`);
|
||||
expect(binaryMedia.status).toBe(200);
|
||||
expect(binaryMedia.headers["content-type"]).toBe(BINARY_MEDIA_BY_TYPE[type].mimeType);
|
||||
expect(binaryMedia.body).toEqual(BINARY_MEDIA_BY_TYPE[type].bytes);
|
||||
|
||||
const uri = created.find((artifact) => artifact.type === type && artifact.title.endsWith("uri reference"));
|
||||
expect(uri).toBeDefined();
|
||||
const missingUriMedia = await REQUEST(app, "GET", `/api/artifacts/${uri!.id}/media`);
|
||||
expect(missingUriMedia.status).toBe(404);
|
||||
}
|
||||
|
||||
const registryMedia = await requestRawBuffer(app, `/api/artifacts/${registryOnly.id}/media`);
|
||||
expect(registryMedia.status).toBe(200);
|
||||
expect(registryMedia.headers["content-type"]).toBe("application/octet-stream");
|
||||
expect(registryMedia.body).toEqual(Buffer.from("registry-level-other-bytes"));
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-07-04-20:10:
|
||||
* FN-7544: a SECOND TaskStore instance against the same DB (mirroring the dashboard-vs-engine or
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Artifact, ArtifactWithTask, MessageStore, TaskStore } from "@fusion/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Artifact, ArtifactType, ArtifactWithTask, MessageStore, TaskStore } from "@fusion/core";
|
||||
import { DASHBOARD_USER_ID } from "@fusion/core";
|
||||
import {
|
||||
createArtifactListTool,
|
||||
@@ -79,6 +82,39 @@ function getText(result: any): string {
|
||||
return first?.type === "text" ? first.text : "";
|
||||
}
|
||||
|
||||
|
||||
|
||||
type RealTaskStoreModule = typeof import("@fusion/core");
|
||||
|
||||
async function createRealTaskStore() {
|
||||
const { TaskStore: RealTaskStore } = await vi.importActual<RealTaskStoreModule>("@fusion/core");
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "agent-artifact-tools-root-"));
|
||||
const globalDir = mkdtempSync(join(tmpdir(), "agent-artifact-tools-global-"));
|
||||
const store = new RealTaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
return { store, rootDir, globalDir };
|
||||
}
|
||||
|
||||
function getArtifactId(result: any): string {
|
||||
const artifactId = result?.details?.artifactId;
|
||||
expect(typeof artifactId).toBe("string");
|
||||
return artifactId;
|
||||
}
|
||||
|
||||
const ARTIFACT_TYPES: ArtifactType[] = ["document", "image", "video", "audio", "other"];
|
||||
|
||||
function mimeFor(type: ArtifactType, variant: "content" | "uri" | "dataBase64"): string {
|
||||
if (variant === "dataBase64") return "image/png";
|
||||
if (variant === "content") return type === "document" ? "text/markdown" : "text/plain";
|
||||
switch (type) {
|
||||
case "document": return "application/pdf";
|
||||
case "image": return "image/png";
|
||||
case "video": return "video/mp4";
|
||||
case "audio": return "audio/mpeg";
|
||||
case "other": return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function findChatTool(name: "fn_artifact_register" | "fn_artifact_list" | "fn_artifact_view", store: TaskStore, messageStore?: MessageStore) {
|
||||
const tool = createChatArtifactTools(store, messageStore).find((candidate) => candidate.name === name);
|
||||
expect(tool).toBeDefined();
|
||||
@@ -587,3 +623,105 @@ describe("artifact tool factory integration", () => {
|
||||
expect(getArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("artifact tools real-store create/list/view invariant", () => {
|
||||
let realStore: TaskStore | null = null;
|
||||
let rootDir: string | null = null;
|
||||
let globalDir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
realStore?.close();
|
||||
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
|
||||
if (globalDir) rmSync(globalDir, { recursive: true, force: true });
|
||||
realStore = null;
|
||||
rootDir = null;
|
||||
globalDir = null;
|
||||
});
|
||||
|
||||
/*
|
||||
* FNXC:ArtifactRegistry 2026-07-09-17:35:
|
||||
* FN-7764 exists because operators need concrete confidence that artifact creation and viewing work for every supported type and payload variant, not only the previously verified image path. This real-store matrix pins the agent and dashboard-chat tool contract from registration through list/view output so future changes cannot silently break non-image artifact discovery.
|
||||
*/
|
||||
it("creates every artifact type and supported variant through agent and chat tools, then lists and views them", async () => {
|
||||
const created = await createRealTaskStore();
|
||||
realStore = created.store as unknown as TaskStore;
|
||||
rootDir = created.rootDir;
|
||||
globalDir = created.globalDir;
|
||||
const task = await realStore.createTask({ title: "FN-7764 artifact matrix", description: "Exercise artifact tool invariant" });
|
||||
const agentRegister = createArtifactRegisterTool(realStore, AUTHOR_ID);
|
||||
const agentList = createArtifactListTool(realStore);
|
||||
const agentView = createArtifactViewTool(realStore);
|
||||
const chatRegister = findChatTool("fn_artifact_register", realStore);
|
||||
const expected: Array<{ id: string; type: ArtifactType; title: string; variant: "content" | "uri" | "dataBase64"; authorId: string }> = [];
|
||||
|
||||
for (const surface of ["agent", "chat"] as const) {
|
||||
for (const type of ARTIFACT_TYPES) {
|
||||
const contentTitle = `${surface} ${type} inline content`;
|
||||
const contentParams = {
|
||||
type,
|
||||
title: contentTitle,
|
||||
description: `${type} inline artifact created by ${surface}`,
|
||||
mimeType: mimeFor(type, "content"),
|
||||
content: `# ${contentTitle}\nInline ${type} evidence for FN-7764.`,
|
||||
...(surface === "agent" ? { taskId: task.id } : { task_id: task.id }),
|
||||
};
|
||||
const contentResult = await runTool(surface === "agent" ? agentRegister : chatRegister, `${surface}-${type}-content`, contentParams);
|
||||
expect(getText(contentResult)).toContain("Registered artifact");
|
||||
expected.push({ id: getArtifactId(contentResult), type, title: contentTitle, variant: "content", authorId: surface === "agent" ? AUTHOR_ID : "dashboard-chat" });
|
||||
|
||||
const uriTitle = `${surface} ${type} uri reference`;
|
||||
const uriParams = {
|
||||
type,
|
||||
title: uriTitle,
|
||||
description: `${type} uri artifact created by ${surface}`,
|
||||
mimeType: mimeFor(type, "uri"),
|
||||
uri: `artifacts/${surface}-${type}-reference.bin`,
|
||||
...(surface === "agent" ? { taskId: task.id } : { task_id: task.id }),
|
||||
};
|
||||
const uriResult = await runTool(surface === "agent" ? agentRegister : chatRegister, `${surface}-${type}-uri`, uriParams);
|
||||
expect(getText(uriResult)).toContain("Registered artifact");
|
||||
expected.push({ id: getArtifactId(uriResult), type, title: uriTitle, variant: "uri", authorId: surface === "agent" ? AUTHOR_ID : "dashboard-chat" });
|
||||
}
|
||||
|
||||
const imageTitle = `${surface} image dataBase64 bytes`;
|
||||
const dataResult = await runTool(surface === "agent" ? agentRegister : chatRegister, `${surface}-image-dataBase64`, {
|
||||
type: "image",
|
||||
title: imageTitle,
|
||||
description: `PNG bytes created by ${surface}`,
|
||||
mimeType: "image/png",
|
||||
dataBase64: PNG_IMAGE_BYTES.toString("base64"),
|
||||
...(surface === "agent" ? { taskId: task.id } : { task_id: task.id }),
|
||||
});
|
||||
expect(getText(dataResult)).toContain("Registered artifact");
|
||||
expected.push({ id: getArtifactId(dataResult), type: "image", title: imageTitle, variant: "dataBase64", authorId: surface === "agent" ? AUTHOR_ID : "dashboard-chat" });
|
||||
}
|
||||
|
||||
const listResult = await runTool(agentList, "matrix-list-all", { taskId: task.id, limit: 50 });
|
||||
const listText = getText(listResult);
|
||||
for (const item of expected) {
|
||||
expect(listText).toContain(`${item.id} [${item.type}] ${item.title}`);
|
||||
const viewResult = await runTool(agentView, `view-${item.id}`, { id: item.id });
|
||||
const viewText = getText(viewResult);
|
||||
expect(viewText).toContain(`Artifact: ${item.title}`);
|
||||
expect(viewText).toContain(`Type: ${item.type}`);
|
||||
expect(viewText).toContain(`Author: ${item.authorId} (agent)`);
|
||||
expect(viewText).toContain(`Task: ${task.id}`);
|
||||
if (item.variant === "content") {
|
||||
expect(viewText).toContain(`Inline ${item.type} evidence for FN-7764.`);
|
||||
} else {
|
||||
expect(viewText).toContain("URI: artifacts/");
|
||||
expect(viewText).not.toContain("Inline ");
|
||||
}
|
||||
}
|
||||
|
||||
const imageFilter = await runTool(agentList, "matrix-list-image", { taskId: task.id, type: "image", limit: 20 });
|
||||
const imageText = getText(imageFilter);
|
||||
expect(imageText).toContain("[image]");
|
||||
expect(imageText).not.toContain("[audio]");
|
||||
|
||||
const chatFilter = await runTool(agentList, "matrix-list-chat", { taskId: task.id, authorId: "dashboard-chat", search: "dataBase64", limit: 10 });
|
||||
expect(getText(chatFilter)).toContain("dashboard-chat");
|
||||
expect(getText(chatFilter)).toContain("image dataBase64 bytes");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user