feat(frontend): set up Vitest frontend testing infrastructure with CI workflow (#2147)

* feat: set up Vitest frontend testing infrastructure with CI workflow

Migrate existing 4 frontend test files from Node.js native test runner
(node:test + node:assert/strict) to Vitest, reorganize test directory
structure under tests/unit/ mirroring src/ layout, and add a dedicated
CI workflow for frontend unit tests.

- Add vitest as devDependency, remove tsx
- Create vitest.config.ts with @/ path alias
- Migrate tests to Vitest API (test/expect/vi)
- Rename .mjs test files to .ts
- Move tests from src/ to tests/unit/ (mirrors src/ layout)
- Add frontend/Makefile `test` target
- Add .github/workflows/frontend-unit-tests.yml (parallel to backend)
- Update CONTRIBUTING.md, README.md, AGENTS.md, CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix the lint error

* style: fix the lint error

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
yangzheli
2026-04-12 18:00:43 +08:00
committed by GitHub
parent 4d4ddb3d3f
commit 4efc8d404f
16 changed files with 594 additions and 298 deletions
-43
View File
@@ -1,43 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
const { sanitizeRunStreamOptions } = await import(
new URL("./stream-mode.ts", import.meta.url).href
);
void test("drops unsupported stream modes from array payloads", () => {
const sanitized = sanitizeRunStreamOptions({
streamMode: [
"values",
"messages-tuple",
"custom",
"updates",
"events",
"tools",
],
});
assert.deepEqual(sanitized.streamMode, [
"values",
"messages-tuple",
"custom",
"updates",
"events",
]);
});
void test("drops unsupported stream modes from scalar payloads", () => {
const sanitized = sanitizeRunStreamOptions({
streamMode: "tools",
});
assert.equal(sanitized.streamMode, undefined);
});
void test("keeps payloads without streamMode untouched", () => {
const options = {
streamSubgraphs: true,
};
assert.equal(sanitizeRunStreamOptions(options), options);
});
-54
View File
@@ -1,54 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
const { pathOfThread } = await import(
new URL("./utils.ts", import.meta.url).href
);
void test("uses standard chat route when thread has no agent context", () => {
assert.equal(pathOfThread("thread-123"), "/workspace/chats/thread-123");
assert.equal(
pathOfThread({
thread_id: "thread-123",
}),
"/workspace/chats/thread-123",
);
});
void test("uses agent chat route when thread context has agent_name", () => {
assert.equal(
pathOfThread({
thread_id: "thread-123",
context: { agent_name: "researcher" },
}),
"/workspace/agents/researcher/chats/thread-123",
);
});
void test("uses provided context when pathOfThread is called with a thread id", () => {
assert.equal(
pathOfThread("thread-123", { agent_name: "ops agent" }),
"/workspace/agents/ops%20agent/chats/thread-123",
);
});
void test("uses agent chat route when thread metadata has agent_name", () => {
assert.equal(
pathOfThread({
thread_id: "thread-456",
metadata: { agent_name: "coder" },
}),
"/workspace/agents/coder/chats/thread-456",
);
});
void test("prefers context.agent_name over metadata.agent_name", () => {
assert.equal(
pathOfThread({
thread_id: "thread-789",
context: { agent_name: "from-context" },
metadata: { agent_name: "from-metadata" },
}),
"/workspace/agents/from-context/chats/thread-789",
);
});
@@ -1,55 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MACOS_APP_BUNDLE_UPLOAD_MESSAGE,
isLikelyMacOSAppBundle,
splitUnsupportedUploadFiles,
} from "./file-validation.ts";
test("identifies Finder-style .app bundle uploads as unsupported", () => {
assert.equal(
isLikelyMacOSAppBundle({
name: "Vibe Island.app",
type: "application/octet-stream",
}),
true,
);
});
test("keeps normal files and reports rejected app bundles", () => {
const files = [
new File(["demo"], "Vibe Island.app", {
type: "application/octet-stream",
}),
new File(["notes"], "notes.txt", { type: "text/plain" }),
];
const result = splitUnsupportedUploadFiles(files);
assert.equal(result.accepted.length, 1);
assert.equal(result.accepted[0]?.name, "notes.txt");
assert.equal(result.rejected.length, 1);
assert.equal(result.rejected[0]?.name, "Vibe Island.app");
assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE);
});
test("treats empty MIME .app uploads as unsupported", () => {
const result = splitUnsupportedUploadFiles([
new File(["demo"], "Another.app", { type: "" }),
]);
assert.equal(result.accepted.length, 0);
assert.equal(result.rejected.length, 1);
assert.equal(result.message, MACOS_APP_BUNDLE_UPLOAD_MESSAGE);
});
test("returns no message when every file is supported", () => {
const result = splitUnsupportedUploadFiles([
new File(["notes"], "notes.txt", { type: "text/plain" }),
]);
assert.equal(result.accepted.length, 1);
assert.equal(result.rejected.length, 0);
assert.equal(result.message, undefined);
});
@@ -1,150 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
async function loadModule() {
try {
return await import("./prompt-input-files.ts");
} catch (error) {
return { error };
}
}
test("exports the prompt-input file conversion helper", async () => {
const loaded = await loadModule();
assert.ok(
!("error" in loaded),
loaded.error instanceof Error
? loaded.error.message
: "prompt-input-files module is missing",
);
assert.equal(typeof loaded.promptInputFilePartToFile, "function");
});
test("reuses the original File when a prompt attachment already has one", async () => {
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
const file = new File(["hello"], "note.txt", { type: "text/plain" });
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error("fetch should not run when File is already present");
};
try {
const converted = await promptInputFilePartToFile({
type: "file",
filename: file.name,
mediaType: file.type,
url: "blob:http://localhost:2026/stale-preview-url",
file,
});
assert.equal(converted, file);
} finally {
globalThis.fetch = originalFetch;
}
});
test("reconstructs a File from a data URL when no original File is present", async () => {
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
const converted = await promptInputFilePartToFile({
type: "file",
filename: "note.txt",
mediaType: "text/plain",
url: "data:text/plain;base64,aGVsbG8=",
});
assert.ok(converted);
assert.equal(converted.name, "note.txt");
assert.equal(converted.type, "text/plain");
assert.equal(await converted.text(), "hello");
});
test("rewraps the original File when the prompt metadata changes", async () => {
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
const file = new File(["hello"], "note.txt", { type: "text/plain" });
const converted = await promptInputFilePartToFile({
type: "file",
filename: "renamed.txt",
mediaType: "text/markdown",
file,
});
assert.ok(converted);
assert.notEqual(converted, file);
assert.equal(converted.name, "renamed.txt");
assert.equal(converted.type, "text/markdown");
assert.equal(await converted.text(), "hello");
});
test("returns null when upload preparation is missing required data", async () => {
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
const converted = await promptInputFilePartToFile({
type: "file",
mediaType: "text/plain",
});
assert.equal(converted, null);
});
test("returns null when the URL fallback fetch fails", async () => {
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
const originalFetch = globalThis.fetch;
const originalWarn = console.warn;
const warnCalls = [];
console.warn = (...args) => {
warnCalls.push(args);
};
globalThis.fetch = async () => {
throw new Error("network down");
};
try {
const converted = await promptInputFilePartToFile({
type: "file",
filename: "note.txt",
url: "blob:http://localhost:2026/missing-preview-url",
});
assert.equal(converted, null);
assert.equal(warnCalls.length, 1);
} finally {
globalThis.fetch = originalFetch;
console.warn = originalWarn;
}
});
test("returns null when the URL fallback fetch response is non-ok", async () => {
const { promptInputFilePartToFile } = await import("./prompt-input-files.ts");
const originalFetch = globalThis.fetch;
const originalWarn = console.warn;
const warnCalls = [];
console.warn = (...args) => {
warnCalls.push(args);
};
globalThis.fetch = async () =>
new Response("missing", {
status: 404,
statusText: "Not Found",
});
try {
const converted = await promptInputFilePartToFile({
type: "file",
filename: "note.txt",
url: "blob:http://localhost:2026/missing-preview-url",
});
assert.equal(converted, null);
assert.equal(warnCalls.length, 1);
} finally {
globalThis.fetch = originalFetch;
console.warn = originalWarn;
}
});