实现 Makelore 一键提交审核

需求:让非专业用户在项目操作区一次提交,运营审核通过后直接发布。

实现:由 Electron Main 完成安全打包、自动版本、幂等重试和状态脱敏;补齐友好失败反馈、唯一提交入口及隔离 Electron E2E fixture。
This commit is contained in:
2026-08-08 14:44:08 +08:00
parent 7b23cce67a
commit 724290e864
18 changed files with 3056 additions and 31 deletions

View File

@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -8,6 +8,7 @@ import { handleWorksRoutes } from '@electron/api/routes/works';
const readWorksPublishFileMock = vi.hoisted(() => vi.fn());
const readWorksDeployCheckMock = vi.hoisted(() => vi.fn());
const getValidWorksSquareAccessTokenMock = vi.hoisted(() => vi.fn());
vi.mock('@electron/opencode/works-publish-file', () => ({
readWorksPublishFile: (...args: unknown[]) => readWorksPublishFileMock(...args),
@@ -17,6 +18,10 @@ vi.mock('@electron/opencode/works-square-deploy-check', () => ({
readWorksDeployCheck: (...args: unknown[]) => readWorksDeployCheckMock(...args),
}));
vi.mock('@electron/services/works-square-session', () => ({
getValidWorksSquareAccessToken: (...args: unknown[]) => getValidWorksSquareAccessTokenMock(...args),
}));
function createResponse() {
const chunks: string[] = [];
const res = {
@@ -50,6 +55,26 @@ function createRequest(method: string, body?: unknown, headers: Record<string, s
return req as IncomingMessage;
}
async function writePublishableProject(projectPath: string): Promise<void> {
await mkdir(join(projectPath, 'src'), { recursive: true });
await writeFile(join(projectPath, 'package.json'), JSON.stringify({
name: 'space-cleaner',
private: true,
packageManager: 'npm@10.9.2',
scripts: { build: 'vite build' },
devDependencies: { vite: '^7.0.0' },
}), 'utf8');
await writeFile(join(projectPath, 'package-lock.json'), JSON.stringify({
name: 'space-cleaner',
lockfileVersion: 3,
requires: true,
packages: {},
}), 'utf8');
await writeFile(join(projectPath, 'index.html'), '<div id="app"></div>', 'utf8');
await writeFile(join(projectPath, 'src', 'main.ts'), 'console.log("ready")\n', 'utf8');
await writeFile(join(projectPath, 'VERSION.md'), '# Version\n\nCurrent: v2.3.4\n', 'utf8');
}
describe('works square host api routes', () => {
let tempDir: string | null = null;
@@ -57,6 +82,8 @@ describe('works square host api routes', () => {
vi.restoreAllMocks();
readWorksPublishFileMock.mockReset();
readWorksDeployCheckMock.mockReset();
getValidWorksSquareAccessTokenMock.mockReset();
getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token');
});
afterEach(async () => {
@@ -675,6 +702,7 @@ describe('works square host api routes', () => {
updated_at: '2026-06-20T22:55:37.790408+08:00',
playable: false,
runtime_url: null,
owner_email: 'private@example.com',
},
latest_version: {
id: 'version-new',
@@ -682,9 +710,15 @@ describe('works square host api routes', () => {
review_status: 'building',
change_log: 'Add score board',
build_job_id: 'job-new',
build_status: 'failed',
build_error_code: 'BUILD_COMMAND_FAILED',
build_error_message: 'Traceback: C:\\private\\builder.ts token=secret',
release_id: null,
created_at: '2026-06-21T10:30:00+08:00',
internal_trace: 'must not reach renderer',
},
versions: [],
database_debug: 'must not reach renderer',
}), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
@@ -721,11 +755,18 @@ describe('works square host api routes', () => {
review_status: 'building',
change_log: 'Add score board',
build_job_id: 'job-new',
build_status: 'failed',
build_error_code: 'BUILD_COMMAND_FAILED',
release_id: null,
rejection_reason: null,
created_at: '2026-06-21T10:30:00+08:00',
},
versions: [],
},
});
expect(JSON.stringify(response.json())).not.toContain('private@example.com');
expect(JSON.stringify(response.json())).not.toContain('token=secret');
expect(JSON.stringify(response.json())).not.toContain('internal_trace');
expect(fetchMock).toHaveBeenCalledWith(
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
{
@@ -737,6 +778,125 @@ describe('works square host api routes', () => {
);
});
it('loads current project status with the Main-owned session when no renderer token is sent', async () => {
const status = {
project: {
app_id: 'space-cleaner',
title: 'Space Cleaner',
summary: 'Catch space trash',
},
latest_version: {
id: 'version-new',
version_name: 'v1.1.0',
review_status: 'building',
change_log: '通过 Makelore 一键提交',
build_job_id: 'job-new',
build_status: 'failed',
build_error_code: 'BUILD_COMMAND_FAILED',
build_error_message: 'npm run build 执行失败',
release_id: null,
created_at: '2026-08-07T10:30:00+08:00',
worker_debug: 'private worker hostname',
},
versions: [],
};
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(status), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
const handled = await handleWorksRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1/api/works/projects/mine/space-cleaner/status'),
{} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
status: {
project: status.project,
latest_version: {
id: 'version-new',
version_name: 'v1.1.0',
review_status: 'building',
change_log: '通过 Makelore 一键提交',
build_job_id: 'job-new',
build_status: 'failed',
build_error_code: 'BUILD_COMMAND_FAILED',
release_id: null,
rejection_reason: null,
created_at: '2026-08-07T10:30:00+08:00',
},
versions: [],
},
});
expect(JSON.stringify(response.json())).not.toContain('build_error_message');
expect(JSON.stringify(response.json())).not.toContain('worker_debug');
expect(getValidWorksSquareAccessTokenMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith(
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
{
method: 'GET',
headers: { Authorization: 'Bearer main-owned-access-token' },
},
);
});
it('does not expose Main session refresh errors from the current project status route', async () => {
getValidWorksSquareAccessTokenMock.mockRejectedValueOnce(
new Error('refresh failed at C:\\private\\session.ts token=secret'),
);
const response = createResponse();
await handleWorksRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1/api/works/projects/mine/space-cleaner/status'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 503,
code: 'PROJECT_STATUS_UNAVAILABLE',
error: '暂时无法获取作品处理状态,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it('fails safely when a successful status response does not match the allowlisted schema', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
project: { app_id: 'space-cleaner', title: 'Space Cleaner', summary: 'Catch trash' },
latest_version: { build_error_message: 'private traceback token=secret' },
versions: 'not-an-array',
internal: 'database coordinates',
}), { status: 200 }),
));
const response = createResponse();
await handleWorksRoutes(
createRequest('GET'),
response.res,
new URL('http://127.0.0.1/api/works/projects/mine/space-cleaner/status'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 502,
code: 'PROJECT_STATUS_UNAVAILABLE',
error: '暂时无法获取作品处理状态,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it.each(['pass', 'warning'] as const)('uploads a zip version when the deployment check is %s', async (deployStatus) => {
tempDir = await mkdtemp(join(tmpdir(), 'niancode-works-upload-'));
const zipPath = join(tempDir, 'project.zip');
@@ -857,6 +1017,275 @@ describe('works square host api routes', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it('packages and submits source with Main-owned credentials and automatic release metadata', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-publish-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const projectMetadata = {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
category: 'game',
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectMetadata), { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
version_id: 'version-1',
review_status: 'building',
build_job_id: 'job-1',
build_status: 'queued',
build_error_message: 'private builder path token=secret',
internal_trace: 'private worker hostname',
}), { status: 201 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
const handled = await handleWorksRoutes(
createRequest('POST', { projectId: project.id, project: projectMetadata }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({
success: true,
package: {
archiveName: 'project.zip',
fileCount: 5,
manifest: {
schema_version: 1,
kind: 'web',
runtime: 'static',
build: { preset: 'vite', package_manager: 'npm', entry: 'index.html' },
},
},
upload: {
version_id: 'version-1',
review_status: 'building',
},
});
expect(response.json().upload).toEqual({
version_id: 'version-1',
review_status: 'building',
build_job_id: 'job-1',
build_status: 'queued',
});
expect(JSON.stringify(response.json())).not.toContain('build_error_message');
expect(JSON.stringify(response.json())).not.toContain('internal_trace');
expect(response.json().package).not.toHaveProperty('archivePath');
expect(getValidWorksSquareAccessTokenMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]).toEqual([
'https://square.nianxx.cn/api/projects',
{
method: 'POST',
headers: {
Authorization: 'Bearer main-owned-access-token',
'Content-Type': 'application/json',
},
body: JSON.stringify(projectMetadata),
},
]);
const [, uploadInit] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(uploadInit.headers).toMatchObject({
Authorization: 'Bearer main-owned-access-token',
'Idempotency-Key': expect.stringMatching(/^makelore-/),
});
const form = uploadInit.body as FormData;
expect(form.get('version_name')).toBe('v2.3.4');
expect(form.get('change_log')).toBe('通过 Makelore 一键提交');
const archive = form.get('archive');
expect(archive).toBeInstanceOf(File);
expect((archive as File).name).toBe('project.zip');
expect((archive as File).size).toBeGreaterThan(0);
});
it('uses a bounded timestamp version when VERSION.md is oversized', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-version-limit-'));
await writePublishableProject(tempDir);
await writeFile(join(tempDir, 'VERSION.md'), Buffer.alloc(65 * 1024, 0x31));
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
version_id: 'version-1',
review_status: 'building',
}), { status: 201 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleWorksRoutes(
createRequest('POST', {
projectId: project.id,
project: {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
},
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
);
expect(response.json().success).toBe(true);
const uploadInit = fetchMock.mock.calls[1]?.[1] as RequestInit;
expect((uploadInit.body as FormData).get('version_name')).toMatch(/^v\d{14}$/);
});
it('retries transient status and network failures with stable bytes, then rotates the key per Host request', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-retry-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const projectMetadata = {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
};
const retryResponse = new Response('sensitive upstream detail', { status: 503 });
const cancelSpy = vi.spyOn(retryResponse.body!, 'cancel');
const uploadPayload = JSON.stringify({ version_id: 'version-1', review_status: 'building' });
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 201 }))
.mockResolvedValueOnce(retryResponse)
.mockResolvedValueOnce(new Response(uploadPayload, { status: 201 }))
.mockResolvedValueOnce(new Response('{}', { status: 201 }))
.mockRejectedValueOnce(new Error('socket reset with secret upstream detail'))
.mockResolvedValueOnce(new Response(uploadPayload, { status: 201 }));
vi.stubGlobal('fetch', fetchMock);
const ctx = { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never;
for (let index = 0; index < 2; index += 1) {
const response = createResponse();
await handleWorksRoutes(
createRequest('POST', { projectId: project.id, project: projectMetadata }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
ctx,
);
expect(response.json().success).toBe(true);
}
const uploadCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/versions/upload')) as [string, RequestInit][];
expect(uploadCalls).toHaveLength(4);
const keys = uploadCalls.map(([, init]) => (init.headers as Record<string, string>)['Idempotency-Key']);
expect(keys[0]).toBe(keys[1]);
expect(keys[2]).toBe(keys[3]);
expect(keys[2]).not.toBe(keys[0]);
const archiveBytes = await Promise.all(uploadCalls.map(async ([, init]) => {
const archive = (init.body as FormData).get('archive') as File;
return Buffer.from(await archive.arrayBuffer());
}));
expect(archiveBytes[1]).toEqual(archiveBytes[0]);
expect(archiveBytes[2]).toEqual(archiveBytes[0]);
expect(archiveBytes[3]).toEqual(archiveBytes[0]);
expect(cancelSpy).toHaveBeenCalledOnce();
});
it('turns a malformed successful upload response into a safe retryable failure', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-invalid-success-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
review_status: 'building',
build_error_message: 'private traceback token=secret',
internal_trace: 'private worker hostname',
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleWorksRoutes(
createRequest('POST', {
projectId: project.id,
project: {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
},
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 502,
code: 'WORKS_SQUARE_UNAVAILABLE',
error: '发布服务返回结果异常,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it('returns a safe structured error without exposing an upstream response body', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-safe-error-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const upstream = new Response(JSON.stringify({
detail: 'internal database table and stack trace',
token: 'secret-token',
}), { status: 422 });
const cancelSpy = vi.spyOn(upstream.body!, 'cancel');
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(upstream));
const response = createResponse();
await handleWorksRoutes(
createRequest('POST', {
projectId: project.id,
project: {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
},
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 422,
code: 'PROJECT_CREATE_REJECTED',
error: '平台没有接受作品信息,请修改后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('database');
expect(JSON.stringify(response.json())).not.toContain('secret-token');
expect(cancelSpy).toHaveBeenCalledOnce();
});
it('fails safely before packaging when the Main session is unavailable', async () => {
getValidWorksSquareAccessTokenMock.mockResolvedValueOnce(null);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleWorksRoutes(
createRequest('POST', {
projectId: 'project-1',
project: { app_id: 'space-cleaner', title: 'Space Cleaner', summary: 'Catch trash' },
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{ opencodeProjectStore: { listProjects: vi.fn() } } as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 401,
code: 'AUTH_REQUIRED',
error: '登录状态已失效,请重新登录。',
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('transcribes uploaded speech through the Works Square API', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({