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:
1 parent
cf364435eb
commit
fed2202e74
4 files changed
+408
-2
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// 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);
|
||||
Reference in new issue
Block a user