在 Makelore 构建并预检静态发布产物

This commit is contained in:
2026-08-12 21:19:39 +08:00
parent 3a80625fe2
commit 5b44864265
26 changed files with 1275 additions and 217 deletions

View File

@@ -13,10 +13,31 @@ import {
import { createProjectConfig } from '../../shared/project-config';
const getValidWorksSquareAccessTokenMock = vi.hoisted(() => vi.fn());
const prepareProjectReleaseMock = vi.hoisted(() => vi.fn());
vi.mock('@electron/services/works-square-session', () => ({
getValidWorksSquareAccessToken: (...args: unknown[]) => getValidWorksSquareAccessTokenMock(...args),
}));
vi.mock('@electron/services/project-release-builder', async (importOriginal) => ({
...await importOriginal<typeof import('@electron/services/project-release-builder')>(),
prepareProjectRelease: prepareProjectReleaseMock,
}));
function preparedRelease(projectPath: string) {
const bytes = Buffer.from('zip');
return {
sourceArchive: { path: join(projectPath, 'private-source.zip'), name: 'project.zip', bytes, summary: {
archivePath: join(projectPath, 'private-source.zip'), archiveName: 'project.zip', sha256: 'a'.repeat(64), fileCount: 5,
sourceBytes: 10, archiveBytes: 3, excludedCount: 0, excludedPaths: [],
manifest: { schema_version: 1, project_type: 'mini_game', kind: 'web', runtime: 'static', build: { preset: 'vite', package_manager: 'npm', entry: 'index.html' } },
} },
builtArchive: { path: join(projectPath, 'private-built.zip'), name: 'built-project.zip', bytes: Buffer.from('built') },
distRoot: join(projectPath, 'private-dist'),
staticArtifact: { files: [{ path: 'index.html', bytes: Buffer.from('built') }] },
contract: { schema_version: 1, entry_path: 'index.html', source_digest: 'a'.repeat(64), built_archive_digest: 'b'.repeat(64), artifact_digest: 'c'.repeat(64), file_count: 1, total_bytes: 5, files: [{ path: 'index.html', size: 5, sha256: 'd'.repeat(64) }], security_profile: 'works-square-static-sandbox-v1', toolchain: { client: 'makelore', client_version: '2.0.0', node: '22', npm: '11.6.2', vite: '7.3.1' } },
dispose: vi.fn(async () => undefined),
};
}
function createResponse() {
@@ -89,6 +110,8 @@ describe('works square host api routes', () => {
beforeEach(() => {
vi.restoreAllMocks();
prepareProjectReleaseMock.mockReset();
prepareProjectReleaseMock.mockImplementation(async ({ projectPath }: { projectPath: string }) => preparedRelease(projectPath));
rotateRendererCapability();
getValidWorksSquareAccessTokenMock.mockReset();
getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token');
@@ -1083,7 +1106,9 @@ describe('works square host api routes', () => {
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
const recordSubmitted = vi.fn(async () => undefined);
const preflightCurrentProject = vi.fn(async () => ({ ok: true as const }));
const preflightStaticArtifact = vi.fn(async () => ({ ok: true as const }));
const release = preparedRelease(tempDir);
prepareProjectReleaseMock.mockResolvedValueOnce(release);
const handled = await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
@@ -1091,7 +1116,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject },
agentBrowser: { preflightStaticArtifact },
worksSubmissionBinding: { recordSubmitted },
} as never,
);
@@ -1149,6 +1174,14 @@ describe('works square host api routes', () => {
expect(archive).toBeInstanceOf(File);
expect((archive as File).name).toBe('project.zip');
expect((archive as File).size).toBeGreaterThan(0);
const builtArchive = form.get('built_archive');
expect(builtArchive).toBeInstanceOf(File);
expect((builtArchive as File).name).toBe('built-project.zip');
expect(JSON.parse(String(form.get('artifact_contract')))).toMatchObject({
schema_version: 1,
source_digest: 'a'.repeat(64),
built_archive_digest: 'b'.repeat(64),
});
expect(recordSubmitted).toHaveBeenCalledWith(project.id, {
appId: 'space-cleaner',
versionId: 'version-1',
@@ -1156,13 +1189,18 @@ describe('works square host api routes', () => {
reviewStatus: 'building',
zipSha256: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(preflightCurrentProject).toHaveBeenCalledWith(tempDir);
expect(preflightStaticArtifact).toHaveBeenCalledWith(release.staticArtifact);
expect(response.json()).not.toHaveProperty('contract');
expect(JSON.stringify(response.json())).not.toContain('private-dist');
expect(JSON.stringify(response.json())).not.toContain('private-source.zip');
});
it('stops before creating or uploading when the local browser preflight fails', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-preflight-failure-'));
await writePublishableProject(tempDir);
const preflightCurrentProject = vi.fn(async () => {
const release = preparedRelease(tempDir);
prepareProjectReleaseMock.mockResolvedValueOnce(release);
const preflightStaticArtifact = vi.fn(async () => {
throw Object.assign(new Error(`${tempDir} token=secret`), {
code: 'PUBLISH_PREFLIGHT_BLANK',
});
@@ -1186,7 +1224,7 @@ describe('works square host api routes', () => {
opencodeProjectStore: {
listProjects: vi.fn(async () => [{ id: 'project-1', path: tempDir, name: 'space-cleaner' }]),
},
agentBrowser: { preflightCurrentProject },
agentBrowser: { preflightStaticArtifact },
} as never,
);
@@ -1198,10 +1236,31 @@ describe('works square host api routes', () => {
error: '作品打开后没有可见内容。',
});
expect(fetchMock).not.toHaveBeenCalled();
expect(release.dispose).toHaveBeenCalledOnce();
expect(JSON.stringify(response.json())).not.toContain(tempDir);
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it('stops before preflight or upload and disposes when the local build fails', async () => {
prepareProjectReleaseMock.mockRejectedValueOnce(Object.assign(new Error('private path token=secret'), {
name: 'ProjectReleaseBuildError',
code: 'LOCAL_BUILD_FAILED',
}));
const preflightStaticArtifact = vi.fn();
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleWorksRoutes(
createRendererRequest('POST', { projectId: 'project-1', project: { app_id: 'space-cleaner', title: 'Space', summary: 'Clean' } }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{ opencodeProjectStore: { listProjects: vi.fn(async () => [{ id: 'project-1', path: 'private-project' }]) }, agentBrowser: { preflightStaticArtifact } } as never,
);
expect(preflightStaticArtifact).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it('keeps a confirmed submission successful when the local preview mapping cannot be saved', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-mapping-failure-'));
await writePublishableProject(tempDir);
@@ -1228,7 +1287,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
worksSubmissionBinding: {
recordSubmitted: vi.fn(async () => { throw new Error('disk unavailable'); }),
},
@@ -1275,7 +1334,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
@@ -1306,7 +1365,7 @@ describe('works square host api routes', () => {
vi.stubGlobal('fetch', fetchMock);
const ctx = {
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never;
for (let index = 0; index < 2; index += 1) {
@@ -1363,7 +1422,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
@@ -1402,7 +1461,7 @@ describe('works square host api routes', () => {
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
@@ -1418,6 +1477,22 @@ describe('works square host api routes', () => {
expect(cancelSpy).toHaveBeenCalledOnce();
});
it('projects the nested FastAPI client protocol error without exposing its detail', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-protocol-required-'));
await writePublishableProject(tempDir);
const upstream = new Response(JSON.stringify({ detail: { code: 'CLIENT_BUILD_PROTOCOL_REQUIRED', message: 'private token=secret' } }), { status: 422 });
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(upstream));
const response = createResponse();
await handleWorksRoutes(
createRendererRequest('POST', { projectId: 'project-1', project: { app_id: 'space-cleaner', title: 'Space', summary: 'Clean' } }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{ opencodeProjectStore: { listProjects: vi.fn(async () => [{ id: 'project-1', path: tempDir }]) }, agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) } } as never,
);
expect(response.json()).toEqual({ success: false, status: 422, code: 'CLIENT_BUILD_PROTOCOL_REQUIRED', error: '请升级 Makelore 并重新提交。' });
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it('fails safely before packaging when the Main session is unavailable', async () => {
getValidWorksSquareAccessTokenMock.mockResolvedValueOnce(null);
const fetchMock = vi.fn();