From c8a7b2e76c8352e1e992c60f214ef6a74bd9e365 Mon Sep 17 00:00:00 2001 From: Tomasz Pluskiewicz Date: Thu, 21 Nov 2024 12:44:54 +0100 Subject: [PATCH] test: serve subprocess + config --- packages/cli/index.ts | 12 +- packages/cli/lib/command/serve.ts | 46 +++---- packages/cli/lib/config.ts | 31 +++++ packages/cli/lib/serve.ts | 0 packages/cli/package.json | 4 +- .../cli/test/fixtures/config.with-watch.json | 4 + packages/cli/test/kopflos.config.ts | 7 ++ packages/cli/test/lib/command/serve.test.ts | 114 ++++++++++++++++++ packages/cli/test/lib/config.test.ts | 42 ++++++- 9 files changed, 222 insertions(+), 38 deletions(-) delete mode 100644 packages/cli/lib/serve.ts create mode 100644 packages/cli/test/fixtures/config.with-watch.json create mode 100644 packages/cli/test/lib/command/serve.test.ts diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 39eb86a..40a3ddb 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,6 +1,7 @@ import 'ulog' import { fork } from 'node:child_process' import { program } from 'commander' +import log from '@kopflos-cms/logger' import { variable } from './lib/options.js' import deploy from './lib/command/deploy.js' import build from './lib/command/build.js' @@ -19,11 +20,20 @@ program.command('serve') .option('--no-watch', 'Disable watching for changes') .action((options) => { (function serve() { + // running the server in a forked process to be able to restart it + // child process is necessary to bypass node module caching const proc = fork(new URL('./lib/command/serve.js', import.meta.url)) proc.send(options) - proc.on('exit', serve) + proc.on('message', (message) => { + if (message === 'restart') { + proc.kill() + serve() + } else { + log.error(`Unknown message: ${message}`) + } + }) })() }) diff --git a/packages/cli/lib/command/serve.ts b/packages/cli/lib/command/serve.ts index 391393f..4908f7a 100644 --- a/packages/cli/lib/command/serve.ts +++ b/packages/cli/lib/command/serve.ts @@ -3,10 +3,9 @@ import log from '@kopflos-cms/logger' import express from 'express' import * as chokidar from 'chokidar' import kopflos from '@kopflos-cms/express' -import type { KopflosConfig } from '@kopflos-cms/core' -import { loadConfig } from '../config.js' +import { prepareConfig } from '../config.js' -interface ServeArgs { +export interface ServeArgs { mode?: 'development' | 'production' | unknown config?: string port?: number @@ -16,20 +15,13 @@ interface ServeArgs { watch?: boolean } -declare module '@kopflos-cms/core' { - interface KopflosConfig { - watch?: string[] - } -} - async function run({ mode: _mode = 'production', watch = _mode === 'development', - config, port = 1429, host = '0.0.0.0', trustProxy, - variable, + ...rest }: ServeArgs) { let mode: 'development' | 'production' if (_mode !== 'development' && _mode !== 'production') { @@ -43,53 +35,43 @@ async function run({ log.warn('Watch disabled in development mode') } - const { config: loadedConfig, filepath: configPath } = await loadConfig({ - path: config, - }) - - const finalOptions: KopflosConfig = { - mode, - ...loadedConfig, - watch: watch ? [configPath, ...loadedConfig.watch || []] : undefined, - variables: { - ...loadedConfig.variables, - ...variable, - }, - } - const app = express() if (trustProxy) { app.set('trust proxy', trustProxy) } - const { instance, middleware } = await kopflos(finalOptions) + const config = await prepareConfig({ mode, watch, ...rest }) + const { instance, middleware } = await kopflos(config) app.use(middleware) await instance.start() const server = app.listen(port, host, () => { - log.info(`Server running on ${port}. API URL: ${finalOptions.baseIri}`) + log.info(`Server running on ${port}. API URL: ${config.baseIri}`) }) - if (finalOptions.watch) { - log.info(`Watch mode. Watching for changes in: ${finalOptions.watch.join(', ')}`) + if (config.watch) { + log.info(`Watch mode. Watching for changes in: ${config.watch.join(', ')}`) async function restartServer(path: string) { log.info('Changes detected, restarting server') log.debug(`Changed file: ${path}`) await instance.stop() - server.close() - process.exit(1) + server.close(() => { + process.send?.('restart') + }) } - chokidar.watch(finalOptions.watch, { + chokidar.watch(config.watch, { ignoreInitial: true, }) .on('change', restartServer) .on('add', restartServer) .on('unlink', restartServer) } + + process.send?.('ready') } process.on('message', run) diff --git a/packages/cli/lib/config.ts b/packages/cli/lib/config.ts index 26b6e75..b0ad9c8 100644 --- a/packages/cli/lib/config.ts +++ b/packages/cli/lib/config.ts @@ -9,6 +9,12 @@ interface LoadConfig { path: string | undefined } +declare module '@kopflos-cms/core' { + interface KopflosConfig { + watch?: string[] + } +} + export async function loadConfig({ path, root }: LoadConfig): Promise<{ config: KopflosConfig; filepath: string }> { let ccResult: CosmiconfigResult if (path) { @@ -23,3 +29,28 @@ export async function loadConfig({ path, root }: LoadConfig): Promise<{ config: return ccResult } + +interface PrepareConfigArgs { + mode: 'development' | 'production' + config?: string + watch: boolean + variable: Record +} + +export async function prepareConfig({ mode, config, watch, variable }: PrepareConfigArgs): Promise { + const { config: loadedConfig, filepath: configPath } = await loadConfig({ + path: config, + }) + + const watchedPaths = loadedConfig.watch || [] + + return { + mode, + ...loadedConfig, + watch: watch ? [...watchedPaths, configPath] : undefined, + variables: { + ...(loadedConfig.variables || {}), + ...variable, + }, + } +} diff --git a/packages/cli/lib/serve.ts b/packages/cli/lib/serve.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/cli/package.json b/packages/cli/package.json index 5849c38..b374c7e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,7 +40,9 @@ "ulog": "^2.0.0-beta.19" }, "devDependencies": { - "chai": "^5.1.1" + "chai": "^5.1.1", + "mocha-chai-rdf": "^0.1.5", + "tempy": "^3.1.0" }, "mocha": { "extension": [ diff --git a/packages/cli/test/fixtures/config.with-watch.json b/packages/cli/test/fixtures/config.with-watch.json new file mode 100644 index 0000000..572b90b --- /dev/null +++ b/packages/cli/test/fixtures/config.with-watch.json @@ -0,0 +1,4 @@ +{ + "baseIri": "https://example.com/", + "watch": ["lib"] +} diff --git a/packages/cli/test/kopflos.config.ts b/packages/cli/test/kopflos.config.ts index 1a05cb2..6be6466 100644 --- a/packages/cli/test/kopflos.config.ts +++ b/packages/cli/test/kopflos.config.ts @@ -1,5 +1,12 @@ +import url from 'node:url' import type { KopflosConfig } from '@kopflos-cms/core' export default { baseIri: 'https://example.com/', + sparql: { + default: 'https://example.com/query', + }, + watch: [ + url.fileURLToPath(new URL('fixtures', import.meta.url)), + ], } diff --git a/packages/cli/test/lib/command/serve.test.ts b/packages/cli/test/lib/command/serve.test.ts new file mode 100644 index 0000000..5b9c426 --- /dev/null +++ b/packages/cli/test/lib/command/serve.test.ts @@ -0,0 +1,114 @@ +import { fork } from 'node:child_process' +import * as fs from 'node:fs' +import url from 'node:url' +import { createEmpty } from 'mocha-chai-rdf/store.js' +import type { ServeArgs } from '../../../lib/command/serve.js' + +const serve = new URL('../../../lib/command/serve.js', import.meta.url) +const fixturesDir = new URL('../../fixtures/temp/', import.meta.url) + +describe('kopflos/lib/command/serve', function () { + this.timeout(10000) + + let process: ReturnType + + beforeEach(createEmpty) + beforeEach(function () { + process = fork(serve) + fs.mkdirSync(fixturesDir) + }) + + afterEach(function () { + process.kill() + fs.rmSync(fixturesDir, { recursive: true, force: true }) + }) + + context('development mode', function () { + context('watch enabled by default', function () { + it('sends message to parent process when watched files change', runTest({ + config: url.fileURLToPath(new URL('../../kopflos.config.ts', import.meta.url)), + variable: {}, + mode: 'development', + }, function (done) { + process.on('message', (message) => { + if (message === 'restart') { + done() + } else { + done(new Error(`Unexpected message: ${message}`)) + } + }) + + fs.writeFileSync(new URL('./file.txt', fixturesDir), '') + })) + }) + + context('watch disabled', function () { + it('ignores filesystem changes', runTest({ + config: url.fileURLToPath(new URL('../../kopflos.config.ts', import.meta.url)), + variable: {}, + mode: 'development', + watch: false, + }, function (done) { + process.on('message', (message) => { + done(new Error(`Unexpected message: ${message}`)) + }) + + fs.writeFileSync(new URL('./file.txt', fixturesDir), '') + setTimeout(done, 1000) + })) + }) + }) + + context('production mode', function () { + context('watch enabled', function () { + it('sends message to parent process when watched files change', runTest({ + config: url.fileURLToPath(new URL('../../kopflos.config.ts', import.meta.url)), + variable: {}, + mode: 'production', + watch: true, + }, function (done) { + process.on('message', (message) => { + if (message === 'restart') { + done() + } else { + done(new Error(`Unexpected message: ${message}`)) + } + }) + + fs.writeFileSync(new URL('./file.txt', fixturesDir), '') + })) + }) + + context('watch disabled by default', function () { + it('ignores filesystem changes', runTest({ + config: url.fileURLToPath(new URL('../../kopflos.config.ts', import.meta.url)), + variable: {}, + mode: 'production', + }, function (done) { + process.on('message', (message) => { + done(new Error(`Unexpected message: ${message}`)) + }) + + fs.writeFileSync(new URL('./file.txt', fixturesDir), '') + setTimeout(done, 1000) + })) + }) + }) + + function runTest(args: ServeArgs, action: (done: Mocha.Done) => void): Mocha.Func { + return function (done) { + process.once('message', payload => { + if (payload === 'ready') { + action(() => { + done() + process.kill() + }) + } else { + done(new Error(`Unexpected message: ${payload}`)) + } + }) + + process.send(args) + } + } +}) diff --git a/packages/cli/test/lib/config.test.ts b/packages/cli/test/lib/config.test.ts index 86f3077..4e11d45 100644 --- a/packages/cli/test/lib/config.test.ts +++ b/packages/cli/test/lib/config.test.ts @@ -1,18 +1,18 @@ import url from 'node:url' import { expect } from 'chai' -import { loadConfig } from '../../lib/config.js' +import { loadConfig, prepareConfig } from '../../lib/config.js' describe('kopflos/lib/config.js', function () { this.timeout(10000) describe('loadConfig', () => { it('should discover the config file', async () => { - const config = await loadConfig({ + const { config } = await loadConfig({ path: undefined, root: url.fileURLToPath(new URL('..', import.meta.url)), }) - expect(config).to.be.deep.equal({ + expect(config).to.be.deep.include({ baseIri: 'https://example.com/', }) }) @@ -22,7 +22,7 @@ describe('kopflos/lib/config.js', function () { const configPath = url.fileURLToPath(new URL('../fixtures/config.json', import.meta.url)) // when - const config = await loadConfig({ + const { config } = await loadConfig({ path: configPath, }) @@ -32,4 +32,38 @@ describe('kopflos/lib/config.js', function () { }) }) }) + + describe('prepareConfig', () => { + it('sets config itself as watched paths', async () => { + // given + const configPath = url.fileURLToPath(new URL('../fixtures/config.json', import.meta.url)) + + // when + const config = await prepareConfig({ + config: configPath, + mode: 'development', + watch: true, + variable: {}, + }) + + // then + expect(config.watch).to.deep.eq([configPath]) + }) + + it('adds config itself to watched paths', async () => { + // given + const configPath = url.fileURLToPath(new URL('../fixtures/config.with-watch.json', import.meta.url)) + + // when + const config = await prepareConfig({ + config: configPath, + mode: 'development', + watch: true, + variable: {}, + }) + + // then + expect(config.watch).to.contain.all.members([configPath, 'lib']) + }) + }) })