Skip to content

Commit 9d96a9b

Browse files
authored
Merge pull request #15 from ForgeRock/support-field-tests
chore: add-tests
2 parents c2ba0e7 + 194133a commit 9d96a9b

File tree

12 files changed

+193
-59
lines changed

12 files changed

+193
-59
lines changed

.changeset/spicy-fans-speak.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/davinci-client': patch
3+
---
4+
5+
fixes the checks to determine what node state we are in based on the response from p1

.husky/install.mjs

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
if (process.env.NODE_ENV === 'production' || process.env.CI === 'true') {
33
process.exit(0);
44
}
5-
const husky = (await import('husky')).default;
6-
husky();
5+
const husky = (await import('husky')).default
6+
console.log(husky())
+12-9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { PasswordCollector, Updater } from '@forgerock/davinci-client/types';
2+
import { dotToCamelCase } from '../helper.js';
23

34
export default function passwordComponent(
45
formEl: HTMLFormElement,
@@ -8,19 +9,21 @@ export default function passwordComponent(
89
const label = document.createElement('label');
910
const input = document.createElement('input');
1011

11-
label.htmlFor = collector.output.key;
12+
label.htmlFor = dotToCamelCase(collector.output.key);
1213
label.innerText = collector.output.label;
1314
input.type = 'password';
14-
input.id = collector.output.key;
15-
input.name = collector.output.key;
15+
input.id = dotToCamelCase(collector.output.key);
16+
input.name = dotToCamelCase(collector.output.key);
1617

1718
formEl?.appendChild(label);
1819
formEl?.appendChild(input);
1920

20-
formEl?.querySelector(`#${collector.output.key}`)?.addEventListener('blur', (event: Event) => {
21-
const error = updater((event.target as HTMLInputElement).value);
22-
if (error && 'error' in error) {
23-
console.error(error.error.message);
24-
}
25-
});
21+
formEl
22+
?.querySelector(`#${dotToCamelCase(collector.output.key)}`)
23+
?.addEventListener('blur', (event: Event) => {
24+
const error = updater((event.target as HTMLInputElement).value);
25+
if (error && 'error' in error) {
26+
console.error(error.error.message);
27+
}
28+
});
2629
}

e2e/davinci-app/components/text.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import type {
33
ValidatedTextCollector,
44
Updater,
55
} from '@forgerock/davinci-client/types';
6+
import { dotToCamelCase } from '../helper.js';
67

78
export default function usernameComponent(
89
formEl: HTMLFormElement,
910
collector: TextCollector | ValidatedTextCollector,
1011
updater: Updater,
1112
) {
12-
const collectorKey = collector.output.key;
13+
const collectorKey = dotToCamelCase(collector.output.key);
1314
const label = document.createElement('label');
1415
const input = document.createElement('input');
1516

e2e/davinci-app/helper.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function dotToCamelCase(str: string) {
2+
return str
3+
.split('.')
4+
.map((part: string, index: number) =>
5+
index === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
6+
)
7+
.join('');
8+
}

e2e/davinci-app/main.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ const urlParams = new URLSearchParams(window.location.search);
8282

8383
const loginBtn = document.getElementById('logoutButton') as HTMLButtonElement;
8484
loginBtn.addEventListener('click', async () => {
85-
await FRUser.logout({ logoutRedirectUri: window.location.href });
85+
await FRUser.logout({ logoutRedirectUri: `${window.location.origin}/` });
8686

87-
window.location.reload();
87+
//window.location.reload();
8888
});
8989
}
9090

@@ -204,7 +204,7 @@ const urlParams = new URLSearchParams(window.location.search);
204204
node = await davinciClient.start({ query });
205205
} else {
206206
node = resumed;
207-
console.log('node is reusmed');
207+
console.log('node is resumed');
208208
console.log(node);
209209
}
210210

e2e/davinci-app/tsconfig.app.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
"moduleResolution": "Bundler"
66
},
77
"exclude": ["**/*.spec.ts", "**/*.test.ts"],
8-
"include": ["./main.ts", "./server-configs.ts", "components/**/*.ts"],
8+
"include": [
9+
"./main.ts",
10+
"./helper.ts",
11+
"./server-configs.ts",
12+
"components/**/*.ts"
13+
],
914
"references": [
1015
{
1116
"path": "../../packages/davinci-client/tsconfig.lib.json"

e2e/davinci-suites/src/basic.test.ts

+17
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ test('Test happy paths on test page', async ({ page }) => {
2525

2626
const accessToken = await page.locator('#accessTokenValue').innerText();
2727
await expect(accessToken).toBeTruthy();
28+
29+
const logoutButton = page.getByRole('button', { name: 'Logout' });
30+
await expect(logoutButton).toBeVisible();
31+
const revokeCall = page.waitForResponse((response) => {
32+
if (response.url().includes('/revoke') && response.status() === 200) {
33+
return true;
34+
}
35+
});
36+
const signoff = page.waitForResponse((response) => {
37+
if (response.url().includes('/signoff') && response.status() === 302) {
38+
return true;
39+
}
40+
});
41+
await logoutButton.click();
42+
await revokeCall;
43+
await signoff;
44+
await expect(page.getByText('Username/Password Form')).toBeVisible();
2845
});
2946
test('ensure query params passed to start are sent off in authorize call', async ({ page }) => {
3047
const { navigate } = asyncEvents(page);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
test('Should render form fields', async ({ page }) => {
4+
await page.goto('http://localhost:5829/?clientId=60de77d5-dd2c-41ef-8c40-f8bb2381a359');
5+
await page.getByRole('heading', { name: 'Select Test Form' }).click();
6+
await page.getByRole('button', { name: 'Form Fields' }).click();
7+
await page.getByRole('textbox', { name: 'Text Input Label' }).click();
8+
await page.getByRole('textbox', { name: 'Text Input Label' }).click();
9+
10+
const txtInput = page.getByRole('textbox', { name: 'Text Input Label' });
11+
await txtInput.fill('This is some text');
12+
expect(txtInput).toHaveValue('This is some text');
13+
14+
const flowLink = page.getByRole('button', { name: 'Flow Link' });
15+
await flowLink.click();
16+
17+
const flowButton = page.getByRole('button', { name: 'Flow Button' });
18+
await flowButton.click();
19+
20+
const requestPromise = page.waitForRequest((request) => request.url().includes('/customForm'));
21+
await page.getByRole('button', { name: 'Submit' }).click();
22+
const request = await requestPromise;
23+
const parsedData = JSON.parse(request.postData());
24+
const data = parsedData.parameters.data;
25+
expect(data.actionKey).toBe('submit');
26+
expect(data.formData).toEqual({
27+
['text-input-key']: 'This is some text',
28+
['dropdown-field-key']: '',
29+
['radio-group-key']: '',
30+
});
31+
});
32+
33+
test('should render form validation fields', async ({ page }) => {
34+
await page.goto('http://localhost:5829/?clientId=60de77d5-dd2c-41ef-8c40-f8bb2381a359');
35+
await expect(page.getByRole('link', { name: 'Vite logo' })).toBeVisible();
36+
await expect(page.getByRole('button', { name: 'Form Validation' })).toBeVisible();
37+
await expect(page.locator('#form')).toContainText('Form Validation');
38+
await page.getByRole('button', { name: 'Form Validation' }).click();
39+
await expect(page.getByRole('heading', { name: 'Form Fields Validation' })).toBeVisible();
40+
41+
await page.getByRole('textbox', { name: 'Username' }).fill('sdk-user');
42+
await expect(page.getByRole('textbox', { name: 'Username' })).toHaveValue('sdk-user');
43+
44+
const password = page.getByRole('textbox', { name: 'Password' });
45+
await password.type('password');
46+
await expect(password).toHaveValue('password');
47+
48+
await page.getByRole('textbox', { name: 'Email Address' }).fill('[email protected]');
49+
await expect(page.getByRole('textbox', { name: 'Email Address' })).toHaveValue(
50+
51+
);
52+
53+
const requestPromise = page.waitForRequest((request) => request.url().includes('/customForm'));
54+
await page.getByRole('button', { name: 'Submit' }).click();
55+
56+
const request = await requestPromise;
57+
const parsedData = JSON.parse(request.postData());
58+
59+
const data = parsedData.parameters.data;
60+
61+
expect(data.actionKey).toBe('submit');
62+
expect(data.formData).toEqual({
63+
'user.username': 'sdk-user',
64+
'user.password': 'password',
65+
'user.email': '[email protected]',
66+
});
67+
});

packages/davinci-client/src/lib/client.store.ts

+44-36
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,31 @@ export async function davinci({ config }: { config: DaVinciConfig }) {
5555
// Pass store methods to the client
5656
subscribe: store.subscribe,
5757

58+
/**
59+
* Social Login Handler
60+
* Use this as part of an event when clicking on
61+
* a social login button. Pass in the collector responsible
62+
* for the social login being started.
63+
*
64+
* This method will save the `continueUrl`
65+
* and then replace the window with the authenticate
66+
* url from the collector
67+
*
68+
* Can return an error when no continue url is found
69+
* or no authenticate url is found in the collectors
70+
*
71+
* @method: externalIdp
72+
* @param collector IdpCollector
73+
* @returns {function}
74+
*/
75+
externalIdp: (collector: IdpCollector) => {
76+
const rootState: RootState = store.getState();
77+
78+
const serverSlice = nodeSlice.selectors.selectServer(rootState);
79+
80+
return () => authorize(serverSlice, collector);
81+
},
82+
5883
/**
5984
* @method flow - Method for initiating a new flow, different than current flow
6085
* @param {DaVinciAction} action - the action to initiate the flow
@@ -97,6 +122,15 @@ export async function davinci({ config }: { config: DaVinciConfig }) {
97122
return node;
98123
},
99124

125+
/**
126+
* @method: resume - Resume a social login flow when returned to application
127+
* @returns unknown
128+
*/
129+
resume: async ({ continueToken }: { continueToken: string }) => {
130+
const node = store.dispatch(davinciApi.endpoints.resume.initiate({ continueToken }));
131+
return node;
132+
},
133+
100134
/**
101135
* @method start - Method for initiating a DaVinci flow
102136
* @returns {Promise} - a promise that initiates a DaVinci flow and returns a node
@@ -140,13 +174,20 @@ export async function davinci({ config }: { config: DaVinciConfig }) {
140174
};
141175
}
142176

143-
if (collectorToUpdate.category !== 'SingleValueCollector') {
144-
console.error('Collector is not a SingleValueCollector and cannot be updated');
177+
if (
178+
collectorToUpdate.category !== 'MultiValueCollector' &&
179+
collectorToUpdate.category !== 'SingleValueCollector' &&
180+
collectorToUpdate.category !== 'ValidatedSingleValueCollector'
181+
) {
182+
console.error(
183+
'Collector is not a MultiValueCollector, SingleValueCollector or ValidatedSingleValueCollector and cannot be updated',
184+
);
145185
return function () {
146186
return {
147187
type: 'internal_error',
148188
error: {
149-
message: 'Collector is not a SingleValueCollector and cannot be updated',
189+
message:
190+
'Collector is not a SingleValueCollector or ValidatedSingleValueCollector and cannot be updated',
150191
type: 'state_error',
151192
},
152193
} as const;
@@ -271,39 +312,6 @@ export async function davinci({ config }: { config: DaVinciConfig }) {
271312
return nodeSlice.selectors.selectServer(state);
272313
},
273314

274-
/**
275-
* @method: resume - Resume a social login flow when returned to application
276-
* @returns unknown
277-
*/
278-
resume: async ({ continueToken }: { continueToken: string }) => {
279-
const node = store.dispatch(davinciApi.endpoints.resume.initiate({ continueToken }));
280-
return node;
281-
},
282-
/**
283-
* Social Login Handler
284-
* Use this as part of an event when clicking on
285-
* a social login button. Pass in the collector responsible
286-
* for the social login being started.
287-
*
288-
* This method will save the `continueUrl`
289-
* and then replace the window with the authenticate
290-
* url from the collector
291-
*
292-
* Can return an error when no continue url is found
293-
* or no authenticate url is found in the collectors
294-
*
295-
* @method: externalIdp
296-
* @param collector IdpCollector
297-
* @returns {function}
298-
*/
299-
externalIdp: (collector: IdpCollector) => {
300-
const rootState: RootState = store.getState();
301-
302-
const serverSlice = nodeSlice.selectors.selectServer(rootState);
303-
304-
return () => authorize(serverSlice, collector);
305-
},
306-
307315
/**
308316
* Utilities to help query cached responses from server
309317
*/

packages/davinci-client/src/lib/davinci.utils.ts

+22-6
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ import { InternalErrorResponse } from './client.types.js';
2626
export function transformSubmitRequest(node: ContinueNode): DaVinciRequest {
2727
// Filter out ActionCollectors as they are not used in form submissions
2828
const collectors = node.client?.collectors?.filter(
29-
(collector) => collector.category === 'SingleValueCollector',
29+
(collector) =>
30+
collector.category === 'MultiValueCollector' ||
31+
collector.category === 'SingleValueCollector' ||
32+
collector.category === 'ValidatedSingleValueCollector',
3033
);
3134

3235
const formData = collectors?.reduce<{
33-
[key: string]: string | number | boolean;
36+
[key: string]: string | number | boolean | (string | number | boolean)[];
3437
}>((acc, collector) => {
3538
acc[collector.input.key] = collector.input.value;
3639
return acc;
@@ -175,12 +178,25 @@ export function handleResponse(cacheEntry: DaVinciCacheEntry, dispatch: Dispatch
175178
*/
176179
if (cacheEntry.isSuccess) {
177180
const requestId = cacheEntry.requestId;
178-
if ('eventName' in cacheEntry.data && cacheEntry.data.eventName === 'continue') {
179-
const data = cacheEntry.data as DaVinciNextResponse;
180-
dispatch(nodeSlice.actions.next({ data, requestId, httpStatus: status }));
181-
} else if ('session' in cacheEntry.data || 'authorizeResponse' in cacheEntry.data) {
181+
const hasNextUrl = () => {
182+
const data = cacheEntry.data;
183+
184+
if ('_links' in data) {
185+
if ('next' in data._links) {
186+
if ('href' in data._links.next) {
187+
return true;
188+
}
189+
}
190+
}
191+
return false;
192+
};
193+
194+
if ('session' in cacheEntry.data || 'authorizeResponse' in cacheEntry.data) {
182195
const data = cacheEntry.data as DaVinciSuccessResponse;
183196
dispatch(nodeSlice.actions.success({ data, requestId, httpStatus: status }));
197+
} else if (hasNextUrl()) {
198+
const data = cacheEntry.data as DaVinciNextResponse;
199+
dispatch(nodeSlice.actions.next({ data, requestId, httpStatus: status }));
184200
} else {
185201
// If we got here, the response type is unknown and therefore an unrecoverable failure
186202
const data = cacheEntry.data as DaVinciFailureResponse;

packages/davinci-client/src/lib/node.reducer.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build
9292

9393
// *Some* collectors may have default or existing data to display
9494
const data = action.payload.formData[field.key];
95+
9596
// Match specific collectors
9697
switch (field.type) {
9798
case 'CHECKBOX':
@@ -165,7 +166,10 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build
165166
throw new Error('Value argument cannot be undefined');
166167
}
167168

168-
if (collector.category === 'SingleValueCollector') {
169+
if (
170+
collector.category === 'SingleValueCollector' ||
171+
collector.category === 'ValidatedSingleValueCollector'
172+
) {
169173
if (Array.isArray(action.payload.value)) {
170174
throw new Error('SingleValueCollector does not accept an array');
171175
}

0 commit comments

Comments
 (0)