Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e93c74456 | |||
| 7c52464bd1 | |||
| 7466492e3c | |||
| d4ba6cc31a | |||
| bf1b4eb1f2 | |||
| 9e153c2177 |
@@ -1,14 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for uploads.ts — uploadChatFiles and downloadChatFile.
|
||||
*
|
||||
* Covers: empty-file guard, successful upload, error-throw on non-ok,
|
||||
* external-URL window.open bypass, platform-attachment fetch+blob download,
|
||||
* error-throw on non-ok download, URL.createObjectURL lifecycle.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { isPlatformAttachment, resolveAttachmentHref, uploadChatFiles, downloadChatFile } from "../uploads";
|
||||
import type { ChatAttachment } from "../types";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isPlatformAttachment, resolveAttachmentHref } from "../uploads";
|
||||
|
||||
describe("resolveAttachmentHref — URI scheme normalisation", () => {
|
||||
const wsId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
@@ -173,132 +164,3 @@ describe("isPlatformAttachment", () => {
|
||||
expect(isPlatformAttachment("ftp://server/file")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── uploadChatFiles ────────────────────────────────────────────────────────
|
||||
|
||||
describe("uploadChatFiles", () => {
|
||||
const wsId = "test-ws-id";
|
||||
|
||||
// Suppress console.error from AbortSignal.timeout in node environment
|
||||
// where native AbortController may not be fully stubbed.
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let fetchMock: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockReturnValue();
|
||||
fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
fetchMock?.mockRestore();
|
||||
});
|
||||
|
||||
it("returns an empty array when given no files", async () => {
|
||||
const result = await uploadChatFiles(wsId, []);
|
||||
expect(result).toEqual([]);
|
||||
// fetch should NOT be called at all
|
||||
});
|
||||
|
||||
it("returns ChatAttachment[] on successful upload", async () => {
|
||||
const mockFiles: ChatAttachment[] = [
|
||||
{ name: "report.pdf", uri: "workspace:/workspace/report.pdf", size: 1024, mimeType: "application/pdf" },
|
||||
{ name: "data.csv", uri: "workspace:/workspace/data.csv", size: 512, mimeType: "text/csv" },
|
||||
];
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ files: mockFiles }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
|
||||
// Pass two files so the test validates the complete response round-trip
|
||||
// (the mock returns two ChatAttachment objects).
|
||||
const file1 = new File(["content1"], "report.pdf", { type: "application/pdf" });
|
||||
const file2 = new File(["content2"], "data.csv", { type: "text/csv" });
|
||||
const result = await uploadChatFiles(wsId, [file1, file2]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe("report.pdf");
|
||||
expect(result[1].name).toBe("data.csv");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, opts] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toContain(`/workspaces/${wsId}/chat/uploads`);
|
||||
// FormData stores files in order; each appended field is independent.
|
||||
const formFile = (opts.body as FormData).get("files") as File;
|
||||
expect(formFile.name).toBe("report.pdf");
|
||||
expect(formFile.type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("throws Error with status text on non-ok response", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response("Internal Server Error", { status: 500 })
|
||||
);
|
||||
|
||||
const file = new File(["content"], "fail.pdf", { type: "application/pdf" });
|
||||
await expect(uploadChatFiles(wsId, [file])).rejects.toThrow("upload failed: 500 Internal Server Error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── downloadChatFile ────────────────────────────────────────────────────────
|
||||
|
||||
describe("downloadChatFile", () => {
|
||||
const wsId = "test-ws-id";
|
||||
const makeAttachment = (uri: string): ChatAttachment => ({
|
||||
name: "report.pdf",
|
||||
uri,
|
||||
size: 1024,
|
||||
mimeType: "application/pdf",
|
||||
});
|
||||
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockReturnValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("opens external HTTPS URLs in a new tab (no fetch involved)", async () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
await downloadChatFile(wsId, makeAttachment("https://cdn.example.com/file.pdf"));
|
||||
|
||||
expect(openSpy).toHaveBeenCalledOnce();
|
||||
expect(openSpy).toHaveBeenCalledWith("https://cdn.example.com/file.pdf", "_blank", "noopener,noreferrer");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("fetches and triggers blob download for platform attachments", async () => {
|
||||
const blob = new Blob(["hello world"], { type: "application/pdf" });
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
new Response(blob, { status: 200 })
|
||||
);
|
||||
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
|
||||
|
||||
await downloadChatFile(wsId, makeAttachment("workspace:/workspace/report.pdf"));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]![0]).toContain(`/workspaces/${wsId}/chat/download`);
|
||||
expect(openSpy).not.toHaveBeenCalled(); // blob path, not window.open
|
||||
|
||||
fetchMock.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("throws Error on non-ok download response", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
new Response("Not Found", { status: 404 })
|
||||
);
|
||||
|
||||
await expect(
|
||||
downloadChatFile(wsId, makeAttachment("workspace:/workspace/missing.pdf"))
|
||||
).rejects.toThrow("download failed: 404");
|
||||
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,13 +26,16 @@ export function createMessage(
|
||||
content: string,
|
||||
attachments?: ChatAttachment[],
|
||||
): ChatMessage {
|
||||
return {
|
||||
const base = {
|
||||
id: crypto.randomUUID(),
|
||||
role,
|
||||
content,
|
||||
attachments: attachments && attachments.length > 0 ? attachments : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (attachments && attachments.length > 0) {
|
||||
return Object.freeze({ ...base, attachments });
|
||||
}
|
||||
return Object.freeze(base);
|
||||
}
|
||||
|
||||
// appendMessageDeduped adds a ChatMessage to `prev` unless the tail
|
||||
|
||||
@@ -76,6 +76,7 @@ func TestBuildBundleConfigFiles_Skills(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
// 2 skills × 1 file each = 2 files
|
||||
if n := len(files); n != 2 {
|
||||
t.Fatalf("skills: want 2 files, got %d", n)
|
||||
|
||||
@@ -346,7 +346,7 @@ func (g *gitFetcher) Fetch(ctx context.Context, rootDir, host, repoPath, ref str
|
||||
// MkdirTemp creates the dir; git clone refuses to clone into a
|
||||
// non-empty dir. Remove + recreate empty.
|
||||
os.RemoveAll(tmpDir)
|
||||
cloneAndConfig := append(gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir))
|
||||
cloneAndConfig := gitArgs("clone", "--quiet", "--depth=1", "-b", ref, cloneURL, tmpDir)
|
||||
cmd := exec.CommandContext(ctx, "git", cloneAndConfig...)
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
|
||||
@@ -24,7 +24,7 @@ func makeTestOpts(t *testing.T) *LocalBuildOptions {
|
||||
RepoPrefix: "https://git.test/molecule-ai/molecule-ai-workspace-template-",
|
||||
Platform: "linux/amd64",
|
||||
HTTPClient: &http.Client{},
|
||||
preflightLocalBuild: func() error {
|
||||
checkShellDeps: func() error {
|
||||
return nil // tests bypass the real PATH check
|
||||
},
|
||||
remoteHeadSha: func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error) {
|
||||
@@ -46,10 +46,7 @@ func makeTestOpts(t *testing.T) *LocalBuildOptions {
|
||||
dockerTag: func(ctx context.Context, src, dst string) error {
|
||||
return nil
|
||||
},
|
||||
// Stub the shell-dep pre-flight so tests run without docker/git on PATH.
|
||||
checkShellDeps: func() error {
|
||||
return nil
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,10 +674,10 @@ func TestProvisionerStartUsesLocalBuild_LocalMode(t *testing.T) {
|
||||
// caught by this test.
|
||||
}
|
||||
|
||||
// TestEnsureLocalImage_Hooks preflightLocalBuild — when preflight fails,
|
||||
// TestEnsureLocalImage_Hooks checkShellDeps — when preflight fails,
|
||||
func TestEnsureLocalImage_PreflightFailsIfDockerMissing(t *testing.T) {
|
||||
opts := makeTestOpts(t)
|
||||
opts.preflightLocalBuild = func() error {
|
||||
opts.checkShellDeps = func() error {
|
||||
return fmt.Errorf(
|
||||
"local-build mode requires `docker` and `git` on PATH in the platform container; " +
|
||||
"found: docker=<missing>, git=<missing>. " +
|
||||
@@ -702,7 +699,7 @@ func TestEnsureLocalImage_PreflightFailsIfDockerMissing(t *testing.T) {
|
||||
// nil, execution proceeds normally.
|
||||
func TestEnsureLocalImage_PreflightOKPassesThrough(t *testing.T) {
|
||||
opts := makeTestOpts(t)
|
||||
opts.preflightLocalBuild = func() error { return nil }
|
||||
opts.checkShellDeps = func() error { return nil }
|
||||
tag, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user