Add publish dry-run mode with request logging

This commit is contained in:
CPTProgrammer 2026-07-17 17:00:05 +08:00
parent 9dc7934603
commit 9c7fd31a69
No known key found for this signature in database
6 changed files with 207 additions and 2 deletions

View File

@ -138,3 +138,14 @@
- 通过 tRPC subscription 推送进度更新
6. **任一请求失败则取消当前平台的后续所有任务**,不影响另一个平台
7. 全部完成或失败后,弹出结果 Modal 显示最终状态(成功数 / 失败数)
## Dry-run模拟发布
`.env.local` 中设置 `PUBLISH_DRY_RUN=1` 后重启开发服务器,发布流程进入模拟模式:
- 配置解析、版本范围计算、文件读取、multipart 请求组装全部照常执行;
- 两个平台的上传请求在 `ModrinthClient.post` / `CurseForgeClient.uploadPost` 内被拦截,**不会发生任何实际上传**
- 每次拦截会把完整请求摘要metadata JSON、各文件字段名/大小)打印到终端,并追加写入日志文件供人工核对,路径见终端输出;可用环境变量 `PUBLISH_DRY_RUN_LOG_DIR` 指定日志目录,默认为系统临时目录;
- 平台返回伪造响应以维持调用链类型校验Modrinth 为 `{ name: "(dry-run)", ... }`CurseForge 为 `{ id: 0 }`),并带 1.5 秒模拟延迟,让前端进度条逐个推进。
实现位置:`src/lib/utils/dryRun.ts`。

View File

@ -1,3 +1,4 @@
import { isPublishDryRun, interceptUpload } from "../utils/dryRun";
import { readErrorBody, summarizeFormData } from "../utils/fetch";
/** CurseForge API 客户端,封装两套 API 的鉴权与 User-Agent。 */
@ -74,7 +75,11 @@ export class CurseForgeClient {
* Content-Type fetch boundary
*/
async uploadPost<T>(path: string, body: FormData): Promise<T> {
const res = await fetch(`${this.uploadBase}${path}`, {
const url = `${this.uploadBase}${path}`;
if (isPublishDryRun()) {
return interceptUpload("CurseForge", "POST", url, body) as Promise<T>;
}
const res = await fetch(url, {
method: "POST",
headers: this.uploadHeaders(),
body,

View File

@ -0,0 +1,56 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CurseForgeClient } from "./client";
import { uploadFile } from "./file";
import type { UploadMetadata } from "@/types";
describe("uploadFile (dry-run)", () => {
const OLD_ENV = process.env;
let logDir: string;
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
process.env = {
...OLD_ENV,
PUBLISH_DRY_RUN: "1",
};
logDir = await mkdtemp(join(tmpdir(), "curseforge-dryrun-"));
process.env.PUBLISH_DRY_RUN_LOG_DIR = logDir;
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(async () => {
process.env = OLD_ENV;
logSpy.mockRestore();
await rm(logDir, { recursive: true, force: true });
});
const client = new CurseForgeClient("fake-api-token", "fake-legacy-token");
const metadata: UploadMetadata = {
changelog: "test changelog",
changelogType: "markdown",
displayName: "mod-1.0.0-mc1.21.jar",
releaseType: "release",
gameVersions: [10157, 7499],
relations: { projects: [] },
};
it("不上传,返回可通过 UploadResponseSchema 校验的结果,并打印请求摘要", async () => {
const file = new File(["jar-content"], "mod-1.0.0-mc1.21.jar");
const result = await uploadFile(client, 123456, metadata, file);
// uploadFile 内部对 mock 响应执行了 UploadResponseSchema.parse
expect(result.id).toBe(0);
expect(logSpy).toHaveBeenCalledOnce();
const output = logSpy.mock.calls[0][0] as string;
expect(output).toContain("[DRY-RUN] CurseForge POST");
expect(output).toContain("/api/projects/123456/upload-file");
expect(output).toContain('"displayName":"mod-1.0.0-mc1.21.jar"');
expect(output).toContain("mod-1.0.0-mc1.21.jar");
});
});

View File

@ -1,3 +1,4 @@
import { isPublishDryRun, interceptUpload } from "../utils/dryRun";
import { readErrorBody, summarizeFormData } from "../utils/fetch";
/** Modrinth API v2 的基础请求客户端,封装鉴权与 User-Agent。 */
@ -41,7 +42,11 @@ export class ModrinthClient {
* Content-Type fetch boundary
*/
async post<T>(path: string, body: FormData): Promise<T> {
const res = await fetch(`${this.base}${path}`, {
const url = `${this.base}${path}`;
if (isPublishDryRun()) {
return interceptUpload("Modrinth", "POST", url, body) as Promise<T>;
}
const res = await fetch(url, {
method: "POST",
headers: this.headers(),
body,

View File

@ -0,0 +1,73 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ModrinthClient } from "./client";
import { createVersion } from "./version";
import type { CreatableVersion } from "@/types";
describe("createVersion (dry-run)", () => {
const OLD_ENV = process.env;
let logDir: string;
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
process.env = {
...OLD_ENV,
PUBLISH_DRY_RUN: "1",
};
logDir = await mkdtemp(join(tmpdir(), "modrinth-dryrun-"));
process.env.PUBLISH_DRY_RUN_LOG_DIR = logDir;
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(async () => {
process.env = OLD_ENV;
logSpy.mockRestore();
await rm(logDir, { recursive: true, force: true });
});
const client = new ModrinthClient("fake-token", "test-agent");
const baseData: CreatableVersion = {
project_id: "AABBCCDD",
name: "Test v1.0.0",
version_number: "1.0.0-mc1.21",
changelog: "test changelog",
game_versions: ["1.21"],
version_type: "release",
loaders: ["fabric"],
featured: false,
dependencies: [],
file_parts: ["primary", "sources"],
primary_file: "primary",
file_types: { sources: "sources-jar" },
environment: "client_and_server",
};
it("不上传,返回可通过 VersionSchema 校验的结果,并打印请求摘要", async () => {
const primary = new File(["jar-content"], "mod-1.0.0-mc1.21.jar");
const sources = new File(["src-content"], "mod-1.0.0-mc1.21-sources.jar");
const result = await createVersion(client, baseData, { primary, sources });
// createVersion 内部对 mock 响应执行了 VersionSchema.parse能返回即说明结构合法
expect(result.name).toBe("(dry-run)");
expect(result.version_number).toBe("(dry-run)");
expect(logSpy).toHaveBeenCalledOnce();
const output = logSpy.mock.calls[0][0] as string;
expect(output).toContain("[DRY-RUN] Modrinth POST");
expect(output).toContain("/v2/version");
expect(output).toContain('"project_id":"AABBCCDD"');
expect(output).toContain("mod-1.0.0-mc1.21.jar");
expect(output).toContain("mod-1.0.0-mc1.21-sources.jar");
});
it("file_parts 中声明但 files 中缺失时,仍抛出原有的组装期校验错误", async () => {
const primary = new File(["jar"], "mod.jar");
await expect(
createVersion(client, baseData, { primary } as Record<string, File>),
).rejects.toThrow('file field "sources" declared in file_parts');
});
});

55
src/lib/utils/dryRun.ts Normal file
View File

@ -0,0 +1,55 @@
import { appendFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { summarizeFormData } from "./fetch";
/** 模拟上传耗时的延迟(毫秒),让前端进度条有真实的推进感。 */
const DRY_RUN_DELAY_MS = 1500;
/**
* dry-run
* `PUBLISH_DRY_RUN=1` `.env.local` gitignore
*/
export function isPublishDryRun(): boolean {
return process.env.PUBLISH_DRY_RUN === "1";
}
/**
* dry-run
* multipart metadata JSON +
* 便
*
* @returns zod
*/
export async function interceptUpload(
platform: string,
method: string,
url: string,
body: FormData,
): Promise<unknown> {
const summary = summarizeFormData(body);
const logDir = process.env.PUBLISH_DRY_RUN_LOG_DIR ?? tmpdir();
const logFile = join(logDir, `dry-run-${Date.now()}-${platform}.log`);
try {
await mkdir(logDir, { recursive: true });
await appendFile(
logFile,
`[${new Date().toISOString()}] ${method} ${url}\n${summary}\n${"─".repeat(60)}\n`,
"utf-8",
);
} catch {
// 日志文件写不进去不阻断主流程,至少控制台还有输出
}
console.log(
`\n[DRY-RUN] ${platform} ${method} ${url}\n${summary}\n→ 完整请求已写入: ${logFile}\n`,
);
// 模拟网络上传耗时,让进度条逐个推进而不是瞬间跳完
await new Promise((resolve) => setTimeout(resolve, DRY_RUN_DELAY_MS));
return platform === "Modrinth"
? { name: "(dry-run)", version_number: "(dry-run)" }
: { id: 0 };
}