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 = {}): Record { 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': '', 'nginx.conf': 'server { listen 0.0.0.0:8080; }', 'src/main.ts': 'console.log("ready");', ...overrides, }; } type ProjectOptions = { category?: string; files?: Record; includeCanvasCheck?: boolean; localSmokeUnavailable?: boolean; reportChecks?: Record; 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 = 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}$/); }); });