462 lines
17 KiB
TypeScript
462 lines
17 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 } 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}$/);
|
||
});
|
||
});
|