diff --git a/README.md b/README.md index 23da2ffd..5cee27aa 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,13 @@ pnpm dev 默认测试账号:`admin / Easy@2026` +管理端发布前可执行以下命令,验证环境契约、生产镜像和 Nginx 路由: + +```bash +cd easyflow-ui-admin +pnpm verify:deployment +``` + ## 后端 Jar 包构建与部署 ### 构建 Jar diff --git a/easyflow-ui-admin/package.json b/easyflow-ui-admin/package.json index d630db25..3c85f123 100644 --- a/easyflow-ui-admin/package.json +++ b/easyflow-ui-admin/package.json @@ -30,9 +30,11 @@ "preview": "turbo-run preview", "publint": "vsh publint", "reinstall": "pnpm clean --del-lock && pnpm install", + "test:deployment-contract": "vitest run --dom app/vite-base-path-redirect.test.ts app/src/startup-error.test.ts app/src/router/__tests__/environment-contract.test.ts app/src/router/navigation-user-info.test.ts app/src/utils/share-route-context.test.ts app/src/utils/__tests__/login-redirect.test.ts app/src/views/ai/workflow/workflow-share-context.test.ts", "test:unit": "vitest run --dom", "test:e2e": "turbo run test:e2e", "update:deps": "npx taze -r -w", + "verify:deployment": "pnpm run test:deployment-contract && pnpm run build:app && docker build --platform linux/amd64 -f scripts/deploy/Dockerfile.smoke -t easyflow-frontend-smoke:local . && cross-env EASYFLOW_FRONTEND_IMAGE=easyflow-frontend-smoke:local node scripts/deploy/smoke-test.mjs", "version": "pnpm exec changeset version && pnpm install --no-frozen-lockfile", "catalog": "pnpx codemod pnpm/catalog" }, diff --git a/easyflow-ui-admin/scripts/deploy/Dockerfile.smoke b/easyflow-ui-admin/scripts/deploy/Dockerfile.smoke new file mode 100644 index 00000000..57ac023a --- /dev/null +++ b/easyflow-ui-admin/scripts/deploy/Dockerfile.smoke @@ -0,0 +1,12 @@ +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nginx:stable-alpine + +RUN rm -f /etc/nginx/conf.d/default.conf + +COPY app/dist/ /usr/share/nginx/html/flow/ +COPY scripts/deploy/nginx.conf /etc/nginx/nginx.conf + +RUN nginx -t + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/easyflow-ui-admin/scripts/deploy/Dockerfile.smoke.dockerignore b/easyflow-ui-admin/scripts/deploy/Dockerfile.smoke.dockerignore new file mode 100644 index 00000000..2b04591d --- /dev/null +++ b/easyflow-ui-admin/scripts/deploy/Dockerfile.smoke.dockerignore @@ -0,0 +1,7 @@ +** +!app/ +!app/dist/ +!app/dist/** +!scripts/ +!scripts/deploy/ +!scripts/deploy/nginx.conf diff --git a/easyflow-ui-admin/scripts/deploy/smoke-test.mjs b/easyflow-ui-admin/scripts/deploy/smoke-test.mjs new file mode 100644 index 00000000..43f5250c --- /dev/null +++ b/easyflow-ui-admin/scripts/deploy/smoke-test.mjs @@ -0,0 +1,92 @@ +import { execFileSync } from 'node:child_process'; + +const image = process.env.EASYFLOW_FRONTEND_IMAGE || 'easyflow-frontend:0.1'; +const containerName = `easyflow-frontend-smoke-${process.pid}`; + +function docker(args) { + return execFileSync('docker', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +async function waitForServer(origin) { + let lastError; + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const response = await fetch(`${origin}/flow/`); + if (response.ok) { + return; + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Nginx 未在预期时间内启动: ${String(lastError)}`); +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +try { + docker([ + 'run', + '--detach', + '--rm', + '--publish', + '127.0.0.1::80', + '--name', + containerName, + image, + ]); + + const portBinding = docker(['port', containerName, '80/tcp']) + .split('\n') + .find((line) => line.startsWith('127.0.0.1:')); + const port = portBinding?.match(/:(\d+)$/)?.[1]; + assert(port, `无法解析容器端口: ${portBinding || 'empty'}`); + + const origin = `http://127.0.0.1:${port}`; + await waitForServer(origin); + + const rootResponse = await fetch(`${origin}/`, { redirect: 'manual' }); + assert(rootResponse.status === 302, `根路径状态异常: ${rootResponse.status}`); + const rootLocation = rootResponse.headers.get('location'); + assert( + rootLocation && new URL(rootLocation, origin).pathname === '/flow/', + `根路径重定向异常: ${rootLocation}`, + ); + + const flowResponse = await fetch(`${origin}/flow/`); + const indexHtml = await flowResponse.text(); + assert(flowResponse.ok, `/flow/ 状态异常: ${flowResponse.status}`); + assert(indexHtml.includes('