Files
UHD_Media_Tool/scripts/alist-e2e.ts
T
rootandClaude Opus 5.5 fed2202e74 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
2026-10-07 00:01:27 +08:00

141 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Alist / OpenList 往返验收脚本(docs/runbook/storage-alist.md「真机验收」):对任意地址 + 令牌(或账号密码)
// 用本项目的适配器跑一遍:连接 → 建目录 → 上传(秒传头、临时名改名、进度)→ 同名冲突不覆盖 → 同驱动复制(改名)
// → 移动 → 可选跨驱动复制 / 移动(Alist 异步任务)→ 下载回本地比对 sha256 → 不存在 ≠ 失败 → 清理。
//
// 用法:
// deno run -A scripts/alist-e2e.ts --url http://127.0.0.1:5244 --token <令牌> --root /s1 [--root2 /s2] [--size-mb 8]
// deno run -A scripts/alist-e2e.ts --url ... --user admin --password <密码> --root /s1
// 也可用环境变量 UMT_ALIST_REAL_URL / _TOKEN / _USER / _PASSWORD / _ROOT / _ROOT2。退出码 0 = 全部通过。
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 { parseArgs } from "node:util";
import { type AlistAuth, AlistClient, AlistError, redactAlistText } from "../packages/adapters/src/alist/index.ts";
const { values: a } = parseArgs({
args: Deno.args,
options: {
url: { type: "string" },
token: { type: "string" },
user: { type: "string" },
password: { type: "string" },
root: { type: "string" },
root2: { type: "string" },
"size-mb": { type: "string" },
keep: { type: "boolean" },
},
});
const env = (k: string) => Deno.env.get(`UMT_ALIST_REAL_${k}`);
const url = a.url ?? env("URL");
const token = a.token ?? env("TOKEN");
const user = a.user ?? env("USER");
const password = a.password ?? env("PASSWORD");
const root = (a.root ?? env("ROOT") ?? "").replace(/\/+$/, "");
const root2 = (a.root2 ?? env("ROOT2") ?? "").replace(/\/+$/, "");
if (!url || !root || (!token && !user)) {
console.error(
"用法:--url <地址> (--token <令牌> | --user <用户> --password <密码>) --root <可写目录> [--root2 <另一驱动目录>]",
);
Deno.exit(2);
}
const auth: AlistAuth = token
? { kind: "token", token }
: { kind: "password", username: user!, password: password ?? "" };
const secrets = [token, password];
const c = new AlistClient({ baseUrl: url, auth });
const tag = `umt-e2e-${randomBytes(3).toString("hex")}`;
const base = `${root}/${tag}`;
const base2 = root2 ? `${root2}/${tag}` : null;
const size = Math.max(1, Number(a["size-mb"] ?? 8)) * 1024 * 1024 + 7;
const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
let failed = 0;
async function step(name: string, fn: () => Promise<string | undefined | void>) {
const t0 = performance.now();
try {
const note = await fn();
console.log(`✓ ${name}(${Math.round(performance.now() - t0)} ms)${note ? `:${note}` : ""}`);
} catch (e) {
failed++;
const msg = e instanceof AlistError ? `${e.kind}:${e.message}` : String((e as Error)?.message ?? e);
console.log(`✗ ${name}(${Math.round(performance.now() - t0)} ms):${redactAlistText(msg, secrets)}`);
}
}
const expectKind = async (p: Promise<unknown>, kind: string) => {
try {
await p;
} catch (e) {
if (e instanceof AlistError && e.kind === kind) return;
throw e;
}
throw new Error(`应当失败(${kind}),却成功了`);
};
const dir = await mkdtemp(join(tmpdir(), "umt-alist-e2e-"));
const data = randomBytes(size);
await writeFile(join(dir, "source.bin"), data);
console.log(
`Alist 往返验收:${url} 测试目录 ${base}${base2 ? `、${base2}` : ""} 文件 ${(size / 1048576).toFixed(1)} MiB`,
);
await step("连接(版本、用户、根目录)", async () => {
const v = await c.version();
const me = await c.me();
if (!(await c.get(root)).isDir) throw new Error(`${root} 不是目录`);
return `${v ?? "版本未知"},用户 ${me.username}`;
});
await step("建目录", async () => {
await c.mkdir(`${base}/电影 A`);
});
await step("上传(秒传头、临时名 → 改名、进度回调)", async () => {
let last = 0;
const r = await c.upload(join(dir, "source.bin"), `${base}/电影 A/source.bin`, { onProgress: (d) => (last = d) });
if (last !== size) throw new Error(`进度只到 ${last}/${size}`);
return `${r.bytes} 字节,sha256 ${r.hashes.sha256.slice(0, 12)}…`;
});
await step("同名不覆盖(上传 / 复制都报冲突)", async () => {
await expectKind(c.upload(join(dir, "source.bin"), `${base}/电影 A/source.bin`), "conflict");
await c.mkdir(`${base}/dup`);
await c.copyFile(`${base}/电影 A/source.bin`, `${base}/dup/source.bin`);
await expectKind(c.copyFile(`${base}/电影 A/source.bin`, `${base}/dup/source.bin`), "conflict");
});
await step("同驱动复制并改名(暂存目录)", async () => {
const o = await c.copyFile(`${base}/电影 A/source.bin`, `${base}/library/Movie (2020).bin`);
const left = (await c.list(`${base}/library`, { refresh: true })).filter((x) => x.name.startsWith(".umt-stage-"));
if (left.length) throw new Error("暂存目录没清理");
return `${o.size} 字节`;
});
await step("同驱动移动", async () => {
await c.moveFile(`${base}/library/Movie (2020).bin`, `${base}/moved/Moved.bin`);
if (await c.stat(`${base}/library/Movie (2020).bin`, { refresh: true })) throw new Error("源还在");
});
if (base2) {
await step("跨驱动复制 / 移动(等 Alist 任务完成)", async () => {
await c.copyFile(`${base}/moved/Moved.bin`, `${base2}/cross/Copied.bin`);
await c.moveFile(`${base2}/cross/Copied.bin`, `${base}/back/Back.bin`);
return "完成";
});
}
await step("下载回本地并比对", async () => {
const out = join(dir, "back.bin");
const r = await c.download(`${base}/moved/Moved.bin`, out);
const ok = sha(new Uint8Array(await readFile(out))) === sha(data);
if (!ok) throw new Error("内容与源不一致");
return `${r.bytes} 字节,sha256 一致`;
});
await step("不存在 ≠ 失败;容量拿不到不报错", async () => {
await expectKind(c.get(`${base}/nope.bin`), "not_found");
const u = await c.usage(root);
return u ? `剩余 ${(u.free / 1073741824).toFixed(1)} GiB / ${(u.total / 1073741824).toFixed(1)} GiB` : "容量未知";
});
if (!a.keep) {
await step("清理测试目录", async () => {
await c.removePath(base);
if (base2) await c.removePath(base2);
});
}
await rm(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} 步失败` : "\n全部通过");
Deno.exit(failed ? 1 : 0);