chore(release): 1.1.36 - 应用内流式直连下载、透明化直链与排队卡死彻底修复
TallyNote release / linux-x64 (push) Successful in 6m21s
TallyNote release / linux-x64 (push) Successful in 6m21s
This commit is contained in:
@@ -190,6 +190,153 @@ describe("更新 API", () => {
|
||||
const audit = database.sqlite.prepare("SELECT outcome FROM audit_events WHERE action='update.apply_requested' ORDER BY id DESC LIMIT 1").get() as { outcome: string } | undefined;
|
||||
expect(audit?.outcome).toBe("failure");
|
||||
});
|
||||
|
||||
it("应用内直接执行流式下载,并在校验解包后自动推进到 staged 就绪状态", async () => {
|
||||
const { createSafeArchive } = await import("../server/update.js");
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { mkdirSync, writeFileSync } = await import("node:fs");
|
||||
|
||||
const payloadSource = mkdtempSync(path.join(tmpdir(), "tallynote-test-payload-"));
|
||||
mkdirSync(path.join(payloadSource, "dist"), { recursive: true });
|
||||
writeFileSync(path.join(payloadSource, "dist", "server.js"), "console.log(1);");
|
||||
const archivePath = path.join(tmpdir(), `tallynote-archive-${randomUUID()}.tar.gz`);
|
||||
await createSafeArchive(payloadSource, archivePath);
|
||||
|
||||
const archiveBytes = readFileSync(archivePath);
|
||||
const digest = createHash("sha256").update(archiveBytes).digest("hex");
|
||||
const assetName = `tallynote-1.2.0-${detectPlatform().target}-glibc.tar.gz`;
|
||||
|
||||
globalThis.fetch = (async (input: string | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.endsWith("SHA256SUMS")) {
|
||||
return new Response(`${digest} ${assetName}\n`, { status: 200 });
|
||||
}
|
||||
if (url.endsWith(assetName)) {
|
||||
return new Response(archiveBytes, { status: 200, headers: { "content-length": String(archiveBytes.length) } });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
tag_name: "v1.2.0",
|
||||
assets: [
|
||||
{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" },
|
||||
{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }
|
||||
]
|
||||
}), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const session = await login("update-inprocess");
|
||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
expect(checked.statusCode).toBe(200);
|
||||
|
||||
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
|
||||
expect(downloaded.statusCode).toBe(202);
|
||||
const downloadJobId = downloaded.json().job.id as string;
|
||||
|
||||
// Wait for in-process download pipeline to finish
|
||||
let stagedRow: { status: string; actual_sha256: string; download_path: string } | undefined;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
stagedRow = database.sqlite.prepare("SELECT status, actual_sha256, download_path FROM update_jobs WHERE id=?").get(downloadJobId) as any;
|
||||
if (stagedRow?.status === "staged" || stagedRow?.status === "failed") break;
|
||||
}
|
||||
|
||||
expect(stagedRow?.status).toBe("staged");
|
||||
expect(stagedRow?.actual_sha256).toBe(digest);
|
||||
expect(existsSync(path.join(stagedRow!.download_path, "payload", "dist", "server.js"))).toBe(true);
|
||||
|
||||
const statusRes = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
|
||||
expect(statusRes.statusCode).toBe(200);
|
||||
expect(statusRes.json().job).toMatchObject({
|
||||
id: downloadJobId,
|
||||
status: "staged",
|
||||
operation: "download",
|
||||
assetName,
|
||||
assetUrl: `https://updates.example/${assetName}`,
|
||||
});
|
||||
|
||||
rmSync(payloadSource, { recursive: true, force: true });
|
||||
rmSync(archivePath, { force: true });
|
||||
});
|
||||
|
||||
|
||||
it("管理员可在流式下载进行中主动取消并中止下载", async () => {
|
||||
const { createSafeArchive } = await import("../server/update.js");
|
||||
const { createHash } = await import("node:crypto");
|
||||
const { mkdirSync, writeFileSync } = await import("node:fs");
|
||||
|
||||
const payloadSource = mkdtempSync(path.join(tmpdir(), "tallynote-cancel-payload-"));
|
||||
mkdirSync(path.join(payloadSource, "dist"), { recursive: true });
|
||||
writeFileSync(path.join(payloadSource, "dist", "server.js"), "console.log(1);");
|
||||
const archivePath = path.join(tmpdir(), `tallynote-cancel-${randomUUID()}.tar.gz`);
|
||||
await createSafeArchive(payloadSource, archivePath);
|
||||
|
||||
const archiveBytes = readFileSync(archivePath);
|
||||
const digest = createHash("sha256").update(archiveBytes).digest("hex");
|
||||
const assetName = `tallynote-1.2.0-${detectPlatform().target}-glibc.tar.gz`;
|
||||
|
||||
// Mock a slow stream
|
||||
let fetchAborted = false;
|
||||
globalThis.fetch = (async (input: string | URL, init?: any) => {
|
||||
const url = input.toString();
|
||||
if (url.endsWith("SHA256SUMS")) {
|
||||
return new Response(`${digest} ${assetName}\n`, { status: 200 });
|
||||
}
|
||||
if (url.endsWith(assetName)) {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
fetchAborted = true;
|
||||
});
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue(archiveBytes.slice(0, 50));
|
||||
// Simulate hanging network until aborted
|
||||
await new Promise((resolve) => {
|
||||
if (init?.signal?.aborted) return resolve(undefined);
|
||||
init?.signal?.addEventListener("abort", () => resolve(undefined));
|
||||
});
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
return new Response(stream, { status: 200, headers: { "content-length": String(archiveBytes.length) } });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
tag_name: "v1.2.0",
|
||||
assets: [
|
||||
{ name: "SHA256SUMS", browser_download_url: "https://updates.example/SHA256SUMS" },
|
||||
{ name: assetName, browser_download_url: `https://updates.example/${assetName}` }
|
||||
]
|
||||
}), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
const session = await login("update-cancel-inprocess");
|
||||
await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
|
||||
const downloaded = await app.inject({ method: "POST", url: "/api/update/download", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: { version: "1.2.0", confirm: true } });
|
||||
const downloadJobId = downloaded.json().job.id as string;
|
||||
|
||||
// Wait until status becomes downloading
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const row = database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(downloadJobId) as any;
|
||||
if (row?.status === "downloading") break;
|
||||
}
|
||||
|
||||
const cancelRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/update/cancel",
|
||||
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
|
||||
payload: { jobId: downloadJobId }
|
||||
});
|
||||
|
||||
expect(cancelRes.statusCode).toBe(200);
|
||||
expect(cancelRes.json().success).toBe(true);
|
||||
|
||||
const cancelledRow = database.sqlite.prepare("SELECT status FROM update_jobs WHERE id=?").get(downloadJobId) as any;
|
||||
expect(cancelledRow?.status).toBe("cancelled");
|
||||
expect(fetchAborted).toBe(true);
|
||||
|
||||
rmSync(payloadSource, { recursive: true, force: true });
|
||||
rmSync(archivePath, { force: true });
|
||||
});
|
||||
|
||||
it("管理员可主动取消排队中的更新任务并清理请求文件", async () => {
|
||||
const session = await login("update-cancel");
|
||||
mockRelease();
|
||||
|
||||
Reference in New Issue
Block a user