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 = { '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': '', '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> { let lastRecord: Record | 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; 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(); }); });