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

462 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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