184 lines
7.4 KiB
TypeScript
184 lines
7.4 KiB
TypeScript
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<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');
|
|
}
|
|
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<Record<string, unknown>> {
|
|
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>;
|
|
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();
|
|
});
|
|
});
|