|
|
@@ -1,12 +1,7 @@
|
|
|
/**
|
|
|
- * Config hot-reload resilience of the booted include tree. `dsh-app-boot`
|
|
|
- * installs a fail-loud unhandled-rejection handler, so a `refresh()` that
|
|
|
- * rethrows a config-file parse error would kill a live app on one bad
|
|
|
- * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
|
|
|
- * callback nobody else catches). These tests pin the vendored
|
|
|
- * `@cordisjs/plugin-include` contract that boot relies on: an invalid file
|
|
|
- * keeps the last good tree, and a valid re-read re-applies overlay patches
|
|
|
- * exactly like the initial load.
|
|
|
+ * Transactional config replacement through the booted Include and Loader tree.
|
|
|
+ * HMR contains rejected refreshes; direct callers receive the error after the
|
|
|
+ * previous generation has been retained or restored.
|
|
|
*/
|
|
|
|
|
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
|
@@ -15,6 +10,7 @@ import { join } from 'node:path'
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
import type { Context } from 'cordis'
|
|
|
import type { Include } from '@cordisjs/plugin-include'
|
|
|
+import { Group } from '@cordisjs/plugin-loader'
|
|
|
import { boot } from '../src/index.ts'
|
|
|
|
|
|
const NAME = 'dsh-test-bin'
|
|
|
@@ -27,9 +23,10 @@ interface TreeFixture {
|
|
|
include: Include
|
|
|
}
|
|
|
|
|
|
-async function bootTree(configBody: string): Promise<TreeFixture> {
|
|
|
+async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
|
|
|
writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
|
|
|
+ for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
|
|
|
writeFileSync(join(dir, 'cordis.yml'), configBody)
|
|
|
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
|
|
const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
|
|
|
@@ -41,20 +38,41 @@ function entryConfig(ctx: Context, id: string): unknown {
|
|
|
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
|
|
|
}
|
|
|
|
|
|
+function entryById(ctx: Context, id: string) {
|
|
|
+ const entry = [...ctx.loader.entries()].find(entry => entry.options.id === id)
|
|
|
+ if (!entry) throw new Error(`missing loader entry ${id}`)
|
|
|
+ return entry
|
|
|
+}
|
|
|
+
|
|
|
+function plugin(name: string, body = ''): string {
|
|
|
+ return `export default function ${name}(_ctx, config = {}) { ${body} }\n`
|
|
|
+}
|
|
|
+
|
|
|
+async function expectUpdateFailure(task: Promise<void>, stage: string): Promise<void> {
|
|
|
+ try {
|
|
|
+ await task
|
|
|
+ } catch (error) {
|
|
|
+ expect(error).toBeInstanceOf(Error)
|
|
|
+ expect((error as Error).message).toContain(`failed to ${stage} loader entry`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ throw new Error(`expected loader update to fail during ${stage}`)
|
|
|
+}
|
|
|
+
|
|
|
describe('include refresh with an invalid file', () => {
|
|
|
- it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
|
|
|
+ it('rejects while keeping the last good tree, then applies the next valid edit', async () => {
|
|
|
const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n config:\n value: 1\n')
|
|
|
try {
|
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
|
|
|
|
|
writeFileSync(join(dir, 'cordis.yml'), 'invalid: [unclosed\n')
|
|
|
- await expect(include.refresh()).resolves.toBeUndefined()
|
|
|
+ await expect(include.refresh()).rejects.toThrow('failed to parse config file')
|
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
|
|
|
|
|
// An empty file parses to `undefined` without a YAML error; it must be
|
|
|
// treated exactly like a parse failure, not crash the entry walk.
|
|
|
writeFileSync(join(dir, 'cordis.yml'), '')
|
|
|
- await expect(include.refresh()).resolves.toBeUndefined()
|
|
|
+ await expect(include.refresh()).rejects.toThrow('failed to validate config file')
|
|
|
expect(entryConfig(ctx, 'noop')).toEqual({ value: 1 })
|
|
|
|
|
|
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: 2\n')
|
|
|
@@ -67,6 +85,200 @@ describe('include refresh with an invalid file', () => {
|
|
|
})
|
|
|
})
|
|
|
|
|
|
+describe('loader entry replacement', () => {
|
|
|
+ it('imports a changed name before replacing the running plugin', async () => {
|
|
|
+ const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
|
|
+ 'old.mjs': plugin('oldPlugin'),
|
|
|
+ 'new.mjs': plugin('newPlugin'),
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ const entry = entryById(ctx, 'target')
|
|
|
+ await entry.update({ name: './new.mjs' })
|
|
|
+ expect(entry.options.name).toBe('./new.mjs')
|
|
|
+ expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
|
|
+ expect(entry.fiber?.runtime?.callback.name).toBe('newPlugin')
|
|
|
+ expect(entry.options.disabled).toBeUndefined()
|
|
|
+ await entry.fiber?.await()
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('retains the running plugin when the replacement cannot be imported', async () => {
|
|
|
+ const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
|
|
+ 'old.mjs': plugin('oldPlugin'),
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ const entry = entryById(ctx, 'target')
|
|
|
+ const fiber = entry.fiber
|
|
|
+ await expectUpdateFailure(entry.update({ name: './missing.mjs' }), 'import')
|
|
|
+ expect(entry.options.name).toBe('./old.mjs')
|
|
|
+ expect(entry.fiber === fiber).toBe(true)
|
|
|
+ await fiber?.await()
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('restores the previous plugin after replacement application fails', async () => {
|
|
|
+ const { ctx } = await bootTree('- id: target\n name: ./old.mjs\n', {
|
|
|
+ 'old.mjs': plugin('oldPlugin'),
|
|
|
+ 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ const entry = entryById(ctx, 'target')
|
|
|
+ const previous = entry.fiber
|
|
|
+ await expectUpdateFailure(entry.update({ name: './bad.mjs' }), 'apply')
|
|
|
+ expect(entry.options.name).toBe('./old.mjs')
|
|
|
+ expect(entry.fiber === previous).toBe(false)
|
|
|
+ expect(entry.fiber?.runtime?.callback.name).toBe('oldPlugin')
|
|
|
+ expect(entry.options.disabled).toBeUndefined()
|
|
|
+ await entry.fiber?.await()
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('restores the previous config when an in-place restart fails', async () => {
|
|
|
+ const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
|
|
+ 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ const entry = entryById(ctx, 'target')
|
|
|
+ const fiber = entry.fiber
|
|
|
+ await expectUpdateFailure(entry.update({ config: { fail: true } }), 'apply')
|
|
|
+ expect(entry.options.config).toEqual({ fail: false })
|
|
|
+ expect(entry.fiber === fiber).toBe(true)
|
|
|
+ await fiber?.await()
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not persist a failed direct fiber update', async () => {
|
|
|
+ const { ctx } = await bootTree('- id: target\n name: ./configurable.mjs\n config:\n fail: false\n', {
|
|
|
+ 'configurable.mjs': plugin('configurablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ const entry = entryById(ctx, 'target')
|
|
|
+ const fiber = entry.fiber
|
|
|
+ if (!fiber) throw new Error('target entry has no fiber')
|
|
|
+ await expect(fiber.update({ fail: true })).rejects.toThrow('candidate config failed')
|
|
|
+ expect(entry.options.config).toEqual({ fail: false })
|
|
|
+ expect(entry.parent.data.find(options => options.id === 'target')).toBe(entry.options)
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+describe('loader tree replacement', () => {
|
|
|
+ it('rolls back earlier updates and additions when a later entry fails', async () => {
|
|
|
+ const { ctx, dir, include } = await bootTree([
|
|
|
+ '- id: existing',
|
|
|
+ ' name: ./configurable.mjs',
|
|
|
+ ' config:',
|
|
|
+ ' value: old',
|
|
|
+ '',
|
|
|
+ ].join('\n'), {
|
|
|
+ 'configurable.mjs': plugin('configurablePlugin'),
|
|
|
+ 'bad.mjs': plugin('badPlugin', 'throw new Error("candidate apply failed")'),
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ writeFileSync(join(dir, 'cordis.yml'), [
|
|
|
+ '- id: existing',
|
|
|
+ ' name: ./configurable.mjs',
|
|
|
+ ' config:',
|
|
|
+ ' value: candidate',
|
|
|
+ '- id: added',
|
|
|
+ ' name: ./noop.mjs',
|
|
|
+ '- id: bad',
|
|
|
+ ' name: ./bad.mjs',
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
+ await expect(include.refresh()).rejects.toThrow('failed to apply loader entry bad')
|
|
|
+ expect(entryConfig(ctx, 'existing')).toEqual({ value: 'old' })
|
|
|
+ expect([...ctx.loader.entries()].some(entry => entry.options.id === 'added')).toBe(false)
|
|
|
+ expect([...ctx.loader.entries()].some(entry => entry.options.id === 'bad')).toBe(false)
|
|
|
+
|
|
|
+ writeFileSync(join(dir, 'cordis.yml'), [
|
|
|
+ '- id: existing',
|
|
|
+ ' name: ./configurable.mjs',
|
|
|
+ ' config:',
|
|
|
+ ' value: committed',
|
|
|
+ '- id: added',
|
|
|
+ ' name: ./noop.mjs',
|
|
|
+ '',
|
|
|
+ ].join('\n'))
|
|
|
+ await include.refresh()
|
|
|
+ expect(entryConfig(ctx, 'existing')).toEqual({ value: 'committed' })
|
|
|
+ expect(entryById(ctx, 'added').fiber).toBeDefined()
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('stops and restores descendants when an ancestor group is disabled and re-enabled', async () => {
|
|
|
+ const { ctx, dir, include } = await bootTree('- id: noop\n name: ./noop.mjs\n')
|
|
|
+ ctx.loader.builtins.group = Group
|
|
|
+ try {
|
|
|
+ const config = (disabled: boolean) => [
|
|
|
+ '- id: parent',
|
|
|
+ ' name: cordis:group',
|
|
|
+ ' group: true',
|
|
|
+ ` disabled: ${disabled}`,
|
|
|
+ ' config:',
|
|
|
+ ' - id: child',
|
|
|
+ ' name: ./noop.mjs',
|
|
|
+ '',
|
|
|
+ ].join('\n')
|
|
|
+
|
|
|
+ writeFileSync(join(dir, 'cordis.yml'), config(false))
|
|
|
+ await include.refresh()
|
|
|
+ expect(entryById(ctx, 'child').fiber).toBeDefined()
|
|
|
+
|
|
|
+ writeFileSync(join(dir, 'cordis.yml'), config(true))
|
|
|
+ await include.refresh()
|
|
|
+ expect(entryById(ctx, 'child').fiber).toBeUndefined()
|
|
|
+
|
|
|
+ writeFileSync(join(dir, 'cordis.yml'), config(false))
|
|
|
+ await include.refresh()
|
|
|
+ expect(entryById(ctx, 'child').fiber).toBeDefined()
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ it('restores a programmatic entry move when its update fails', async () => {
|
|
|
+ const { ctx } = await bootTree('- id: noop\n name: ./noop.mjs\n', {
|
|
|
+ 'movable.mjs': plugin('movablePlugin', 'if (config.fail) throw new Error("candidate config failed")'),
|
|
|
+ })
|
|
|
+ ctx.loader.builtins.group = Group
|
|
|
+ try {
|
|
|
+ const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [] })
|
|
|
+ const targetId = await ctx.loader.create({ name: './movable.mjs', config: { fail: false } })
|
|
|
+ const target = entryById(ctx, targetId)
|
|
|
+ const source = target.parent
|
|
|
+ const sourceIndex = source.data.indexOf(target.options)
|
|
|
+ const destination = entryById(ctx, groupId).subgroup
|
|
|
+ if (!destination) throw new Error('created loader group has no subgroup')
|
|
|
+
|
|
|
+ await expectUpdateFailure(
|
|
|
+ ctx.loader.update(targetId, { config: { fail: true } }, groupId),
|
|
|
+ 'apply',
|
|
|
+ )
|
|
|
+
|
|
|
+ expect(target.parent).toBe(source)
|
|
|
+ expect(Object.getPrototypeOf(target.ctx)).toBe(source.ctx)
|
|
|
+ expect(source.data.indexOf(target.options)).toBe(sourceIndex)
|
|
|
+ expect(destination.data).not.toContain(target.options)
|
|
|
+ expect(target.options.config).toEqual({ fail: false })
|
|
|
+ } finally {
|
|
|
+ await ctx.fiber.dispose()
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
describe('include refresh with overlay patches', () => {
|
|
|
it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
|