merge: integrate remote learning module safely
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
brother7 committed 2026-08-17 01:05:49 +08:00
commit f7171a471a
125 files changed
+11451 -12132

No files matched your search

+425 -6
View File
@@ -1100,6 +1100,7 @@ describe('works square host api routes', () => {
app_id: 'space-cleaner',
title: 'Space Cleaner',
summary: 'Catch space trash',
status: 'draft',
},
latest_version: {
id: 'version-new',
@@ -1218,6 +1219,42 @@ describe('works square host api routes', () => {
expect(JSON.stringify(response.json())).not.toContain('token=secret');
});
it.each([
['missing', undefined],
['unknown', 'retired'],
])('fails safely when the project status is %s', async (_case, status) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
project: {
app_id: 'space-cleaner',
title: 'Space Cleaner',
summary: 'Catch trash',
...(status === undefined ? {} : { status }),
internal: 'private token=secret',
},
latest_version: null,
versions: [],
}), { 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([
['missing', {}],
['incorrect', { [RENDERER_CAPABILITY_HEADER]: 'incorrect-capability' }],
@@ -1265,6 +1302,7 @@ describe('works square host api routes', () => {
category: 'game',
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
.mockResolvedValueOnce(new Response(JSON.stringify(projectMetadata), { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
version_id: 'version-1',
@@ -1321,8 +1359,8 @@ describe('works square host api routes', () => {
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([
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[1]).toEqual([
'https://square.nianxx.cn/api/projects',
{
method: 'POST',
@@ -1333,7 +1371,7 @@ describe('works square host api routes', () => {
body: JSON.stringify(projectMetadata),
},
]);
const [, uploadInit] = fetchMock.mock.calls[1] as [string, RequestInit];
const [, uploadInit] = fetchMock.mock.calls[2] as [string, RequestInit];
expect(uploadInit.headers).toMatchObject({
Authorization: 'Bearer main-owned-access-token',
'Idempotency-Key': expect.stringMatching(/^makelore-/),
@@ -1366,6 +1404,380 @@ describe('works square host api routes', () => {
expect(JSON.stringify(response.json())).not.toContain('private-source.zip');
});
it('fails before upload or create when a 404 preflight cannot atomically attach the submitted cover', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-cover-publish-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const projectMetadata = {
app_id: 'space-cleaner',
title: '太空清洁队',
summary: '收集漂浮垃圾的小游戏。',
description: '清理轨道上的垃圾并完成关卡。',
cover_url: null,
creator_name: '小明',
creator_age: 12,
category: 'game',
age_band: '6-12岁',
difficulty: '入门',
};
const cover = {
fileName: 'cover.png',
mimeType: 'image/png',
dataBase64: Buffer.from('cover bytes').toString('base64'),
};
const fetchMock = vi.fn().mockResolvedValueOnce(new Response('{}', { status: 404 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: projectMetadata, cover }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 503,
code: 'WORKS_SQUARE_UNAVAILABLE',
error: '发布服务暂时无法安全保存封面,请稍后重试。',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects'))).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
});
it('preserves published metadata and does not upload an unused cover when submitting a new version', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-published-resubmit-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const submittedMetadata = {
app_id: 'space-cleaner',
title: '不会保存的新标题',
summary: '不会保存的新简介',
creator_name: '新作者',
creator_age: 12,
status: 'published',
};
const cover = {
fileName: 'replacement.png',
mimeType: 'image/png',
dataBase64: Buffer.from('unused replacement cover').toString('base64'),
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
project: {
app_id: 'space-cleaner',
title: '现有标题',
summary: '现有简介',
status: 'published',
cover_url: 'coverimage/existing.jpg',
},
latest_version: null,
versions: [],
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
version_id: 'version-2',
review_status: 'building',
build_job_id: 'job-2',
build_status: 'queued',
}), { status: 201 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', {
projectId: project.id,
project: submittedMetadata,
cover,
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({
success: true,
upload: { version_id: 'version-2' },
metadata_disposition: {
code: 'PUBLISHED_METADATA_PRESERVED',
message: '作品已发布;本次仅提交新版本,名称、简介、作者信息和封面保持不变。',
},
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'https://square.nianxx.cn/api/projects/space-cleaner/versions/upload',
);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
});
it.each([
['missing', undefined],
['unknown', 'retired'],
])('stops publishing when the ownership status is %s', async (_case, status) => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-unsafe-ownership-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const cover = {
fileName: 'replacement.png',
mimeType: 'image/png',
dataBase64: Buffer.from('must not upload').toString('base64'),
};
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
project: {
app_id: 'space-cleaner',
title: 'Existing title',
summary: 'Existing summary',
...(status === undefined ? {} : { status }),
internal: 'private token=secret',
},
latest_version: null,
versions: [],
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', {
projectId: project.id,
project: { app_id: 'space-cleaner', title: 'New title', summary: 'New summary' },
cover,
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 502,
code: 'PROJECT_OWNERSHIP_UNCONFIRMED',
error: '这个作品的归属暂时无法确认,请稍后重试。',
});
expect(JSON.stringify(response.json())).not.toContain('token=secret');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each(['draft', 'published'])('does not discard new-project form metadata when route preflight now sees %s', async (status) => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-form-race-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
project: {
app_id: 'space-cleaner',
title: 'Existing title',
summary: 'Existing summary',
status,
},
latest_version: null,
versions: [],
}), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', {
projectId: project.id,
project: { app_id: 'space-cleaner', title: 'Form title', summary: 'Form summary' },
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 409,
code: 'PROJECT_METADATA_CONFLICT',
error: '作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects'))).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
});
it('fails closed when a 404 create conflict reveals published metadata that the form did not show', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-publish-race-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const submittedMetadata = {
app_id: 'space-cleaner',
title: 'Must not replace published title',
summary: 'Must not replace published summary',
};
const ownedStatus = (status: 'published') => ({
project: {
app_id: 'space-cleaner',
title: 'Existing title',
summary: 'Existing summary',
status,
},
latest_version: null,
versions: [],
});
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
.mockResolvedValueOnce(new Response('{}', { status: 409 }))
.mockResolvedValueOnce(new Response(JSON.stringify(ownedStatus('published')), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 409,
code: 'PROJECT_METADATA_CONFLICT',
error: '作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
});
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
'https://square.nianxx.cn/api/projects',
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
]);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
});
it('fails closed without PATCH or version upload when a create conflict reconfirms draft ownership', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-draft-conflict-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const submittedMetadata = {
app_id: 'space-cleaner',
title: 'Updated title',
summary: 'Updated summary',
};
const draftStatus = {
project: {
app_id: 'space-cleaner',
title: 'Existing title',
summary: 'Existing summary',
status: 'draft',
},
latest_version: null,
versions: [],
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
.mockResolvedValueOnce(new Response('{}', { status: 409 }))
.mockResolvedValueOnce(new Response(JSON.stringify(draftStatus), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', { projectId: project.id, project: submittedMetadata }),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: false,
status: 409,
code: 'PROJECT_METADATA_CONFLICT',
error: '作品状态已变化,本次未提交版本;请重新打开发布窗口确认现有资料。',
});
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/versions/upload'))).toBe(false);
});
it('submits an existing draft as version-only without create, cover upload, or metadata PATCH', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-existing-draft-'));
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const draftStatus = {
project: {
app_id: 'space-cleaner',
title: 'Existing title',
summary: 'Existing summary',
status: 'draft',
},
latest_version: null,
versions: [],
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(draftStatus), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
version_id: 'version-2',
review_status: 'building',
build_job_id: 'job-2',
build_status: 'queued',
}), { status: 201 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
prepareProjectReleaseMock.mockResolvedValueOnce(preparedRelease(tempDir));
await handleWorksRoutes(
createRendererRequest('POST', {
projectId: project.id,
project: {
app_id: 'space-cleaner',
title: 'Existing title',
summary: 'Existing summary',
status: 'draft',
},
}),
response.res,
new URL('http://127.0.0.1/api/works/projects/publish-source'),
{
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
agentBrowser: { preflightStaticArtifact: vi.fn(async () => ({ ok: true })) },
} as never,
);
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({ success: true, upload: { version_id: 'version-2' } });
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
'https://square.nianxx.cn/api/projects/mine/space-cleaner/status',
'https://square.nianxx.cn/api/projects/space-cleaner/versions/upload',
]);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/projects/covers'))).toBe(false);
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
});
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);
@@ -1437,6 +1849,7 @@ describe('works square host api routes', () => {
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
.mockResolvedValueOnce(new Response('{}', { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
version_id: 'version-1',
@@ -1475,7 +1888,7 @@ describe('works square host api routes', () => {
},
});
expect(JSON.stringify(response.json())).not.toContain('disk unavailable');
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('uses a bounded timestamp version when VERSION.md is oversized', async () => {
@@ -1484,6 +1897,7 @@ describe('works square host api routes', () => {
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: 404 }))
.mockResolvedValueOnce(new Response('{}', { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
version_id: 'version-1',
@@ -1510,7 +1924,7 @@ describe('works square host api routes', () => {
);
expect(response.json().success).toBe(true);
const uploadInit = fetchMock.mock.calls[1]?.[1] as RequestInit;
const uploadInit = fetchMock.mock.calls[2]?.[1] as RequestInit;
expect((uploadInit.body as FormData).get('version_name')).toMatch(/^v\d{14}$/);
});
@@ -1527,9 +1941,11 @@ describe('works square host api routes', () => {
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: 404 }))
.mockResolvedValueOnce(new Response('{}', { status: 201 }))
.mockResolvedValueOnce(retryResponse)
.mockResolvedValueOnce(new Response(uploadPayload, { status: 201 }))
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
.mockResolvedValueOnce(new Response('{}', { status: 201 }))
.mockRejectedValueOnce(new Error('socket reset with secret upstream detail'))
.mockResolvedValueOnce(new Response(uploadPayload, { status: 201 }));
@@ -1571,6 +1987,7 @@ describe('works square host api routes', () => {
await writePublishableProject(tempDir);
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
.mockResolvedValueOnce(new Response('{}', { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
review_status: 'building',
@@ -1616,7 +2033,9 @@ describe('works square host api routes', () => {
token: 'secret-token',
}), { status: 422 });
const cancelSpy = vi.spyOn(upstream.body!, 'cancel');
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(upstream));
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(new Response('{}', { status: 404 }))
.mockResolvedValueOnce(upstream));
const response = createResponse();
await handleWorksRoutes(