Files
zeyu.fz d9e8601a50 feat(knowledge): 支持上传目录路径并递归扫描文件
- 支持上传参数中传入目录路径,递归扫描子目录下文件
- 自动忽略 node_modules、.git 等常见工具目录
- 不支持的文件格式不会报错,跳过并列表提示
- 上传时校验扩展名和大小限制,支持批量文件上传
- 输出中增加跳过的文件列表,verbose 模式下显示详细文件名
- 测试覆盖目录上传、文件跳过和空目录等场景
- 更新相关文档,说明新支持的目录上传功能及注意事项
2026-08-05 22:29:39 +08:00

145 lines
5.6 KiB
TypeScript

import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { ExitCode } from "bailian-cli-core";
import {
checkUploadFile,
expandUploadPaths,
UPLOAD_FORMAT_RULES,
} from "../../src/commands/knowledge/upload-support.ts";
const fixtureDir = mkdtempSync(join(tmpdir(), "upload-support-"));
function writeFixture(fileName: string, content: string): string {
const filePath = join(fixtureDir, fileName);
writeFileSync(filePath, content);
return filePath;
}
describe("checkUploadFile", () => {
test("白名单内的小文件通过且无警告", () => {
const filePath = writeFixture("note.md", "# hello");
const result = checkUploadFile(filePath);
expect(result.sizeBytes).toBeGreaterThan(0);
expect(result.warning).toBeUndefined();
});
test("白名单外扩展名 (.zip) 抛 USAGE 并列出支持格式", () => {
const filePath = writeFixture("archive.zip", "PK");
try {
checkUploadFile(filePath);
expect.unreachable("should throw");
} catch (error) {
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.USAGE);
expect((error as { hint?: string }).hint).toMatch(/\.pdf/);
}
});
test("文件不存在抛 GENERAL 且 hint 含 errno", () => {
try {
checkUploadFile(join(fixtureDir, "missing.md"));
expect.unreachable("should throw");
} catch (error) {
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.GENERAL);
expect((error as { hint?: string }).hint).toMatch(/ENOENT/);
}
});
test("软限类型 (.md) 超 10MB 返回警告不拦截", () => {
const filePath = writeFixture("big.md", "x".repeat(11 * 1024 * 1024));
const result = checkUploadFile(filePath);
expect(result.warning).toMatch(/10 MB/);
});
});
describe("UPLOAD_FORMAT_RULES", () => {
test("硬限/软限分类符合设计表", () => {
expect(UPLOAD_FORMAT_RULES[".pdf"]).toEqual({ maxBytes: 150 * 1024 * 1024, enforce: "block" });
expect(UPLOAD_FORMAT_RULES[".png"]).toEqual({ maxBytes: 20 * 1024 * 1024, enforce: "block" });
expect(UPLOAD_FORMAT_RULES[".xlsx"]).toEqual({ maxBytes: 10 * 1024 * 1024, enforce: "warn" });
expect(UPLOAD_FORMAT_RULES[".csv"]).toBeDefined(); // inconsistent across public docs; kept in the allowlist
});
});
describe("expandUploadPaths", () => {
const expandFixtureDir = mkdtempSync(join(tmpdir(), "expand-upload-"));
function setupExpandFixtures(): void {
// Root-level files
writeFileSync(join(expandFixtureDir, "readme.md"), "# root");
writeFileSync(join(expandFixtureDir, "data.csv"), "a,b,c");
writeFileSync(join(expandFixtureDir, "config.json"), "{}"); // unsupported
writeFileSync(join(expandFixtureDir, "script.py"), "print(1)"); // unsupported
// Subdirectory with files
const subDir = join(expandFixtureDir, "subdir");
mkdirSync(subDir);
writeFileSync(join(subDir, "notes.txt"), "hello");
writeFileSync(join(subDir, "archive.zip"), "PK"); // unsupported
// node_modules should be ignored
const nodeModulesDir = join(expandFixtureDir, "node_modules");
mkdirSync(nodeModulesDir);
writeFileSync(join(nodeModulesDir, "index.js"), "module.exports = {}");
// .git should be ignored
const gitDir = join(expandFixtureDir, ".git");
mkdirSync(gitDir);
writeFileSync(join(gitDir, "HEAD"), "ref: refs/heads/main");
}
setupExpandFixtures();
test("目录递归扫描: 支持的文件收集, 不支持的跳过, 忽略目录不进入", () => {
const result = expandUploadPaths([expandFixtureDir]);
const fileNames = result.files.map((filePath) => filePath.split("/").pop());
const skippedNames = result.skipped.map((filePath) => filePath.split("/").pop());
expect(fileNames).toContain("readme.md");
expect(fileNames).toContain("data.csv");
expect(fileNames).toContain("notes.txt");
// Unsupported files in root and subdir
expect(skippedNames).toContain("config.json");
expect(skippedNames).toContain("script.py");
expect(skippedNames).toContain("archive.zip");
// node_modules and .git contents should NOT appear
expect(fileNames).not.toContain("index.js");
expect(skippedNames).not.toContain("index.js");
expect(fileNames).not.toContain("HEAD");
expect(skippedNames).not.toContain("HEAD");
});
test("单个文件路径直接返回", () => {
const filePath = join(expandFixtureDir, "readme.md");
const result = expandUploadPaths([filePath]);
expect(result.files).toEqual([filePath]);
expect(result.skipped).toEqual([]);
});
test("混合文件和目录路径", () => {
const filePath = join(expandFixtureDir, "readme.md");
const result = expandUploadPaths([filePath, expandFixtureDir]);
// readme.md appears once from the direct file, once from the directory scan
const mdCount = result.files.filter((path) => path.endsWith("readme.md")).length;
expect(mdCount).toBe(2);
});
test("空目录返回空数组", () => {
const emptyDir = mkdtempSync(join(tmpdir(), "empty-upload-"));
const result = expandUploadPaths([emptyDir]);
expect(result.files).toEqual([]);
expect(result.skipped).toEqual([]);
});
test("不存在的路径抛 GENERAL 且 hint 含 errno", () => {
try {
expandUploadPaths([join(expandFixtureDir, "nonexistent.md")]);
expect.unreachable("should throw");
} catch (error) {
expect((error as { exitCode: number }).exitCode).toBe(ExitCode.GENERAL);
expect((error as { hint?: string }).hint).toMatch(/ENOENT/);
}
});
});