Files
makelore/tests/unit/works-cloud-deployment.test.ts
2026-07-29 17:22:35 +08:00

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