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, 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 execFileAsync = promisify(execFile); const tempDirectories: string[] = []; 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'); } await execFileAsync('zip', ['-q', '-0', '-r', zipPath, '.'], { 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> { 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; if (record.status === expected) return record; } 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}`); } afterEach(async () => { await Promise.all(tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); describe('Main-owned Works Square cloud deployment', () => { 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(); }); });