收敛客户端静态发布链路
This commit is contained in:
@@ -52,14 +52,14 @@ function createContext(deployment: Record<string, unknown> | null, activeProject
|
||||
path: 'D:/projects/planet-game',
|
||||
})),
|
||||
},
|
||||
worksCloudDeployment: {
|
||||
worksSubmissionBinding: {
|
||||
get: vi.fn(async () => deployment),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const submittedDeployment = {
|
||||
schema_version: 1,
|
||||
schema_version: 2,
|
||||
project_id: 'project-1',
|
||||
status: 'submitted',
|
||||
requested_at: '2026-08-03T01:00:00.000Z',
|
||||
@@ -78,7 +78,8 @@ function matchingRemotePayload(overrides: {
|
||||
project: {
|
||||
app_id: 'planet-game',
|
||||
playable: true,
|
||||
runtime_url: '/apps/planet-game/',
|
||||
play_url: '/apps/planet-game/',
|
||||
runtime_url: '/apps/legacy-planet-game/',
|
||||
version_name: 'v0.7.0',
|
||||
...overrides.project,
|
||||
},
|
||||
@@ -144,7 +145,39 @@ describe('device preview Host API route', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns only a trusted absolute HTTPS runtime URL for the submitted version', async () => {
|
||||
it('explains that a legacy automatic deployment must be resubmitted', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext({
|
||||
schema_version: 2,
|
||||
project_id: 'project-1',
|
||||
status: 'legacy_retired',
|
||||
requested_at: '2026-08-01T00:00:00.000Z',
|
||||
updated_at: '2026-08-10T00:00:00.000Z',
|
||||
error_code: 'LEGACY_AUTO_DEPLOY_RETIRED',
|
||||
message: '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
preview: {
|
||||
state: 'unavailable',
|
||||
projectId: 'project-1',
|
||||
updatedAt: '2026-08-10T00:00:00.000Z',
|
||||
message: '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers the trusted static play URL over the legacy runtime alias', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload()),
|
||||
{ status: 200 },
|
||||
@@ -185,11 +218,35 @@ describe('device preview Host API route', () => {
|
||||
expect(JSON.stringify(response.json())).not.toContain('managed-secret-token');
|
||||
});
|
||||
|
||||
it('keeps the legacy runtime alias as a one-release fallback', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
project: { play_url: null, runtime_url: '/apps/runtime-fallback/' },
|
||||
})),
|
||||
{ status: 200 },
|
||||
)));
|
||||
const response = createResponse();
|
||||
|
||||
await handleDevicePreviewRoutes(
|
||||
createRequest(),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'),
|
||||
createContext(submittedDeployment),
|
||||
);
|
||||
|
||||
expect(response.json()).toMatchObject({
|
||||
preview: {
|
||||
state: 'ready',
|
||||
launchUrl: 'https://square.nianxx.cn/apps/runtime-fallback/',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates an exact short-lived owner preview for a pending-review release', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
project: { playable: false, runtime_url: null },
|
||||
project: { playable: false, play_url: null, runtime_url: null },
|
||||
latestVersion: { review_status: 'pending_review' },
|
||||
})),
|
||||
{ status: 200 },
|
||||
@@ -237,7 +294,7 @@ describe('device preview Host API route', () => {
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
project: { playable: false, runtime_url: null },
|
||||
project: { playable: false, play_url: null, runtime_url: null },
|
||||
latestVersion: { review_status: 'pending_review' },
|
||||
})),
|
||||
{ status: 200 },
|
||||
@@ -262,9 +319,9 @@ describe('device preview Host API route', () => {
|
||||
it.each([
|
||||
['an untrusted origin', 'https://evil.example/steal'],
|
||||
['an overlong URL', `https://square.nianxx.cn/apps/${'x'.repeat(1_100)}`],
|
||||
])('refuses a runtime URL from %s', async (_case, runtimeUrl) => {
|
||||
])('refuses a play URL from %s', async (_case, playUrl) => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({ project: { runtime_url: runtimeUrl } })),
|
||||
JSON.stringify(matchingRemotePayload({ project: { play_url: playUrl } })),
|
||||
{ status: 200 },
|
||||
)));
|
||||
const response = createResponse();
|
||||
@@ -445,7 +502,7 @@ describe('device preview Host API route', () => {
|
||||
const previewUrl = 'https://square.nianxx.cn/apps/planet-game/preview';
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(
|
||||
JSON.stringify(matchingRemotePayload({
|
||||
project: { runtime_url: undefined, preview_url: previewUrl },
|
||||
project: { play_url: undefined, runtime_url: undefined, preview_url: previewUrl },
|
||||
})),
|
||||
{ status: 200 },
|
||||
)));
|
||||
|
||||
@@ -1063,9 +1063,12 @@ describe('OpencodeManager', () => {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
||||
const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-'));
|
||||
const retiredSkillDir = join(userDataDir, 'opencode', 'niancode-config', 'skills', 'student-growth-logger');
|
||||
const retiredDeploySkillDir = join(userDataDir, 'opencode', 'niancode-config', 'skills', 'deploy-publish-check');
|
||||
try {
|
||||
mkdirSync(retiredSkillDir, { recursive: true });
|
||||
mkdirSync(retiredDeploySkillDir, { recursive: true });
|
||||
writeFileSync(join(retiredSkillDir, 'SKILL.md'), '---\nname: student-growth-logger\n---\n');
|
||||
writeFileSync(join(retiredDeploySkillDir, 'SKILL.md'), '---\nname: deploy-publish-check\n---\n');
|
||||
|
||||
const { children, spawn } = createSpawnHarness();
|
||||
const manager = new OpencodeManager({
|
||||
@@ -1085,6 +1088,7 @@ describe('OpencodeManager', () => {
|
||||
await startPromise;
|
||||
|
||||
expect(existsSync(retiredSkillDir)).toBe(false);
|
||||
expect(existsSync(retiredDeploySkillDir)).toBe(false);
|
||||
} finally {
|
||||
rmSync(userDataDir, { recursive: true, force: true });
|
||||
rmSync(bundledCourseSkillsDir, { recursive: true, force: true });
|
||||
|
||||
@@ -953,217 +953,19 @@ describe('opencode host api routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a missing root works-publish.json for a known project', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-works-publish-missing-'));
|
||||
try {
|
||||
const project = {
|
||||
id: 'prj_publish_missing',
|
||||
path: projectPath,
|
||||
name: 'missing-publish',
|
||||
createdAt: '2026-07-06T09:00:00.000Z',
|
||||
updatedAt: '2026-07-06T09:00:00.000Z',
|
||||
lastOpenedAt: '2026-07-06T09:00:00.000Z',
|
||||
};
|
||||
const response = createResponse();
|
||||
|
||||
it.each(['works-publish', 'works-deploy-check', 'works-cloud-deploy'])(
|
||||
'does not expose the retired %s coordination route',
|
||||
async (route) => {
|
||||
const handled = await handleOpencodeRoutes(
|
||||
createRequest('GET'),
|
||||
response.res,
|
||||
new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-publish`),
|
||||
{
|
||||
opencodeProjectStore: {
|
||||
listProjects: vi.fn(async () => [project]),
|
||||
},
|
||||
} as never,
|
||||
createResponse().res,
|
||||
new URL(`http://127.0.0.1/api/opencode/projects/project-1/${route}`),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
status: 'missing',
|
||||
filePath: join(projectPath, 'works-publish.json'),
|
||||
});
|
||||
} finally {
|
||||
await rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports invalid root works-publish.json content', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-works-publish-invalid-'));
|
||||
try {
|
||||
await writeFile(join(projectPath, 'works-publish.json'), '{"title":"No app id"}\n', 'utf8');
|
||||
const project = {
|
||||
id: 'prj_publish_invalid',
|
||||
path: projectPath,
|
||||
name: 'invalid-publish',
|
||||
createdAt: '2026-07-06T09:00:00.000Z',
|
||||
updatedAt: '2026-07-06T09:00:00.000Z',
|
||||
lastOpenedAt: '2026-07-06T09:00:00.000Z',
|
||||
};
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleOpencodeRoutes(
|
||||
createRequest('GET'),
|
||||
response.res,
|
||||
new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-publish`),
|
||||
{
|
||||
opencodeProjectStore: {
|
||||
listProjects: vi.fn(async () => [project]),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
status: 'invalid',
|
||||
filePath: join(projectPath, 'works-publish.json'),
|
||||
});
|
||||
expect(String((response.json() as { error?: string }).error)).toContain('Missing app_id');
|
||||
} finally {
|
||||
await rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reads and normalizes root works-publish.json for a known project', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-works-publish-ready-'));
|
||||
try {
|
||||
await writeFile(
|
||||
join(projectPath, 'works-publish.json'),
|
||||
`${JSON.stringify({
|
||||
app_id: 'course-app',
|
||||
title: 'Course App',
|
||||
summary: 'A short summary',
|
||||
category: 'web',
|
||||
age_band: '8-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: 'First upload',
|
||||
zip_file_path: 'D:/repo/course-app/dist/course-app.zip',
|
||||
}, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
const project = {
|
||||
id: 'prj_publish_ready',
|
||||
path: projectPath,
|
||||
name: 'ready-publish',
|
||||
createdAt: '2026-07-06T09:00:00.000Z',
|
||||
updatedAt: '2026-07-06T09:00:00.000Z',
|
||||
lastOpenedAt: '2026-07-06T09:00:00.000Z',
|
||||
};
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleOpencodeRoutes(
|
||||
createRequest('GET'),
|
||||
response.res,
|
||||
new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-publish`),
|
||||
{
|
||||
opencodeProjectStore: {
|
||||
listProjects: vi.fn(async () => [project]),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
status: 'ready',
|
||||
filePath: join(projectPath, 'works-publish.json'),
|
||||
publish: {
|
||||
app_id: 'course-app',
|
||||
title: 'Course App',
|
||||
summary: 'A short summary',
|
||||
category: 'web',
|
||||
age_band: '8-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: 'First upload',
|
||||
zip_file_path: 'D:/repo/course-app/dist/course-app.zip',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a missing machine deployment check for a known project', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-deploy-check-missing-'));
|
||||
try {
|
||||
const project = {
|
||||
id: 'prj_deploy_check_missing',
|
||||
path: projectPath,
|
||||
name: 'missing-deploy-check',
|
||||
createdAt: '2026-07-12T09:00:00.000Z',
|
||||
updatedAt: '2026-07-12T09:00:00.000Z',
|
||||
lastOpenedAt: '2026-07-12T09:00:00.000Z',
|
||||
};
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleOpencodeRoutes(
|
||||
createRequest('GET'),
|
||||
response.res,
|
||||
new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-deploy-check`),
|
||||
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
status: 'missing',
|
||||
filePath: join(projectPath, 'works-deploy-check.json'),
|
||||
});
|
||||
} finally {
|
||||
await rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('arms and reads the Main-owned cloud deployment coordinator', async () => {
|
||||
const project = {
|
||||
id: 'prj_cloud_deploy',
|
||||
path: 'D:/repo/cloud-deploy',
|
||||
name: 'cloud deploy',
|
||||
createdAt: '2026-07-12T09:00:00.000Z',
|
||||
updatedAt: '2026-07-12T09:00:00.000Z',
|
||||
lastOpenedAt: '2026-07-12T09:00:00.000Z',
|
||||
};
|
||||
const deployment = {
|
||||
schema_version: 1,
|
||||
project_id: project.id,
|
||||
status: 'armed' as const,
|
||||
requested_at: '2026-07-12T09:00:00.000Z',
|
||||
updated_at: '2026-07-12T09:00:00.000Z',
|
||||
};
|
||||
const coordinator = {
|
||||
arm: vi.fn(async () => deployment),
|
||||
get: vi.fn(async () => deployment),
|
||||
};
|
||||
const context = {
|
||||
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
|
||||
worksCloudDeployment: coordinator,
|
||||
} as never;
|
||||
|
||||
const postResponse = createResponse();
|
||||
expect(await handleOpencodeRoutes(
|
||||
createRequest('POST'),
|
||||
postResponse.res,
|
||||
new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-cloud-deploy`),
|
||||
context,
|
||||
)).toBe(true);
|
||||
expect(postResponse.statusCode).toBe(202);
|
||||
expect(postResponse.json()).toEqual({ success: true, deployment });
|
||||
expect(coordinator.arm).toHaveBeenCalledWith(project.id);
|
||||
|
||||
const getResponse = createResponse();
|
||||
expect(await handleOpencodeRoutes(
|
||||
createRequest('GET'),
|
||||
getResponse.res,
|
||||
new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-cloud-deploy`),
|
||||
context,
|
||||
)).toBe(true);
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
expect(getResponse.json()).toEqual({ success: true, deployment });
|
||||
expect(coordinator.get).toHaveBeenCalledWith(project.id);
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('lists sessions through the active project directory', async () => {
|
||||
const response = createResponse();
|
||||
|
||||
@@ -40,17 +40,17 @@ async function writeLegacyPromotionPlan(projectPath: string, content: string): P
|
||||
await writeFile(join(projectPath, 'PROMOTION_PLAN.md'), content, 'utf8');
|
||||
}
|
||||
|
||||
function createServerPublishFile(appId: string): string {
|
||||
function createServerBindingFile(projectId: string, appId: string): string {
|
||||
return `${JSON.stringify({
|
||||
schema_version: 2,
|
||||
project_id: projectId,
|
||||
status: 'submitted',
|
||||
requested_at: '2026-08-10T00:00:00.000Z',
|
||||
updated_at: '2026-08-10T00:00:00.000Z',
|
||||
app_id: appId,
|
||||
title: '测试游戏',
|
||||
summary: '合规同步测试',
|
||||
category: 'game',
|
||||
age_band: '10-16',
|
||||
difficulty: 'beginner',
|
||||
version_id: 'version-1',
|
||||
version_name: 'v0.1.0',
|
||||
change_log: 'test',
|
||||
zip_file_path: 'dist/test.zip',
|
||||
review_status: 'building',
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
@@ -95,11 +95,14 @@ describe('project progress compliance synchronization', () => {
|
||||
const localPath = await createProjectDirectory('local');
|
||||
await writeGameDocuments(serverPath, '# server gdd', '# server tasks');
|
||||
await writeGameDocuments(localPath, '# local gdd', '# local tasks');
|
||||
await writeFile(join(serverPath, 'works-publish.json'), createServerPublishFile('server-game'), 'utf8');
|
||||
|
||||
const projectStore = createProjectStore(createMemoryProjectStorage());
|
||||
const serverProject = await projectStore.rememberProject(serverPath);
|
||||
const localProject = await projectStore.rememberProject(localPath);
|
||||
await writeFile(
|
||||
join(serverPath, 'works-cloud-deploy.json'),
|
||||
createServerBindingFile(serverProject.id, 'server-game'),
|
||||
'utf8',
|
||||
);
|
||||
const harness = createWatcherHarness();
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const sync = createProjectProgressSync(projectStore, {
|
||||
@@ -129,7 +132,7 @@ describe('project progress compliance synchronization', () => {
|
||||
project_key: localProject.id,
|
||||
});
|
||||
|
||||
const serverChanged = harness.callbacks.get(serverPath);
|
||||
const serverChanged = harness.callbacks.get(serverProject.path);
|
||||
expect(serverChanged).toBeDefined();
|
||||
serverChanged?.('GDD.md');
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
@@ -149,10 +152,13 @@ describe('project progress compliance synchronization', () => {
|
||||
const productOverview = '# 产品运营介绍\n\n一句话价值:让玩家在三分钟内完成一次太空清洁任务。';
|
||||
await writeGameDocuments(projectPath, '# gdd', '# tasks');
|
||||
await writeProductOverview(projectPath, productOverview);
|
||||
await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('promotion-game'), 'utf8');
|
||||
|
||||
const projectStore = createProjectStore(createMemoryProjectStorage());
|
||||
const project = await projectStore.rememberProject(projectPath);
|
||||
await writeFile(
|
||||
join(projectPath, 'works-cloud-deploy.json'),
|
||||
createServerBindingFile(project.id, 'promotion-game'),
|
||||
'utf8',
|
||||
);
|
||||
const harness = createWatcherHarness();
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const sync = createProjectProgressSync(projectStore, {
|
||||
@@ -180,10 +186,13 @@ describe('project progress compliance synchronization', () => {
|
||||
await writeGameDocuments(projectPath, '# gdd', '# tasks');
|
||||
await writeProductOverview(projectPath, productOverview);
|
||||
await writeLegacyPromotionPlan(projectPath, '# 旧运营宣传计划\n\n不应覆盖新文档');
|
||||
await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('overview-preferred-game'), 'utf8');
|
||||
|
||||
const projectStore = createProjectStore(createMemoryProjectStorage());
|
||||
const project = await projectStore.rememberProject(projectPath);
|
||||
await writeFile(
|
||||
join(projectPath, 'works-cloud-deploy.json'),
|
||||
createServerBindingFile(project.id, 'overview-preferred-game'),
|
||||
'utf8',
|
||||
);
|
||||
const harness = createWatcherHarness();
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const sync = createProjectProgressSync(projectStore, {
|
||||
@@ -206,10 +215,13 @@ describe('project progress compliance synchronization', () => {
|
||||
const legacyPromotionPlan = '# 旧运营宣传计划\n\n卖点:用简单操作理解环保行动。';
|
||||
await writeGameDocuments(projectPath, '# gdd', '# tasks');
|
||||
await writeLegacyPromotionPlan(projectPath, legacyPromotionPlan);
|
||||
await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('legacy-promotion-game'), 'utf8');
|
||||
|
||||
const projectStore = createProjectStore(createMemoryProjectStorage());
|
||||
const project = await projectStore.rememberProject(projectPath);
|
||||
await writeFile(
|
||||
join(projectPath, 'works-cloud-deploy.json'),
|
||||
createServerBindingFile(project.id, 'legacy-promotion-game'),
|
||||
'utf8',
|
||||
);
|
||||
const harness = createWatcherHarness();
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
const sync = createProjectProgressSync(projectStore, {
|
||||
@@ -250,8 +262,12 @@ describe('project progress compliance synchronization', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/agent/prompt'))).toBe(false);
|
||||
|
||||
await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('bound-promotion-game'), 'utf8');
|
||||
harness.callbacks.get(projectPath)?.('works-publish.json');
|
||||
await writeFile(
|
||||
join(projectPath, 'works-cloud-deploy.json'),
|
||||
createServerBindingFile(project.id, 'bound-promotion-game'),
|
||||
'utf8',
|
||||
);
|
||||
harness.callbacks.get(project.path)?.('works-cloud-deploy.json');
|
||||
await sync.flushProject(project.id);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
@@ -268,10 +284,13 @@ describe('project progress compliance synchronization', () => {
|
||||
const secondOverview = '# 更新后的产品运营介绍\n\n新增真实试玩证据。';
|
||||
await writeGameDocuments(projectPath, '# gdd', '# tasks');
|
||||
await writeProductOverview(projectPath, firstOverview);
|
||||
await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('retry-promotion-game'), 'utf8');
|
||||
|
||||
const projectStore = createProjectStore(createMemoryProjectStorage());
|
||||
const project = await projectStore.rememberProject(projectPath);
|
||||
await writeFile(
|
||||
join(projectPath, 'works-cloud-deploy.json'),
|
||||
createServerBindingFile(project.id, 'retry-promotion-game'),
|
||||
'utf8',
|
||||
);
|
||||
const harness = createWatcherHarness();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
|
||||
@@ -396,13 +415,13 @@ describe('project progress compliance synchronization', () => {
|
||||
synchronizers.push(sync);
|
||||
|
||||
await sync.start();
|
||||
expect(harness.closeMocks.get(projectPath)).toBeDefined();
|
||||
expect(harness.closeMocks.get(project.path)).toBeDefined();
|
||||
await projectStore.removeProject(project.id);
|
||||
expect(sync.getWatchedProjectIds()).toEqual([]);
|
||||
expect(harness.closeMocks.get(projectPath)).toHaveBeenCalledTimes(1);
|
||||
expect(harness.closeMocks.get(project.path)).toHaveBeenCalledTimes(1);
|
||||
|
||||
sync.stop();
|
||||
expect(harness.closeMocks.get(projectPath)).toHaveBeenCalledTimes(1);
|
||||
expect(harness.closeMocks.get(project.path)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not expose document content in error logs or request URLs', async () => {
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { promisify } from 'node:util';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createWorksCloudDeployment } from '@electron/services/works-cloud-deployment';
|
||||
import type { OpencodeProject } from '@electron/opencode/project-store';
|
||||
import { WORKS_CLOUD_DEPLOYMENT_FILE_NAME } from '../../shared/works-cloud-deployment';
|
||||
import { REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS } from '../../shared/works-square-deploy-check';
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const project: OpencodeProject = {
|
||||
id: 'prj_cloud_deployment_test',
|
||||
path: '',
|
||||
name: 'cloud deployment test',
|
||||
createdAt: '2026-07-12T09:00:00.000Z',
|
||||
updatedAt: '2026-07-12T09:00:00.000Z',
|
||||
lastOpenedAt: '2026-07-12T09:00:00.000Z',
|
||||
};
|
||||
|
||||
function createStore(projectRecord: OpencodeProject) {
|
||||
return {
|
||||
listProjects: vi.fn(async () => [projectRecord]),
|
||||
subscribe: vi.fn(() => () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function createReadyProject(): Promise<{ projectPath: string; zipPath: string }> {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-cloud-deployment-'));
|
||||
tempDirectories.push(projectPath);
|
||||
const sourcePath = join(projectPath, 'package-source');
|
||||
const zipPath = join(projectPath, 'cloud-upload.zip');
|
||||
await mkdir(sourcePath, { recursive: true });
|
||||
const files: Record<string, string> = {
|
||||
'niancode.yml': [
|
||||
'runtime: compose',
|
||||
'compose:',
|
||||
' file: docker-compose.yml',
|
||||
' public_service: frontend',
|
||||
' public_port: 8080',
|
||||
].join('\n'),
|
||||
'docker-compose.yml': [
|
||||
'services:',
|
||||
' frontend:',
|
||||
' build:',
|
||||
' context: .',
|
||||
' dockerfile: Dockerfile',
|
||||
' ports:',
|
||||
' - "127.0.0.1::8080"',
|
||||
].join('\n'),
|
||||
Dockerfile: [
|
||||
'FROM node:20-alpine AS build',
|
||||
'WORKDIR /app',
|
||||
'COPY package.json package-lock.json ./',
|
||||
'RUN npm ci',
|
||||
'COPY . .',
|
||||
'RUN npm run build',
|
||||
'FROM nginx:1.27-alpine',
|
||||
'COPY nginx.conf /etc/nginx/conf.d/default.conf',
|
||||
'COPY --from=build /app/dist /usr/share/nginx/html',
|
||||
'EXPOSE 8080',
|
||||
'CMD ["nginx", "-g", "daemon off;"]',
|
||||
].join('\n'),
|
||||
'package.json': JSON.stringify({ scripts: { build: 'vite build' }, devDependencies: { vite: '^7.0.0' } }),
|
||||
'package-lock.json': '{}',
|
||||
'vite.config.js': 'export default { base: "./" };',
|
||||
'index.html': '<script type="module" src="./assets/index.js"></script>',
|
||||
'nginx.conf': 'server { listen 0.0.0.0:8080; }',
|
||||
'src/main.ts': 'console.log("ready");',
|
||||
};
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const filePath = join(sourcePath, name);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
const zipCommand = process.platform === 'win32'
|
||||
? { file: 'tar', args: ['-a', '-cf', zipPath, ...Object.keys(files)] }
|
||||
: { file: 'zip', args: ['-q', '-0', '-r', zipPath, '.'] };
|
||||
await execFileAsync(zipCommand.file, zipCommand.args, { cwd: sourcePath });
|
||||
const zipSha256 = createHash('sha256').update(await readFile(zipPath)).digest('hex');
|
||||
const publish = {
|
||||
app_id: 'cloud-deployment-test',
|
||||
title: '云端部署测试',
|
||||
summary: '用于测试 Main 自动提交。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '自动提交测试',
|
||||
zip_file_path: zipPath,
|
||||
};
|
||||
await writeFile(join(projectPath, 'works-publish.json'), `${JSON.stringify(publish, null, 2)}\n`, 'utf8');
|
||||
await writeFile(join(projectPath, '部署报告.md'), '# PASS\n本机动态检查已执行。\n', 'utf8');
|
||||
const checks = Object.fromEntries(REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS.map((id) => [id, {
|
||||
status: 'PASS',
|
||||
detail: `${id} 通过`,
|
||||
command: `check ${id}`,
|
||||
execution: 'executed',
|
||||
}]));
|
||||
await writeFile(join(projectPath, 'works-deploy-check.json'), `${JSON.stringify({
|
||||
schema_version: 1,
|
||||
status: 'PASS',
|
||||
checked_at: '2026-07-12T00:00:00.000Z',
|
||||
zip_file_path: zipPath,
|
||||
zip_sha256: zipSha256,
|
||||
checks,
|
||||
}, null, 2)}\n`, 'utf8');
|
||||
return { projectPath, zipPath };
|
||||
}
|
||||
|
||||
async function waitForStatus(projectPath: string, expected: string): Promise<Record<string, unknown>> {
|
||||
let lastRecord: Record<string, unknown> | undefined;
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
try {
|
||||
const record = JSON.parse(await readFile(join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME), 'utf8')) as Record<string, unknown>;
|
||||
lastRecord = record;
|
||||
if (record.status === expected) return record;
|
||||
if (record.status === 'failed') {
|
||||
throw new Error(`Deployment failed while waiting for ${expected}: ${String(record.error)}`);
|
||||
}
|
||||
} catch {
|
||||
// The coordinator may not have written the first status yet.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${expected}; last record: ${JSON.stringify(lastRecord)}`);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('Main-owned Works Square cloud deployment', () => {
|
||||
it('records an already submitted one-click release without starting another upload', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-cloud-record-'));
|
||||
tempDirectories.push(projectPath);
|
||||
const projectRecord = { ...project, path: projectPath };
|
||||
const fetchMock = vi.fn();
|
||||
const coordinator = createWorksCloudDeployment(createStore(projectRecord), {
|
||||
apiBaseUrl: 'https://square.test',
|
||||
fetchImpl: fetchMock,
|
||||
watchDirectory: () => ({ close: vi.fn() }),
|
||||
});
|
||||
|
||||
const record = await coordinator.recordSubmitted(projectRecord.id, {
|
||||
appId: 'makelore-project',
|
||||
versionId: 'version-remote-7',
|
||||
versionName: 'v2.0.0',
|
||||
reviewStatus: 'building',
|
||||
zipSha256: 'a'.repeat(64),
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
project_id: projectRecord.id,
|
||||
status: 'submitted',
|
||||
app_id: 'makelore-project',
|
||||
version_id: 'version-remote-7',
|
||||
version_name: 'v2.0.0',
|
||||
review_status: 'building',
|
||||
zip_sha256: 'a'.repeat(64),
|
||||
});
|
||||
await expect(coordinator.get(projectRecord.id)).resolves.toEqual(record);
|
||||
expect(JSON.parse(await readFile(
|
||||
join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME),
|
||||
'utf8',
|
||||
))).toEqual(record);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an armed task waiting for the package instead of asking for server SSH', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-cloud-waiting-'));
|
||||
tempDirectories.push(projectPath);
|
||||
const projectRecord = { ...project, path: projectPath };
|
||||
const coordinator = createWorksCloudDeployment(createStore(projectRecord), {
|
||||
apiBaseUrl: 'https://square.test',
|
||||
debounceMs: 0,
|
||||
getAccessToken: async () => 'access-token',
|
||||
watchDirectory: () => ({ close: vi.fn() }),
|
||||
});
|
||||
await coordinator.start();
|
||||
await coordinator.arm(projectRecord.id);
|
||||
|
||||
const record = await waitForStatus(projectPath, 'waiting_for_package');
|
||||
expect(record.error).toBeUndefined();
|
||||
coordinator.stop();
|
||||
});
|
||||
|
||||
it('submits a ready ZIP from Main without exposing the access token to the Agent', async () => {
|
||||
const { projectPath, zipPath } = await createReadyProject();
|
||||
const projectRecord = { ...project, path: projectPath };
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 201 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ version_id: 'version-remote-1', review_status: 'building' }), { status: 201 }));
|
||||
const coordinator = createWorksCloudDeployment(createStore(projectRecord), {
|
||||
apiBaseUrl: 'https://square.test',
|
||||
debounceMs: 0,
|
||||
fetchImpl: fetchMock,
|
||||
getAccessToken: async () => 'access-token',
|
||||
watchDirectory: () => ({ close: vi.fn() }),
|
||||
});
|
||||
await coordinator.start();
|
||||
await coordinator.arm(projectRecord.id);
|
||||
|
||||
const record = await waitForStatus(projectPath, 'submitted');
|
||||
expect(record).toMatchObject({
|
||||
project_id: projectRecord.id,
|
||||
app_id: 'cloud-deployment-test',
|
||||
version_id: 'version-remote-1',
|
||||
review_status: 'building',
|
||||
});
|
||||
expect(record.zip_sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://square.test/api/projects');
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://square.test/api/projects/cloud-deployment-test/versions/upload');
|
||||
expect((fetchMock.mock.calls[1]?.[1] as RequestInit).headers).toEqual({ Authorization: 'Bearer access-token' });
|
||||
const form = (fetchMock.mock.calls[1]?.[1] as RequestInit).body as FormData;
|
||||
expect(form.get('version_name')).toBe('v1.0.0');
|
||||
expect(form.get('change_log')).toBe('自动提交测试');
|
||||
expect((form.get('archive') as File).name).toBe('cloud-upload.zip');
|
||||
expect(zipPath).toContain('cloud-upload.zip');
|
||||
coordinator.stop();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDeployProductBuildPrompt } from '@/lib/works-publish-helpers';
|
||||
|
||||
describe('deployment product build prompt', () => {
|
||||
it('requires three-state machine evidence without blocking on missing local tools', () => {
|
||||
const prompt = buildDeployProductBuildPrompt({
|
||||
projectName: '测试作品',
|
||||
projectPath: '/tmp/test-project',
|
||||
zipPath: null,
|
||||
});
|
||||
|
||||
expect(prompt).toContain('works-deploy-check.json');
|
||||
expect(prompt).toContain('compose_config');
|
||||
expect(prompt).toContain('fresh_directory_smoke');
|
||||
expect(prompt).toContain('sandbox_browser_smoke');
|
||||
expect(prompt).toContain('execution: unavailable');
|
||||
expect(prompt).toContain('Main 会在交接文件就绪');
|
||||
expect(prompt).toContain('不要让用户去服务器手动启动 Docker');
|
||||
expect(prompt).toContain('PASS、SKIPPED、BLOCKED');
|
||||
expect(prompt).toContain('Docker 或浏览器自动化不可用时写 SKIPPED');
|
||||
expect(prompt).toContain('zip_sha256');
|
||||
expect(prompt).toContain('哈希不一致仍然 BLOCKED');
|
||||
});
|
||||
});
|
||||
@@ -7,18 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleWorksRoutes } from '@electron/api/routes/works';
|
||||
import { createProjectConfig } from '../../shared/project-config';
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
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),
|
||||
}));
|
||||
@@ -87,8 +77,6 @@ describe('works square host api routes', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
readWorksPublishFileMock.mockReset();
|
||||
readWorksDeployCheckMock.mockReset();
|
||||
getValidWorksSquareAccessTokenMock.mockReset();
|
||||
getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token');
|
||||
});
|
||||
@@ -708,6 +696,7 @@ describe('works square host api routes', () => {
|
||||
status: 'draft',
|
||||
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
||||
playable: false,
|
||||
play_url: '/apps/space-cleaner/',
|
||||
runtime_url: null,
|
||||
owner_email: 'private@example.com',
|
||||
},
|
||||
@@ -754,6 +743,7 @@ describe('works square host api routes', () => {
|
||||
status: 'draft',
|
||||
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
||||
playable: false,
|
||||
play_url: '/apps/space-cleaner/',
|
||||
runtime_url: null,
|
||||
},
|
||||
latest_version: {
|
||||
@@ -785,6 +775,23 @@ describe('works square host api routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose the retired direct ZIP upload route', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleWorksRoutes(
|
||||
createRequest('POST', {}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/space-cleaner/versions/upload'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads current project status with the Main-owned session when no renderer token is sent', async () => {
|
||||
const status = {
|
||||
project: {
|
||||
@@ -904,126 +911,6 @@ describe('works square host api routes', () => {
|
||||
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');
|
||||
await writeFile(zipPath, Buffer.from('zip bytes'));
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1',
|
||||
review_status: 'building',
|
||||
}), { status: 201 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' };
|
||||
readWorksPublishFileMock.mockResolvedValue({
|
||||
status: 'ready',
|
||||
filePath: join(tempDir, 'works-publish.json'),
|
||||
publish: {
|
||||
app_id: 'space-cleaner',
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
category: 'game',
|
||||
age_band: '8-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: 'First submitted version',
|
||||
zip_file_path: zipPath,
|
||||
},
|
||||
});
|
||||
readWorksDeployCheckMock.mockResolvedValue({
|
||||
status: deployStatus,
|
||||
filePath: join(tempDir, 'works-deploy-check.json'),
|
||||
checks: {},
|
||||
});
|
||||
|
||||
const handled = await handleWorksRoutes(
|
||||
createRequest('POST', {
|
||||
accessToken: 'access-token',
|
||||
projectId: project.id,
|
||||
versionName: 'v1.0.0',
|
||||
changeLog: 'First submitted version',
|
||||
zipFilePath: zipPath,
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/space-cleaner/versions/upload'),
|
||||
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
upload: {
|
||||
version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1',
|
||||
review_status: 'building',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://square.nianxx.cn/api/projects/space-cleaner/versions/upload');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({ Authorization: 'Bearer access-token' });
|
||||
expect(init.body).toBeInstanceOf(FormData);
|
||||
|
||||
const form = init.body as FormData;
|
||||
expect(form.get('version_name')).toBe('v1.0.0');
|
||||
expect(form.get('change_log')).toBe('First submitted version');
|
||||
const archive = form.get('archive');
|
||||
expect(archive).toBeInstanceOf(File);
|
||||
expect((archive as File).name).toBe('project.zip');
|
||||
expect((archive as File).type).toBe('application/zip');
|
||||
});
|
||||
|
||||
it('blocks upload when the deployment check is not PASS', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'niancode-works-upload-blocked-'));
|
||||
const zipPath = join(tempDir, 'project.zip');
|
||||
await writeFile(zipPath, Buffer.from('zip bytes'));
|
||||
const project = { id: 'project-blocked', path: tempDir, name: 'blocked' };
|
||||
readWorksPublishFileMock.mockResolvedValue({
|
||||
status: 'ready',
|
||||
filePath: join(tempDir, 'works-publish.json'),
|
||||
publish: {
|
||||
app_id: 'blocked-app',
|
||||
title: '阻断测试',
|
||||
summary: '阻断测试',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '阻断测试',
|
||||
zip_file_path: zipPath,
|
||||
},
|
||||
});
|
||||
readWorksDeployCheckMock.mockResolvedValue({
|
||||
status: 'blocked',
|
||||
filePath: join(tempDir, 'works-deploy-check.json'),
|
||||
error: 'HTTP smoke 失败',
|
||||
});
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleWorksRoutes(
|
||||
createRequest('POST', {
|
||||
accessToken: 'access-token',
|
||||
projectId: project.id,
|
||||
versionName: 'v1.0.0',
|
||||
changeLog: '阻断测试',
|
||||
zipFilePath: zipPath,
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/blocked-app/versions/upload'),
|
||||
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ success: false, error: 'BLOCKED: HTTP smoke 失败' });
|
||||
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);
|
||||
@@ -1054,7 +941,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]) },
|
||||
worksCloudDeployment: { recordSubmitted },
|
||||
worksSubmissionBinding: { recordSubmitted },
|
||||
} as never,
|
||||
);
|
||||
|
||||
@@ -1146,7 +1033,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]) },
|
||||
worksCloudDeployment: {
|
||||
worksSubmissionBinding: {
|
||||
recordSubmitted: vi.fn(async () => { throw new Error('disk unavailable'); }),
|
||||
},
|
||||
} as never,
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { readWorksDeployCheck } from '@electron/opencode/works-square-deploy-check';
|
||||
import {
|
||||
GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID,
|
||||
REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS,
|
||||
} from '../../shared/works-square-deploy-check';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const projects: string[] = [];
|
||||
|
||||
function validCompose(volumes = ''): string {
|
||||
return [
|
||||
'services:',
|
||||
' frontend:',
|
||||
' build:',
|
||||
' context: .',
|
||||
' dockerfile: Dockerfile',
|
||||
' ports:',
|
||||
' - "127.0.0.1::8080"',
|
||||
volumes ? ` volumes:\n${volumes}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function validFiles(compose = validCompose(), overrides: Record<string, string> = {}): Record<string, string> {
|
||||
return {
|
||||
'niancode.yml': [
|
||||
'runtime: compose',
|
||||
'compose:',
|
||||
' file: docker-compose.yml',
|
||||
' public_service: frontend',
|
||||
' public_port: 8080',
|
||||
].join('\n'),
|
||||
'docker-compose.yml': compose,
|
||||
Dockerfile: [
|
||||
'FROM node:20-alpine AS build',
|
||||
'WORKDIR /app',
|
||||
'COPY package.json package-lock.json ./',
|
||||
'RUN npm ci',
|
||||
'COPY . .',
|
||||
'RUN npm run build',
|
||||
'FROM nginx:1.27-alpine',
|
||||
'COPY nginx.conf /etc/nginx/conf.d/default.conf',
|
||||
'COPY --from=build /app/dist /usr/share/nginx/html',
|
||||
'EXPOSE 8080',
|
||||
'CMD ["nginx", "-g", "daemon off;"]',
|
||||
].join('\n'),
|
||||
'package.json': JSON.stringify({ scripts: { build: 'vite build' }, devDependencies: { vite: '^7.0.0' } }),
|
||||
'package-lock.json': '{}',
|
||||
'vite.config.js': 'export default { base: "./" };',
|
||||
'index.html': '<script type="module" src="./assets/index.js"></script>',
|
||||
'nginx.conf': 'server { listen 0.0.0.0:8080; }',
|
||||
'src/main.ts': 'console.log("ready");',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type ProjectOptions = {
|
||||
category?: string;
|
||||
files?: Record<string, string>;
|
||||
includeCanvasCheck?: boolean;
|
||||
localSmokeUnavailable?: boolean;
|
||||
reportChecks?: Record<string, { status: 'PASS' | 'SKIPPED' | 'BLOCKED'; detail: string; command: string }>;
|
||||
reportStatus?: 'PASS' | 'SKIPPED' | 'BLOCKED';
|
||||
};
|
||||
|
||||
async function createProject(compose = validCompose(), options: ProjectOptions = {}): Promise<{ projectPath: string; zipPath: string }> {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-deploy-check-'));
|
||||
projects.push(projectPath);
|
||||
const zipPath = join(projectPath, 'project-upload.zip');
|
||||
const sourcePath = join(projectPath, 'package-source');
|
||||
await mkdir(sourcePath, { recursive: true });
|
||||
for (const [name, content] of Object.entries(validFiles(compose, options.files))) {
|
||||
const filePath = join(sourcePath, name);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, content, 'utf8');
|
||||
}
|
||||
await execFileAsync('zip', ['-q', '-0', '-r', zipPath, '.'], { cwd: sourcePath });
|
||||
const zipSha256 = createHash('sha256').update(await readFile(zipPath)).digest('hex');
|
||||
const publish = {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: options.category ?? 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: zipPath,
|
||||
};
|
||||
await writeFile(join(projectPath, 'works-publish.json'), `${JSON.stringify(publish, null, 2)}\n`, 'utf8');
|
||||
await writeFile(join(projectPath, '部署报告.md'), options.localSmokeUnavailable
|
||||
? '# BLOCKED\n本机 Docker/浏览器不可用,交由 Works Square 云端构建验证。\n'
|
||||
: options.includeCanvasCheck
|
||||
? '# PASS\n逻辑分辨率:800x450\n桌面视口:1280x720,主画布实际尺寸:1280x720,最大等比预期尺寸:1280x720\n移动视口:390x844,主画布实际尺寸:390x219.375,最大等比预期尺寸:390x219.375\nresize 复测:已重新布局\n'
|
||||
: '# PASS\n', 'utf8');
|
||||
const checks: Record<string, unknown> = Object.fromEntries(REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS.map((id) => [id, options.localSmokeUnavailable
|
||||
? {
|
||||
status: 'BLOCKED',
|
||||
detail: `${id}:本机 Docker/浏览器不可用,交由 Works Square 云端构建验证`,
|
||||
command: `check ${id}`,
|
||||
execution: 'unavailable',
|
||||
}
|
||||
: {
|
||||
status: 'PASS',
|
||||
detail: `${id} 通过`,
|
||||
command: `check ${id}`,
|
||||
execution: 'executed',
|
||||
}]));
|
||||
if (options.includeCanvasCheck) {
|
||||
checks[GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID] = {
|
||||
status: 'PASS',
|
||||
detail: '桌面、移动和 resize 复测通过',
|
||||
command: 'playwright canvas viewport smoke',
|
||||
canvas_evidence: {
|
||||
logical_resolution: { width: 800, height: 450 },
|
||||
desktop_viewport: { width: 1280, height: 720 },
|
||||
mobile_viewport: { width: 390, height: 844 },
|
||||
desktop_canvas: { width: 1280, height: 720 },
|
||||
mobile_canvas: { width: 390, height: 219.375 },
|
||||
expected_max_canvas: {
|
||||
desktop: { width: 1280, height: 720 },
|
||||
mobile: { width: 390, height: 219.375 },
|
||||
},
|
||||
resize_verified: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
Object.assign(checks, options.reportChecks);
|
||||
await writeFile(join(projectPath, 'works-deploy-check.json'), `${JSON.stringify({
|
||||
schema_version: 1,
|
||||
status: options.localSmokeUnavailable ? 'BLOCKED' : options.reportStatus ?? 'PASS',
|
||||
checked_at: '2026-07-12T00:00:00.000Z',
|
||||
zip_file_path: zipPath,
|
||||
zip_sha256: zipSha256,
|
||||
checks,
|
||||
}, null, 2)}\n`, 'utf8');
|
||||
return { projectPath, zipPath };
|
||||
}
|
||||
|
||||
function readProjectCheck(projectPath: string) {
|
||||
return readWorksDeployCheck(projectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(projectPath, 'project-upload.zip'),
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(projects.splice(0).map((projectPath) => rm(projectPath, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('Works Square deployment package checks', () => {
|
||||
it('accepts SKIPPED for unavailable Docker checks', async () => {
|
||||
const { projectPath } = await createProject(validCompose(), {
|
||||
reportStatus: 'SKIPPED',
|
||||
reportChecks: {
|
||||
compose_build: {
|
||||
status: 'SKIPPED',
|
||||
detail: 'Docker not installed on current environment',
|
||||
command: 'docker compose build --pull=false',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await readProjectCheck(projectPath);
|
||||
|
||||
expect(result.report?.checks.compose_build?.status).toBe('SKIPPED');
|
||||
});
|
||||
|
||||
it('normalizes legacy environment-only BLOCKED checks to SKIPPED', async () => {
|
||||
const { projectPath } = await createProject(validCompose(), {
|
||||
reportStatus: 'BLOCKED',
|
||||
reportChecks: {
|
||||
sandbox_browser_smoke: {
|
||||
status: 'BLOCKED',
|
||||
detail: 'No browser automation available (Playwright/Puppeteer)',
|
||||
command: 'playwright test sandbox-smoke.spec.ts',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await readProjectCheck(projectPath);
|
||||
|
||||
expect(result.report?.checks.sandbox_browser_smoke?.status).toBe('SKIPPED');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not verified', 'docker compose build --pull=false'],
|
||||
['docker compose up exited 1', 'docker compose up -d'],
|
||||
])('does not normalize a non-environment failure: %s', async (detail, command) => {
|
||||
const { projectPath } = await createProject(validCompose(), {
|
||||
reportStatus: 'BLOCKED',
|
||||
reportChecks: {
|
||||
compose_up: { status: 'BLOCKED', detail, command },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await readProjectCheck(projectPath);
|
||||
|
||||
expect(result.report?.checks.compose_up?.status).toBe('BLOCKED');
|
||||
});
|
||||
|
||||
it('returns warning when only environment-dependent checks are skipped', async () => {
|
||||
const { projectPath } = await createProject(validCompose(), {
|
||||
reportStatus: 'SKIPPED',
|
||||
reportChecks: {
|
||||
compose_build: {
|
||||
status: 'SKIPPED',
|
||||
detail: 'Docker not installed on current environment',
|
||||
command: 'docker compose build --pull=false',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect((await readProjectCheck(projectPath)).status).toBe('warning');
|
||||
});
|
||||
|
||||
it('blocks a hash mismatch even when dynamic checks are skipped', async () => {
|
||||
const { projectPath } = await createProject(validCompose(), {
|
||||
reportStatus: 'SKIPPED',
|
||||
reportChecks: {
|
||||
compose_build: {
|
||||
status: 'SKIPPED',
|
||||
detail: 'Docker not installed on current environment',
|
||||
command: 'docker compose build --pull=false',
|
||||
},
|
||||
},
|
||||
});
|
||||
await writeFile(join(projectPath, 'project-upload.zip'), Buffer.from('changed zip contents'));
|
||||
|
||||
const result = await readProjectCheck(projectPath);
|
||||
|
||||
expect(result.status).toBe('blocked');
|
||||
expect(result.error).toContain('当前 zip');
|
||||
});
|
||||
|
||||
it('blocks an executed dynamic failure', async () => {
|
||||
const { projectPath } = await createProject(validCompose(), {
|
||||
reportStatus: 'BLOCKED',
|
||||
reportChecks: {
|
||||
compose_up: {
|
||||
status: 'BLOCKED',
|
||||
detail: 'docker compose up exited 1',
|
||||
command: 'docker compose up -d',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect((await readProjectCheck(projectPath)).status).toBe('blocked');
|
||||
});
|
||||
|
||||
it('passes a self-contained Compose/Vite package with a matching report', async () => {
|
||||
const { projectPath } = await createProject();
|
||||
const result = await readWorksDeployCheck(projectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(projectPath, 'project-upload.zip'),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('pass');
|
||||
expect(result.report?.status).toBe('PASS');
|
||||
expect(Object.values(result.checks ?? {}).every((check) => check.status === 'PASS')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks bind mounts even when the agent report says PASS', async () => {
|
||||
const { projectPath } = await createProject('services:\n frontend:\n build: .\n ports:\n - "127.0.0.1::8080"\n volumes:\n - .:/app');
|
||||
const result = await readWorksDeployCheck(projectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(projectPath, 'project-upload.zip'),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('blocked');
|
||||
expect(result.error).toContain('bind mount');
|
||||
});
|
||||
|
||||
it('blocks a report that is no longer bound to the current ZIP', async () => {
|
||||
const { projectPath } = await createProject();
|
||||
await writeFile(join(projectPath, 'project-upload.zip'), Buffer.from('changed zip contents'));
|
||||
const result = await readWorksDeployCheck(projectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(projectPath, 'project-upload.zip'),
|
||||
});
|
||||
|
||||
expect(result.status).toBe('blocked');
|
||||
expect(result.error).toContain('当前 zip');
|
||||
});
|
||||
|
||||
it('allows safe Compose defaults and requires an all-interface container listener', async () => {
|
||||
const safeCompose = validCompose().replace(
|
||||
' ports:',
|
||||
' environment:\n API_URL: "${API_URL:-http://backend:8000}"\n ports:',
|
||||
);
|
||||
const { projectPath } = await createProject(safeCompose);
|
||||
const safeResult = await readWorksDeployCheck(projectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(projectPath, 'project-upload.zip'),
|
||||
});
|
||||
expect(safeResult.status).toBe('pass');
|
||||
|
||||
const safeEnvFileCompose = validCompose().replace(
|
||||
' ports:',
|
||||
' env_file:\n - .env.example\n ports:',
|
||||
);
|
||||
const { projectPath: safeEnvFileProjectPath } = await createProject(safeEnvFileCompose, {
|
||||
files: { '.env.example': 'API_URL=http://backend:8000\n' },
|
||||
});
|
||||
const safeEnvFileResult = await readWorksDeployCheck(safeEnvFileProjectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(safeEnvFileProjectPath, 'project-upload.zip'),
|
||||
});
|
||||
expect(safeEnvFileResult.status).toBe('pass');
|
||||
|
||||
const { projectPath: unresolvedEnvProjectPath } = await createProject(
|
||||
validCompose().replace(
|
||||
' ports:',
|
||||
' environment:\n API_URL: "${API_URL}"\n ports:',
|
||||
),
|
||||
);
|
||||
const unresolvedEnvResult = await readWorksDeployCheck(unresolvedEnvProjectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(unresolvedEnvProjectPath, 'project-upload.zip'),
|
||||
});
|
||||
expect(unresolvedEnvResult.status).toBe('blocked');
|
||||
expect(unresolvedEnvResult.error).toContain('未声明环境变量');
|
||||
|
||||
const { projectPath: blockedProjectPath } = await createProject(validCompose(), {
|
||||
files: { 'nginx.conf': 'server { listen 127.0.0.1:8080; }' },
|
||||
});
|
||||
const blockedResult = await readWorksDeployCheck(blockedProjectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(blockedProjectPath, 'project-upload.zip'),
|
||||
});
|
||||
expect(blockedResult.status).toBe('blocked');
|
||||
expect(blockedResult.error).toContain('0.0.0.0:8080');
|
||||
});
|
||||
|
||||
it('requires responsive canvas evidence for game projects', async () => {
|
||||
const files = {
|
||||
'style.css': 'html, body, #app, #game-container { width: 100%; height: 100%; margin: 0; } canvas { display: block; }',
|
||||
'src/game.ts': 'const config = { width: 800, height: 450, parent: "game-container", scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH } }; window.addEventListener("resize", () => config);',
|
||||
};
|
||||
const { projectPath } = await createProject(validCompose(), { category: 'game', files, includeCanvasCheck: true });
|
||||
const result = await readWorksDeployCheck(projectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '游戏部署检查测试包',
|
||||
summary: '用于测试游戏部署门禁。',
|
||||
category: 'game',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(projectPath, 'project-upload.zip'),
|
||||
});
|
||||
expect(result.status).toBe('pass');
|
||||
expect(result.checks?.[GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID]?.status).toBe('PASS');
|
||||
});
|
||||
|
||||
it('blocks a game project when canvas smoke evidence is missing', async () => {
|
||||
const files = {
|
||||
'style.css': 'html, body, #app, #game-container { width: 100%; height: 100%; margin: 0; } canvas { display: block; }',
|
||||
'src/game.ts': 'const config = { scale: { mode: Phaser.Scale.FIT } };',
|
||||
};
|
||||
const { projectPath } = await createProject(validCompose(), { category: 'game', files });
|
||||
const result = await readWorksDeployCheck(projectPath, {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '游戏部署检查测试包',
|
||||
summary: '用于测试游戏部署门禁。',
|
||||
category: 'game',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(projectPath, 'project-upload.zip'),
|
||||
});
|
||||
expect(result.status).toBe('blocked');
|
||||
expect(result.error).toContain('game_canvas_smoke');
|
||||
});
|
||||
|
||||
it('keeps local mode strict but allows explicitly unavailable local smoke in cloud mode', async () => {
|
||||
const { projectPath } = await createProject(validCompose(), { localSmokeUnavailable: true });
|
||||
const publish = {
|
||||
app_id: 'deploy-check-fixture',
|
||||
title: '部署检查测试包',
|
||||
summary: '用于测试部署门禁。',
|
||||
category: 'web',
|
||||
age_band: '10-12',
|
||||
difficulty: 'beginner',
|
||||
version_name: 'v1.0.0',
|
||||
change_log: '测试包',
|
||||
zip_file_path: join(projectPath, 'project-upload.zip'),
|
||||
};
|
||||
|
||||
const localResult = await readWorksDeployCheck(projectPath, publish);
|
||||
expect(localResult.status).toBe('blocked');
|
||||
|
||||
const cloudResult = await readWorksDeployCheck(projectPath, publish, { mode: 'cloud' });
|
||||
expect(cloudResult.status).toBe('pass');
|
||||
expect(cloudResult.zip_sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
fetchWorksProjects,
|
||||
publishWorksProjectSource,
|
||||
toPlazaCard,
|
||||
uploadWorksProjectZip,
|
||||
WorksSquareApiError,
|
||||
type ProjectPublic,
|
||||
} from '@/lib/works-square';
|
||||
@@ -313,43 +312,6 @@ describe('works square client', () => {
|
||||
expect(error.message).toBe('Conflict');
|
||||
});
|
||||
|
||||
it('uploads a project zip version with the current access token', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
upload: {
|
||||
version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1',
|
||||
review_status: 'building',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await uploadWorksProjectZip({
|
||||
accessToken: 'access-token',
|
||||
appId: 'space cleaner',
|
||||
projectId: 'project-1',
|
||||
versionName: 'v1.0.0',
|
||||
changeLog: 'Initial upload',
|
||||
zipFilePath: '/tmp/project.zip',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1',
|
||||
review_status: 'building',
|
||||
});
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/works/projects/space%20cleaner/versions/upload',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
accessToken: 'access-token',
|
||||
projectId: 'project-1',
|
||||
versionName: 'v1.0.0',
|
||||
changeLog: 'Initial upload',
|
||||
zipFilePath: '/tmp/project.zip',
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('asks Main to package and submit source without renderer-owned secrets or archive fields', async () => {
|
||||
const publishResult = {
|
||||
package: {
|
||||
@@ -483,7 +445,8 @@ describe('works square client', () => {
|
||||
difficulty: 'beginner',
|
||||
updated_at: '2026-06-20T22:55:37.790408+08:00',
|
||||
playable: true,
|
||||
runtime_url: '/apps/space-cleaner/',
|
||||
play_url: '/apps/space-cleaner/',
|
||||
runtime_url: '/apps/legacy-space-cleaner/',
|
||||
};
|
||||
|
||||
expect(toPlazaCard(project)).toEqual({
|
||||
@@ -499,4 +462,16 @@ describe('works square client', () => {
|
||||
runtimeUrl: '/apps/space-cleaner/',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps runtime_url as a compatibility fallback for one release', () => {
|
||||
const project: ProjectPublic = {
|
||||
app_id: 'legacy-game',
|
||||
title: 'Legacy Game',
|
||||
summary: 'Compatibility fixture',
|
||||
playable: true,
|
||||
runtime_url: '/apps/legacy-game/',
|
||||
};
|
||||
|
||||
expect(toPlazaCard(project).runtimeUrl).toBe('/apps/legacy-game/');
|
||||
});
|
||||
});
|
||||
|
||||
130
tests/unit/works-submission-binding.test.ts
Normal file
130
tests/unit/works-submission-binding.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createWorksSubmissionBindingStore,
|
||||
readWorksSubmissionBinding,
|
||||
} from '@electron/services/works-submission-binding';
|
||||
import type { OpencodeProject } from '@electron/opencode/project-store';
|
||||
import {
|
||||
WORKS_SUBMISSION_BINDING_FILE_NAME,
|
||||
WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
} from '../../shared/works-submission-binding';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
async function createProject(): Promise<OpencodeProject> {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-submission-binding-'));
|
||||
temporaryDirectories.push(projectPath);
|
||||
return {
|
||||
id: 'project-1',
|
||||
path: projectPath,
|
||||
name: 'submission binding test',
|
||||
createdAt: '2026-08-10T00:00:00.000Z',
|
||||
updatedAt: '2026-08-10T00:00:00.000Z',
|
||||
lastOpenedAt: '2026-08-10T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function createStore(project: OpencodeProject) {
|
||||
return { listProjects: vi.fn(async () => [project]) };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => (
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)));
|
||||
});
|
||||
|
||||
describe('Works Square submission binding store', () => {
|
||||
it('records the exact one-click submitted version without owning upload behavior', async () => {
|
||||
const project = await createProject();
|
||||
const store = createWorksSubmissionBindingStore(createStore(project));
|
||||
|
||||
const record = await store.recordSubmitted(project.id, {
|
||||
appId: 'planet-game',
|
||||
versionId: 'version-7',
|
||||
versionName: 'v0.7.0',
|
||||
reviewStatus: 'building',
|
||||
zipSha256: 'a'.repeat(64),
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
project_id: project.id,
|
||||
status: 'submitted',
|
||||
app_id: 'planet-game',
|
||||
version_id: 'version-7',
|
||||
version_name: 'v0.7.0',
|
||||
review_status: 'building',
|
||||
zip_sha256: 'a'.repeat(64),
|
||||
});
|
||||
await expect(store.get(project.id)).resolves.toEqual(record);
|
||||
await expect(readWorksSubmissionBinding(project.path)).resolves.toEqual(record);
|
||||
});
|
||||
|
||||
it.each(['armed', 'waiting_for_package', 'waiting_for_login', 'uploading', 'failed'])(
|
||||
'retires a legacy %s task with an understandable next step',
|
||||
async (status) => {
|
||||
const project = await createProject();
|
||||
await writeFile(
|
||||
join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME),
|
||||
`${JSON.stringify({
|
||||
schema_version: 1,
|
||||
project_id: project.id,
|
||||
status,
|
||||
requested_at: '2026-08-01T00:00:00.000Z',
|
||||
updated_at: '2026-08-01T00:05:00.000Z',
|
||||
})}\n`,
|
||||
'utf8',
|
||||
);
|
||||
const store = createWorksSubmissionBindingStore(createStore(project));
|
||||
|
||||
await store.start();
|
||||
|
||||
const record = await store.get(project.id);
|
||||
expect(record).toMatchObject({
|
||||
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
project_id: project.id,
|
||||
status: 'legacy_retired',
|
||||
error_code: 'LEGACY_AUTO_DEPLOY_RETIRED',
|
||||
message: '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。',
|
||||
});
|
||||
expect(JSON.parse(await readFile(
|
||||
join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME),
|
||||
'utf8',
|
||||
))).toEqual(record);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves a submitted legacy version binding while upgrading its schema', async () => {
|
||||
const project = await createProject();
|
||||
await writeFile(
|
||||
join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME),
|
||||
`${JSON.stringify({
|
||||
schema_version: 1,
|
||||
project_id: project.id,
|
||||
status: 'submitted',
|
||||
requested_at: '2026-08-01T00:00:00.000Z',
|
||||
updated_at: '2026-08-01T00:05:00.000Z',
|
||||
app_id: 'planet-game',
|
||||
version_id: 'version-6',
|
||||
version_name: 'v0.6.0',
|
||||
review_status: 'approved',
|
||||
})}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const record = await readWorksSubmissionBinding(project.path);
|
||||
|
||||
expect(record).toMatchObject({
|
||||
schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION,
|
||||
status: 'submitted',
|
||||
app_id: 'planet-game',
|
||||
version_id: 'version-6',
|
||||
version_name: 'v0.6.0',
|
||||
review_status: 'approved',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user