@planning-inspectorate/dynamic-forms 3.3.3 → 3.3.4

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.
package/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # snapshots always use \n
2
+ test/snapshots/** text eol=lf
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planning-inspectorate/dynamic-forms",
3
- "version": "3.3.3",
3
+ "version": "3.3.4",
4
4
  "description": "This package is for building GDS forms using a configuration-based approach.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -33,14 +33,16 @@
33
33
  "devDependencies": {
34
34
  "@commitlint/cli": "^20.0.0",
35
35
  "@commitlint/config-conventional": "^20.0.0",
36
+ "@eslint/js": "^10.0.1",
36
37
  "body-parser": "^2.2.0",
37
38
  "conventional-changelog-conventionalcommits": "^9.1.0",
38
39
  "cookie-parser": "^1.4.7",
39
- "eslint": "^9.29.0",
40
+ "eslint": "^10.0.0",
40
41
  "eslint-config-prettier": "^10.1.5",
41
42
  "express": "^5.1.0",
42
43
  "express-session": "^1.18.2",
43
- "govuk-frontend": "^5.11.0",
44
+ "globals": "^17.3.0",
45
+ "govuk-frontend": "^6.0.0",
44
46
  "husky": "^9.1.7",
45
47
  "lint-staged": "^16.1.2",
46
48
  "prettier": "^3.6.2",
@@ -5,8 +5,7 @@ import { ManageListSection } from '#src/components/manage-list/manage-list-secti
5
5
  import { mockJourney } from '#test/mock/journey.js';
6
6
  import { mockRandomUUID } from '#test/mock/uuid.js';
7
7
  import { configureNunjucksTestEnv } from '#test/utils/nunjucks.js';
8
- import path from 'path';
9
- import { snapshotsDir } from '#test/utils/utils.js';
8
+ import { assertSnapshot } from '#test/utils/utils.js';
10
9
 
11
10
  describe('components/manage-list/question', () => {
12
11
  const TITLE = 'Things';
@@ -96,13 +95,7 @@ describe('components/manage-list/question', () => {
96
95
  const { q } = questionWithManageQuestions(ctx, { showAnswersInSummary: true });
97
96
  const answerForSummary = q.formatAnswerForSummary('section-1', mockJourney(), [{}, {}, {}]);
98
97
  assert.strictEqual(answerForSummary.length, 1);
99
- ctx.assert.fileSnapshot(
100
- answerForSummary[0].value.replaceAll('\r\n', '\n'),
101
- path.join(snapshotsDir(), 'manage-list-answer-summary.html'),
102
- {
103
- serializers: [(v) => v]
104
- }
105
- );
98
+ assertSnapshot(ctx, answerForSummary[0].value, 'manage-list-answer-summary.html');
106
99
  });
107
100
 
108
101
  it('should render with answers', (ctx) => {
@@ -114,9 +107,7 @@ describe('components/manage-list/question', () => {
114
107
  };
115
108
  q.renderAction(res, viewModel);
116
109
  assert.strictEqual(res.render.mock.callCount(), 1);
117
- ctx.assert.fileSnapshot(res.render.mock.calls[0].result, path.join(snapshotsDir(), 'manage-list-render.html'), {
118
- serializers: [(v) => v]
119
- });
110
+ assertSnapshot(ctx, res.render.mock.calls[0].result, 'manage-list-render.html');
120
111
  });
121
112
 
122
113
  it('should render with answers & questions', (ctx) => {
@@ -130,13 +121,7 @@ describe('components/manage-list/question', () => {
130
121
  };
131
122
  q.renderAction(res, viewModel);
132
123
  assert.strictEqual(res.render.mock.callCount(), 1);
133
- ctx.assert.fileSnapshot(
134
- res.render.mock.calls[0].result,
135
- path.join(snapshotsDir(), 'manage-list-render-questions.html'),
136
- {
137
- serializers: [(v) => v]
138
- }
139
- );
124
+ assertSnapshot(ctx, res.render.mock.calls[0].result, 'manage-list-render-questions.html');
140
125
  });
141
126
 
142
127
  describe('getDataToSave', () => {
@@ -123,7 +123,7 @@
123
123
  }) }}
124
124
 
125
125
  {{ govukButton({
126
- text: "Undo all changes",
126
+ text: "Remove all redactions",
127
127
  classes: "govuk-button--secondary",
128
128
  id: "redact-undo-all",
129
129
  type: "button"
@@ -192,16 +192,20 @@
192
192
  showError('No changes to undo');
193
193
  }
194
194
  });
195
- redactUndoAll.addEventListener('click', () => {
196
- const unredated = unredactedText.dataset.original;
197
- if (history.length === 0 && textArea.value === unredated) {
198
- showError('No changes to undo');
199
- return;
200
- }
201
- hideError();
202
- textArea.value = unredated || '';
203
- history.length = 0;
204
- });
195
+ redactUndoAll.addEventListener('click', () => {
196
+ const unredated = unredactedText.dataset.original;
197
+ if (history.length === 0 && textArea.value === unredated) {
198
+ showError('No changes to undo');
199
+ return;
200
+ }
201
+ hideError();
202
+ textArea.value = unredated || '';
203
+ history.length = 0;
204
+
205
+ for (const rejectLink of rejectLinks) {
206
+ rejectLink.text = 'Accept';
207
+ }
208
+ });
205
209
  for (const rejectLink of rejectLinks) {
206
210
  rejectLink.addEventListener('click', () => {
207
211
  const index = parseInt(rejectLink.getAttribute('data-redact').replace('suggestion-reject-', ''));
@@ -1,8 +1,7 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'assert';
3
3
  import { manageListQuestions, questionsInOrder } from './questions.js';
4
- import path from 'path';
5
- import { escapeForRegExp, snapshotsDir } from './utils/utils.js';
4
+ import { assertSnapshot, escapeForRegExp } from './utils/utils.js';
6
5
  import { createAppWithQuestions, mockAnswer, mockAnswerBody } from './utils/question-test-utils.js';
7
6
  import { COMPONENT_TYPES } from '#src/index.js';
8
7
 
@@ -30,9 +29,7 @@ describe('check-your-answers', () => {
30
29
  new RegExp(`<a[^>]* href="questions/${q.url}">\\s*Answer\\s*<span[^>]*>\\s*${escapedQ}\\s*</span></a>`, 'i')
31
30
  );
32
31
  }
33
- ctx.assert.fileSnapshot(text, path.join(snapshotsDir(), 'check-your-answers-not-started.html'), {
34
- serializers: [(v) => v]
35
- });
32
+ assertSnapshot(ctx, text, 'check-your-answers-not-started.html');
36
33
  });
37
34
 
38
35
  it(`should render all question answers`, async (ctx) => {
@@ -80,8 +77,6 @@ describe('check-your-answers', () => {
80
77
  new RegExp(`<a[^>]* href="questions/${q.url}">\\s*Change\\s*<span[^>]*>\\s*${escapedQ}\\s*</span></a>`, 'i')
81
78
  );
82
79
  }
83
- ctx.assert.fileSnapshot(text, path.join(snapshotsDir(), 'check-your-answers-with-answers.html'), {
84
- serializers: [(v) => v]
85
- });
80
+ assertSnapshot(ctx, text, 'check-your-answers-with-answers.html');
86
81
  });
87
82
  });
@@ -0,0 +1,431 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'assert';
3
+ import { Section } from '#src/section.js';
4
+ import { Journey } from '#src/journey/journey.js';
5
+ import { COMPONENT_TYPES } from '#src/index.js';
6
+ import { createQuestions } from '#src/questions/create-questions.js';
7
+ import { questionClasses } from '#src/questions/questions.js';
8
+ import { whenQuestionHasAnswer } from '#src/components/utils/question-has-answer.js';
9
+ import { BOOLEAN_OPTIONS } from '#src/components/boolean/question.js';
10
+ import { createAppWithQuestions, renderQuestionCheck, postAnswer } from '#test/utils/question-test-utils.js';
11
+
12
+ const JOURNEY_ID = 'conditional-journey';
13
+
14
+ /**
15
+ * Question props for conditional journey tests
16
+ * @type {Record<string, import('#src/questions/question-props.js').QuestionProps>}
17
+ */
18
+ const conditionalQuestionProps = {
19
+ // Section 1: Insurance (simple + chained conditions)
20
+ wantsInsurance: {
21
+ type: COMPONENT_TYPES.BOOLEAN,
22
+ title: 'Want Insurance',
23
+ question: 'Do you want to add travel insurance?',
24
+ fieldName: 'wantsInsurance',
25
+ url: 'wants-insurance',
26
+ label: 'Add insurance?'
27
+ },
28
+ insuranceLevel: {
29
+ type: COMPONENT_TYPES.RADIO,
30
+ title: 'Insurance Level',
31
+ question: 'What level of insurance do you want?',
32
+ fieldName: 'insuranceLevel',
33
+ url: 'insurance-level',
34
+ label: 'Select an insurance level',
35
+ options: [
36
+ { value: 'basic', text: 'Basic' },
37
+ { value: 'premium', text: 'Premium' }
38
+ ]
39
+ },
40
+ premiumBenefits: {
41
+ type: COMPONENT_TYPES.CHECKBOX,
42
+ title: 'Premium Benefits',
43
+ question: 'Which premium benefits do you want?',
44
+ fieldName: 'premiumBenefits',
45
+ url: 'premium-benefits',
46
+ label: 'Select all that apply',
47
+ options: [
48
+ { value: 'cancellation', text: 'Cancellation cover' },
49
+ { value: 'medical', text: 'Medical cover' },
50
+ { value: 'luggage', text: 'Luggage cover' }
51
+ ]
52
+ },
53
+ // Section 2: Trip (leads to section condition)
54
+ tripType: {
55
+ type: COMPONENT_TYPES.RADIO,
56
+ title: 'Trip Type',
57
+ question: 'What type of trip are you planning?',
58
+ fieldName: 'tripType',
59
+ url: 'trip-type',
60
+ label: 'Select a trip type',
61
+ options: [
62
+ { value: 'domestic', text: 'Domestic' },
63
+ { value: 'international', text: 'International' }
64
+ ]
65
+ },
66
+ // Section 3: Visa (section-wide condition based on tripType)
67
+ visaRequired: {
68
+ type: COMPONENT_TYPES.BOOLEAN,
69
+ title: 'Visa Required',
70
+ question: 'Do you need a visa for your destination?',
71
+ fieldName: 'visaRequired',
72
+ url: 'visa-required',
73
+ label: 'Visa required?'
74
+ },
75
+ visaType: {
76
+ type: COMPONENT_TYPES.RADIO,
77
+ title: 'Visa Type',
78
+ question: 'What type of visa do you need?',
79
+ fieldName: 'visaType',
80
+ url: 'visa-type',
81
+ label: 'Select a visa type',
82
+ options: [
83
+ { value: 'tourist', text: 'Tourist' },
84
+ { value: 'business', text: 'Business' }
85
+ ]
86
+ },
87
+ // Section 4: Accommodation (multi-question condition)
88
+ accommodation: {
89
+ type: COMPONENT_TYPES.RADIO,
90
+ title: 'Accommodation',
91
+ question: 'Where will you stay?',
92
+ fieldName: 'accommodation',
93
+ url: 'accommodation',
94
+ label: 'Select accommodation type',
95
+ options: [
96
+ { value: 'hotel', text: 'Hotel' },
97
+ { value: 'hostel', text: 'Hostel' },
98
+ { value: 'apartment', text: 'Apartment' }
99
+ ]
100
+ },
101
+ hotelStars: {
102
+ type: COMPONENT_TYPES.RADIO,
103
+ title: 'Hotel Stars',
104
+ question: 'How many stars for your hotel?',
105
+ fieldName: 'hotelStars',
106
+ url: 'hotel-stars',
107
+ label: 'Select star rating',
108
+ options: [
109
+ { value: '3', text: '3 stars' },
110
+ { value: '4', text: '4 stars' },
111
+ { value: '5', text: '5 stars' }
112
+ ]
113
+ },
114
+ hotelBreakfast: {
115
+ type: COMPONENT_TYPES.BOOLEAN,
116
+ title: 'Hotel Breakfast',
117
+ question: 'Do you want breakfast included?',
118
+ fieldName: 'hotelBreakfast',
119
+ url: 'hotel-breakfast',
120
+ label: 'Include breakfast?'
121
+ },
122
+ // Section 5: Contact (always shown)
123
+ contactEmail: {
124
+ type: COMPONENT_TYPES.EMAIL,
125
+ title: 'Contact Email',
126
+ question: 'What is your email address?',
127
+ fieldName: 'contactEmail',
128
+ url: 'contact-email',
129
+ label: 'Email address'
130
+ }
131
+ };
132
+
133
+ /**
134
+ * Create the questions for conditional tests
135
+ */
136
+ const conditionalQuestions = createQuestions(conditionalQuestionProps, questionClasses, {});
137
+
138
+ /**
139
+ * Create a single journey with various conditional logic scenarios:
140
+ *
141
+ * Section 1 - Insurance (simple + chained conditions):
142
+ * - wantsInsurance: always shown
143
+ * - insuranceLevel: shown if wantsInsurance = 'yes' (simple condition)
144
+ * - premiumBenefits: shown if insuranceLevel = 'premium' (chained condition)
145
+ *
146
+ * Section 2 - Trip:
147
+ * - tripType: always shown
148
+ *
149
+ * Section 3 - Visa (section-wide condition):
150
+ * - Entire section shown only if tripType = 'international'
151
+ * - visaRequired: shown if section condition met
152
+ * - visaType: shown if section condition met AND visaRequired = 'yes'
153
+ *
154
+ * Section 4 - Accommodation (multi-question condition):
155
+ * - accommodation: always shown
156
+ * - hotelStars + hotelBreakfast: shown only if accommodation = 'hotel' (multi-question group)
157
+ *
158
+ * Section 5 - Contact:
159
+ * - contactEmail: always shown
160
+ */
161
+ function createConditionalJourney(questions, response) {
162
+ return new Journey({
163
+ journeyId: JOURNEY_ID,
164
+ sections: [
165
+ // Section 1: Insurance with simple and chained conditions
166
+ new Section('Insurance', 'insurance')
167
+ .addQuestion(questions.wantsInsurance)
168
+ .addQuestion(questions.insuranceLevel)
169
+ .withCondition(whenQuestionHasAnswer(questions.wantsInsurance, BOOLEAN_OPTIONS.YES))
170
+ .addQuestion(questions.premiumBenefits)
171
+ .withCondition(whenQuestionHasAnswer(questions.insuranceLevel, 'premium')),
172
+
173
+ // Section 2: Trip type (determines section condition for visa)
174
+ new Section('Trip Details', 'trip').addQuestion(questions.tripType),
175
+
176
+ // Section 3: Visa with section-wide condition
177
+ new Section('Visa', 'visa')
178
+ .withSectionCondition(whenQuestionHasAnswer(questions.tripType, 'international'))
179
+ .addQuestion(questions.visaRequired)
180
+ .addQuestion(questions.visaType)
181
+ .withCondition(whenQuestionHasAnswer(questions.visaRequired, BOOLEAN_OPTIONS.YES)),
182
+
183
+ // Section 4: Accommodation with multi-question condition
184
+ new Section('Accommodation', 'accommodation')
185
+ .addQuestion(questions.accommodation)
186
+ .startMultiQuestionCondition('hotel-group', whenQuestionHasAnswer(questions.accommodation, 'hotel'))
187
+ .addQuestion(questions.hotelStars)
188
+ .addQuestion(questions.hotelBreakfast)
189
+ .endMultiQuestionCondition('hotel-group'),
190
+
191
+ // Section 5: Contact (always shown)
192
+ new Section('Contact', 'contact').addQuestion(questions.contactEmail)
193
+ ],
194
+ taskListUrl: 'check-your-answers',
195
+ journeyTemplate: 'views/layout-journey.njk',
196
+ taskListTemplate: 'views/layout-check-your-answers.njk',
197
+ journeyTitle: 'Conditional Journey',
198
+ returnToListing: false,
199
+ makeBaseUrl: () => '/',
200
+ initialBackLink: '/start',
201
+ response
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Helper to create an app with the conditional journey
207
+ * @param {import('node:test').TestContext} ctx
208
+ * @returns {Promise<import('#test/utils/test-server.js').TestServer>}
209
+ */
210
+ function createAppWithJourney(ctx) {
211
+ return createAppWithQuestions(ctx, {
212
+ journeyId: JOURNEY_ID,
213
+ questions: conditionalQuestions,
214
+ createJourneyFn: createConditionalJourney
215
+ });
216
+ }
217
+
218
+ const q = conditionalQuestionProps;
219
+
220
+ describe('conditional journey tests', () => {
221
+ describe('simple condition (whenQuestionHasAnswer on single question)', () => {
222
+ it('should show conditional question when condition is met', async (ctx) => {
223
+ const testServer = await createAppWithJourney(ctx);
224
+
225
+ // First, render the initial question
226
+ await renderQuestionCheck(ctx, testServer, '/insurance/wants-insurance', q.wantsInsurance.question);
227
+
228
+ // Answer 'yes' to wantsInsurance
229
+ const location = await postAnswer(testServer, '/insurance/wants-insurance', {
230
+ [q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.YES
231
+ });
232
+
233
+ // Should redirect to insuranceLevel (conditional question)
234
+ assert.strictEqual(location, 'insurance/insurance-level');
235
+
236
+ // Render the conditional question
237
+ await renderQuestionCheck(ctx, testServer, '/insurance/insurance-level', q.insuranceLevel.question);
238
+ });
239
+
240
+ it('should skip conditional question when condition is not met', async (ctx) => {
241
+ const testServer = await createAppWithJourney(ctx);
242
+
243
+ // Answer 'no' to wantsInsurance
244
+ const location = await postAnswer(testServer, '/insurance/wants-insurance', {
245
+ [q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.NO
246
+ });
247
+
248
+ // Should skip insuranceLevel and premiumBenefits, go to next section (trip)
249
+ assert.strictEqual(location, 'trip/trip-type');
250
+ });
251
+ });
252
+
253
+ describe('chained conditions (multiple dependent conditions)', () => {
254
+ it('should show both conditional questions when all conditions are met', async (ctx) => {
255
+ const testServer = await createAppWithJourney(ctx);
256
+
257
+ // Answer 'yes' to wantsInsurance
258
+ await postAnswer(testServer, '/insurance/wants-insurance', {
259
+ [q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.YES
260
+ });
261
+
262
+ // Answer 'premium' to insuranceLevel
263
+ const location = await postAnswer(testServer, '/insurance/insurance-level', {
264
+ [q.insuranceLevel.fieldName]: 'premium'
265
+ });
266
+
267
+ // Should go to premiumBenefits
268
+ assert.strictEqual(location, 'insurance/premium-benefits');
269
+
270
+ // Render the premium benefits question
271
+ await renderQuestionCheck(ctx, testServer, '/insurance/premium-benefits', q.premiumBenefits.question);
272
+ });
273
+
274
+ it('should skip second conditional question when its condition is not met', async (ctx) => {
275
+ const testServer = await createAppWithJourney(ctx);
276
+
277
+ // Answer 'yes' to wantsInsurance
278
+ await postAnswer(testServer, '/insurance/wants-insurance', {
279
+ [q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.YES
280
+ });
281
+
282
+ // Answer 'basic' to insuranceLevel (not 'premium')
283
+ const location = await postAnswer(testServer, '/insurance/insurance-level', {
284
+ [q.insuranceLevel.fieldName]: 'basic'
285
+ });
286
+
287
+ // Should skip premiumBenefits and go to next section (trip)
288
+ assert.strictEqual(location, 'trip/trip-type');
289
+ });
290
+
291
+ it('should skip all conditional questions when first condition is not met', async (ctx) => {
292
+ const testServer = await createAppWithJourney(ctx);
293
+
294
+ // Answer 'no' to wantsInsurance
295
+ const location = await postAnswer(testServer, '/insurance/wants-insurance', {
296
+ [q.wantsInsurance.fieldName]: BOOLEAN_OPTIONS.NO
297
+ });
298
+
299
+ // Should skip insuranceLevel and premiumBenefits, go to next section (trip)
300
+ assert.strictEqual(location, 'trip/trip-type');
301
+ });
302
+ });
303
+
304
+ describe('section-wide conditions (withSectionCondition)', () => {
305
+ it('should show section questions when section condition is met', async (ctx) => {
306
+ const testServer = await createAppWithJourney(ctx);
307
+
308
+ // Answer 'international' to tripType
309
+ const location = await postAnswer(testServer, '/trip/trip-type', {
310
+ [q.tripType.fieldName]: 'international'
311
+ });
312
+
313
+ // Should go to visa section (visaRequired)
314
+ assert.strictEqual(location, 'visa/visa-required');
315
+
316
+ // Render the visa question
317
+ await renderQuestionCheck(ctx, testServer, '/visa/visa-required', q.visaRequired.question);
318
+ });
319
+
320
+ it('should skip entire section when section condition is not met', async (ctx) => {
321
+ const testServer = await createAppWithJourney(ctx);
322
+
323
+ // Answer 'domestic' to tripType
324
+ const location = await postAnswer(testServer, '/trip/trip-type', {
325
+ [q.tripType.fieldName]: 'domestic'
326
+ });
327
+
328
+ // Should skip visa section and go to accommodation section
329
+ assert.strictEqual(location, 'accommodation/accommodation');
330
+ });
331
+
332
+ it('should combine section condition with question condition', async (ctx) => {
333
+ const testServer = await createAppWithJourney(ctx);
334
+
335
+ // Answer 'international' to tripType
336
+ await postAnswer(testServer, '/trip/trip-type', {
337
+ [q.tripType.fieldName]: 'international'
338
+ });
339
+
340
+ // Answer 'yes' to visaRequired
341
+ const location = await postAnswer(testServer, '/visa/visa-required', {
342
+ [q.visaRequired.fieldName]: BOOLEAN_OPTIONS.YES
343
+ });
344
+
345
+ // Should go to visaType (condition met)
346
+ assert.strictEqual(location, 'visa/visa-type');
347
+
348
+ // Render the visa type question
349
+ await renderQuestionCheck(ctx, testServer, '/visa/visa-type', q.visaType.question);
350
+ });
351
+
352
+ it('should skip conditional question within section when its condition is not met', async (ctx) => {
353
+ const testServer = await createAppWithJourney(ctx);
354
+
355
+ // Answer 'international' to tripType
356
+ await postAnswer(testServer, '/trip/trip-type', {
357
+ [q.tripType.fieldName]: 'international'
358
+ });
359
+
360
+ // Answer 'no' to visaRequired
361
+ const location = await postAnswer(testServer, '/visa/visa-required', {
362
+ [q.visaRequired.fieldName]: BOOLEAN_OPTIONS.NO
363
+ });
364
+
365
+ // Should skip visaType and go to accommodation section
366
+ assert.strictEqual(location, 'accommodation/accommodation');
367
+ });
368
+ });
369
+
370
+ describe('multi-question conditions (startMultiQuestionCondition)', () => {
371
+ it('should show all questions in group when condition is met', async (ctx) => {
372
+ const testServer = await createAppWithJourney(ctx);
373
+
374
+ // Answer 'hotel' to accommodation
375
+ const location = await postAnswer(testServer, '/accommodation/accommodation', {
376
+ [q.accommodation.fieldName]: 'hotel'
377
+ });
378
+
379
+ // Should go to hotelStars (first question in group)
380
+ assert.strictEqual(location, 'accommodation/hotel-stars');
381
+
382
+ // Render the hotel stars question
383
+ await renderQuestionCheck(ctx, testServer, '/accommodation/hotel-stars', q.hotelStars.question);
384
+
385
+ // Answer hotelStars
386
+ const nextLocation = await postAnswer(testServer, '/accommodation/hotel-stars', {
387
+ [q.hotelStars.fieldName]: '4'
388
+ });
389
+
390
+ // Should go to hotelBreakfast (second question in group)
391
+ assert.strictEqual(nextLocation, 'accommodation/hotel-breakfast');
392
+
393
+ // Render the hotel breakfast question
394
+ await renderQuestionCheck(ctx, testServer, '/accommodation/hotel-breakfast', q.hotelBreakfast.question);
395
+ });
396
+
397
+ it('should skip all questions in group when condition is not met', async (ctx) => {
398
+ const testServer = await createAppWithJourney(ctx);
399
+
400
+ // Answer 'hostel' to accommodation (not 'hotel')
401
+ const location = await postAnswer(testServer, '/accommodation/accommodation', {
402
+ [q.accommodation.fieldName]: 'hostel'
403
+ });
404
+
405
+ // Should skip hotelStars and hotelBreakfast, go to contact section
406
+ assert.strictEqual(location, 'contact/contact-email');
407
+ });
408
+
409
+ it('should proceed to question after group ends', async (ctx) => {
410
+ const testServer = await createAppWithJourney(ctx);
411
+
412
+ // Answer 'hotel' to accommodation
413
+ await postAnswer(testServer, '/accommodation/accommodation', {
414
+ [q.accommodation.fieldName]: 'hotel'
415
+ });
416
+
417
+ // Answer hotelStars
418
+ await postAnswer(testServer, '/accommodation/hotel-stars', {
419
+ [q.hotelStars.fieldName]: '5'
420
+ });
421
+
422
+ // Answer hotelBreakfast (last in group)
423
+ const location = await postAnswer(testServer, '/accommodation/hotel-breakfast', {
424
+ [q.hotelBreakfast.fieldName]: BOOLEAN_OPTIONS.YES
425
+ });
426
+
427
+ // Should go to contact section (after group ends)
428
+ assert.strictEqual(location, 'contact/contact-email');
429
+ });
430
+ });
431
+ });
@@ -1,11 +1,14 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'assert';
3
3
  import { manageListQuestions, questionsInOrder } from './questions.js';
4
- import path from 'path';
5
4
  import { COMPONENT_TYPES } from '../src/index.js';
6
- import { escapeForRegExp, snapshotsDir } from './utils/utils.js';
7
- import { createAppWithQuestions, mockAnswerBody } from '#test/utils/question-test-utils.js';
8
- import { mockRandomUUID } from '#test/mock/uuid.js';
5
+ import { assertSnapshot } from './utils/utils.js';
6
+ import {
7
+ createAppWithQuestions,
8
+ mockAnswerBody,
9
+ renderQuestionCheck,
10
+ postAnswer
11
+ } from '#test/utils/question-test-utils.js';
9
12
 
10
13
  describe('question pages', () => {
11
14
  it('should have a test for each question type', () => {
@@ -31,18 +34,11 @@ describe('question pages', () => {
31
34
  * @param {import('#src/questions/question.js')} q
32
35
  * @returns {Promise<void>}
33
36
  */
34
- async function renderQuestionCheck(ctx, url, snapshotName, q) {
35
- mockRandomUUID(ctx);
37
+ async function renderQuestionAndSnapshot(ctx, url, snapshotName, q) {
36
38
  const testServer = await createAppWithQuestions(ctx);
39
+ const text = await renderQuestionCheck(ctx, testServer, url, q.question);
37
40
 
38
- const response = await testServer.get(url, {
39
- redirect: 'manual'
40
- });
41
- assert.strictEqual(response.status, 200);
42
- const text = await response.text();
43
- // be sure to escape parentheses in the question text for the regex
44
- assert.match(text, new RegExp(escapeForRegExp(q.question), 'i'));
45
- assert.match(text, /<form action="" method="post"/i);
41
+ // Check options if present
46
42
  if (q.options) {
47
43
  for (const option of q.options) {
48
44
  if (q.type === COMPONENT_TYPES.SELECT) {
@@ -56,9 +52,8 @@ describe('question pages', () => {
56
52
  }
57
53
  }
58
54
  }
59
- ctx.assert.fileSnapshot(text, path.join(snapshotsDir(), snapshotName + '.html'), {
60
- serializers: [(v) => v]
61
- });
55
+
56
+ assertSnapshot(ctx, text, snapshotName + '.html');
62
57
  }
63
58
 
64
59
  /**
@@ -70,22 +65,14 @@ describe('question pages', () => {
70
65
  async function postQuestionCheck(ctx, url, q) {
71
66
  const testServer = await createAppWithQuestions(ctx);
72
67
  const payload = mockAnswerBody(q);
73
- const response = await testServer.post(url, payload, { redirect: 'manual' });
74
- if (![302, 303].includes(response.status)) {
75
- const text = await response.text();
76
- console.log(`Response for ${q.url}:\n`, text);
77
- }
78
- // Should redirect (302 or 303)
79
- assert.ok([302, 303].includes(response.status), `Expected redirect, got ${response.status}`);
80
- // Should redirect to the next question (if there is one)
81
- return response.headers.get('location');
68
+ return postAnswer(testServer, url, payload);
82
69
  }
83
70
 
84
71
  for (let i = 0; i < questionsInOrder.length; i++) {
85
72
  const q = questionsInOrder[i];
86
73
 
87
74
  it(`should render question: ${q.url}`, async (ctx) => {
88
- await renderQuestionCheck(ctx, '/questions/' + q.url, q.url, q);
75
+ await renderQuestionAndSnapshot(ctx, '/questions/' + q.url, q.url, q);
89
76
  });
90
77
 
91
78
  it(`should POST valid data for question: ${q.url} and redirect to next`, async (ctx) => {
@@ -105,7 +92,7 @@ describe('question pages', () => {
105
92
  for (let i = 0; i < questions.length; i++) {
106
93
  const q = questions[i];
107
94
  it(`should render question: ${q.url}`, async (ctx) => {
108
- await renderQuestionCheck(ctx, '/' + qUrl(q), manageListQuestion.url + '_' + q.url, q);
95
+ await renderQuestionAndSnapshot(ctx, '/' + qUrl(q), manageListQuestion.url + '_' + q.url, q);
109
96
  });
110
97
 
111
98
  it(`should POST valid data for question: ${q.url} and redirect to next`, async (ctx) => {
@@ -43,7 +43,7 @@
43
43
  </button>
44
44
 
45
45
  <button type="button" class="govuk-button govuk-button--secondary" data-module="govuk-button" id="redact-undo-all">
46
- Undo all changes
46
+ Remove all redactions
47
47
  </button>
48
48
  </div>
49
49
 
@@ -107,16 +107,20 @@
107
107
  showError('No changes to undo');
108
108
  }
109
109
  });
110
- redactUndoAll.addEventListener('click', () => {
111
- const unredated = unredactedText.dataset.original;
112
- if (history.length === 0 && textArea.value === unredated) {
113
- showError('No changes to undo');
114
- return;
115
- }
116
- hideError();
117
- textArea.value = unredated || '';
118
- history.length = 0;
119
- });
110
+ redactUndoAll.addEventListener('click', () => {
111
+ const unredated = unredactedText.dataset.original;
112
+ if (history.length === 0 && textArea.value === unredated) {
113
+ showError('No changes to undo');
114
+ return;
115
+ }
116
+ hideError();
117
+ textArea.value = unredated || '';
118
+ history.length = 0;
119
+
120
+ for (const rejectLink of rejectLinks) {
121
+ rejectLink.text = 'Accept';
122
+ }
123
+ });
120
124
  for (const rejectLink of rejectLinks) {
121
125
  rejectLink.addEventListener('click', () => {
122
126
  const index = parseInt(rejectLink.getAttribute('data-redact').replace('suggestion-reject-', ''));
@@ -1,3 +1,4 @@
1
+ import assert from 'assert';
1
2
  import { getQuestions } from '#test/questions.js';
2
3
  import { COMPONENT_TYPES } from '#src/index.js';
3
4
  import { createApp } from '#test/utils/app.js';
@@ -9,16 +10,28 @@ import validate from '#src/validator/validator.js';
9
10
  import { validationErrorHandler } from '#src/validator/validation-error-handler.js';
10
11
  import { TestServer } from '#test/utils/test-server.js';
11
12
  import { BOOLEAN_OPTIONS } from '#src/components/boolean/question.js';
13
+ import { escapeForRegExp } from '#test/utils/utils.js';
14
+ import { mockRandomUUID } from '#test/mock/uuid.js';
15
+
16
+ /**
17
+ * @typedef {Object} CreateAppOptions
18
+ * @property {string} journeyId - The journey ID for session storage
19
+ * @property {(questions: Object, response: import('#src/journey/journey-response.js').JourneyResponse) => import('#src/journey/journey.js').Journey} createJourneyFn - Function to create the journey
20
+ * @property {Object} questions - The questions object
21
+ */
12
22
 
13
23
  /**
14
24
  * @param {import('node:test').TestContext} ctx
25
+ * @param {CreateAppOptions} [options]
15
26
  * @returns {Promise<TestServer>}
16
27
  */
17
- export async function createAppWithQuestions(ctx) {
28
+ export async function createAppWithQuestions(ctx, options) {
18
29
  const app = createApp();
19
- const questions = getQuestions();
20
- const getJourney = buildGetJourney((req, journeyResponse) => createJourney(questions, journeyResponse));
21
- const getJourneyResponse = buildGetJourneyResponseFromSession(JOURNEY_ID);
30
+ const questions = options?.questions ?? getQuestions();
31
+ const journeyId = options?.journeyId ?? JOURNEY_ID;
32
+ const createJourneyFn = options?.createJourneyFn ?? createJourney;
33
+ const getJourney = buildGetJourney((req, journeyResponse) => createJourneyFn(questions, journeyResponse));
34
+ const getJourneyResponse = buildGetJourneyResponseFromSession(journeyId);
22
35
 
23
36
  // app.use((req, res, next) => {
24
37
  // console.log(req.method, req.url, req.session);
@@ -198,3 +211,38 @@ export function mockAnswer(q) {
198
211
  return 'test';
199
212
  }
200
213
  }
214
+
215
+ /**
216
+ * Helper to render a question and check it's displayed correctly
217
+ * @param {import('node:test').TestContext} ctx
218
+ * @param {import('./test-server.js').TestServer} testServer
219
+ * @param {string} url
220
+ * @param {string} questionText
221
+ * @returns {Promise<string>}
222
+ */
223
+ export async function renderQuestionCheck(ctx, testServer, url, questionText) {
224
+ mockRandomUUID(ctx);
225
+ const response = await testServer.get(url, { redirect: 'manual' });
226
+ assert.strictEqual(response.status, 200, `Expected 200 for ${url}, got ${response.status}`);
227
+ const text = await response.text();
228
+ assert.match(text, new RegExp(escapeForRegExp(questionText), 'i'));
229
+ assert.match(text, /<form action="" method="post"/i);
230
+ return text;
231
+ }
232
+
233
+ /**
234
+ * Helper to POST an answer and check the redirect
235
+ * @param {import('./test-server.js').TestServer} testServer
236
+ * @param {string} url
237
+ * @param {Record<string, unknown>} payload
238
+ * @returns {Promise<string|null>}
239
+ */
240
+ export async function postAnswer(testServer, url, payload) {
241
+ const response = await testServer.post(url, payload, { redirect: 'manual' });
242
+ if (![302, 303].includes(response.status)) {
243
+ const text = await response.text();
244
+ console.log(`Response for ${url}:\n`, text);
245
+ }
246
+ assert.ok([302, 303].includes(response.status), `Expected redirect, got ${response.status}`);
247
+ return response.headers.get('location');
248
+ }
@@ -152,6 +152,7 @@ export class TestServer {
152
152
  return response;
153
153
  } catch (error) {
154
154
  if (error instanceof Error && error.name === 'AbortError') {
155
+ // eslint-disable-next-line preserve-caught-error
155
156
  throw new Error(`Request to ${path} timed out after ${this.#timeoutMs}ms`);
156
157
  }
157
158
  throw error;
@@ -2,13 +2,28 @@ import path from 'path';
2
2
  import { mock } from 'node:test';
3
3
 
4
4
  export function testDir() {
5
- return path.join(path.dirname(new URL(import.meta.url).pathname), '..');
5
+ return path.join(import.meta.dirname, '..');
6
6
  }
7
7
 
8
8
  export function snapshotsDir() {
9
9
  return path.join(testDir(), 'snapshots');
10
10
  }
11
11
 
12
+ /**
13
+ * @param {import('node:test').TestContext} ctx
14
+ * @param {string} content
15
+ * @param {string} name
16
+ */
17
+ export function assertSnapshot(ctx, content, name) {
18
+ ctx.assert.fileSnapshot(
19
+ content.replaceAll('\r\n', '\n'), // OS agnostic line endings
20
+ path.join(snapshotsDir(), name),
21
+ {
22
+ serializers: [(v) => v]
23
+ }
24
+ );
25
+ }
26
+
12
27
  /**
13
28
  * @returns {import('pino').BaseLogger}
14
29
  */