@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.
- package/.prettierignore +2 -1
- package/README.md +9 -1
- package/package.json +7 -2
- package/src/components/address/question.js +1 -1
- package/src/components/address/question.test.js +3 -3
- package/src/components/multi-field-input/index.njk +2 -2
- package/src/components/multi-field-input/question.js +5 -1
- package/src/components/radio/index.njk +2 -2
- package/src/components/text-entry-redact/question.test.js +9 -9
- package/src/controller.test.js +2 -1
- package/src/index.js +8 -2
- package/src/lib/session-answer-store.test.js +1 -1
- package/src/questions/question-props.d.ts +5 -24
- package/src/questions/question.test.js +1 -1
- package/src/section.js +3 -0
- package/src/validator/validator.js +4 -1
- package/test/check-your-answers.test.js +69 -0
- package/test/journey.js +41 -0
- package/test/journey.test.js +21 -0
- package/test/questions.js +189 -0
- package/test/questions.test.js +246 -0
- package/test/snapshots/add-insurance.html +44 -0
- package/test/snapshots/check-your-answers-not-started.html +201 -0
- package/test/snapshots/check-your-answers-with-answers.html +201 -0
- package/test/snapshots/companions.html +51 -0
- package/test/snapshots/departure-date.html +57 -0
- package/test/snapshots/holiday-activities.html +49 -0
- package/test/snapshots/holiday-arrival.html +101 -0
- package/test/snapshots/holiday-description.html +33 -0
- package/test/snapshots/holiday-destination.html +56 -0
- package/test/snapshots/holiday-period.html +102 -0
- package/test/snapshots/holiday-snack.html +33 -0
- package/test/snapshots/hotel-address.html +65 -0
- package/test/snapshots/luggage-weight-units.html +75 -0
- package/test/snapshots/nights.html +28 -0
- package/test/snapshots/secret-wish.html +173 -0
- package/test/snapshots/travel-class.html +52 -0
- package/test/snapshots/travel-insurance-type.html +56 -0
- package/test/utils/app.js +41 -0
- package/test/utils/nunjucks.js +20 -0
- package/test/utils/test-server.js +167 -0
- package/test/utils/test-server.test.js +75 -0
- package/{src/lib/test-utils.js → test/utils/utils.js} +15 -16
- package/test/views/layout-check-your-answers.njk +1 -0
- /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 {
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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" %}
|
|
File without changes
|