From 66024e68b6e2657df7a5c2aabb9e3d407e6dd4d9 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 5 Aug 2026 12:58:06 +0100 Subject: [PATCH 1/8] feat: add InvalidFormDefinitionError hierarchy for definition failures --- src/server/plugins/engine/errors.test.ts | 39 +++++++++++++++++ src/server/plugins/engine/errors.ts | 53 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/server/plugins/engine/errors.test.ts create mode 100644 src/server/plugins/engine/errors.ts diff --git a/src/server/plugins/engine/errors.test.ts b/src/server/plugins/engine/errors.test.ts new file mode 100644 index 000000000..d6b7ff7d4 --- /dev/null +++ b/src/server/plugins/engine/errors.test.ts @@ -0,0 +1,39 @@ +import { + ConditionBuildError, + InvalidFormDefinitionError, + UnknownComponentTypeError, + UnknownPageControllerError +} from '~/src/server/plugins/engine/errors.js' + +describe('InvalidFormDefinitionError hierarchy', () => { + it('ConditionBuildError carries the condition name and cause', () => { + const cause = new Error('parse error [1:24]: Expected EOF') + const err = new ConditionBuildError('Existing user', { cause }) + + expect(err).toBeInstanceOf(InvalidFormDefinitionError) + expect(err.name).toBe('ConditionBuildError') + expect(err.conditionName).toBe('Existing user') + expect(err.message).toBe("Failed to build condition 'Existing user'") + expect(err.cause).toBe(cause) + }) + + it('UnknownPageControllerError keeps the legacy message text', () => { + const err = new UnknownPageControllerError('NoSuchPageController') + + expect(err).toBeInstanceOf(InvalidFormDefinitionError) + expect(err.name).toBe('UnknownPageControllerError') + expect(err.controllerName).toBe('NoSuchPageController') + expect(err.message).toBe( + 'Page controller NoSuchPageController does not exist' + ) + }) + + it('UnknownComponentTypeError keeps the legacy message text', () => { + const err = new UnknownComponentTypeError('NopeField') + + expect(err).toBeInstanceOf(InvalidFormDefinitionError) + expect(err.name).toBe('UnknownComponentTypeError') + expect(err.componentType).toBe('NopeField') + expect(err.message).toBe('Component type NopeField does not exist') + }) +}) diff --git a/src/server/plugins/engine/errors.ts b/src/server/plugins/engine/errors.ts new file mode 100644 index 000000000..a8e0b3ef8 --- /dev/null +++ b/src/server/plugins/engine/errors.ts @@ -0,0 +1,53 @@ +/** + * Base class for errors caused by a form definition that passed schema + * validation but cannot be used by the engine. Never thrown directly — + * throw a subclass. Consumers (e.g. forms-runner error pages) detect the + * family with `instanceof InvalidFormDefinitionError`. + */ +export class InvalidFormDefinitionError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'InvalidFormDefinitionError' + } +} + +/** + * Thrown when a condition in the definition cannot be compiled into an + * evaluatable expression (e.g. it references the wrong component or list). + */ +export class ConditionBuildError extends InvalidFormDefinitionError { + public readonly conditionName: string + + constructor(conditionName: string, options?: ErrorOptions) { + super(`Failed to build condition '${conditionName}'`, options) + this.name = 'ConditionBuildError' + this.conditionName = conditionName + } +} + +/** + * Thrown when a page names a controller that is neither built in nor + * registered by the host application. + */ +export class UnknownPageControllerError extends InvalidFormDefinitionError { + public readonly controllerName: string + + constructor(controllerName: string) { + super(`Page controller ${controllerName} does not exist`) + this.name = 'UnknownPageControllerError' + this.controllerName = controllerName + } +} + +/** + * Thrown when a component declares a type with no registered implementation. + */ +export class UnknownComponentTypeError extends InvalidFormDefinitionError { + public readonly componentType: string + + constructor(componentType: string) { + super(`Component type ${componentType} does not exist`) + this.name = 'UnknownComponentTypeError' + this.componentType = componentType + } +} From 43be6701265a24da8b9de40523f3468d49618ec2 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 5 Aug 2026 13:06:04 +0100 Subject: [PATCH 2/8] feat: throw typed InvalidFormDefinitionError subclasses at definition failure sites --- .../engine/components/helpers/components.ts | 3 +- src/server/plugins/engine/errors.test.ts | 128 ++++++++++++++++++ src/server/plugins/engine/models/FormModel.ts | 9 +- .../engine/pageControllers/helpers/pages.ts | 3 +- 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/server/plugins/engine/components/helpers/components.ts b/src/server/plugins/engine/components/helpers/components.ts index ff10294dc..728fc8616 100644 --- a/src/server/plugins/engine/components/helpers/components.ts +++ b/src/server/plugins/engine/components/helpers/components.ts @@ -6,6 +6,7 @@ import { ListFormComponent } from '~/src/server/plugins/engine/components/ListFo import { escapeMarkdown } from '~/src/server/plugins/engine/components/helpers/index.js' import * as Components from '~/src/server/plugins/engine/components/index.js' import { markdown } from '~/src/server/plugins/engine/components/markdownParser.js' +import { UnknownComponentTypeError } from '~/src/server/plugins/engine/errors.js' import { type Translator } from '~/src/server/plugins/engine/i18n/types.js' import { type FormState } from '~/src/server/plugins/engine/types.js' @@ -210,7 +211,7 @@ export function createComponent( } if (typeof component === 'undefined') { - throw new Error(`Component type ${def.type} does not exist`) + throw new UnknownComponentTypeError(def.type) } return component diff --git a/src/server/plugins/engine/errors.test.ts b/src/server/plugins/engine/errors.test.ts index d6b7ff7d4..d1063979c 100644 --- a/src/server/plugins/engine/errors.test.ts +++ b/src/server/plugins/engine/errors.test.ts @@ -1,9 +1,12 @@ +import { type FormDefinition } from '@defra/forms-model' + import { ConditionBuildError, InvalidFormDefinitionError, UnknownComponentTypeError, UnknownPageControllerError } from '~/src/server/plugins/engine/errors.js' +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' describe('InvalidFormDefinitionError hierarchy', () => { it('ConditionBuildError carries the condition name and cause', () => { @@ -37,3 +40,128 @@ describe('InvalidFormDefinitionError hierarchy', () => { expect(err.message).toBe('Component type NopeField does not exist') }) }) + +const brokenConditionDef = { + name: 'Broken condition fixture', + engine: 'V2', + schema: 2, + startPage: '/summary', + pages: [ + { + id: '449c053b-9201-4312-9a75-187afc6ba48b', + path: '/licence', + title: 'Licence', + components: [ + { + id: 'a7c0242f-2a31-45b2-8c71-ff2ac7f53288', + name: 'xVrYaJ', + type: 'YesNoField', + title: 'Do you have a licence?', + shortDescription: 'Licence', + options: { required: true }, + schema: {}, + list: '4fa26e9c-07cf-47cd-a9dd-5cec0dd3f544' + } + ], + next: [] + }, + { + id: '449c053b-9201-4312-9a75-187afc6ba48c', + path: '/summary', + title: 'Summary', + controller: 'SummaryPageController', + components: [], + next: [] + } + ], + lists: [ + { + id: '4fa26e9c-07cf-47cd-a9dd-5cec0dd3f544', + name: 'XtfRYR', + title: 'User type list', + type: 'string', + items: [ + { + id: '55fe0067-d011-4d33-886c-e1aa266637c3', + text: 'existing user', + value: 'existing user' + }, + { + id: '2277c7e5-7fef-46c6-993b-d294116d6d6b', + text: 'new user', + value: 'new user' + } + ] + } + ], + sections: [], + conditions: [ + { + id: '3f9d3a35-6dee-4706-806c-3f776129f631', + displayName: 'Existing user', + items: [ + { + id: '7d7f58ee-c860-4d24-8a13-de5cb9af53d8', + componentId: 'a7c0242f-2a31-45b2-8c71-ff2ac7f53288', + operator: 'is', + type: 'ListItemRef', + value: { + listId: '4fa26e9c-07cf-47cd-a9dd-5cec0dd3f544', + itemId: ['55fe0067-d011-4d33-886c-e1aa266637c3'] + } + } + ] + } + ] +} as unknown as FormDefinition + +const unknownControllerDef = { + ...structuredClone(brokenConditionDef), + name: 'Unknown controller fixture', + conditions: [] +} as unknown as FormDefinition +// remove the bogus list and point page 1 at a controller that does not exist +const brokenPage = unknownControllerDef.pages[0] as unknown as { + components: { list?: string }[] + controller: string +} +delete brokenPage.components[0].list +brokenPage.controller = 'NoSuchPageController' + +describe('typed errors thrown from real failure sites', () => { + it('FormModel throws ConditionBuildError for an uncompilable condition', () => { + const build = () => new FormModel(brokenConditionDef, { basePath: 'test' }) + + expect(build).toThrow(ConditionBuildError) + + let thrown: unknown + try { + build() + } catch (err) { + thrown = err + } + + const conditionErr = thrown as ConditionBuildError + expect(conditionErr.conditionName).toBe('Existing user') + expect(conditionErr.cause).toBeInstanceOf(Error) + expect((conditionErr.cause as Error).message).toContain('parse error') + }) + + it('FormModel throws UnknownPageControllerError for an unregistered controller', () => { + const build = () => + new FormModel(unknownControllerDef, { basePath: 'test' }) + + expect(build).toThrow(UnknownPageControllerError) + + let thrown: unknown + try { + build() + } catch (err) { + thrown = err + } + + expect((thrown as UnknownPageControllerError).controllerName).toBe( + 'NoSuchPageController' + ) + }) +}) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index 5bbf07aa5..c61996553 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -39,6 +39,7 @@ import { type Component } from '~/src/server/plugins/engine/components/helpers/components.js' import { todayAsDateOnly } from '~/src/server/plugins/engine/date-helper.js' +import { ConditionBuildError } from '~/src/server/plugins/engine/errors.js' import { findPage, getError, @@ -292,7 +293,13 @@ export class FormModel { }) const { name, displayName, value } = condition - const expr = this.toConditionExpression(value, parser) + + let expr + try { + expr = this.toConditionExpression(value, parser) + } catch (cause) { + throw new ConditionBuildError(displayName, { cause }) + } const fn = (evaluationState: FormState) => { const ctx = this.toConditionContext(evaluationState, this.conditions) diff --git a/src/server/plugins/engine/pageControllers/helpers/pages.ts b/src/server/plugins/engine/pageControllers/helpers/pages.ts index c87c9b3dd..e30b4fe8d 100644 --- a/src/server/plugins/engine/pageControllers/helpers/pages.ts +++ b/src/server/plugins/engine/pageControllers/helpers/pages.ts @@ -4,6 +4,7 @@ import { type Page } from '@defra/forms-model' +import { UnknownPageControllerError } from '~/src/server/plugins/engine/errors.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import * as PageControllers from '~/src/server/plugins/engine/pageControllers/index.js' @@ -66,7 +67,7 @@ export function createPage(model: FormModel, pageDef: Page) { } if (typeof controller === 'undefined') { - throw new Error(`Page controller ${pageDef.controller} does not exist`) + throw new UnknownPageControllerError(pageDef.controller) } return controller From 78c7fe6982eab7ec90c0dcf53440e39c3e22418e Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 5 Aug 2026 15:02:36 +0100 Subject: [PATCH 3/8] feat: wrap schema validation failures in SchemaValidationError --- src/server/plugins/engine/errors.test.ts | 33 +++++++++++++++++++ src/server/plugins/engine/errors.ts | 24 +++++++++++--- .../plugins/engine/models/FormModel.test.ts | 16 +++++++-- src/server/plugins/engine/models/FormModel.ts | 7 ++-- 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/server/plugins/engine/errors.test.ts b/src/server/plugins/engine/errors.test.ts index d1063979c..8e9267108 100644 --- a/src/server/plugins/engine/errors.test.ts +++ b/src/server/plugins/engine/errors.test.ts @@ -3,6 +3,7 @@ import { type FormDefinition } from '@defra/forms-model' import { ConditionBuildError, InvalidFormDefinitionError, + SchemaValidationError, UnknownComponentTypeError, UnknownPageControllerError } from '~/src/server/plugins/engine/errors.js' @@ -165,3 +166,35 @@ describe('typed errors thrown from real failure sites', () => { ) }) }) + +describe('SchemaValidationError', () => { + it('is thrown by FormModel for a schema-invalid definition, wrapping the raw Joi error', () => { + const schemaInvalidDef = structuredClone(brokenConditionDef) + schemaInvalidDef.conditions = [] + // duplicate page path violates the schema's uniqueness rule + schemaInvalidDef.pages.push(structuredClone(schemaInvalidDef.pages[0])) + + const build = () => new FormModel(schemaInvalidDef, { basePath: 'test' }) + + let thrown: unknown + try { + build() + } catch (err) { + thrown = err + } + + expect(thrown).toBeInstanceOf(SchemaValidationError) + expect(thrown).toBeInstanceOf(InvalidFormDefinitionError) + + const schemaError = thrown as SchemaValidationError + expect(schemaError.name).toBe('SchemaValidationError') + expect(schemaError.message).toContain('Invalid form definition:') + + const cause = schemaError.cause as { + isJoi?: boolean + details?: unknown[] + } + expect(cause.isJoi).toBe(true) + expect(Array.isArray(cause.details)).toBe(true) + }) +}) diff --git a/src/server/plugins/engine/errors.ts b/src/server/plugins/engine/errors.ts index a8e0b3ef8..23fac9c0c 100644 --- a/src/server/plugins/engine/errors.ts +++ b/src/server/plugins/engine/errors.ts @@ -1,8 +1,11 @@ +import { type ValidationError } from 'joi' + /** - * Base class for errors caused by a form definition that passed schema - * validation but cannot be used by the engine. Never thrown directly — - * throw a subclass. Consumers (e.g. forms-runner error pages) detect the - * family with `instanceof InvalidFormDefinitionError`. + * Base class for errors caused by an invalid form definition — whether it + * fails schema validation or passes the schema but cannot be used by the + * engine. Never thrown directly — throw a subclass. Consumers (e.g. + * forms-runner error pages) detect the family with + * `instanceof InvalidFormDefinitionError`. */ export class InvalidFormDefinitionError extends Error { constructor(message: string, options?: ErrorOptions) { @@ -51,3 +54,16 @@ export class UnknownComponentTypeError extends InvalidFormDefinitionError { this.componentType = componentType } } + +/** + * Thrown when a form definition fails Joi schema validation. The raw + * ValidationError is preserved as `cause` so consumers can reach the + * per-field details; the message carries Joi's own summary so log lines + * remain diagnostic for consumers that only read `error.message`. + */ +export class SchemaValidationError extends InvalidFormDefinitionError { + constructor(cause: ValidationError) { + super(`Invalid form definition: ${cause.message}`, { cause }) + this.name = 'SchemaValidationError' + } +} diff --git a/src/server/plugins/engine/models/FormModel.test.ts b/src/server/plugins/engine/models/FormModel.test.ts index 76574e1cc..d3084e2b7 100644 --- a/src/server/plugins/engine/models/FormModel.test.ts +++ b/src/server/plugins/engine/models/FormModel.test.ts @@ -9,6 +9,7 @@ import { } from '@defra/forms-model' import { todayAsDateOnly } from '~/src/server/plugins/engine/date-helper.js' +import { SchemaValidationError } from '~/src/server/plugins/engine/errors.js' import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' import { buildFormContextRequest } from '~/src/server/plugins/engine/pageControllers/__stubs__/request.js' import { type FormContextRequest } from '~/src/server/plugins/engine/types.js' @@ -107,13 +108,22 @@ describe('FormModel', () => { ).toBeDefined() }) - it('throws an error if schema validation fails', () => { + it('throws a SchemaValidationError if schema validation fails', () => { + const validationError = new Error('Validation error') formDefinitionV2Schema.validate = jest.fn().mockReturnValueOnce({ - error: 'Validation error' + error: validationError }) expect(() => new FormModel(definitionV2, { basePath: 'test' })).toThrow( - 'Validation error' + SchemaValidationError + ) + + formDefinitionV2Schema.validate = jest.fn().mockReturnValueOnce({ + error: validationError + }) + + expect(() => new FormModel(definitionV2, { basePath: 'test' })).toThrow( + 'Invalid form definition: Validation error' ) }) diff --git a/src/server/plugins/engine/models/FormModel.ts b/src/server/plugins/engine/models/FormModel.ts index c61996553..0309e8826 100644 --- a/src/server/plugins/engine/models/FormModel.ts +++ b/src/server/plugins/engine/models/FormModel.ts @@ -39,7 +39,10 @@ import { type Component } from '~/src/server/plugins/engine/components/helpers/components.js' import { todayAsDateOnly } from '~/src/server/plugins/engine/date-helper.js' -import { ConditionBuildError } from '~/src/server/plugins/engine/errors.js' +import { + ConditionBuildError, + SchemaValidationError +} from '~/src/server/plugins/engine/errors.js' import { findPage, getError, @@ -125,7 +128,7 @@ export class FormModel { const result = schema.validate(def, { abortEarly: false }) if (result.error) { - throw result.error + throw new SchemaValidationError(result.error) } // Make a clone of the shallow copy returned From fa7a6d33db9bad4fab95e4f3dde7821468bd22a8 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 6 Aug 2026 15:00:33 +0100 Subject: [PATCH 4/8] refactor: move definition fixtures to __stubs__ builders --- .../plugins/engine/__stubs__/definitions.ts | 141 ++++++++++++++++++ src/server/plugins/engine/errors.test.ts | 107 ++----------- 2 files changed, 151 insertions(+), 97 deletions(-) create mode 100644 src/server/plugins/engine/__stubs__/definitions.ts diff --git a/src/server/plugins/engine/__stubs__/definitions.ts b/src/server/plugins/engine/__stubs__/definitions.ts new file mode 100644 index 000000000..ef49ef0c6 --- /dev/null +++ b/src/server/plugins/engine/__stubs__/definitions.ts @@ -0,0 +1,141 @@ +import { + ComponentType, + ConditionType, + ControllerType, + Engine, + OperatorName, + SchemaVersion, + type FormDefinition, + type PageQuestion +} from '@defra/forms-model' + +const YES_NO_COMPONENT_ID = 'a7c0242f-2a31-45b2-8c71-ff2ac7f53288' +const USER_TYPE_LIST_ID = '4fa26e9c-07cf-47cd-a9dd-5cec0dd3f544' +const EXISTING_USER_ITEM_ID = '55fe0067-d011-4d33-886c-e1aa266637c3' + +/** + * A minimal, schema-valid V2 definition: one YesNo question page and a + * summary page. Each call returns a fresh object, so tests can mutate their + * copy freely without affecting each other. + */ +export function buildDefinition(): FormDefinition { + return { + name: 'Stub definition', + engine: Engine.V2, + schema: SchemaVersion.V2, + startPage: '/summary', + pages: [ + { + id: '449c053b-9201-4312-9a75-187afc6ba48b', + path: '/licence', + title: 'Licence', + components: [ + { + id: YES_NO_COMPONENT_ID, + name: 'xVrYaJ', + type: ComponentType.YesNoField, + title: 'Do you have a licence?', + shortDescription: 'Licence', + options: { required: true } + } + ], + next: [] + }, + { + id: '449c053b-9201-4312-9a75-187afc6ba48c', + path: '/summary', + title: 'Summary', + controller: ControllerType.Summary, + components: [] + } + ], + lists: [], + sections: [], + conditions: [] + } +} + +/** + * A definition that passes schema validation but cannot be used by the + * engine: a ListItemRef condition points at the YesNoField (boolean), so the + * condition expression builder produces an unparseable expression. Distilled + * from a real production incident. + */ +export function buildBrokenConditionDefinition(): FormDefinition { + const definition = buildDefinition() + + definition.name = 'Broken condition fixture' + + const questionPage = definition.pages[0] as PageQuestion + // A YesNoField cannot legitimately carry a custom list — that is the + // corruption this fixture models — so the TS component types (rightly) + // have no `list` property here and a cast is required. + ;(questionPage.components[0] as { list?: string }).list = USER_TYPE_LIST_ID + definition.lists = [ + { + id: USER_TYPE_LIST_ID, + name: 'XtfRYR', + title: 'User type list', + type: 'string', + items: [ + { + id: EXISTING_USER_ITEM_ID, + text: 'existing user', + value: 'existing user' + }, + { + id: '2277c7e5-7fef-46c6-993b-d294116d6d6b', + text: 'new user', + value: 'new user' + } + ] + } + ] + definition.conditions = [ + { + id: '3f9d3a35-6dee-4706-806c-3f776129f631', + displayName: 'Existing user', + items: [ + { + id: '7d7f58ee-c860-4d24-8a13-de5cb9af53d8', + componentId: YES_NO_COMPONENT_ID, + operator: OperatorName.Is, + type: ConditionType.ListItemRef, + value: { + listId: USER_TYPE_LIST_ID, + itemId: [EXISTING_USER_ITEM_ID] + } + } + ] + } + ] + + return definition +} + +/** + * A schema-valid definition whose first page names a controller that is + * neither built in nor registered by the host application. + */ +export function buildUnknownControllerDefinition(): FormDefinition { + const definition = buildDefinition() + + definition.name = 'Unknown controller fixture' + definition.pages[0].controller = + 'NoSuchPageController' as unknown as ControllerType + + return definition +} + +/** + * A definition that fails schema validation: the question page appears + * twice, violating the pages uniqueness rule. + */ +export function buildSchemaInvalidDefinition(): FormDefinition { + const definition = buildDefinition() + + definition.name = 'Schema invalid fixture' + definition.pages.push(buildDefinition().pages[0]) + + return definition +} diff --git a/src/server/plugins/engine/errors.test.ts b/src/server/plugins/engine/errors.test.ts index 8e9267108..6b0325b40 100644 --- a/src/server/plugins/engine/errors.test.ts +++ b/src/server/plugins/engine/errors.test.ts @@ -1,5 +1,8 @@ -import { type FormDefinition } from '@defra/forms-model' - +import { + buildBrokenConditionDefinition, + buildSchemaInvalidDefinition, + buildUnknownControllerDefinition +} from '~/src/server/plugins/engine/__stubs__/definitions.js' import { ConditionBuildError, InvalidFormDefinitionError, @@ -42,96 +45,10 @@ describe('InvalidFormDefinitionError hierarchy', () => { }) }) -const brokenConditionDef = { - name: 'Broken condition fixture', - engine: 'V2', - schema: 2, - startPage: '/summary', - pages: [ - { - id: '449c053b-9201-4312-9a75-187afc6ba48b', - path: '/licence', - title: 'Licence', - components: [ - { - id: 'a7c0242f-2a31-45b2-8c71-ff2ac7f53288', - name: 'xVrYaJ', - type: 'YesNoField', - title: 'Do you have a licence?', - shortDescription: 'Licence', - options: { required: true }, - schema: {}, - list: '4fa26e9c-07cf-47cd-a9dd-5cec0dd3f544' - } - ], - next: [] - }, - { - id: '449c053b-9201-4312-9a75-187afc6ba48c', - path: '/summary', - title: 'Summary', - controller: 'SummaryPageController', - components: [], - next: [] - } - ], - lists: [ - { - id: '4fa26e9c-07cf-47cd-a9dd-5cec0dd3f544', - name: 'XtfRYR', - title: 'User type list', - type: 'string', - items: [ - { - id: '55fe0067-d011-4d33-886c-e1aa266637c3', - text: 'existing user', - value: 'existing user' - }, - { - id: '2277c7e5-7fef-46c6-993b-d294116d6d6b', - text: 'new user', - value: 'new user' - } - ] - } - ], - sections: [], - conditions: [ - { - id: '3f9d3a35-6dee-4706-806c-3f776129f631', - displayName: 'Existing user', - items: [ - { - id: '7d7f58ee-c860-4d24-8a13-de5cb9af53d8', - componentId: 'a7c0242f-2a31-45b2-8c71-ff2ac7f53288', - operator: 'is', - type: 'ListItemRef', - value: { - listId: '4fa26e9c-07cf-47cd-a9dd-5cec0dd3f544', - itemId: ['55fe0067-d011-4d33-886c-e1aa266637c3'] - } - } - ] - } - ] -} as unknown as FormDefinition - -const unknownControllerDef = { - ...structuredClone(brokenConditionDef), - name: 'Unknown controller fixture', - conditions: [] -} as unknown as FormDefinition -// remove the bogus list and point page 1 at a controller that does not exist -const brokenPage = unknownControllerDef.pages[0] as unknown as { - components: { list?: string }[] - controller: string -} -delete brokenPage.components[0].list -brokenPage.controller = 'NoSuchPageController' - describe('typed errors thrown from real failure sites', () => { it('FormModel throws ConditionBuildError for an uncompilable condition', () => { - const build = () => new FormModel(brokenConditionDef, { basePath: 'test' }) + const build = () => + new FormModel(buildBrokenConditionDefinition(), { basePath: 'test' }) expect(build).toThrow(ConditionBuildError) @@ -150,7 +67,7 @@ describe('typed errors thrown from real failure sites', () => { it('FormModel throws UnknownPageControllerError for an unregistered controller', () => { const build = () => - new FormModel(unknownControllerDef, { basePath: 'test' }) + new FormModel(buildUnknownControllerDefinition(), { basePath: 'test' }) expect(build).toThrow(UnknownPageControllerError) @@ -169,12 +86,8 @@ describe('typed errors thrown from real failure sites', () => { describe('SchemaValidationError', () => { it('is thrown by FormModel for a schema-invalid definition, wrapping the raw Joi error', () => { - const schemaInvalidDef = structuredClone(brokenConditionDef) - schemaInvalidDef.conditions = [] - // duplicate page path violates the schema's uniqueness rule - schemaInvalidDef.pages.push(structuredClone(schemaInvalidDef.pages[0])) - - const build = () => new FormModel(schemaInvalidDef, { basePath: 'test' }) + const build = () => + new FormModel(buildSchemaInvalidDefinition(), { basePath: 'test' }) let thrown: unknown try { From ec61b5121b29d50bdacafaa319f50a450e22b116 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 6 Aug 2026 15:04:39 +0100 Subject: [PATCH 5/8] refactor: describe error tests by behaviour, not migration history --- src/server/plugins/engine/errors.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/plugins/engine/errors.test.ts b/src/server/plugins/engine/errors.test.ts index 6b0325b40..00a633fd2 100644 --- a/src/server/plugins/engine/errors.test.ts +++ b/src/server/plugins/engine/errors.test.ts @@ -24,7 +24,7 @@ describe('InvalidFormDefinitionError hierarchy', () => { expect(err.cause).toBe(cause) }) - it('UnknownPageControllerError keeps the legacy message text', () => { + it('UnknownPageControllerError carries the controller name', () => { const err = new UnknownPageControllerError('NoSuchPageController') expect(err).toBeInstanceOf(InvalidFormDefinitionError) @@ -35,7 +35,7 @@ describe('InvalidFormDefinitionError hierarchy', () => { ) }) - it('UnknownComponentTypeError keeps the legacy message text', () => { + it('UnknownComponentTypeError carries the component type', () => { const err = new UnknownComponentTypeError('NopeField') expect(err).toBeInstanceOf(InvalidFormDefinitionError) From d543d08133842b90667ca33967d564fad97077e8 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 6 Aug 2026 15:44:41 +0100 Subject: [PATCH 6/8] feat: type SchemaValidationError cause as ValidationError --- src/server/plugins/engine/errors.test.ts | 8 ++------ src/server/plugins/engine/errors.ts | 4 ++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/server/plugins/engine/errors.test.ts b/src/server/plugins/engine/errors.test.ts index 00a633fd2..278dcd89e 100644 --- a/src/server/plugins/engine/errors.test.ts +++ b/src/server/plugins/engine/errors.test.ts @@ -103,11 +103,7 @@ describe('SchemaValidationError', () => { expect(schemaError.name).toBe('SchemaValidationError') expect(schemaError.message).toContain('Invalid form definition:') - const cause = schemaError.cause as { - isJoi?: boolean - details?: unknown[] - } - expect(cause.isJoi).toBe(true) - expect(Array.isArray(cause.details)).toBe(true) + expect(schemaError.cause.isJoi).toBe(true) + expect(Array.isArray(schemaError.cause.details)).toBe(true) }) }) diff --git a/src/server/plugins/engine/errors.ts b/src/server/plugins/engine/errors.ts index 23fac9c0c..8e64b4115 100644 --- a/src/server/plugins/engine/errors.ts +++ b/src/server/plugins/engine/errors.ts @@ -62,6 +62,10 @@ export class UnknownComponentTypeError extends InvalidFormDefinitionError { * remain diagnostic for consumers that only read `error.message`. */ export class SchemaValidationError extends InvalidFormDefinitionError { + // Error types `cause` as unknown; this constructor only accepts a Joi + // ValidationError, so narrow the declaration for consumers (type-only). + declare cause: ValidationError + constructor(cause: ValidationError) { super(`Invalid form definition: ${cause.message}`, { cause }) this.name = 'SchemaValidationError' From 642b3f350300c7d90591547ff94178c8e69cf015 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 6 Aug 2026 18:51:39 +0100 Subject: [PATCH 7/8] docs: warn subclass authors that error messages are user-facing --- src/server/plugins/engine/errors.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/server/plugins/engine/errors.ts b/src/server/plugins/engine/errors.ts index 8e64b4115..493af107c 100644 --- a/src/server/plugins/engine/errors.ts +++ b/src/server/plugins/engine/errors.ts @@ -6,6 +6,11 @@ import { type ValidationError } from 'joi' * engine. Never thrown directly — throw a subclass. Consumers (e.g. * forms-runner error pages) detect the family with * `instanceof InvalidFormDefinitionError`. + * + * When subclassing, remember that the message and the messages of any + * `cause` chain are shown to form authors on preview error pages. Only + * describe the problem with the form itself — never include sensitive + * details such as file paths, configuration values or secrets. */ export class InvalidFormDefinitionError extends Error { constructor(message: string, options?: ErrorOptions) { From 2848353117b20b61c2f723f8af54328cd2bb1d50 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Thu, 6 Aug 2026 19:12:56 +0100 Subject: [PATCH 8/8] test: unknown component types are reachable with data alone --- .../plugins/engine/__stubs__/definitions.ts | 14 +++++++++++++ src/server/plugins/engine/errors.test.ts | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/server/plugins/engine/__stubs__/definitions.ts b/src/server/plugins/engine/__stubs__/definitions.ts index ef49ef0c6..4762193f8 100644 --- a/src/server/plugins/engine/__stubs__/definitions.ts +++ b/src/server/plugins/engine/__stubs__/definitions.ts @@ -127,6 +127,20 @@ export function buildUnknownControllerDefinition(): FormDefinition { return definition } +/** + * Schema-valid (component types are free strings in the schema), but the + * question uses a type the engine has no component class for. + */ +export function buildUnknownComponentDefinition(): FormDefinition { + const definition = buildDefinition() + + definition.name = 'Unknown component fixture' + const questionPage = definition.pages[0] as PageQuestion + questionPage.components[0].type = 'MyUnknownField' as unknown as ComponentType + + return definition +} + /** * A definition that fails schema validation: the question page appears * twice, violating the pages uniqueness rule. diff --git a/src/server/plugins/engine/errors.test.ts b/src/server/plugins/engine/errors.test.ts index 278dcd89e..e4a72f073 100644 --- a/src/server/plugins/engine/errors.test.ts +++ b/src/server/plugins/engine/errors.test.ts @@ -1,6 +1,7 @@ import { buildBrokenConditionDefinition, buildSchemaInvalidDefinition, + buildUnknownComponentDefinition, buildUnknownControllerDefinition } from '~/src/server/plugins/engine/__stubs__/definitions.js' import { @@ -84,6 +85,26 @@ describe('typed errors thrown from real failure sites', () => { }) }) +describe('typed errors thrown from real failure sites (components)', () => { + it('FormModel throws UnknownComponentTypeError for an unregistered component type', () => { + const build = () => + new FormModel(buildUnknownComponentDefinition(), { basePath: 'test' }) + + expect(build).toThrow(UnknownComponentTypeError) + + let thrown: unknown + try { + build() + } catch (err) { + thrown = err + } + + expect((thrown as UnknownComponentTypeError).componentType).toBe( + 'MyUnknownField' + ) + }) +}) + describe('SchemaValidationError', () => { it('is thrown by FormModel for a schema-invalid definition, wrapping the raw Joi error', () => { const build = () =>