diff --git a/.env.e2e.example b/.env.e2e.example index d2a03e948..fc8a8f20a 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -16,9 +16,6 @@ APP_KEY=test-key-e2e SOCKET_PATH=storage/gateway-e2e.sock __E2E__=true -# 指定运行测试的服务地址;当 APP_BASE_URL 不为空时,将不会在本地启动测试服务 -# APP_BASE_URL=http://localhost:20000 - # 启用 mock-collections 插件 APPEND_PRESET_BUILT_IN_PLUGINS=mock-collections diff --git a/.github/workflows/nocobase-test-e2e.yml b/.github/workflows/nocobase-test-e2e.yml index 7118b486a..9e4aba89f 100644 --- a/.github/workflows/nocobase-test-e2e.yml +++ b/.github/workflows/nocobase-test-e2e.yml @@ -62,9 +62,10 @@ jobs: __E2E__: true - run: npx playwright install --with-deps - name: Test with postgres - run: yarn test:e2e + run: yarn e2e test -x --skip-reporter env: __E2E__: true + APP_ENV: production LOGGER_LEVEL: error DB_DIALECT: postgres DB_HOST: postgres diff --git a/package.json b/package.json index 2ea219e9a..e4fa91b9d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "tar": "nocobase tar", "test": "nocobase test", "test:client": "vitest", + "e2e": "nocobase e2e", "test:e2e": "tsx ./scripts/runE2e.setup.ts", "test:e2e:codegen": "tsx ./scripts/codegen.setup.ts", "test:e2e:server": "tsx ./scripts/nocobase.setup.ts", diff --git a/packages/core/cli/bin/index.js b/packages/core/cli/bin/index.js index 28953a098..2ce27cec4 100755 --- a/packages/core/cli/bin/index.js +++ b/packages/core/cli/bin/index.js @@ -2,7 +2,7 @@ const dotenv = require('dotenv'); const { resolve } = require('path'); -const { existsSync } = require('fs'); +const fs = require('fs'); const chalk = require('chalk'); const { genTsConfigPaths } = require('../src/util'); @@ -25,11 +25,26 @@ const env = { }; if (!process.env.APP_ENV_PATH && process.argv[2] && process.argv[2] === 'test') { - if (existsSync(resolve(process.cwd(), '.env.test'))) { + if (fs.existsSync(resolve(process.cwd(), '.env.test'))) { process.env.APP_ENV_PATH = '.env.test'; } } +if (process.argv[2] === 'e2e') { + // 用于存放 playwright 自动生成的相关的文件 + if (!fs.existsSync('playwright')) { + fs.mkdirSync('playwright'); + } + if (!fs.existsSync('.env.e2e') && fs.existsSync('.env.e2e.example')) { + const env = fs.readFileSync('.env.e2e.example'); + fs.writeFileSync('.env.e2e', env); + } + if (!fs.existsSync('.env.e2e')) { + throw new Error('Please create .env.e2e file first!'); + } + process.env.APP_ENV_PATH = '.env.e2e'; +} + genTsConfigPaths(); dotenv.config({ @@ -42,6 +57,10 @@ for (const key in env) { } } +if (process.argv[2] === 'e2e' && !process.env.APP_BASE_URL) { + process.env.APP_BASE_URL = `http://127.0.0.1:${process.env.APP_PORT}`; +} + if (require('semver').satisfies(process.version, '<16')) { console.error(chalk.red('[nocobase cli]: Node.js version must be >= 16')); process.exit(1); diff --git a/packages/core/cli/src/commands/e2e.js b/packages/core/cli/src/commands/e2e.js new file mode 100644 index 000000000..23abb24b0 --- /dev/null +++ b/packages/core/cli/src/commands/e2e.js @@ -0,0 +1,199 @@ +const { Command } = require('commander'); +const { run, isPortReachable } = require('../util'); +const { execSync } = require('node:child_process'); +const axios = require('axios'); + +/** + * 检查服务是否启动成功 + */ +const checkServer = async (duration = 1000, max = 60 * 10) => { + return new Promise((resolve, reject) => { + let count = 0; + const timer = setInterval(async () => { + if (count++ > max) { + clearInterval(timer); + return reject(new Error('Server start timeout.')); + } + + // if (!(await checkPort(PORT))) { + // return; + // } + + const url = `${process.env.APP_BASE_URL}/api/__health_check`; + // console.log('url', url); + + axios + .get(url) + .then((response) => { + if (response.status === 200) { + clearInterval(timer); + resolve(true); + } + }) + .catch((error) => { + console.error('Request error:', error.message); + }); + }, duration); + }); +}; + +/** + * 检查 UI 是否启动成功 + * @param duration + */ +const checkUI = async (duration = 1000, max = 60 * 10) => { + return new Promise((resolve, reject) => { + let count = 0; + const timer = setInterval(async () => { + if (count++ > max) { + clearInterval(timer); + return reject(new Error('UI start timeout.')); + } + + axios + .get(`${process.env.APP_BASE_URL}/__umi/api/bundle-status`) + .then((response) => { + if (response.data === 'ok') { + clearInterval(timer); + resolve(true); + return; + } + if (response.data.bundleStatus.done) { + clearInterval(timer); + resolve(true); + } + }) + .catch((error) => { + console.error('Request error:', error.message); + }); + }, duration); + }); +}; + +async function appReady() { + console.log('check server...'); + await checkServer(); + console.log('server is ready, check UI...'); + await checkUI(); + console.log('UI is ready.'); +} + +async function runApp(options = {}) { + console.log('installing...'); + await run('nocobase', ['install', '-f']); + if (await isPortReachable(process.env.APP_PORT)) { + console.log('app started'); + return; + } + console.log('starting...'); + run('nocobase', [process.env.APP_ENV === 'production' ? 'start' : 'dev'], options); +} + +const commonConfig = { + stdio: 'inherit', +}; + +const runCodegenSync = () => { + try { + execSync( + `npx playwright codegen --load-storage=playwright/.auth/codegen.auth.json ${process.env.APP_BASE_URL} --save-storage=playwright/.auth/codegen.auth.json`, + commonConfig, + ); + } catch (err) { + if (err.message.includes('auth.json')) { + execSync( + `npx playwright codegen ${process.env.APP_BASE_URL} --save-storage=playwright/.auth/codegen.auth.json`, + commonConfig, + ); + } else { + console.error(err); + } + } +}; + +const filterArgv = () => { + const arr = process.argv.slice(4); + const argv = []; + for (let index = 0; index < arr.length; index++) { + const element = arr[index]; + if (element === '--url') { + index++; + continue; + } + if (element.startsWith('--url=')) { + continue; + } + if (element === '--skip-reporter') { + continue; + } + argv.push(element); + } + return argv; +}; + +/** + * + * @param {Command} cli + */ +module.exports = (cli) => { + const e2e = cli.command('e2e').hook('preAction', () => { + if (process.env.APP_BASE_URL) { + process.env.APP_BASE_URL = process.env.APP_BASE_URL.replace('localhost', '127.0.0.1'); + console.log('APP_BASE_URL:', process.env.APP_BASE_URL); + } + }); + e2e + .command('test') + .allowUnknownOption() + .option('--url [url]') + .option('--skip-reporter') + .action(async (options) => { + if (options.skipReporter) { + process.env.PLAYWRIGHT_SKIP_REPORTER = true; + } + if (options.url) { + process.env.APP_BASE_URL = options.url.replace('localhost', '127.0.0.1'); + } else { + await runApp({ + stdio: 'ignore', + }); + } + await appReady(); + await run('npx', ['playwright', 'test', ...filterArgv()]); + process.exit(); + }); + + e2e + .command('codegen') + .allowUnknownOption() + .option('--url [url]') + .action(async (options) => { + if (options.url) { + process.env.APP_BASE_URL = options.url.replace('localhost', '127.0.0.1'); + } else { + await runApp({ + stdio: 'ignore', + }); + } + await appReady(); + runCodegenSync(); + }); + + e2e + .command('start-app') + .option('--production') + .option('--port [port]') + .action(async (options) => { + if (options.production) { + process.env.APP_ENV = 'production'; + } + if (options.port) { + process.env.APP_PORT = options.port; + } + runApp(); + }); + + e2e.command('reinstall-app').action(async (options) => { + await run('nocobase', ['install', '-f'], options); + }); +}; diff --git a/packages/core/cli/src/commands/index.js b/packages/core/cli/src/commands/index.js index 6fe96ea70..cbcc895d0 100644 --- a/packages/core/cli/src/commands/index.js +++ b/packages/core/cli/src/commands/index.js @@ -13,6 +13,7 @@ module.exports = (cli) => { require('./dev')(cli); require('./start')(cli); require('./test')(cli); + require('./e2e')(cli); require('./clean')(cli); require('./doc')(cli); require('./umi')(cli); diff --git a/packages/core/server/src/gateway/index.ts b/packages/core/server/src/gateway/index.ts index cc0c2f275..db3ab86cd 100644 --- a/packages/core/server/src/gateway/index.ts +++ b/packages/core/server/src/gateway/index.ts @@ -145,6 +145,12 @@ export class Gateway extends EventEmitter { async requestHandler(req: IncomingMessage, res: ServerResponse) { const { pathname } = parse(req.url); + if (pathname === '/__umi/api/bundle-status') { + res.statusCode = 200; + res.end('ok'); + return; + } + if (pathname.startsWith('/storage/uploads/')) { await compress(req, res); return handler(req, res, { diff --git a/playwright.config.ts b/playwright.config.ts index b830a0fd6..3d5010fcd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -22,7 +22,9 @@ export default defineConfig({ workers: 1, // Reporter to use - reporter: [['html', { outputFolder: './playwright/tests-report' }]], + reporter: process.env.PLAYWRIGHT_SKIP_REPORTER + ? undefined + : [['html', { outputFolder: './playwright/tests-report' }]], outputDir: './playwright/test-results',