test(storage): 真实 OpenList 往返测试与 alist-e2e 验收脚本

- packages/adapters alist.real.test.ts:上传 / 冲突 / 同驱动与跨驱动复制移动 / 下载比对(环境变量开启)
- apps/server alist-organize.real.test.ts:本地 → OpenList(整理复制)→ 本地(移动)→ OpenList 内整理
- scripts/alist-e2e.ts:对任意地址 + 令牌或账号密码跑往返,退出码表示是否全部通过
- 写后目标暂不可见时刷新父目录列表(OpenList 的 get 不认 refresh)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-07 00:01:27 +08:00
1 parent cf364435eb
commit fed2202e74
4 files changed
+408 -2

No files matched your search

@@ -0,0 +1,102 @@
import { createHash, randomBytes } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { type AlistAuth, AlistClient } from "./client.ts";
// 真实 Alist / OpenList(环境变量开启,缺省跳过):
// UMT_ALIST_REAL_URL 地址,如 http://127.0.0.1:55244
// UMT_ALIST_REAL_TOKEN 永久令牌;或 UMT_ALIST_REAL_USER + UMT_ALIST_REAL_PASSWORD
// UMT_ALIST_REAL_ROOT 可写测试目录(会在里面建 umt-it-<随机>/,结束删除),如 /s1
// UMT_ALIST_REAL_ROOT2 可选:另一个挂载(驱动)下的可写目录,测跨驱动复制 / 移动(异步任务)
// 跑法:docs/runbook/storage-alist.md「真机验收」。
const URL_ = Deno.env.get("UMT_ALIST_REAL_URL");
const ROOT = Deno.env.get("UMT_ALIST_REAL_ROOT");
const ROOT2 = Deno.env.get("UMT_ALIST_REAL_ROOT2");
const auth: AlistAuth | null = Deno.env.get("UMT_ALIST_REAL_TOKEN")
? { kind: "token", token: Deno.env.get("UMT_ALIST_REAL_TOKEN")! }
: Deno.env.get("UMT_ALIST_REAL_USER")
? {
kind: "password",
username: Deno.env.get("UMT_ALIST_REAL_USER")!,
password: Deno.env.get("UMT_ALIST_REAL_PASSWORD") ?? "",
}
: null;
const enabled = !!URL_ && !!ROOT && !!auth;
const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
describe.skipIf(!enabled)("真实 Alist / OpenList:本地 → Alist → 本地往返", () => {
let c: AlistClient;
let dir: string;
let remote: string;
let remote2: string | null = null;
const data = randomBytes(3 * 1024 * 1024 + 123);
beforeAll(async () => {
c = new AlistClient({ baseUrl: URL_!, auth: auth!, qps: 10, concurrency: 4 });
dir = await mkdtemp(join(tmpdir(), "umt-alist-real-"));
remote = `${ROOT!.replace(/\/+$/, "")}/umt-it-${randomBytes(3).toString("hex")}`;
if (ROOT2) remote2 = `${ROOT2.replace(/\/+$/, "")}/umt-it-${randomBytes(3).toString("hex")}`;
await writeFile(join(dir, "source.bin"), data);
});
afterAll(async () => {
await c?.removePath(remote).catch(() => undefined);
if (remote2) await c?.removePath(remote2).catch(() => undefined);
await rm(dir, { recursive: true, force: true });
});
it("连接:版本 / 用户 / 根目录", async () => {
const v = await c.version();
const me = await c.me();
console.log(`[real] Alist 版本 ${v},用户 ${me.username}`);
expect((await c.get(ROOT!)).isDir).toBe(true);
});
it("上传(临时名 → 改名、带秒传头)→ 同名冲突不覆盖", async () => {
let last = 0;
const r = await c.upload(join(dir, "source.bin"), `${remote}/电影 A/source.bin`, { onProgress: (d) => (last = d) });
console.log(`[real] 上传 ${r.bytes} 字节,sha256 ${r.hashes.sha256.slice(0, 12)}…,进度回调到 ${last}`);
expect(r.obj.size).toBe(data.length);
expect(last).toBe(data.length);
const names = (await c.list(`${remote}/电影 A`, { refresh: true })).map((o) => o.name);
expect(names).toEqual(["source.bin"]);
await expect(c.upload(join(dir, "source.bin"), `${remote}/电影 A/source.bin`)).rejects.toMatchObject({
kind: "conflict",
});
});
it("同驱动复制(改名走暂存目录)/ 移动 / 改名", async () => {
const cp = await c.copyFile(`${remote}/电影 A/source.bin`, `${remote}/library/Movie (2020).bin`);
expect(cp.size).toBe(data.length);
const mv = await c.moveFile(`${remote}/library/Movie (2020).bin`, `${remote}/library2/Moved.bin`);
expect(mv.path).toBe(`${remote}/library2/Moved.bin`);
expect(await c.stat(`${remote}/library/Movie (2020).bin`)).toBeNull();
const lib = (await c.list(`${remote}/library`, { refresh: true })).map((o) => o.name);
expect(lib.filter((n) => n.startsWith(".umt-stage-"))).toEqual([]);
console.log(`[real] Alist 内复制、移动完成,暂存目录已清理`);
});
it.skipIf(!ROOT2)("跨驱动复制 / 移动:轮询 Alist 任务到完成", async () => {
const cp = await c.copyFile(`${remote}/library2/Moved.bin`, `${remote2}/cross/Copied.bin`);
expect(cp.size).toBe(data.length);
const mv = await c.moveFile(`${remote2}/cross/Copied.bin`, `${remote}/back/Back.bin`);
expect(mv.size).toBe(data.length);
console.log(`[real] 跨驱动复制、移动完成(任务轮询)`);
});
it("下载回本地:内容与源一致;本地已存在冲突", async () => {
const out = join(dir, "back.bin");
const r = await c.download(`${remote}/library2/Moved.bin`, out);
const got = new Uint8Array(await readFile(out));
console.log(`[real] 下载 ${r.bytes} 字节,与源 sha256 一致:${sha(got) === sha(data)}`);
expect(sha(got)).toBe(sha(data));
await expect(c.download(`${remote}/library2/Moved.bin`, out)).rejects.toMatchObject({ kind: "conflict" });
});
it("不存在 ≠ 失败;容量拿不到返回 null 不报错", async () => {
await expect(c.get(`${remote}/nope.bin`)).rejects.toMatchObject({ kind: "not_found" });
const u = await c.usage(ROOT!);
console.log(`[real] 容量:${u ? `${u.free}/${u.total}` : "未知"}`);
});
});
+8 -2
View File
@@ -596,11 +596,17 @@ export class AlistClient {
async waitExists(path: string, opts: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<AlistObj> {
const deadline = this.o.now() + (opts.timeoutMs ?? 60_000);
let delay = 500;
const p = normAlistPath(path);
for (;;) {
opts.signal?.throwIfAborted();
const o = await this.stat(path, { refresh: true });
const o = await this.stat(p, { refresh: true });
if (o) return o;
if (this.o.now() >= deadline) throw new AlistError("not_found", `写入后目标仍不存在:${normAlistPath(path)}`);
// OpenList 的 get 不认 refresh:刷新父目录列表让缓存失效,再按列表结果判断
const hit = (await this.list(posix.dirname(p), { refresh: true }).catch(() => [])).find(
(x) => x.name === posix.basename(p),
);
if (hit) return hit;
if (this.o.now() >= deadline) throw new AlistError("not_found", `写入后目标仍不存在:${p}`);
await this.o.sleep(delay);
delay = Math.min(delay * 2, 10_000);
}