@planning-inspectorate/dynamic-forms 1.14.0 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.prettierignore +2 -1
  2. package/README.md +9 -1
  3. package/package.json +7 -2
  4. package/src/components/address/question.js +1 -1
  5. package/src/components/address/question.test.js +3 -3
  6. package/src/components/multi-field-input/index.njk +2 -2
  7. package/src/components/multi-field-input/question.js +5 -1
  8. package/src/components/radio/index.njk +2 -2
  9. package/src/components/text-entry-redact/question.test.js +9 -9
  10. package/src/controller.test.js +2 -1
  11. package/src/index.js +8 -2
  12. package/src/lib/session-answer-store.test.js +1 -1
  13. package/src/questions/question-props.d.ts +5 -24
  14. package/src/questions/question.test.js +1 -1
  15. package/src/section.js +3 -0
  16. package/src/validator/validator.js +4 -1
  17. package/test/check-your-answers.test.js +69 -0
  18. package/test/journey.js +41 -0
  19. package/test/journey.test.js +21 -0
  20. package/test/questions.js +189 -0
  21. package/test/questions.test.js +246 -0
  22. package/test/snapshots/add-insurance.html +44 -0
  23. package/test/snapshots/check-your-answers-not-started.html +201 -0
  24. package/test/snapshots/check-your-answers-with-answers.html +201 -0
  25. package/test/snapshots/companions.html +51 -0
  26. package/test/snapshots/departure-date.html +57 -0
  27. package/test/snapshots/holiday-activities.html +49 -0
  28. package/test/snapshots/holiday-arrival.html +101 -0
  29. package/test/snapshots/holiday-description.html +33 -0
  30. package/test/snapshots/holiday-destination.html +56 -0
  31. package/test/snapshots/holiday-period.html +102 -0
  32. package/test/snapshots/holiday-snack.html +33 -0
  33. package/test/snapshots/hotel-address.html +65 -0
  34. package/test/snapshots/luggage-weight-units.html +75 -0
  35. package/test/snapshots/nights.html +28 -0
  36. package/test/snapshots/secret-wish.html +173 -0
  37. package/test/snapshots/travel-class.html +52 -0
  38. package/test/snapshots/travel-insurance-type.html +56 -0
  39. package/test/utils/app.js +41 -0
  40. package/test/utils/nunjucks.js +20 -0
  41. package/test/utils/test-server.js +167 -0
  42. package/test/utils/test-server.test.js +75 -0
  43. package/{src/lib/test-utils.js → test/utils/utils.js} +15 -16
  44. package/test/views/layout-check-your-answers.njk +1 -0
  45. /package/{src/lib/test-layout.njk → test/views/layout-question.njk} +0 -0
@@ -0,0 +1,173 @@
1
+ <div class="govuk-grid-row">
2
+ <div class="govuk-grid-column-two-thirds">
3
+ </div>
4
+ </div>
5
+
6
+
7
+ <div class="govuk-grid-row">
8
+ <div class="govuk-grid-column-two-thirds">
9
+ <form action="" method="post" novalidate>
10
+ <input type="hidden" name="_csrf" value="">
11
+
12
+ <h1 class="govuk-label-wrapper">
13
+ <label class="govuk-label govuk-label--l" for="secretWish">
14
+ Share a secret wish for your holiday (will be redacted).
15
+ </label>
16
+ </h1>
17
+
18
+
19
+
20
+
21
+ <h2 class="govuk-heading-m">Redaction preview</h2>
22
+
23
+ <details id="secretWish-unredacted" class="govuk-details" data-original="">
24
+ <summary class="govuk-details__summary">
25
+ <span class="govuk-details__summary-text">
26
+ Original
27
+ </span>
28
+ </summary>
29
+ <div class="govuk-details__text">
30
+
31
+ </div>
32
+ </details>
33
+
34
+
35
+ <div class="govuk-form-group">
36
+ <label class="govuk-label" for="secretWish">
37
+ Secret wish
38
+ </label>
39
+ <textarea class="govuk-textarea" id="secretWish" name="secretWish" rows="8" readonly="true"></textarea>
40
+ </div>
41
+
42
+
43
+ <div class="govuk-button-group">
44
+ <button type="button" class="govuk-button govuk-button--secondary" data-module="govuk-button" id="redact-selected-text">
45
+ Redact selected text
46
+ </button>
47
+
48
+ <button type="button" class="govuk-button govuk-button--secondary" data-module="govuk-button" id="redact-undo">
49
+ Undo
50
+ </button>
51
+
52
+ <button type="button" class="govuk-button govuk-button--secondary" data-module="govuk-button" id="redact-undo-all">
53
+ Undo all changes
54
+ </button>
55
+ </div>
56
+
57
+ <button type="submit" class="govuk-button" data-module="govuk-button" data-cy="button-save-and-continue">
58
+ Continue
59
+ </button>
60
+ </form>
61
+ </div>
62
+
63
+ <script >
64
+ /**
65
+ * @param {HTMLTextAreaElement} textArea
66
+ * @param {HTMLButtonElement} redactSelectedText
67
+ * @param {HTMLButtonElement} redactUndo
68
+ * @param {HTMLButtonElement} redactUndoAll
69
+ * @param {HTMLDetailsElement} unredactedText
70
+ * @param {HTMLAnchorElement[]} rejectLinks
71
+ * @param {import('@azure/ai-text-analytics').PiiEntity[]} suggestions
72
+ */
73
+ function registerEventListeners({textArea, redactSelectedText, redactUndo, redactUndoAll, unredactedText, rejectLinks, suggestions}) {
74
+ const formGroup = textArea.parentElement;
75
+ const errorMessage = document.createElement('p');
76
+ errorMessage.id = 'secretWish-error';
77
+ errorMessage.classList.add('govuk-error-message');
78
+
79
+ const history = [];
80
+ function showError(message) {
81
+ formGroup.classList.add('govuk-form-group--error');
82
+ errorMessage.textContent = message;
83
+ formGroup.prepend(errorMessage);
84
+ }
85
+ function hideError() {
86
+ textArea.parentElement.classList.remove('govuk-form-group--error');
87
+ errorMessage.remove();
88
+ }
89
+ redactSelectedText.addEventListener('click', () => {
90
+ const { selectionStart, selectionEnd } = textArea;
91
+ if (selectionStart === selectionEnd) {
92
+ // no selected text
93
+ showError('Select text to redact');
94
+ return;
95
+ }
96
+ hideError();
97
+ // replace the text with the 'full block' █ character
98
+ const selectedText = textArea.value.substring(selectionStart, selectionEnd);
99
+ const redactedText = '█'.repeat(selectionEnd - selectionStart);
100
+ textArea.value = textArea.value.substring(0, selectionStart) + redactedText + textArea.value.substring(selectionEnd);
101
+ // add to history for undo functionality
102
+ history.push({
103
+ selectionStart,
104
+ selectionEnd,
105
+ selectedText
106
+ });
107
+ });
108
+ redactUndo.addEventListener('click', () => {
109
+ const { selectionStart, selectionEnd, selectedText } = history.pop() || {};
110
+ if (selectionStart !== undefined && selectionEnd !== undefined && selectedText) {
111
+ hideError();
112
+ textArea.value = textArea.value.substring(0, selectionStart) + selectedText + textArea.value.substring(selectionEnd);
113
+ } else {
114
+ showError('No changes to undo');
115
+ }
116
+ });
117
+ redactUndoAll.addEventListener('click', () => {
118
+ const unredated = unredactedText.dataset.original;
119
+ if (history.length === 0 && textArea.value === unredated) {
120
+ showError('No changes to undo');
121
+ return;
122
+ }
123
+ hideError();
124
+ textArea.value = unredated || '';
125
+ history.length = 0;
126
+ });
127
+ for (const rejectLink of rejectLinks) {
128
+ rejectLink.addEventListener('click', () => {
129
+ const index = parseInt(rejectLink.getAttribute('data-redact').replace('suggestion-reject-', ''));
130
+ const suggestion = suggestions[index];
131
+ if (rejectLink.text === 'Reject') {
132
+ textArea.value = textArea.value.substring(0, suggestion.offset) + suggestion.text + textArea.value.substring(suggestion.offset + suggestion.length);
133
+ rejectLink.text = 'Accept';
134
+ } else {
135
+ const redactedText = '█'.repeat(suggestion.length);
136
+ textArea.value = textArea.value.substring(0, suggestion.offset) + redactedText+ textArea.value.substring(suggestion.offset + suggestion.length);
137
+ rejectLink.text = 'Reject';
138
+ }
139
+ });
140
+ }
141
+ }
142
+
143
+ function initRedact() {
144
+ const textArea = document.querySelector('#secretWish');
145
+ const redactSelectedText = document.querySelector('#redact-selected-text');
146
+ const redactUndo = document.querySelector('#redact-undo');
147
+ const redactUndoAll = document.querySelector('#redact-undo-all');
148
+ const unredactedText = document.querySelector('#secretWish-unredacted');
149
+ const rejectLinks = document.querySelectorAll('[data-redact]');
150
+ const suggestions = []
151
+
152
+ if (!textArea || !(textArea instanceof HTMLTextAreaElement)) {
153
+ return;
154
+ }
155
+ if (!redactSelectedText || !(redactSelectedText instanceof HTMLButtonElement)) {
156
+ return;
157
+ }
158
+ if (!redactUndo || !(redactUndo instanceof HTMLButtonElement)) {
159
+ return;
160
+ }
161
+ if (!redactUndoAll || !(redactUndoAll instanceof HTMLButtonElement)) {
162
+ return;
163
+ }
164
+ if (!unredactedText || !(unredactedText instanceof HTMLDetailsElement)) {
165
+ return;
166
+ }
167
+
168
+ registerEventListeners({textArea, redactSelectedText, redactUndo, redactUndoAll, unredactedText, rejectLinks, suggestions});
169
+ }
170
+
171
+ initRedact();
172
+ </script>
173
+ </div>
@@ -0,0 +1,52 @@
1
+ <div class="govuk-grid-row">
2
+ <div class="govuk-grid-column-two-thirds">
3
+ </div>
4
+ </div>
5
+
6
+
7
+ <div class="govuk-grid-row">
8
+ <div class="govuk-grid-column-two-thirds">
9
+ <form action="" method="POST" novalidate>
10
+ <input type="hidden" name="_csrf" value="">
11
+
12
+
13
+ <div class="govuk-form-group">
14
+ <h1 class="govuk-label-wrapper">
15
+ <label class="govuk-label govuk-label--l" for="travelClass">
16
+ Which class do you want to travel in?
17
+ </label>
18
+ </h1>
19
+ <select class="govuk-select" id="travelClass" name="travelClass">
20
+ <option value="economy" data-cy="answer-economy">Economy</option>
21
+ <option value="business" data-cy="answer-business">Business</option>
22
+ <option value="first" data-cy="answer-first">First Class</option>
23
+ </select>
24
+ </div>
25
+
26
+
27
+ <button type="submit" class="govuk-button" data-module="govuk-button" data-cy="button-save-and-continue">
28
+ Continue
29
+ </button>
30
+
31
+
32
+ </form>
33
+ </div>
34
+ </div>
35
+ <script src="/assets/js/accessible-autocomplete.min.js"></script>
36
+ <script >
37
+ if (window.accessibleAutocomplete) {
38
+ accessibleAutocomplete.enhanceSelectElement({
39
+ selectElement: document.querySelector('#travelClass'),
40
+ onConfirm: () => {
41
+ let input = document.querySelector('#travelClass');
42
+ let select = document.querySelector('#travelClass-select');
43
+ let options = Array.from(select.options);
44
+ if (!input.value || !options.some(option => option.value === input.value)) {
45
+ select.options.selectedIndex = -1;
46
+ } else {
47
+ select.options.selectedIndex = options.findIndex(option => option.value === input.value);
48
+ }
49
+ }
50
+ })
51
+ }
52
+ </script>
@@ -0,0 +1,56 @@
1
+ <div class="govuk-grid-row">
2
+ <div class="govuk-grid-column-two-thirds">
3
+ </div>
4
+ </div>
5
+
6
+
7
+ <div class="govuk-grid-row">
8
+ <div class="govuk-grid-column-two-thirds">
9
+ <form action="" method="post" novalidate>
10
+ <input type="hidden" name="_csrf" value="">
11
+
12
+
13
+ <fieldset class="govuk-fieldset">
14
+ <legend class="govuk-fieldset__legend govuk-fieldset__legend--l">
15
+ <h1 class="govuk-fieldset__heading">
16
+ Which type of travel insurance would you like?
17
+ </h1>
18
+ </legend>
19
+
20
+ <p class="govuk-body"></p>
21
+
22
+
23
+ <div class="govuk-form-group">
24
+ <div class="govuk-radios govuk-radios" data-module="govuk-radios">
25
+ <div class="govuk-radios__item">
26
+ <input class="govuk-radios__input" id="travelInsuranceType" name="travelInsuranceType" type="radio" value="basic" data-cy="answer-basic">
27
+ <label class="govuk-label govuk-radios__label" for="travelInsuranceType">
28
+ Basic
29
+ </label>
30
+ </div>
31
+ <div class="govuk-radios__item">
32
+ <input class="govuk-radios__input" id="travelInsuranceType-2" name="travelInsuranceType" type="radio" value="comprehensive" data-cy="answer-comprehensive">
33
+ <label class="govuk-label govuk-radios__label" for="travelInsuranceType-2">
34
+ Comprehensive
35
+ </label>
36
+ </div>
37
+ <div class="govuk-radios__item">
38
+ <input class="govuk-radios__input" id="travelInsuranceType-3" name="travelInsuranceType" type="radio" value="premium" data-cy="answer-premium">
39
+ <label class="govuk-label govuk-radios__label" for="travelInsuranceType-3">
40
+ Premium
41
+ </label>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+
47
+
48
+ </fieldset>
49
+
50
+
51
+ <button type="submit" class="govuk-button" data-module="govuk-button" data-cy="button-save-and-continue">
52
+ Continue
53
+ </button>
54
+ </form>
55
+ </div>
56
+ </div>
@@ -0,0 +1,41 @@
1
+ import bodyParser from 'body-parser';
2
+ import express from 'express';
3
+ import session from 'express-session';
4
+ import crypto from 'node:crypto';
5
+ import { configureNunjucksTestEnv } from './nunjucks.js';
6
+
7
+ /**
8
+ * Create an Express app configured for testing with Nunjucks and session support.
9
+ * @returns {import('express').Express}
10
+ */
11
+ export function createApp() {
12
+ const app = express();
13
+
14
+ // configure body-parser, to populate req.body
15
+ // see https://expressjs.com/en/resources/middleware/body-parser.html
16
+ app.use(bodyParser.urlencoded({ extended: true }));
17
+ app.use(bodyParser.json());
18
+
19
+ app.use(
20
+ session({
21
+ secret: crypto.randomUUID(),
22
+ resave: false,
23
+ saveUninitialized: false,
24
+ store: new session.MemoryStore(),
25
+ unset: 'destroy',
26
+ cookie: {
27
+ secure: false,
28
+ maxAge: 86_400_000 // 1 day in milliseconds
29
+ }
30
+ })
31
+ );
32
+
33
+ const nunjucksEnvironment = configureNunjucksTestEnv();
34
+ // Set the express view engine to nunjucks
35
+ // calls to res.render will use nunjucks
36
+ nunjucksEnvironment.addGlobal('govukRebrand', true);
37
+ nunjucksEnvironment.express(app);
38
+ app.set('view engine', 'njk');
39
+
40
+ return app;
41
+ }
@@ -0,0 +1,20 @@
1
+ import nunjucks from 'nunjucks';
2
+ import { createRequire } from 'module';
3
+ import path from 'path';
4
+ import { testDir as getTestDir } from './utils.js';
5
+
6
+ export function configureNunjucksTestEnv() {
7
+ const require = createRequire(import.meta.url);
8
+ const testDir = getTestDir();
9
+ const srcDir = path.resolve(testDir, '..', 'src');
10
+ const govukFrontendRoot = path.resolve(require.resolve('govuk-frontend'), '../..');
11
+
12
+ return nunjucks.configure([testDir, srcDir, govukFrontendRoot], {
13
+ // output with dangerous characters are escaped automatically
14
+ autoescape: true,
15
+ // automatically remove trailing newlines from a block/tag
16
+ trimBlocks: true,
17
+ // automatically remove leading whitespace from a block/tag
18
+ lstripBlocks: true
19
+ });
20
+ }
@@ -0,0 +1,167 @@
1
+ import http from 'http';
2
+
3
+ const DEFAULT_OPTIONS = {
4
+ credentials: 'include'
5
+ };
6
+
7
+ export class TestServer {
8
+ // Private cookie jar for storing cookies between requests
9
+ /** @type {Object<string, string>} */
10
+ #cookieJar = {};
11
+
12
+ /**
13
+ * @param {import('http').RequestListener} requestListener
14
+ * @param {object} [options]
15
+ * @param {boolean} [options.rememberCookies] - If true, cookies are stored and sent with each request
16
+ */
17
+ constructor(requestListener, options = {}) {
18
+ this.requestListener = requestListener;
19
+ this.server = null;
20
+ this.port = null;
21
+ this.rememberCookies = Boolean(options.rememberCookies);
22
+ }
23
+
24
+ /**
25
+ * Starts the server on a random port.
26
+ * @returns {Promise<void>}
27
+ */
28
+ async start() {
29
+ if (this.server) return;
30
+ this.server = http.createServer(this.requestListener);
31
+ await new Promise((resolve, reject) => {
32
+ this.server.listen(0, '127.0.0.1');
33
+ this.server.on('listening', () => {
34
+ this.port = this.server.address().port;
35
+ resolve();
36
+ });
37
+ this.server.on('error', reject);
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Stops the server.
43
+ * @returns {Promise<void>}
44
+ */
45
+ async stop() {
46
+ if (!this.server) return;
47
+ await new Promise((resolve, reject) => {
48
+ this.server.close((err) => {
49
+ if (err) reject(err);
50
+ else resolve();
51
+ });
52
+ });
53
+ this.server = null;
54
+ this.port = null;
55
+ }
56
+
57
+ /**
58
+ * Performs a GET request to the server.
59
+ * @param {string} path
60
+ * @param {RequestInit} [options]
61
+ * @returns {Promise<Response>}
62
+ */
63
+ async get(path, options = {}) {
64
+ if (!this.server || !this.port) {
65
+ throw new Error('Server is not started');
66
+ }
67
+ const fetchOptions = { headers: {}, ...DEFAULT_OPTIONS, ...options, method: 'GET' };
68
+ this.#addCookies(fetchOptions.headers);
69
+ const response = await fetch(`http://localhost:${this.port}${path}`, fetchOptions);
70
+ if (this.rememberCookies) {
71
+ this.#updateCookies(response);
72
+ }
73
+ return response;
74
+ }
75
+
76
+ /**
77
+ * Performs a GET request to the server, following a maximum number of redirects.
78
+ * @param {string} path
79
+ * @param {number} maxRedirects
80
+ * @param {RequestInit} [options]
81
+ * @returns {Promise<Response>}
82
+ */
83
+ async getWithRedirects(path, maxRedirects, options = {}) {
84
+ if (!this.server || !this.port) {
85
+ throw new Error('Server is not started');
86
+ }
87
+ if (maxRedirects === 0) {
88
+ // can just use get() directly otherwise
89
+ throw new Error('maxRedirects must be greater than 0');
90
+ }
91
+ let response;
92
+ let currentPath = path;
93
+ let numRedirects = 0;
94
+ while (true) {
95
+ response = await this.get(currentPath, { ...options, redirect: 'manual' });
96
+ if (
97
+ response.status >= 300 &&
98
+ response.status < 400 &&
99
+ response.headers.has('location') &&
100
+ numRedirects < maxRedirects
101
+ ) {
102
+ currentPath = response.headers.get('location');
103
+ numRedirects++;
104
+ continue;
105
+ }
106
+ break;
107
+ }
108
+ if (this.rememberCookies) {
109
+ this.#updateCookies(response);
110
+ }
111
+ return response;
112
+ }
113
+
114
+ /**
115
+ * Performs a POST request to the server.
116
+ * @param {string} path
117
+ * @param {object} [body]
118
+ * @param {RequestInit} [options]
119
+ * @returns {Promise<Response>}
120
+ */
121
+ async post(path, body = {}, options = {}) {
122
+ if (!this.server || !this.port) {
123
+ throw new Error('Server is not started');
124
+ }
125
+ const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
126
+ const fetchOptions = {
127
+ ...DEFAULT_OPTIONS,
128
+ ...options,
129
+ method: 'POST',
130
+ headers,
131
+ body: JSON.stringify(body)
132
+ };
133
+ this.#addCookies(fetchOptions.headers);
134
+ const response = await fetch(`http://localhost:${this.port}${path}`, fetchOptions);
135
+ if (this.rememberCookies) {
136
+ this.#updateCookies(response);
137
+ }
138
+ return response;
139
+ }
140
+
141
+ #addCookies(headers) {
142
+ if (!this.rememberCookies) return;
143
+ if (this.#cookies) {
144
+ headers.Cookie = this.#cookies;
145
+ }
146
+ }
147
+
148
+ get #cookies() {
149
+ return Object.entries(this.#cookieJar)
150
+ .map(([k, v]) => `${k}=${v}`)
151
+ .join('; ');
152
+ }
153
+
154
+ /**
155
+ * Update the internal cookie jar from a fetch Response
156
+ * @param {Response} response
157
+ */
158
+ #updateCookies(response) {
159
+ const cookies = response.headers.getSetCookie();
160
+ if (!cookies) return;
161
+ const cookieKv = cookies.map((c) => c.split(';')[0]);
162
+ for (const c of cookieKv) {
163
+ const [k, v] = c.split('=');
164
+ this.#cookieJar[k] = v;
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,75 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import express from 'express';
4
+ import cookieParser from 'cookie-parser';
5
+ import { TestServer } from './test-server.js';
6
+
7
+ // Basic express app for testing
8
+ function createApp() {
9
+ const app = express();
10
+ app.use(express.json());
11
+ app.use(cookieParser());
12
+
13
+ app.get('/hello', (req, res) => {
14
+ res.send('world');
15
+ });
16
+
17
+ app.post('/echo', (req, res) => {
18
+ res.json(req.body);
19
+ });
20
+
21
+ app.get('/set-cookie', (req, res) => {
22
+ res.cookie('foo', 'bar');
23
+ res.send('cookie set');
24
+ });
25
+
26
+ app.get('/check-cookie', (req, res) => {
27
+ const foo = req.cookies ? req.cookies.foo : undefined;
28
+ res.json({ foo });
29
+ });
30
+
31
+ return app;
32
+ }
33
+
34
+ /**
35
+ * @param {import('node:test').TestContext} ctx
36
+ * @returns {Promise<TestServer>}
37
+ */
38
+ async function newTestServer(ctx) {
39
+ const server = new TestServer(createApp());
40
+ await server.start();
41
+ ctx.after(async () => await server.stop());
42
+ return server;
43
+ }
44
+
45
+ describe('TestServer', () => {
46
+ it('should GET /hello', async (ctx) => {
47
+ const server = await newTestServer(ctx);
48
+ const res = await server.get('/hello');
49
+ assert.equal(res.status, 200);
50
+ const text = await res.text();
51
+ assert.equal(text, 'world');
52
+ });
53
+
54
+ it('should POST /echo', async (ctx) => {
55
+ const server = await newTestServer(ctx);
56
+ const payload = { foo: 'bar' };
57
+ const res = await server.post('/echo', payload);
58
+ assert.equal(res.status, 200);
59
+ const json = await res.json();
60
+ assert.deepEqual(json, payload);
61
+ });
62
+
63
+ it('should handle cookies', async (ctx) => {
64
+ const server = await newTestServer(ctx);
65
+ server.rememberCookies = true;
66
+ // Set cookie
67
+ let res = await server.get('/set-cookie');
68
+ assert.equal(res.status, 200);
69
+ // Now check cookie is sent back
70
+ res = await server.get('/check-cookie');
71
+ assert.equal(res.status, 200);
72
+ const json = await res.json();
73
+ assert.equal(json.foo, 'bar');
74
+ });
75
+ });
@@ -1,11 +1,13 @@
1
- import { mock } from 'node:test';
2
- import { createRequire } from 'node:module';
3
- import nunjucks from 'nunjucks';
4
1
  import path from 'path';
5
- import { fileURLToPath } from 'url';
2
+ import { mock } from 'node:test';
3
+
4
+ export function testDir() {
5
+ return path.join(path.dirname(new URL(import.meta.url).pathname), '..');
6
+ }
6
7
 
7
- const __filename = fileURLToPath(import.meta.url);
8
- const __dirname = path.dirname(__filename);
8
+ export function snapshotsDir() {
9
+ return path.join(testDir(), 'snapshots');
10
+ }
9
11
 
10
12
  /**
11
13
  * @returns {import('pino').BaseLogger}
@@ -28,7 +30,6 @@ export const mockReq = () => ({
28
30
  params: {},
29
31
  body: {}
30
32
  });
31
-
32
33
  export const mockRes = () => {
33
34
  const res = {
34
35
  locals: {},
@@ -43,13 +44,11 @@ export const mockRes = () => {
43
44
  return res;
44
45
  };
45
46
 
46
- export function configureNunjucks() {
47
- const require = createRequire(import.meta.url);
48
- const paths = [
49
- // get the path to the govuk-frontend folder, in node_modules, using the node require resolution
50
- path.resolve(require.resolve('govuk-frontend'), '../..'),
51
- // path to src folder
52
- path.join(__dirname, '..')
53
- ];
54
- return nunjucks.configure(paths);
47
+ /**
48
+ * escapes all RegExp meta-characters in a string
49
+ * @param {string} s
50
+ * @returns {string}
51
+ */
52
+ export function escapeForRegExp(s) {
53
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
55
54
  }
@@ -0,0 +1 @@
1
+ {% extends "components/task-list/index.njk" %}