diff --git a/packages/backend/package.json b/packages/backend/package.json index f50b844d8a..71ca366366 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -17,7 +17,7 @@ "compile-config": "node ./scripts/compile_config.js", "build": "rolldown -c", "build:unit": "rolldown -c --sourcemap", - "build:e2e": "swc src -d src-js -D --strip-leading-paths && swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths", + "build:e2e": "rolldown -c --e2e", "watch:swc": "swc src -d built -D -w --strip-leading-paths", "build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json", "watch": "pnpm compile-config && node ./scripts/watch.mjs", diff --git a/packages/backend/rolldown.config.ts b/packages/backend/rolldown.config.ts index 5ca07a5f87..87e6e9961b 100644 --- a/packages/backend/rolldown.config.ts +++ b/packages/backend/rolldown.config.ts @@ -53,6 +53,7 @@ function backendDevServerPlugin(): Plugin { export default defineConfig((args) => { const isWatchMode = args.watch != null && args.watch !== 'false'; + const isE2E = args.e2e != null && args.e2e !== 'false'; // 通常のビルド時にexternalとするモジュール const externalModules: ExternalOption = [ @@ -75,33 +76,52 @@ export default defineConfig((args) => { 'oauth2orize', ]; - return { - input: [ - './src/boot/entry.ts', - './src/boot/cli.ts', - './src/config.ts', - './src/postgres.ts', - './src/server/api/openapi/gen-spec.ts', - ], - platform: 'node', - tsconfig: true, - plugins: [ - esmShim(), - (isWatchMode ? backendDevServerPlugin() : undefined), - ], - output: { - keepNames: true, - minify: !isWatchMode, - sourcemap: isWatchMode, - dir: './built', - cleanDir: !isWatchMode, - format: 'esm', - }, - watch: { - include: ['src/**/*.{ts,js,mjs,cjs,tsx,json}'], - clearScreen: false, - }, - // ビルドの高速化のために、watchモードのときは外部モジュールは全てバンドルしないようにする - external: isWatchMode ? /^(?!@\/)[^.\/](?!:[\/\\])/ : externalModules, - }; + if (isE2E) { + return { + input: './test-server/entry.ts', + platform: 'node', + tsconfig: './test-server/tsconfig.json', + plugins: [ + esmShim(), + ], + output: { + keepNames: true, + sourcemap: true, + dir: './built-test', + cleanDir: true, + format: 'esm', + }, + external: externalModules, + }; + } else { + return { + input: [ + './src/boot/entry.ts', + './src/boot/cli.ts', + './src/config.ts', + './src/postgres.ts', + './src/server/api/openapi/gen-spec.ts', + ], + platform: 'node', + tsconfig: true, + plugins: [ + esmShim(), + (isWatchMode ? backendDevServerPlugin() : undefined), + ], + output: { + keepNames: true, + minify: !isWatchMode, + sourcemap: isWatchMode, + dir: './built', + cleanDir: !isWatchMode, + format: 'esm', + }, + watch: { + include: ['src/**/*.{ts,js,mjs,cjs,tsx,json}'], + clearScreen: false, + }, + // ビルドの高速化のために、watchモードのときは外部モジュールは全てバンドルしないようにする + external: isWatchMode ? /^(?!@\/)[^.\/](?!:[\/\\])/ : externalModules, + }; + } }); diff --git a/packages/backend/test-server/entry.ts b/packages/backend/test-server/entry.ts index 04bf62d209..1ae6869abf 100644 --- a/packages/backend/test-server/entry.ts +++ b/packages/backend/test-server/entry.ts @@ -1,6 +1,10 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { takeCoverage } from 'node:v8'; import { portToPid } from 'pid-port'; import fkill from 'fkill'; -import Fastify from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { execaNode, type ResultPromise } from 'execa'; import { NestFactory } from '@nestjs/core'; import { MainModule } from '@/MainModule.js'; import { ServerService } from '@/server/ServerService.js'; @@ -10,18 +14,27 @@ import { INestApplicationContext } from '@nestjs/common'; const config = loadConfig(); const originEnv = JSON.stringify(process.env); +const entryFilePath = fileURLToPath(import.meta.url); +const controllerPort = config.port + 1000; +const isExecutedDirectly = process.argv[1] != null && entryFilePath === process.argv[1]; process.env.NODE_ENV = 'test'; let app: INestApplicationContext; let serverService: ServerService; +let controllerServer: FastifyInstance | null = null; +let shutdownPromise: Promise | null = null; + +async function flushCoverage() { + if (process.env.NODE_V8_COVERAGE) { + takeCoverage(); + } +} /** - * テスト用のサーバインスタンスを起動する + * テスト用のサーバインスタンスを起動する */ -async function launch() { - await killTestServer(); - +async function launchApplication() { console.log('starting application...'); app = await NestFactory.createApplicationContext(MainModule, { @@ -30,21 +43,39 @@ async function launch() { serverService = app.get(ServerService); await serverService.launch(); - await startControllerEndpoints(); - // ジョブキューは必要な時にテストコード側で起動する // ジョブキューが動くとテスト結果の確認に支障が出ることがあるので意図的に動かさないでいる console.log('application initialized.'); } +async function disposeApplication() { + await flushCoverage(); + + if (serverService) { + await serverService.dispose(); + } + + if (app) { + await app.close(); + } + // @ts-expect-error cleanup for relaunch in the same process + app = undefined; + // @ts-expect-error cleanup for relaunch in the same process + serverService = undefined; +} + +async function relaunchApplication() { + await disposeApplication(); + await launchApplication(); +} + /** * 既に重複したポートで待ち受けしているサーバがある場合はkillする */ -async function killTestServer() { - // +async function killServerAtPort(port: number) { try { - const pid = await portToPid(config.port); + const pid = await portToPid(port); if (pid) { await fkill(pid, { force: true }); } @@ -53,15 +84,44 @@ async function killTestServer() { } } +async function killTestServers() { + await Promise.all([ + killServerAtPort(config.port), + killServerAtPort(controllerPort), + ]); +} + +async function shutdownChildProcess() { + if (shutdownPromise) { + return shutdownPromise; + } + + shutdownPromise = (async () => { + if (controllerServer) { + await controllerServer.close(); + controllerServer = null; + } + + await disposeApplication(); + })().finally(() => { + shutdownPromise = null; + }); + + return shutdownPromise; +} + /** * 別プロセスに切り離してしまったが故に出来なくなった環境変数の書き換え等を実現するためのエンドポイントを作る * @param port */ -async function startControllerEndpoints(port = config.port + 1000) { +async function startControllerEndpoints(port = controllerPort) { const fastify = Fastify(); + fastify.get('/healthz', async () => { + return { ok: true }; + }); + fastify.post<{ Body: { key?: string, value?: string } }>('/env', async (req, res) => { - console.log(req.body); const key = req.body['key']; if (!key) { res.code(400).send({ success: false }); @@ -75,24 +135,126 @@ async function startControllerEndpoints(port = config.port + 1000) { fastify.post<{ Body: { key?: string, value?: string } }>('/env-reset', async (req, res) => { process.env = JSON.parse(originEnv); - - await serverService.dispose(); - await app.close(); - - await killTestServer(); - - console.log('starting application...'); - - app = await NestFactory.createApplicationContext(MainModule, { - logger: new NestLogger(), - }); - serverService = app.get(ServerService); - await serverService.launch(); + await relaunchApplication(); res.code(200).send({ success: true }); }); + fastify.post('/shutdown', async (_req, res) => { + res.code(200).send({ success: true }); + + setImmediate(() => { + void shutdownChildProcess().finally(() => { + process.exit(0); + }); + }); + }); + await fastify.listen({ port: port, host: 'localhost' }); + controllerServer = fastify; } -export default launch; +async function runServerProcess() { + await killTestServers(); + + const terminate = async (signal: NodeJS.Signals) => { + console.log(`received ${signal}, shutting down test server...`); + await shutdownChildProcess(); + process.exit(0); + }; + + process.on('SIGINT', () => { + void terminate('SIGINT'); + }); + process.on('SIGTERM', () => { + void terminate('SIGTERM'); + }); + + await launchApplication(); + await startControllerEndpoints(); +} + +async function waitForControllerReady() { + for (let attempt = 0; attempt < 120; attempt++) { + try { + const response = await fetch(`http://127.0.0.1:${controllerPort}/healthz`); + if (response.ok) { + return; + } + } catch { + // NOP + } + + await delay(500); + } + + throw new Error('test server did not become ready in time'); +} + +async function requestChildShutdown() { + const response = await fetch(`http://127.0.0.1:${controllerPort}/shutdown`, { + method: 'POST', + body: JSON.stringify({}), + }); + + if (!response.ok) { + throw new Error('failed to shut down test server'); + } +} + +async function waitForChildExit(child: ResultPromise) { + await child.catch(() => { + // NOP + }); +} + +function terminateChild(child: ResultPromise, signal: NodeJS.Signals = 'SIGTERM') { + child.kill(signal); + + const timeout = setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, 5000); + + void child.finally(() => { + clearTimeout(timeout); + }); +} + +export default async function globalSetup() { + await killTestServers(); + + const child = execaNode(entryFilePath, [], { + stdout: process.stdout, + stderr: process.stderr, + env: { + ...process.env, + NODE_ENV: 'test', + }, + }); + + try { + await waitForControllerReady(); + } catch (error) { + terminateChild(child); + throw error; + } + + return async () => { + try { + await requestChildShutdown(); + } catch { + terminateChild(child); + } + + await waitForChildExit(child); + }; +} + +if (isExecutedDirectly) { + void runServerProcess().catch((error: unknown) => { + console.error(error); + process.exit(1); + }); +} diff --git a/packages/backend/test-server/tsconfig.json b/packages/backend/test-server/tsconfig.json index c773f779e6..33978ec95f 100644 --- a/packages/backend/test-server/tsconfig.json +++ b/packages/backend/test-server/tsconfig.json @@ -25,7 +25,6 @@ "isolatedModules": true, "jsx": "react-jsx", "jsxImportSource": "@kitajs/html", - "rootDir": "../src", "paths": { "@/*": ["../src/*"] }, diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts index c253de31f2..1f5dfd1b1c 100644 --- a/packages/backend/vitest.config.ts +++ b/packages/backend/vitest.config.ts @@ -12,6 +12,8 @@ export const baseConfig = defineConfig({ }, restoreMocks: true, testTimeout: 60000, + hookTimeout: 60000, + teardownTimeout: 60000, maxWorkers: 1, logHeapUsage: true, vmMemoryLimit: 1024,