Add publish dry-run mode with request logging
This commit is contained in:
parent
9dc7934603
commit
9c7fd31a69
@ -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`。
|
||||
|
||||
@ -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,
|
||||
|
||||
56
src/lib/curseforge/file.test.ts
Normal file
56
src/lib/curseforge/file.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
73
src/lib/modrinth/version.test.ts
Normal file
73
src/lib/modrinth/version.test.ts
Normal 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
55
src/lib/utils/dryRun.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user