4 Commits
Author SHA1 Message Date
杨豪 4334234062 feat: add upload_asset tool (16th tool) for image/asset upload
Publish to npm / publish (push) Successful in 26s
- POST /api/asset/upload with multipart field name file[] (files/files[]
  are silently swallowed by the reverse proxy on this deployment, per
  SiYuan v3.8.2 kernel/model/upload.go)
- audit: target notebook requires W permission
- code:0 + empty succMap is treated as a hard error (the exact silent
  failure that forced a previous session to fall back to raw HTTP)
- client: extract callRaw, allow FormData body, 120s timeout for uploads
- self-check: src/upload.test.ts (stubbed fetch, no HTTP)
- live-tested against siyuan.ipao.vip; probe asset cleaned up
2026-09-03 17:21:11 +08:00
rogee e33c50754c fix: initialize and sync notebook config on startup
Publish to npm / publish (push) Successful in 29s
2026-09-02 22:41:21 +08:00
rogee 776f31fb47 fix: use package-root extension entrypoint
Publish to npm / publish (push) Successful in 30s
2026-09-02 22:29:10 +08:00
rogee 1b47f33a7e fix: use explicit pi extension entrypoint
Publish to npm / publish (push) Successful in 28s
2026-09-02 22:16:56 +08:00
10 changed files with 244 additions and 31 deletions
+2
View File
@@ -43,3 +43,5 @@ permissions 取值为 R / W / D 的自由组合,或仅为 ["NONE"]。name 仅
11、把 15 个工具全部映射进 extension,工具名与 siyuan-mcp 保持一致:unified_search、get_document_content、create_document、append_to_document、update_document、move_documents、get_document_tree、append_to_daily_note、list_notebooks、get_recently_updated_documents、create_snapshot、list_snapshots、rollback_to_snapshot、list_all_tags、batch_replace_tag。 11、把 15 个工具全部映射进 extension,工具名与 siyuan-mcp 保持一致:unified_search、get_document_content、create_document、append_to_document、update_document、move_documents、get_document_tree、append_to_daily_note、list_notebooks、get_recently_updated_documents、create_snapshot、list_snapshots、rollback_to_snapshot、list_all_tags、batch_replace_tag。
12、留一个审计逻辑的最小自检(纯函数测试权限矩阵判定即可,不需要 mock HTTP)。 12、留一个审计逻辑的最小自检(纯函数测试权限矩阵判定即可,不需要 mock HTTP)。
13、(2026-09-04 补充)发布图文文章需要图片上传能力,新增第 16 个工具 `upload_asset`(不在 siyuan-mcp 工具集内,为 pi-siyuan 扩展):读本地文件列表 → POST /api/asset/uploadmultipart 字段名 **file[]**files/files[] 会被该部署反代吞成空 succMap)→ 返回 succMap 原文件名→assets/ 路径。审计:目标笔记本需 W。上传后把返回的 assets/ 路径填进 create_document 的 Markdown 图片链接。code:0 + 空 succMap 视为失败必须报错(该部署字段名回归时的静默失败症状)。
+6 -6
View File
@@ -4,11 +4,11 @@
## 特性 ## 特性
- **15 个工具**与 siyuan-mcp 同名`unified_search``get_document_content``create_document``append_to_document``update_document``move_documents``get_document_tree``append_to_daily_note``list_notebooks``get_recently_updated_documents``create_snapshot``list_snapshots``rollback_to_snapshot``list_all_tags``batch_replace_tag` - **16 个工具**:前 15 个与 siyuan-mcp 同名`unified_search``get_document_content``create_document``append_to_document``update_document``move_documents``get_document_tree``append_to_daily_note``list_notebooks``get_recently_updated_documents``create_snapshot``list_snapshots``rollback_to_snapshot``list_all_tags``batch_replace_tag`),外加 `upload_asset`(上传本地图片/附件,multipart 字段名 `file[]`,需目标笔记本 W;返回 `succMap` 原文件名 → `assets/` 路径,可直接填进 Markdown 图片链接,用于发布图文文章)
- **渐进式披露**:初始仅激活一个 `siyuan_discover` loader 工具(含连通性检查),其余 15 个工具按需增量加载,保持系统提示词前缀稳定 - **渐进式披露**:初始仅激活一个 `siyuan_discover` loader 工具(含连通性检查),其余 16 个工具按需增量加载,保持系统提示词前缀稳定
- **笔记本级 RWD 权限审计**:R 读 / W 写 / D 破坏性(rollback、move 移出、删除类),可自由组合;`NONE` = 禁止一切操作 - **笔记本级 RWD 权限审计**:R 读 / W 写 / D 破坏性(rollback、move 移出、删除类),可自由组合;`NONE` = 禁止一切操作
- 拒绝消息包含:目标笔记本(id+name)、需要的权限、缺失的权限、被拒绝的操作名 - 拒绝消息包含:目标笔记本(id+name)、需要的权限、缺失的权限、被拒绝的操作名
- 新笔记本自动以 `R` 权限同步进配置文件(发现入口 `list_notebooks`审计豁免) - Pi 启动及调用 `list_notebooks` 时自动同步笔记本;新笔记本以 `R` 权限写入配置文件(审计豁免)
- 连接信息:环境变量优先,配置文件兜底 - 连接信息:环境变量优先,配置文件兜底
## 安装 ## 安装
@@ -33,7 +33,7 @@ pi -e git:git.ipao.vip:rogee/pi-siyuan
```bash ```bash
git clone git@git.ipao.vip:rogee/pi-siyuan.git ~/.pi/agent/extensions/pi-siyuan git clone git@git.ipao.vip:rogee/pi-siyuan.git ~/.pi/agent/extensions/pi-siyuan
# src/ 内含 index.tspi 会自动加载 extensions/*/index.ts # 包根目录含 index.tspi 会自动加载 extensions/*/index.ts
``` ```
发布流程:推送 tag(如 `v0.1.0`)触发 Gitea workflow 自动发布到 npm`.gitea/workflows/publish-npm.yml`,使用仓库 secret `NPM_PACKAGE_TOKEN`)。 发布流程:推送 tag(如 `v0.1.0`)触发 Gitea workflow 自动发布到 npm`.gitea/workflows/publish-npm.yml`,使用仓库 secret `NPM_PACKAGE_TOKEN`)。
@@ -48,7 +48,7 @@ export SIYUAN_API_URL="https://your-siyuan-host" # 默认 http://127.0.0.1:680
export SIYUAN_TOKEN="your-api-token" # SiYuan 设置 → 关于 → API token export SIYUAN_TOKEN="your-api-token" # SiYuan 设置 → 关于 → API token
``` ```
缺失时回落到 `~/.pi/agent/extensions/pi-siyuan/config.json` 修改 `~/.zshenv` 后需新开 shell 并重启 Pi(已有进程不会自动获得新环境变量)。缺失时回落到 `~/.pi/agent/extensions/pi-siyuan/config.json`
```json ```json
{ {
@@ -64,7 +64,7 @@ export SIYUAN_TOKEN="your-api-token" # SiYuan 设置 → 关于
- `permissions``R`/`W`/`D` 自由组合,或 `["NONE"]`(禁止一切操作) - `permissions``R`/`W`/`D` 自由组合,或 `["NONE"]`(禁止一切操作)
- `name` 仅做 human-readable,一切逻辑按 `id` 匹配 - `name` 仅做 human-readable,一切逻辑按 `id` 匹配
- 首次调用 `list_notebooks` 会把未记录的笔记本以 `["R"]` 补写进该文件 - Pi 启动时会创建该配置文件并同步笔记本;调用 `list_notebooks` 时也会再次同步。未记录的笔记本以 `["R"]` 补写,已有笔记本名称变化时更新 `name`
### 权限规则速查 ### 权限规则速查
+1
View File
@@ -0,0 +1 @@
export { default } from "./src/index.ts";
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pi-siyuan", "name": "pi-siyuan",
"version": "0.1.0", "version": "0.1.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pi-siyuan", "name": "pi-siyuan",
"version": "0.1.0", "version": "0.1.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"typebox": "1.3.7" "typebox": "1.3.7"
+6 -5
View File
@@ -5,8 +5,8 @@
"dependencies": { "dependencies": {
"typebox": "1.3.7" "typebox": "1.3.7"
}, },
"version": "0.1.0", "version": "0.1.4",
"description": "SiYuan Note extension for pi coding agent \u2014 HTTP API wrapper with notebook-level RWD permission auditing", "description": "SiYuan Note extension for pi coding agent HTTP API wrapper with notebook-level RWD permission auditing",
"keywords": [ "keywords": [
"pi-package", "pi-package",
"pi-extension", "pi-extension",
@@ -20,14 +20,15 @@
}, },
"pi": { "pi": {
"extensions": [ "extensions": [
"./src" "./index.ts"
] ]
}, },
"devDependencies": { "devDependencies": {
"@earendil-works/pi-coding-agent": "0.84.4" "@earendil-works/pi-coding-agent": "0.84.4"
}, },
"main": "src/index.ts", "main": "index.ts",
"files": [ "files": [
"index.ts",
"src", "src",
"README.md", "README.md",
"LICENSE", "LICENSE",
@@ -36,7 +37,7 @@
], ],
"author": "rogee", "author": "rogee",
"scripts": { "scripts": {
"check": "node --experimental-strip-types --no-warnings src/audit.test.ts" "check": "node --experimental-strip-types --no-warnings src/audit.test.ts && node --experimental-strip-types --no-warnings src/upload.test.ts"
}, },
"prepublishOnly": "node --experimental-strip-types --no-warnings src/audit.test.ts" "prepublishOnly": "node --experimental-strip-types --no-warnings src/audit.test.ts"
} }
+15
View File
@@ -4,12 +4,14 @@
*/ */
import assert from "node:assert"; import assert from "node:assert";
import { existsSync, rmSync } from "node:fs";
import { import {
permsOf, permsOf,
auditNotebook, auditNotebook,
auditAny, auditAny,
auditAll, auditAll,
denyMessage, denyMessage,
loadConfig,
syncNotebooks, syncNotebooks,
} from "./audit.ts"; } from "./audit.ts";
@@ -87,6 +89,19 @@ assert.ok(
assert.ok(msg.includes("get_document_content"), "含操作名"); assert.ok(msg.includes("get_document_content"), "含操作名");
assert.ok(/需要 R/.test(msg) && /缺失 R/.test(msg), "含需要/缺失权限"); assert.ok(/需要 R/.test(msg) && /缺失 R/.test(msg), "含需要/缺失权限");
// ---- 配置首次加载自动初始化 ----
const initDir = "/tmp/pi-siyuan-audit-test";
const initPath = `${initDir}/nested/config.json`;
rmSync(initDir, { recursive: true, force: true });
const initialized = loadConfig(initPath);
assert.equal(existsSync(initPath), true, "配置文件应在首次加载时创建");
assert.deepEqual(initialized, {
apiUrl: "http://127.0.0.1:6806",
token: "",
notebooks: [],
});
rmSync(initDir, { recursive: true, force: true });
// ---- syncNotebooks(第 10 条)— 用临时路径,不污染真实配置 ---- // ---- syncNotebooks(第 10 条)— 用临时路径,不污染真实配置 ----
const cfg: any = { const cfg: any = {
apiUrl: "u", apiUrl: "u",
+9 -5
View File
@@ -5,7 +5,7 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import type { SiYuanClient } from "./siyuan-client.ts"; import type { SiYuanClient } from "./siyuan-client.ts";
export type Perm = "R" | "W" | "D"; export type Perm = "R" | "W" | "D";
@@ -86,7 +86,13 @@ export function loadConfig(cfgPath: string = CONFIG_PATH): PiSiyuanConfig {
// 损坏则重建 // 损坏则重建
} }
} }
return { apiUrl: "", token: "", notebooks: [] }; const cfg = {
apiUrl: "http://127.0.0.1:6806",
token: "",
notebooks: [],
};
saveConfig(cfg, cfgPath);
return cfg;
} }
export function resolveConnection(): { apiUrl: string; token: string } { export function resolveConnection(): { apiUrl: string; token: string } {
@@ -121,9 +127,7 @@ export function syncNotebooks(
} }
export function saveConfig(cfg: PiSiyuanConfig, cfgPath: string = CONFIG_PATH) { export function saveConfig(cfg: PiSiyuanConfig, cfgPath: string = CONFIG_PATH) {
mkdirSync(join(homedir(), ".pi/agent/extensions/pi-siyuan"), { mkdirSync(dirname(cfgPath), { recursive: true });
recursive: true,
});
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n"); writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
} }
+72 -6
View File
@@ -4,7 +4,7 @@
* 架构(AGENTS.md 第 5 条):直接封装 SiYuan HTTP API + 权限审计层。 * 架构(AGENTS.md 第 5 条):直接封装 SiYuan HTTP API + 权限审计层。
* 工具划分与参数设计参考 porkll/siyuan-mcp,不依赖其代码。 * 工具划分与参数设计参考 porkll/siyuan-mcp,不依赖其代码。
* *
* 渐进式披露(goal 4):15 个工具全部 registerTool 注册, * 渐进式披露(goal 4):16 个工具全部 registerTool 注册,
* 但初始只激活 siyuan_discover 一个 loader 工具; * 但初始只激活 siyuan_discover 一个 loader 工具;
* loader 按需 setActiveTools 增量激活匹配的工具。 * loader 按需 setActiveTools 增量激活匹配的工具。
* *
@@ -12,6 +12,8 @@
*/ */
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import { Type } from "typebox"; import { Type } from "typebox";
import { SiYuanClient } from "./siyuan-client.ts"; import { SiYuanClient } from "./siyuan-client.ts";
import { import {
@@ -23,7 +25,8 @@ import {
type PiSiyuanConfig, type PiSiyuanConfig,
} from "./audit.ts"; } from "./audit.ts";
// 15 个工具名(AGENTS.md 第 11 条与 porkll/siyuan-mcp 一致 // 16 个工具名(AGENTS.md 第 11 条 + 第 13 条;前 15 个与 porkll/siyuan-mcp 一致
// upload_asset 为 pi-siyuan 扩展:发布图文文章所需的图片/附件上传)
const SIYUAN_TOOL_NAMES = new Set([ const SIYUAN_TOOL_NAMES = new Set([
"unified_search", "unified_search",
"get_document_content", "get_document_content",
@@ -40,6 +43,7 @@ const SIYUAN_TOOL_NAMES = new Set([
"rollback_to_snapshot", "rollback_to_snapshot",
"list_all_tags", "list_all_tags",
"batch_replace_tag", "batch_replace_tag",
"upload_asset",
]); ]);
interface ToolDeps { interface ToolDeps {
@@ -95,7 +99,7 @@ export default function siyuanExtension(pi: ExtensionAPI) {
}; };
} }
// ============ 15 个工具(第 11 条) ============ // ============ 16 个工具(第 11/13 条) ============
pi.registerTool({ pi.registerTool({
name: "list_notebooks", name: "list_notebooks",
@@ -375,11 +379,68 @@ export default function siyuanExtension(pi: ExtensionAPI) {
// ============ 渐进式披露 loadergoal 4 ============ // ============ 渐进式披露 loadergoal 4 ============
const ASSET_MIME: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
pdf: "application/pdf",
mp3: "audio/mpeg",
mp4: "video/mp4",
};
pi.registerTool({
name: "upload_asset",
label: "Upload Asset",
description:
"上传本地图片/附件到 SiYuan assets(发布图文文章用;需目标笔记本 W 权限)。返回 succMap:原文件名 → assets/ 路径,可直接作为 create_document/append_to_document 里 Markdown 图片链接。一次最多 50 个文件。",
parameters: Type.Object({
notebook: Type.String({ description: "目标笔记本 ID(审计 W" }),
paths: Type.Array(Type.String(), {
description: "本地文件路径列表(相对当前目录或绝对路径)",
}),
}),
execute: audited("upload_asset", async (p) => {
deps.auditor.auditNotebookId(p.notebook, ["W"], "upload_asset");
if (!p.paths.length) return fail(new Error("paths 不能为空"));
if (p.paths.length > 50)
return fail(new Error("一次最多上传 50 个文件"));
const files: Array<{ name: string; data: Uint8Array; mime?: string }> = [];
for (const path of p.paths) {
let data: Buffer;
try {
data = readFileSync(path);
} catch {
return fail(new Error(`读取文件失败:${path}`));
}
const ext = basename(path).split(".").pop()?.toLowerCase() ?? "";
files.push({
name: basename(path),
data: new Uint8Array(data),
mime: ASSET_MIME[ext],
});
}
const r = await client.uploadAssets(files);
const lines = Object.entries(r.succMap).map(
([name, url]) => `${name}${url}`,
);
if (r.errFiles.length)
lines.push(`失败:${r.errFiles.join(", ")}`);
return ok(
`上传成功 ${Object.keys(r.succMap).length}/${files.length}${r.errFiles.length ? `,失败 ${r.errFiles.length}` : ""}\n` +
lines.join("\n"),
{ succMap: r.succMap, errFiles: r.errFiles },
);
}),
});
pi.registerTool({ pi.registerTool({
name: "siyuan_discover", name: "siyuan_discover",
label: "SiYuan Discover", label: "SiYuan Discover",
description: description:
"搜索并激活 SiYuan 笔记工具(15 个工具按需加载)。输入能力关键词(如 search / read / write / snapshot / tag / notebook / move / daily)。", "搜索并激活 SiYuan 笔记工具(16 个工具按需加载)。输入能力关键词(如 search / read / write / upload / image / snapshot / tag / notebook / move / daily)。",
promptSnippet: "操作 SiYuan 笔记时先用 siyuan_discover 激活对应工具", promptSnippet: "操作 SiYuan 笔记时先用 siyuan_discover 激活对应工具",
parameters: Type.Object({ parameters: Type.Object({
query: Type.Optional( query: Type.Optional(
@@ -422,11 +483,16 @@ export default function siyuanExtension(pi: ExtensionAPI) {
}, },
}); });
// 初始只激活 loader(渐进式披露) // 初始只激活 loader(渐进式披露),并同步笔记本权限配置
pi.on("session_start", () => { pi.on("session_start", async () => {
const initial = pi const initial = pi
.getActiveTools() .getActiveTools()
.filter((name) => !SIYUAN_TOOL_NAMES.has(name)); .filter((name) => !SIYUAN_TOOL_NAMES.has(name));
pi.setActiveTools([...new Set([...initial, "siyuan_discover"])]); pi.setActiveTools([...new Set([...initial, "siyuan_discover"])]);
try {
syncNotebooks(cfg, await client.lsNotebooks());
} catch {
// SiYuan 离线不应阻止 Pi 启动;工具调用时会返回连接错误
}
}); });
} }
+54 -6
View File
@@ -130,6 +130,40 @@ export class SiYuanClient {
return { docId, created: true }; return { docId, created: true };
} }
// ---- 附件 ----
/**
* 上传图片/附件(/api/asset/upload)。
* 该部署实测契约:multipart 字段名必须是 file[]files/files[] 会被吞成空 succMap
* SiYuan v3.8.2 kernel/model/upload.go: form.File["file[]"]),无 Authorization 401。
* 响应 {errFiles, succFiles:[{index,name,path}], succMap:{原名: "assets/新名"}}。
*/
async uploadAssets(
files: Array<{ name: string; data: Uint8Array; mime?: string }>,
): Promise<{ errFiles: string[]; succMap: Record<string, string> }> {
if (!files.length)
throw new SiYuanError("uploadAssets: 文件列表为空");
const fd = new FormData();
for (const f of files) {
fd.append(
"file[]",
new Blob([f.data], {
type: f.mime || "application/octet-stream",
}),
f.name,
);
}
const d = await this.callForm("/api/asset/upload", fd);
const errFiles: string[] = d?.errFiles ?? [];
const succMap: Record<string, string> = d?.succMap ?? {};
// 字段名回归/反代吞 body 时的症状就是 code:0 + 空 succMap —— 静默失败,必须显式报错
if (!Object.keys(succMap).length && !errFiles.length)
throw new SiYuanError(
"上传返回空 succMap(文件未落库):疑似 multipart 字段名回归或反代吞掉 body,当前字段名 file[]",
);
return { errFiles, succMap };
}
// ---- 搜索(fulltext 端点被吞,用 SQL---- // ---- 搜索(fulltext 端点被吞,用 SQL----
async searchContent(query: string, limit: number): Promise<any[]> { async searchContent(query: string, limit: number): Promise<any[]> {
@@ -181,16 +215,30 @@ export class SiYuanClient {
} }
private async call(path: string, body: unknown): Promise<any> { private async call(path: string, body: unknown): Promise<any> {
return this.callRaw(
path,
{ "Content-Type": "application/json" },
JSON.stringify(body),
);
}
private async callForm(path: string, body: FormData): Promise<any> {
// FormData 由 fetch 自动生成含 boundary 的 Content-Type
return this.callRaw(path, {}, body);
}
private async callRaw(
path: string,
extraHeaders: Record<string, string>,
body?: BodyInit,
): Promise<any> {
let res: Response; let res: Response;
try { try {
res = await fetch(this.apiUrl + path, { res = await fetch(this.apiUrl + path, {
method: "POST", method: "POST",
headers: { headers: { Authorization: `Token ${this.token}`, ...extraHeaders },
Authorization: `Token ${this.token}`, body,
"Content-Type": "application/json", signal: AbortSignal.timeout(120000),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000),
}); });
} catch (e: any) { } catch (e: any) {
throw new SiYuanError(`连接 SiYuan 失败 (${this.apiUrl}): ${e.message}`); throw new SiYuanError(`连接 SiYuan 失败 (${this.apiUrl}): ${e.message}`);
+76
View File
@@ -0,0 +1,76 @@
/**
* uploadAssets 最小自检(无 HTTP):stub fetch 捕获 FormData
* 验证该部署实测契约——multipart 字段名必须是 file[]AGENTS.md 第 13 条)。
* 运行:node --experimental-strip-types src/upload.test.ts
*/
import assert from "node:assert";
import { SiYuanClient } from "./siyuan-client.ts";
// ---- 捕获 fetch ----
let captured: { url: string; init: RequestInit } | null = null;
const realFetch = globalThis.fetch;
globalThis.fetch = (async (url: any, init: any) => {
captured = { url: String(url), init };
return new Response(
JSON.stringify({
code: 0,
msg: "",
data: {
errFiles: null,
succFiles: [{ index: 0, name: "a.png", path: "assets/a-x.png" }],
succMap: { "a.png": "assets/a-x.png" },
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
const client = new SiYuanClient("http://127.0.0.1:6806", "test-token");
// 1. 正常上传:字段名 file[]、Authorization 头、不手工设 multipart Content-Type
const r = await client.uploadAssets([
{ name: "a.png", data: new Uint8Array([1, 2, 3]), mime: "image/png" },
{ name: "b.jpg", data: new Uint8Array([4, 5]) },
]);
assert.ok(captured, "fetch 应被调用");
assert.ok(captured!.url.endsWith("/api/asset/upload"), "打到 /api/asset/upload");
assert.equal(
(captured!.init.headers as any).Authorization,
"Token test-token",
"带 Token 认证头",
);
assert.equal(
(captured!.init.headers as any)["Content-Type"],
undefined,
"multipart Content-Type 必须由 fetch 自动生成(含 boundary),不能手工设置",
);
const form = captured!.init.body as FormData;
assert.equal(form.getAll("file[]").length, 2, "两个文件都用 file[] 字段");
assert.equal((form.get("file[]") as File).name, "a.png", "保留原文件名");
assert.deepEqual(r.succMap, { "a.png": "assets/a-x.png" });
// 2. 空 succMap(字段名回归/反代吞 body 的静默失败症状)必须报错
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
code: 0,
msg: "",
data: { errFiles: null, failedFiles: [], succFiles: [], succMap: {} },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
await assert.rejects(
() => client.uploadAssets([{ name: "x.png", data: new Uint8Array([1]) }]),
/空 succMap/,
"code:0 + 空 succMap 必须抛错(这正是历史上 files[] 被8吞掉的症状)",
);
// 3. 空文件列表直接拒绝
await assert.rejects(
() => client.uploadAssets([]),
/文件列表为空/,
);
globalThis.fetch = realFetch;
console.log("upload self-check: all assertions passed ✅");