From cd598f0e8df9f55e0882fe8a374fb9f343e38969 Mon Sep 17 00:00:00 2001 From: Aaron Scully Date: Mon, 23 Feb 2026 20:22:52 +0000 Subject: [PATCH 1/3] Resolving the issue if tilde (~) in import statements when creating the project package. --- .gitignore | 2 + docs/BUILDING_THE_PACKAGE.md | 125 +++++++++++++++++++++++++++++++ package-lock.json | 121 ++++++++++++++++++++++++++++++ package.json | 8 +- scripts/resolve-tilde-imports.js | 45 +++++++++++ tsconfig.alias.json | 8 ++ 6 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 docs/BUILDING_THE_PACKAGE.md create mode 100644 scripts/resolve-tilde-imports.js create mode 100644 tsconfig.alias.json diff --git a/.gitignore b/.gitignore index 04fa75585..ec92b681a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ npm-debug.log coverage .public .server +.src +.src.bak .cache .env tsconfig.tsbuildinfo diff --git a/docs/BUILDING_THE_PACKAGE.md b/docs/BUILDING_THE_PACKAGE.md new file mode 100644 index 000000000..d39d05e76 --- /dev/null +++ b/docs/BUILDING_THE_PACKAGE.md @@ -0,0 +1,125 @@ +--- +layout: default +title: Building the package +render_with_liquid: false +nav_order: 5 +--- + +# Building the package + +1. [Overview](#overview) +2. [Build steps](#build-steps) +3. [Path alias resolution](#path-alias-resolution) +4. [Packaging for npm](#packaging-for-npm) +5. [Package contents](#package-contents) + +## Overview + +The build pipeline compiles TypeScript and JavaScript source files into a publishable npm package. It produces server-side JavaScript, client-side bundles, TypeScript declaration files and a copy of the source files with resolved import paths. + +To run the full build: + +```shell +npm run build +``` + +This executes four steps in sequence: `build:server`, `build:client`, `build:types` and `build:src`. + +## Build steps + +### `build:server` + +Compiles the server-side source code using [Babel](https://babeljs.io/). TypeScript and JavaScript files in `src/` are transpiled to JavaScript and output to `.server/`. Test files (`**/*.test.ts`) are excluded. Source maps are generated alongside each output file. + +Babel is configured with `babel-plugin-module-resolver` which resolves the `~` path alias (see [Path alias resolution](#path-alias-resolution)) to relative paths in the compiled `.js` output. + +### `build:client` + +Bundles client-side JavaScript and stylesheets using [webpack](https://webpack.js.org/). The output is written to `.public/` (minified assets) and `.server/client/` (shared scripts and styles). The `NODE_ENV` defaults to `production`. + +### `build:types` + +Generates TypeScript declaration files (`.d.ts`) from the source and outputs them to `.server/`. This step runs two tools in sequence: + +1. **`tsc`** compiles declarations using `tsconfig.build.json`. Because TypeScript preserves path aliases in its output, the generated `.d.ts` files initially contain unresolved `~` imports. +2. **`tsc-alias`** post-processes the `.d.ts` files using `tsconfig.alias.json` to replace the `~` path aliases with relative paths. + +`tsconfig.alias.json` exists separately from `tsconfig.build.json` because the path mappings need to be adjusted for `tsc-alias` to work correctly. The build config has `rootDir: ./src` which strips the `src/` prefix from output paths. This means `tsc-alias` needs the mapping `~/src/* -> ./*` (rather than `~/* -> ./*`) so it can locate the target files within the `.server/` output directory. + +### `build:src` + +Runs `scripts/resolve-tilde-imports.js` which copies the `src/` directory to `.src/` and resolves all `~/src/...` import paths to relative paths in the copy. The original `src/` directory is left untouched. + +This is necessary because the source files are shipped in the npm package (for source map support) and consumers cannot resolve the `~` path alias. + +## Path alias resolution + +During development, the codebase uses a `~` path alias as a shorthand for the project root. For example: + +```typescript +import { config } from '~/src/config/index.js' +``` + +This alias is defined in `tsconfig.json`: + +```json +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "~/*": ["./*"] + } + } +} +``` + +The `~` alias improves the developer experience by avoiding deeply nested relative paths like `../../../../config/index.js`. However, package consumers do not have this alias configured, so all `~` references must be resolved to relative paths before the package is published. + +Three separate mechanisms handle this resolution across the different output types: + +| Output | Tool | Config | +| ------------------- | ---------------------------------- | --------------------- | +| `.server/**/*.js` | `babel-plugin-module-resolver` | `babel.config.cjs` | +| `.server/**/*.d.ts` | `tsc-alias` | `tsconfig.alias.json` | +| `.src/**/*.ts` | `scripts/resolve-tilde-imports.js` | N/A | + +## Packaging for npm + +The `package.json` `files` field controls which directories are included in the published package: + +```json +{ + "files": [".server", ".public", "src"] +} +``` + +Note that `src` is listed here (not `.src`). The `prepack` and `postpack` lifecycle scripts handle the swap: + +1. **`prepack`** runs before `npm pack` or `npm publish`. It moves the original `src/` to `.src.bak/` and moves the resolved `.src/` into `src/`. This means npm packs the resolved copy under the `src` directory name. +2. **`postpack`** runs after packing completes. It restores the original `src/` and moves the resolved copy back to `.src/`. + +This swap approach avoids destructive operations on the working `src/` directory. At no point are the original source files modified. + +### Build and publish workflow + +```shell +npm run build # Produces .server/, .public/ and .src/ +npm pack # prepack swaps .src -> src, packs, postpack restores +``` + +Or equivalently: + +```shell +npm run build +npm publish +``` + +## Package contents + +The published package contains: + +| Directory | Contents | +| ---------- | ------------------------------------------------------------------------------------ | +| `.server/` | Compiled JavaScript (`.js`), declaration files (`.d.ts`) and source maps (`.js.map`) | +| `.public/` | Minified client-side assets | +| `src/` | TypeScript and JavaScript source files with resolved import paths | diff --git a/package-lock.json b/package-lock.json index 23ea3b464..f7fe27d6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -131,6 +131,7 @@ "stylelint": "^16.25.0", "stylelint-config-gds": "^2.0.0", "terser-webpack-plugin": "^5.3.14", + "tsc-alias": "^1.8.16", "tsx": "^4.20.6", "typescript": "^5.9.3", "webpack": "^5.102.1", @@ -16123,6 +16124,20 @@ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", "license": "MIT" }, + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/nanoid": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", @@ -17092,6 +17107,19 @@ "node": ">=4" } }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/png-js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz", @@ -18020,6 +18048,16 @@ "node": ">=20" } }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -20763,6 +20801,89 @@ "typescript": ">=4.8.4" } }, + "node_modules/tsc-alias": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz", + "integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, + "node_modules/tsc-alias/node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tsc-alias/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/tsc-alias/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tsc-alias/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/tsc-alias/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", diff --git a/package.json b/package.json index 108b82481..7167c84ec 100644 --- a/package.json +++ b/package.json @@ -34,16 +34,19 @@ "./package.json": "./package.json" }, "scripts": { - "build": "rm -rf ./.server && npm run build:server && npm run build:client && npm run build:types", + "build": "rm -rf ./.server ./.src && npm run build:server && npm run build:client && npm run build:types && npm run build:src", "build:client": "NODE_ENV=${NODE_ENV:-production} webpack", "build:server": "babel --delete-dir-on-start --extensions \".js\",\".ts\" --ignore \"**/*.test.ts\" --copy-files --no-copy-ignored --source-maps --out-dir ./.server ./src", - "build:types": "tsc -p tsconfig.build.json", + "build:src": "node scripts/resolve-tilde-imports.js", + "build:types": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.alias.json", "dev": "concurrently \"npm run client:watch\" \"npm run server:watch:dev\" --kill-others --names \"client,server\" --prefix-colors \"red.dim,blue.dim\"", "dev:debug": "concurrently \"npm run client:watch\" \"npm run server:watch:debug\" --kill-others --names \"client,server\" --prefix-colors \"red.dim,blue.dim\"", "format": "npm run format:check -- --write", "format:check": "prettier --cache --cache-location .cache/prettier --cache-strategy content --check \"**/*.{cjs,js,json,md,mjs,scss,ts}\"", "generate-schema-docs": "node scripts/generate-schema-docs.js", "postinstall": "npm run setup:husky", + "postpack": "mv src .src && mv .src.bak src", + "prepack": "mv src .src.bak && mv .src src", "lint": "npm run lint:editorconfig && npm run lint:js && npm run lint:types", "lint:editorconfig": "editorconfig-checker", "lint:fix": "npm run lint:js -- --fix", @@ -191,6 +194,7 @@ "stylelint": "^16.25.0", "stylelint-config-gds": "^2.0.0", "terser-webpack-plugin": "^5.3.14", + "tsc-alias": "^1.8.16", "tsx": "^4.20.6", "typescript": "^5.9.3", "webpack": "^5.102.1", diff --git a/scripts/resolve-tilde-imports.js b/scripts/resolve-tilde-imports.js new file mode 100644 index 000000000..4d109e1bd --- /dev/null +++ b/scripts/resolve-tilde-imports.js @@ -0,0 +1,45 @@ +import { cp, readFile, writeFile } from 'node:fs/promises' +import { dirname, relative, sep } from 'node:path' +import { glob } from 'node:fs/promises' + +/** + * Copies `src` to `.src` and resolves `~/src/...` path aliases to relative + * paths. This is needed because the `src` directory is shipped in the npm + * package and consumers cannot resolve the `~` alias. + */ + +// Copy src to .src +await cp('src', '.src', { recursive: true }) + +for await (const entry of glob('.src/**/*.{ts,js}')) { + const content = await readFile(entry, 'utf-8') + + // Match from '~/src/...' and from "~/src/..." + if (!content.includes("'~/") && !content.includes('"~/')) { + continue + } + + const updated = content.replace( + /(from\s+['"])~\/src\/(.*?)(['"])/g, + (match, prefix, importPath, suffix) => { + // .src mirrors src, so resolve relative to .src + const fileDir = dirname(entry) + const targetPath = `.src/${importPath}` + let relativePath = relative(fileDir, targetPath) + + // Ensure it starts with ./ or ../ + if (!relativePath.startsWith('.')) { + relativePath = `./${relativePath}` + } + + // Normalise path separators for Windows compatibility + relativePath = relativePath.split(sep).join('/') + + return `${prefix}${relativePath}${suffix}` + } + ) + + if (updated !== content) { + await writeFile(entry, updated) + } +} diff --git a/tsconfig.alias.json b/tsconfig.alias.json new file mode 100644 index 000000000..b56501d64 --- /dev/null +++ b/tsconfig.alias.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "paths": { + "~/src/*": ["./*"] + } + } +} From 0ba2b2afd35da6fe3967300b229c07a0dd2c7aa6 Mon Sep 17 00:00:00 2001 From: Aaron Scully Date: Mon, 2 Mar 2026 17:38:35 +0000 Subject: [PATCH 2/3] Improving the caching service for consuming services. --- src/server/plugins/engine/types.ts | 5 +++++ src/server/services/cacheService.ts | 23 ++++++++++------------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/server/plugins/engine/types.ts b/src/server/plugins/engine/types.ts index 901950e2e..f55afdf74 100644 --- a/src/server/plugins/engine/types.ts +++ b/src/server/plugins/engine/types.ts @@ -55,6 +55,11 @@ import { type Services } from '~/src/server/types.js' export type AnyFormRequest = FormRequest | FormRequestPayload export type AnyRequest = Request | AnyFormRequest +export interface CacheRequest { + yar: { id: string | undefined } + params: { slug?: string; state?: string } +} + /** * Form submission state stores the following in Redis: * Props containing user's submitted values as `{ [inputId]: value }` or as `{ [sectionName]: { [inputName]: value } }` diff --git a/src/server/services/cacheService.ts b/src/server/services/cacheService.ts index 3db9b9e18..425e30912 100644 --- a/src/server/services/cacheService.ts +++ b/src/server/services/cacheService.ts @@ -6,7 +6,7 @@ import { config } from '~/src/config/index.js' import { type createServer } from '~/src/server/index.js' import { type AnyFormRequest, - type AnyRequest, + type CacheRequest, type FormConfirmationState, type FormPayload, type FormState, @@ -39,14 +39,14 @@ export class CacheService { this.logger = server.logger } - async getState(request: AnyRequest): Promise { + async getState(request: CacheRequest): Promise { const key = this.Key(request) const cached = await this.cache.get(key) return cached ?? {} } - async setState(request: AnyFormRequest, state: FormSubmissionState) { + async setState(request: CacheRequest, state: FormSubmissionState) { const key = this.Key(request) const ttl = config.get('sessionTimeout') @@ -56,7 +56,7 @@ export class CacheService { } async getConfirmationState( - request: AnyFormRequest + request: CacheRequest ): Promise { const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation) const value = await this.cache.get(key) @@ -65,7 +65,7 @@ export class CacheService { } async setConfirmationState( - request: AnyFormRequest, + request: CacheRequest, confirmationState: FormConfirmationState ) { const key = this.Key(request, ADDITIONAL_IDENTIFIER.Confirmation) @@ -74,7 +74,7 @@ export class CacheService { return this.cache.set(key, confirmationState, ttl) } - async clearState(request: AnyFormRequest) { + async clearState(request: CacheRequest) { if (request.yar.id) { await this.cache.drop(this.Key(request)) } @@ -123,10 +123,7 @@ export class CacheService { * ``` * @returns The updated state after removal */ - async resetComponentStates( - request: AnyFormRequest, - componentNames: string[] - ) { + async resetComponentStates(request: CacheRequest, componentNames: string[]) { const state = await this.getState(request) for (const componentName of componentNames) { @@ -142,13 +139,13 @@ export class CacheService { * @param request - hapi request object * @param additionalIdentifier - appended to the id */ - Key(request: AnyRequest, additionalIdentifier?: ADDITIONAL_IDENTIFIER) { + Key(request: CacheRequest, additionalIdentifier?: ADDITIONAL_IDENTIFIER) { if (!request.yar.id) { throw new Error('No session ID found') } - const state = (request.params.state as string) || '' - const slug = (request.params.slug as string) || '' + const state = request.params.state ?? '' + const slug = request.params.slug ?? '' const key = `${request.yar.id}:${state}:${slug}:` return { From 42520dfa31aae0b790f953e647b1d3d06763e948 Mon Sep 17 00:00:00 2001 From: Aaron Scully Date: Mon, 2 Mar 2026 21:31:01 +0000 Subject: [PATCH 3/3] Resolving linting warnings. --- .../plugins/engine/beta/form-context.test.ts | 30 +++++++++++----- .../machine/v2.location.test.ts | 20 ++++++++--- .../engine/pageControllers/helpers/state.ts | 2 +- .../helpers/submission.test.ts | 28 ++++++++++----- src/server/plugins/map/routes/get-os-token.js | 14 +++++++- src/server/plugins/map/routes/index.js | 4 ++- src/server/plugins/map/service.js | 5 ++- .../plugins/postcode-lookup/routes/index.js | 28 ++++++++++----- src/server/plugins/postcode-lookup/service.js | 5 ++- test/client/javascripts/location-map.test.js | 35 ++++++++++++------- test/form/definitions.test.js | 1 + test/form/titles.test.js | 6 ++++ 12 files changed, 130 insertions(+), 48 deletions(-) diff --git a/src/server/plugins/engine/beta/form-context.test.ts b/src/server/plugins/engine/beta/form-context.test.ts index 609441d88..66ad865fd 100644 --- a/src/server/plugins/engine/beta/form-context.test.ts +++ b/src/server/plugins/engine/beta/form-context.test.ts @@ -45,17 +45,26 @@ jest.mock('../pageControllers/index.ts', () => { jest.mock('../helpers.ts', () => ({ __esModule: true, - getCacheService: (...args: unknown[]) => mockGetCacheService(...args), - checkEmailAddressForLiveFormSubmission: (...args: unknown[]) => + getCacheService: (...args: unknown[]): unknown => + mockGetCacheService(...args), + checkEmailAddressForLiveFormSubmission: (...args: unknown[]): unknown => mockCheckEmailAddressForLiveFormSubmission(...args) })) -const mockServices = jest.requireMock( - '~/src/server/plugins/engine/services/index.js' -) +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const mockServices: { + formsService: { getFormMetadata: jest.Mock; getFormDefinition: jest.Mock } +} = jest.requireMock('~/src/server/plugins/engine/services/index.js') const mockFormsService = mockServices.formsService -const { FormModel } = jest.requireMock('../models/index.ts') -const { TerminalPageController: MockTerminalPageController } = jest.requireMock( + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const { FormModel }: { FormModel: jest.Mock } = + jest.requireMock('../models/index.ts') + +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment +const { + TerminalPageController: MockTerminalPageController +}: { TerminalPageController: new () => { path: string } } = jest.requireMock( '../pageControllers/index.ts' ) @@ -107,7 +116,12 @@ describe('getFormContext helper', () => { errors }) - const summaryRequest = mockCacheService.getState.mock.calls[0][0] + const summaryRequest: { + params: Record + path: string + url: URL + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + } = mockCacheService.getState.mock.calls[0][0] expect(summaryRequest.params).toEqual({ path: 'summary', diff --git a/src/server/plugins/engine/outputFormatters/machine/v2.location.test.ts b/src/server/plugins/engine/outputFormatters/machine/v2.location.test.ts index a47ece75b..2a6ca7318 100644 --- a/src/server/plugins/engine/outputFormatters/machine/v2.location.test.ts +++ b/src/server/plugins/engine/outputFormatters/machine/v2.location.test.ts @@ -121,7 +121,9 @@ describe('Machine V2 formatter - Location fields', () => { ] const result = format(context, items, model, submitResponse, formStatus) - const payload = JSON.parse(result) + const payload = JSON.parse(result) as { + data: { main: Record } + } // Verify the payload uses full property names, not abbreviated expect(payload.data.main.locationLL).toEqual({ @@ -161,7 +163,9 @@ describe('Machine V2 formatter - Location fields', () => { ] const result = format(context, items, model, submitResponse, formStatus) - const payload = JSON.parse(result) + const payload = JSON.parse(result) as { + data: { main: Record } + } // Verify the payload uses full property names expect(payload.data.main.locationEN).toEqual({ @@ -211,7 +215,9 @@ describe('Machine V2 formatter - Location fields', () => { ] const result = format(context, items, model, submitResponse, formStatus) - const payload = JSON.parse(result) + const payload = JSON.parse(result) as { + data: { main: Record } + } expect(payload.data.main.gridRef).toBe('TQ123456') expect(payload.data.main.ngField).toBe('NG12345678') @@ -287,7 +293,9 @@ describe('Machine V2 formatter - Location fields', () => { ] const result = format(context, items, model, submitResponse, formStatus) - const payload = JSON.parse(result) + const payload = JSON.parse(result) as { + data: { main: Record } + } // Check all location fields use full property names expect(payload.data.main).toEqual({ @@ -333,7 +341,9 @@ describe('Machine V2 formatter - Location fields', () => { ] const result = format(context, items, model, submitResponse, formStatus) - const payload = JSON.parse(result) + const payload = JSON.parse(result) as { + data: { main: Record } + } // Undefined location fields should be undefined in v2 (not null like in v1) expect(payload.data.main.locationLL).toBeUndefined() diff --git a/src/server/plugins/engine/pageControllers/helpers/state.ts b/src/server/plugins/engine/pageControllers/helpers/state.ts index 6d7e2c621..882a32be9 100644 --- a/src/server/plugins/engine/pageControllers/helpers/state.ts +++ b/src/server/plugins/engine/pageControllers/helpers/state.ts @@ -55,7 +55,7 @@ export function stripParam(query: FormQuery, paramToRemove: string) { * Any hidden parameters defined in the FormDefinition may be pre-filled by URL parameter values. * Other parameters are ignored for security reasons. * @param request - * @param model + * @param page */ export async function prefillStateFromQueryParameters( request: AnyFormRequest, diff --git a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts index 62688bd85..8094480de 100644 --- a/src/server/plugins/engine/pageControllers/helpers/submission.test.ts +++ b/src/server/plugins/engine/pageControllers/helpers/submission.test.ts @@ -11,7 +11,9 @@ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' describe('Submission helpers', () => { describe('buildPaymentRecords', () => { it('should return empty array when no payment state exists', () => { - const mockPaymentField = Object.create(PaymentField.prototype) + const mockPaymentField = Object.create( + PaymentField.prototype + ) as PaymentField mockPaymentField.getPaymentStateFromState = jest .fn() .mockReturnValue(undefined) @@ -43,7 +45,9 @@ describe('Submission helpers', () => { } } - const mockPaymentField = Object.create(PaymentField.prototype) + const mockPaymentField = Object.create( + PaymentField.prototype + ) as PaymentField mockPaymentField.getPaymentStateFromState = jest .fn() .mockReturnValue(mockPaymentState) @@ -91,7 +95,9 @@ describe('Submission helpers', () => { } } - const mockPaymentField = Object.create(PaymentField.prototype) + const mockPaymentField = Object.create( + PaymentField.prototype + ) as PaymentField mockPaymentField.getPaymentStateFromState = jest .fn() .mockReturnValue(mockPaymentState) @@ -120,7 +126,7 @@ describe('Submission helpers', () => { }) it('should process regular fields correctly', () => { - const mockTextField = Object.create(TextField.prototype) + const mockTextField = Object.create(TextField.prototype) as TextField mockTextField.getDisplayStringFromState = jest .fn() .mockReturnValue('John Doe') @@ -159,7 +165,9 @@ describe('Submission helpers', () => { } } - const mockPaymentField = Object.create(PaymentField.prototype) + const mockPaymentField = Object.create( + PaymentField.prototype + ) as PaymentField mockPaymentField.getPaymentStateFromState = jest .fn() .mockReturnValue(mockPaymentState) @@ -185,7 +193,7 @@ describe('Submission helpers', () => { }) it('should handle mixed regular and payment fields', () => { - const mockTextField = Object.create(TextField.prototype) + const mockTextField = Object.create(TextField.prototype) as TextField mockTextField.getDisplayStringFromState = jest .fn() .mockReturnValue('test@example.com') @@ -201,7 +209,9 @@ describe('Submission helpers', () => { preAuth: { status: 'success', createdAt: '2026-01-26T12:00:00.000Z' } } - const mockPaymentField = Object.create(PaymentField.prototype) + const mockPaymentField = Object.create( + PaymentField.prototype + ) as PaymentField mockPaymentField.getPaymentStateFromState = jest .fn() .mockReturnValue(mockPaymentState) @@ -246,7 +256,7 @@ describe('Submission helpers', () => { describe('buildRepeaterRecords', () => { it('should return empty array when no repeater items', () => { - const mockField = Object.create(TextField.prototype) + const mockField = Object.create(TextField.prototype) as TextField const items = [ { @@ -263,7 +273,7 @@ describe('Submission helpers', () => { }) it('should process repeater items correctly', () => { - const mockSubField = Object.create(TextField.prototype) + const mockSubField = Object.create(TextField.prototype) as TextField mockSubField.getDisplayStringFromState = jest .fn() .mockReturnValue('123 Main St') diff --git a/src/server/plugins/map/routes/get-os-token.js b/src/server/plugins/map/routes/get-os-token.js index 3a0631aec..55850911e 100644 --- a/src/server/plugins/map/routes/get-os-token.js +++ b/src/server/plugins/map/routes/get-os-token.js @@ -19,7 +19,9 @@ export async function getAccessToken(options) { } const creds = `${key}:${secret}` - const result = await post('https://api.os.uk/oauth2/token/v1', { + const postByType = /** @type {typeof post} */ (post) + + const result = await postByType('https://api.os.uk/oauth2/token/v1', { headers: { Authorization: `Basic ${btoa(creds)}`, 'Content-Type': 'application/x-www-form-urlencoded' @@ -30,12 +32,22 @@ export async function getAccessToken(options) { const data = result.payload + if (!data) { + throw new Error('Failed to obtain OS API token') + } + cachedToken = data.access_token tokenExpiry = now + (data.expires_in - 60) * 1000 // refresh early return cachedToken } +/** + * @typedef {object} OsTokenResponse + * @property {string} access_token - The access token + * @property {number} expires_in - The expiry in seconds + */ + /** * @import { MapConfiguration } from '~/src/server/plugins/map/types.js' */ diff --git a/src/server/plugins/map/routes/index.js b/src/server/plugins/map/routes/index.js index 532a0b15b..294cf3332 100644 --- a/src/server/plugins/map/routes/index.js +++ b/src/server/plugins/map/routes/index.js @@ -85,7 +85,9 @@ function tileProxyRoute(options) { const url = `https://api.os.uk/maps/vector/v1/vts/tile/${z}/${y}/${x}.pbf?srs=3857` - const { payload, res } = await get(url, { + const getBuffer = /** @type {typeof get} */ (get) + + const { payload, res } = await getBuffer(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/x-protobuf' diff --git a/src/server/plugins/map/service.js b/src/server/plugins/map/service.js index b23384645..68d4156fb 100644 --- a/src/server/plugins/map/service.js +++ b/src/server/plugins/map/service.js @@ -22,7 +22,10 @@ function empty() { * @param {string} endpoint - the OS api endpoint */ function logErrorAndReturnEmpty(err, endpoint) { - const msg = `${getErrorMessage(err)} ${(Boom.isBoom(err) && err.data?.payload?.error?.message) ?? ''}` + /** @type {{ payload?: { error?: { message?: string } } } | false} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const boomData = Boom.isBoom(err) && err.data + const msg = `${getErrorMessage(err)} ${(boomData && boomData.payload?.error?.message) ?? ''}` logger.error(err, `Exception occured calling OS names ${endpoint} - ${msg}}`) diff --git a/src/server/plugins/postcode-lookup/routes/index.js b/src/server/plugins/postcode-lookup/routes/index.js index 34feba53a..63a87bdd6 100644 --- a/src/server/plugins/postcode-lookup/routes/index.js +++ b/src/server/plugins/postcode-lookup/routes/index.js @@ -170,10 +170,14 @@ function postRoute(options) { * @param {PostcodeLookupConfiguration} options */ async function detailsPostHandler(request, h, options) { - const { payload } = request const session = getSessionState(request) const { ordnanceSurveyApiKey: apiKey } = options - const { value: details, error } = detailsPayloadSchema.validate(payload) + + /** @type {{ value: PostcodeLookupDetailsPayload, error?: import('joi').ValidationError }} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { value: details, error } = detailsPayloadSchema.validate( + request.payload + ) let model @@ -201,10 +205,12 @@ async function detailsPostHandler(request, h, options) { * @param {PostcodeLookupConfiguration} options */ async function selectPostHandler(request, h, options) { - const { payload } = request const session = getSessionState(request) const { ordnanceSurveyApiKey: apiKey } = options - const { value: select, error } = selectPayloadSchema.validate(payload) + + /** @type {{ value: PostcodeLookupSelectPayload, error?: import('joi').ValidationError }} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { value: select, error } = selectPayloadSchema.validate(request.payload) if (error) { const model = await selectViewModel({ session, apiKey }, select, error) @@ -232,12 +238,16 @@ async function selectPostHandler(request, h, options) { * @param {ResponseToolkit} h */ function manualPostHandler(request, h) { - const { payload } = request const session = getSessionState(request) - const { value: manual, error } = manualPayloadSchema.validate(payload, { - abortEarly: false - }) + /** @type {{ value: PostcodeLookupManualPayload, error?: import('joi').ValidationError }} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { value: manual, error } = manualPayloadSchema.validate( + request.payload, + { + abortEarly: false + } + ) if (error) { const model = manualViewModel(session, manual, error) @@ -254,7 +264,7 @@ function manualPostHandler(request, h) { /** * @import { ResponseToolkit, ServerRoute } from '@hapi/hapi' - * @import { PostcodeLookupManualPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupPostRequest, PostcodeLookupConfiguration, PostcodeLookupDispatchData, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js' + * @import { PostcodeLookupManualPayload, PostcodeLookupDetailsPayload, PostcodeLookupSelectPayload, Address, PostcodeLookupGetRequestRefs, PostcodeLookupPostRequestRefs, PostcodeLookupRequest, PostcodeLookupPostRequest, PostcodeLookupConfiguration, PostcodeLookupDispatchData, PostcodeLookupSessionData } from '~/src/server/plugins/postcode-lookup/types.js' * @import { FormRequestPayload, FormResponseToolkit } from '~/src/server/routes/types.js' * @import { ExternalStateAppendage } from '~/src/server/plugins/engine/types.js' */ diff --git a/src/server/plugins/postcode-lookup/service.js b/src/server/plugins/postcode-lookup/service.js index d8614bcd1..bd74f202e 100644 --- a/src/server/plugins/postcode-lookup/service.js +++ b/src/server/plugins/postcode-lookup/service.js @@ -19,7 +19,10 @@ function empty() { * @param {string} endpoint - the OS api endpoint */ function logErrorAndReturnEmpty(err, endpoint) { - const msg = `${getErrorMessage(err)} ${(Boom.isBoom(err) && err.data?.payload?.error?.message) ?? ''}` + /** @type {{ payload?: { error?: { message?: string } } } | false} */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const boomData = Boom.isBoom(err) && err.data + const msg = `${getErrorMessage(err)} ${(boomData && boomData.payload?.error?.message) ?? ''}` logger.error(err, `Exception occured calling OS places ${endpoint} - ${msg}}`) diff --git a/test/client/javascripts/location-map.test.js b/test/client/javascripts/location-map.test.js index 2dcb6ccab..77e527b95 100644 --- a/test/client/javascripts/location-map.test.js +++ b/test/client/javascripts/location-map.test.js @@ -4,6 +4,20 @@ import { makeTileRequestTransformer } from '~/src/client/javascripts/location-map.js' +/** + * Extracts a callback from a mock's calls array. + * @template {(...args: unknown[]) => unknown} T + * @param {jest.Mock} mock - the mock + * @param {number} callIndex - call index + * @param {number} argIndex - argument index + * @returns {T} + */ +function getMockCallback(mock, callIndex, argIndex) { + return /** @type {T} */ ( + /** @type {unknown[][]} */ (mock.mock.calls)[callIndex][argIndex] + ) +} + describe('Location Maps Client JS', () => { /** @type {jest.Mock} */ let onMock @@ -108,7 +122,7 @@ describe('Location Maps Client JS', () => { expect.any(Function) ) - const onMapReady = onMock.mock.calls[0][1] + const onMapReady = getMockCallback(onMock, 0, 1) expect(typeof onMapReady).toBe('function') // Manually invoke onMapReady callback @@ -144,8 +158,7 @@ describe('Location Maps Client JS', () => { expect(addMarkerMock).toHaveBeenCalledTimes(1) expect(flyToMock).toHaveBeenCalledTimes(1) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const onInteractMarkerChange = onMock.mock.calls[1][1] + const onInteractMarkerChange = getMockCallback(onMock, 1, 1) expect(typeof onInteractMarkerChange).toBe('function') onInteractMarkerChange({ coords: [-2.1478238, 54.155676] }) }) @@ -167,7 +180,7 @@ describe('Location Maps Client JS', () => { expect.any(Function) ) - const onMapReady = onMock.mock.calls[0][1] + const onMapReady = getMockCallback(onMock, 0, 1) expect(typeof onMapReady).toBe('function') // Manually invoke onMapReady callback @@ -273,7 +286,7 @@ describe('Location Maps Client JS', () => { expect.any(Function) ) - const onMapReady = onMock.mock.calls[0][1] + const onMapReady = getMockCallback(onMock, 0, 1) expect(typeof onMapReady).toBe('function') // Manually invoke onMapReady callback @@ -309,8 +322,7 @@ describe('Location Maps Client JS', () => { expect(addMarkerMock).toHaveBeenCalledTimes(1) expect(flyToMock).toHaveBeenCalledTimes(1) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const onInteractMarkerChange = onMock.mock.calls[1][1] + const onInteractMarkerChange = getMockCallback(onMock, 1, 1) expect(typeof onInteractMarkerChange).toBe('function') onInteractMarkerChange({ coords: [-2.147823, 54.155676] @@ -334,7 +346,7 @@ describe('Location Maps Client JS', () => { expect.any(Function) ) - const onMapReady = onMock.mock.calls[0][1] + const onMapReady = getMockCallback(onMock, 0, 1) expect(typeof onMapReady).toBe('function') // Manually invoke onMapReady callback @@ -425,7 +437,7 @@ describe('Location Maps Client JS', () => { expect.any(Function) ) - const onMapReady = onMock.mock.calls[0][1] + const onMapReady = getMockCallback(onMock, 0, 1) expect(typeof onMapReady).toBe('function') // Manually invoke onMapReady callback @@ -457,8 +469,7 @@ describe('Location Maps Client JS', () => { expect(addMarkerMock).toHaveBeenCalledTimes(1) expect(flyToMock).toHaveBeenCalledTimes(1) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - const onInteractMarkerChange = onMock.mock.calls[1][1] + const onInteractMarkerChange = getMockCallback(onMock, 1, 1) expect(typeof onInteractMarkerChange).toBe('function') onInteractMarkerChange({ coords: [-2.147823, 54.155676] @@ -480,7 +491,7 @@ describe('Location Maps Client JS', () => { expect.any(Function) ) - const onMapReady = onMock.mock.calls[0][1] + const onMapReady = getMockCallback(onMock, 0, 1) expect(typeof onMapReady).toBe('function') // Manually invoke onMapReady callback diff --git a/test/form/definitions.test.js b/test/form/definitions.test.js index 9f990ce1d..45f8c011a 100644 --- a/test/form/definitions.test.js +++ b/test/form/definitions.test.js @@ -32,6 +32,7 @@ describe('Form definition JSON V1', () => { // This is likely due to inconsistencies between the form schemas in forms-runner // and the latest schema definitions in the plugin repository. // Once the schemas are aligned across repositories, this test can be re-enabled. + // eslint-disable-next-line jest/no-disabled-tests it.skip('passes schema validation', async () => { for (const filename of filenames) { const definition = await getForm(join(directory, filename)) diff --git a/test/form/titles.test.js b/test/form/titles.test.js index db9e3a1fc..d12fe4854 100644 --- a/test/form/titles.test.js +++ b/test/form/titles.test.js @@ -100,6 +100,7 @@ describe('Title and section title', () => { // This is likely due to inconsistencies between the form schemas in forms-runner // and the latest schema definitions in the plugin repository. // Once the schemas are aligned across repositories, this test can be re-enabled. + // eslint-disable-next-line jest/no-disabled-tests it.skip('does not render the section title if it is the same as the title', async () => { jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) @@ -122,6 +123,7 @@ describe('Title and section title', () => { // This is likely due to inconsistencies between the form schemas in forms-runner // and the latest schema definitions in the plugin repository. // Once the schemas are aligned across repositories, this test can be re-enabled. + // eslint-disable-next-line jest/no-disabled-tests it.skip('render warning when notification email is not set', async () => { jest.mocked(getFormMetadata).mockResolvedValue(fixtures.form.metadata) @@ -140,6 +142,7 @@ describe('Title and section title', () => { // This is likely due to inconsistencies between the form schemas in forms-runner // and the latest schema definitions in the plugin repository. // Once the schemas are aligned across repositories, this test can be re-enabled. + // eslint-disable-next-line jest/no-disabled-tests it.skip('does not render the warning when notification email is set', async () => { jest.mocked(getFormMetadata).mockResolvedValue({ ...fixtures.form.metadata, @@ -161,6 +164,7 @@ describe('Title and section title', () => { // This is likely due to inconsistencies between the form schemas in forms-runner // and the latest schema definitions in the plugin repository. // Once the schemas are aligned across repositories, this test can be re-enabled. + // eslint-disable-next-line jest/no-disabled-tests it.skip('does render the section title if it is not the same as the title', async () => { const { container } = await renderResponse(server, { url: `${basePath}/applicant-one-address`, @@ -188,6 +192,7 @@ describe('Title and section title', () => { // This is likely due to inconsistencies between the form schemas in forms-runner // and the latest schema definitions in the plugin repository. // Once the schemas are aligned across repositories, this test can be re-enabled. + // eslint-disable-next-line jest/no-disabled-tests it.skip('does not render the section title if hideTitle is set to true', async () => { const { container } = await renderResponse(server, { url: `${basePath}/applicant-two`, @@ -209,6 +214,7 @@ describe('Title and section title', () => { // This is likely due to inconsistencies between the form schemas in forms-runner // and the latest schema definitions in the plugin repository. // Once the schemas are aligned across repositories, this test can be re-enabled. + // eslint-disable-next-line jest/no-disabled-tests it.skip('render title with optional when there is single component in page and is selected as optional', async () => { const { container } = await renderResponse(server, { url: `${basePath}/applicant-two-address-optional`,