收敛客户端静态发布链路

This commit is contained in:
2026-08-10 17:04:29 +08:00
parent 2e737dee31
commit 4df047708b
37 changed files with 604 additions and 3071 deletions

View File

@@ -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 },
)));

View File

@@ -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 });

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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,

View File

@@ -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}$/);
});
});

View File

@@ -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/');
});
});

View 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',
});
});
});