Skip to content

Commit 0f39473

Browse files
committed
chore(davinci-client): improve polling
1 parent e5ad583 commit 0f39473

6 files changed

Lines changed: 153 additions & 26 deletions

File tree

e2e/davinci-app/components/polling.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export default function pollingComponent(
2525
formEl.appendChild(button);
2626

2727
button.onclick = async () => {
28+
button.disabled = true;
29+
2830
const p = document.createElement('p');
2931
p.innerText = 'Polling...';
3032
formEl?.appendChild(p);
@@ -54,5 +56,7 @@ export default function pollingComponent(
5456
formEl?.appendChild(resultEl);
5557

5658
await submitForm();
59+
60+
button.disabled = false;
5761
};
5862
}

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

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { wellknownApi } from './wellknown.api.js';
2020
import type { InternalErrorResponse, PollingStatus } from './client.types.js';
2121
import { PollingCollector } from './collector.types.js';
2222

23+
const isRecord = (value: unknown): value is Record<string, unknown> =>
24+
typeof value === 'object' && value !== null;
25+
2326
export function createClientStore<ActionType extends ActionTypes>({
2427
requestMiddleware,
2528
logger,
@@ -133,7 +136,8 @@ export async function handleChallengePolling({
133136
message: 'Not in a continue node state, must be in a continue node to use poll method',
134137
type: 'state_error',
135138
},
136-
} as InternalErrorResponse;
139+
type: 'internal_error',
140+
};
137141
}
138142

139143
// Construct the challenge polling endpoint
@@ -144,7 +148,8 @@ export async function handleChallengePolling({
144148
message: 'No self link found in server info for challenge polling operation',
145149
type: 'internal_error',
146150
},
147-
} as InternalErrorResponse;
151+
type: 'internal_error',
152+
};
148153
}
149154

150155
const selfUrl = links['self'].href;
@@ -160,7 +165,8 @@ export async function handleChallengePolling({
160165
'Failed to construct challenge polling endpoint. Requires host and environment ID.',
161166
type: 'parse_error',
162167
},
163-
} as InternalErrorResponse;
168+
type: 'internal_error',
169+
};
164170
}
165171

166172
const interactionId = serverSlice.interactionId;
@@ -170,7 +176,8 @@ export async function handleChallengePolling({
170176
message: 'Missing interactionId in server info for challenge polling',
171177
type: 'internal_error',
172178
},
173-
} as InternalErrorResponse;
179+
type: 'internal_error',
180+
};
174181
}
175182

176183
const challengeEndpoint = `${baseUrl}/${envId}/davinci/user/credentials/challenge/${challenge}/status`;
@@ -191,12 +198,10 @@ export async function handleChallengePolling({
191198

192199
const challengePollµ = Micro.repeat(queryµ, {
193200
while: ({ data, error }) =>
194-
retriesLeft > 0 && !error && !(data as Record<string, unknown>)['isChallengeComplete'],
201+
retriesLeft > 0 && !error && isRecord(data) && !data['isChallengeComplete'],
195202
schedule: Micro.scheduleSpaced(pollInterval),
196203
}).pipe(
197204
Micro.flatMap(({ data, error }) => {
198-
const pollResponse = data as Record<string, unknown>;
199-
200205
// Check for any errors and return the appropriate status
201206
if (error) {
202207
// SerializedError
@@ -217,26 +222,35 @@ export async function handleChallengePolling({
217222
if ('status' in error) {
218223
status = error.status;
219224

220-
const errorDetails = error.data as Record<string, unknown>;
221-
const serviceName = errorDetails['serviceName'];
225+
if (isRecord(error.data)) {
226+
const errorDetails = error.data as Record<string, unknown>;
227+
const serviceName = errorDetails['serviceName'];
222228

223-
// Check for an expired challenge
224-
if (status === 400 && serviceName && serviceName === 'challengeExpired') {
225-
log.debug('Challenge expired for polling');
226-
return Micro.succeed('expired' as PollingStatus);
227-
} else {
228-
// If we're here there is some other type of network error and status != 200
229-
// e.g. A bad challenge can return a httpStatus of 400 with code 4019
230-
log.debug('Network error occurred during polling');
231-
return Micro.succeed('error' as PollingStatus);
229+
// Check for an expired challenge
230+
if (status === 400 && serviceName && serviceName === 'challengeExpired') {
231+
log.debug('Challenge expired for polling');
232+
return Micro.succeed('expired' as PollingStatus);
233+
}
232234
}
235+
236+
// If we're here there is possibly some other type of network error and status != 200
237+
// e.g. A bad challenge can return a httpStatus of 400 with code 4019
238+
log.debug('Unknown error occurred during polling');
239+
return Micro.succeed('error' as PollingStatus);
233240
}
234241
}
235242

236243
// If a successful response is recieved it can be either a timeout or true success
244+
if (!isRecord(data)) {
245+
log.debug('Unable to parse polling response');
246+
return Micro.succeed('error' as PollingStatus);
247+
}
248+
const pollResponse = data;
249+
237250
if (pollResponse['isChallengeComplete'] === true) {
238251
const pollStatus = pollResponse['status'];
239252
if (!pollStatus) {
253+
log.debug('Unable to determine polling status');
240254
return Micro.succeed('error' as PollingStatus);
241255
} else {
242256
return Micro.succeed(pollStatus as PollingStatus);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,8 @@ export type PollingField = {
217217
key: string;
218218
pollInterval: number;
219219
pollRetries: number;
220-
pollChallengeStatus: boolean;
221-
challenge: string;
220+
pollChallengeStatus?: boolean;
221+
challenge?: string;
222222
};
223223

224224
export type UnknownField = Record<string, unknown>;

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,102 @@ describe('transformSubmitRequest', () => {
114114
const result = transformSubmitRequest(node, logger({ level: 'none' }));
115115
expect(result).toEqual(expectedRequest);
116116
});
117+
118+
it('should set eventType to "submit" when PollingCollector exists but has no payload', () => {
119+
const node: ContinueNode = {
120+
cache: {
121+
key: '123',
122+
},
123+
client: {
124+
action: 'SIGNON',
125+
collectors: [
126+
{
127+
category: 'SingleValueAutoCollector',
128+
error: null,
129+
type: 'PollingCollector',
130+
id: 'polling-field-2',
131+
name: 'polling-field',
132+
input: {
133+
key: 'polling-field',
134+
value: '',
135+
type: 'POLLING',
136+
},
137+
output: {
138+
key: 'polling-field',
139+
type: 'POLLING',
140+
config: {
141+
pollInterval: 2000,
142+
pollRetries: 20,
143+
pollChallengeStatus: true,
144+
challenge: '123_456-7890',
145+
},
146+
},
147+
},
148+
],
149+
status: 'continue',
150+
},
151+
error: null,
152+
httpStatus: 200,
153+
server: {
154+
id: '123',
155+
eventName: 'login',
156+
interactionId: '456',
157+
status: 'continue',
158+
},
159+
status: 'continue',
160+
};
161+
162+
const result = transformSubmitRequest(node, logger({ level: 'none' }));
163+
expect(result.parameters.eventType).toBe('submit');
164+
});
165+
166+
it('should set eventType to "polling" when PollingCollector has populated payload', () => {
167+
const node: ContinueNode = {
168+
cache: {
169+
key: '123',
170+
},
171+
client: {
172+
action: 'SIGNON',
173+
collectors: [
174+
{
175+
category: 'SingleValueAutoCollector',
176+
error: null,
177+
type: 'PollingCollector',
178+
id: 'polling-field-2',
179+
name: 'polling-field',
180+
input: {
181+
key: 'polling-field',
182+
value: 'complete',
183+
type: 'POLLING',
184+
},
185+
output: {
186+
key: 'polling-field',
187+
type: 'POLLING',
188+
config: {
189+
pollInterval: 2000,
190+
pollRetries: 20,
191+
pollChallengeStatus: true,
192+
challenge: '123_456-7890',
193+
},
194+
},
195+
},
196+
],
197+
status: 'continue',
198+
},
199+
error: null,
200+
httpStatus: 200,
201+
server: {
202+
id: '123',
203+
eventName: 'login',
204+
interactionId: '456',
205+
status: 'continue',
206+
},
207+
status: 'continue',
208+
};
209+
210+
const result = transformSubmitRequest(node, logger({ level: 'none' }));
211+
expect(result.parameters.eventType).toBe('polling');
212+
});
117213
});
118214

119215
describe('transformActionRequest', () => {

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
/**
3232
* @function transformSubmitRequest - Transforms a NextNode into a DaVinciRequest for form submissions
3333
* @param {ContinueNode} node - The node to transform into a DaVinciRequest
34+
* @param {ReturnType<typeof loggerFn>} logger - Logger instance
3435
* @returns {DaVinciRequest} - The transformed request object
3536
*/
3637
export function transformSubmitRequest(
@@ -63,10 +64,14 @@ export function transformSubmitRequest(
6364
return acc;
6465
}, {});
6566

66-
// Set eventType based on PollingCollector presence
67-
const eventType = collectors?.some((collector) => collector.type === 'PollingCollector')
68-
? 'polling'
69-
: 'submit';
67+
// Determine eventType: check if there's a PollingCollector with a non-empty string value
68+
const hasPollingWithPayload = collectors?.some(
69+
(collector) =>
70+
collector.type === 'PollingCollector' &&
71+
typeof collector.input.value === 'string' &&
72+
collector.input.value,
73+
);
74+
const eventType = hasPollingWithPayload ? 'polling' : 'submit';
7075

7176
logger.debug('Transforming submit request', { node, formData });
7277

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,21 @@ export const nodeCollectorReducer = createReducer(initialCollectorValues, (build
337337
* transformed to `'node/poll'` for the action type.
338338
*/
339339
.addCase(pollCollectorValues, (state) => {
340-
// For challenge polling, track and decrement retries when this reducer is called
340+
// For continue polling, track and decrement retries when this reducer is called
341341
const pollCollector = state.find((collector) => collector.type === 'PollingCollector');
342342

343-
if (!pollCollector?.output.config.retriesRemaining) {
343+
if (!pollCollector) {
344344
throw new Error('No poll collector found to update');
345345
}
346346

347+
if (pollCollector.output.config.retriesRemaining === undefined) {
348+
throw new Error('Polling collector does not track retriesRemaining');
349+
}
350+
351+
if (pollCollector.output.config.retriesRemaining <= 0) {
352+
throw new Error('No poll retries left');
353+
}
354+
347355
pollCollector.output.config.retriesRemaining--;
348356
});
349357
});

0 commit comments

Comments
 (0)