Skip to content

Commit

Permalink
test: serve subprocess + config
Browse files Browse the repository at this point in the history
  • Loading branch information
tpluscode committed Nov 21, 2024
1 parent bb46a15 commit c8a7b2e
Show file tree
Hide file tree
Showing 9 changed files with 222 additions and 38 deletions.
12 changes: 11 additions & 1 deletion packages/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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}`)
}
})
})()
})

Expand Down
46 changes: 14 additions & 32 deletions packages/cli/lib/command/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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') {
Expand All @@ -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)
31 changes: 31 additions & 0 deletions packages/cli/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<string, unknown>
}

export async function prepareConfig({ mode, config, watch, variable }: PrepareConfigArgs): Promise<KopflosConfig> {
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,
},
}
}
Empty file removed packages/cli/lib/serve.ts
Empty file.
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/test/fixtures/config.with-watch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"baseIri": "https://example.com/",
"watch": ["lib"]
}
7 changes: 7 additions & 0 deletions packages/cli/test/kopflos.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import url from 'node:url'
import type { KopflosConfig } from '@kopflos-cms/core'

export default <KopflosConfig> {
baseIri: 'https://example.com/',
sparql: {
default: 'https://example.com/query',
},
watch: [
url.fileURLToPath(new URL('fixtures', import.meta.url)),
],
}
114 changes: 114 additions & 0 deletions packages/cli/test/lib/command/serve.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fork>

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)
}
}
})
42 changes: 38 additions & 4 deletions packages/cli/test/lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -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/',
})
})
Expand All @@ -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,
})

Expand All @@ -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'])
})
})
})

0 comments on commit c8a7b2e

Please sign in to comment.