@tummycrypt/acuity-middleware 0.1.0
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/.github/workflows/build-paper.yml +39 -0
- package/.github/workflows/ci.yml +37 -0
- package/Dockerfile +53 -0
- package/README.md +103 -0
- package/docs/blog-post.mdx +240 -0
- package/docs/paper/IEEEtran.bst +2409 -0
- package/docs/paper/IEEEtran.cls +6347 -0
- package/docs/paper/acuity-middleware-paper.tex +375 -0
- package/docs/paper/balance.sty +87 -0
- package/docs/paper/references.bib +231 -0
- package/docs/paper.md +400 -0
- package/flake.nix +32 -0
- package/modal-app.py +82 -0
- package/package.json +48 -0
- package/src/adapters/acuity-scraper.ts +543 -0
- package/src/adapters/types.ts +193 -0
- package/src/core/types.ts +325 -0
- package/src/index.ts +75 -0
- package/src/middleware/acuity-wizard.ts +456 -0
- package/src/middleware/browser-service.ts +183 -0
- package/src/middleware/errors.ts +70 -0
- package/src/middleware/index.ts +80 -0
- package/src/middleware/remote-adapter.ts +246 -0
- package/src/middleware/selectors.ts +308 -0
- package/src/middleware/server.ts +372 -0
- package/src/middleware/steps/bypass-payment.ts +226 -0
- package/src/middleware/steps/extract.ts +174 -0
- package/src/middleware/steps/fill-form.ts +359 -0
- package/src/middleware/steps/index.ts +27 -0
- package/src/middleware/steps/navigate.ts +537 -0
- package/src/middleware/steps/read-availability.ts +399 -0
- package/src/middleware/steps/read-slots.ts +405 -0
- package/src/middleware/steps/submit.ts +168 -0
- package/src/server.ts +5 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wizard Step: Read Time Slots from Acuity Calendar
|
|
3
|
+
*
|
|
4
|
+
* Navigates to the service calendar via click-through,
|
|
5
|
+
* advances to the target date, clicks the day tile,
|
|
6
|
+
* and reads all available time slot buttons.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Effect } from 'effect';
|
|
10
|
+
import type { Page, ElementHandle } from 'playwright-core';
|
|
11
|
+
import { BrowserService } from '../browser-service.js';
|
|
12
|
+
import { WizardStepError } from '../errors.js';
|
|
13
|
+
import { resolveSelector, Selectors } from '../selectors.js';
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// TYPES
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export interface ReadSlotsParams {
|
|
20
|
+
/** Service name to match against the service list */
|
|
21
|
+
readonly serviceName: string;
|
|
22
|
+
/** Appointment type ID (used to verify correct service selected) */
|
|
23
|
+
readonly appointmentTypeId?: string;
|
|
24
|
+
/** Target date (YYYY-MM-DD) */
|
|
25
|
+
readonly date: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SlotResult {
|
|
29
|
+
readonly datetime: string; // ISO 8601
|
|
30
|
+
readonly available: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// =============================================================================
|
|
34
|
+
// IMPLEMENTATION
|
|
35
|
+
// =============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read time slots for a specific date by navigating the Acuity wizard.
|
|
39
|
+
*
|
|
40
|
+
* Flow:
|
|
41
|
+
* 1. Load service page → find service → click "Book"
|
|
42
|
+
* 2. Navigate calendar to target month
|
|
43
|
+
* 3. Click target day tile
|
|
44
|
+
* 4. Read all time slot buttons
|
|
45
|
+
*/
|
|
46
|
+
export const readTimeSlots = (params: ReadSlotsParams) =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const { acquirePage, config } = yield* BrowserService;
|
|
49
|
+
const page: Page = yield* acquirePage;
|
|
50
|
+
|
|
51
|
+
// Step 1: Load service page
|
|
52
|
+
yield* Effect.tryPromise({
|
|
53
|
+
try: () => page.goto(config.baseUrl, { waitUntil: 'networkidle', timeout: config.timeout }),
|
|
54
|
+
catch: (e) =>
|
|
55
|
+
new WizardStepError({
|
|
56
|
+
step: 'read-slots',
|
|
57
|
+
message: `Failed to load service page: ${e instanceof Error ? e.message : String(e)}`,
|
|
58
|
+
cause: e,
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Step 2: Click the target service's "Book" button
|
|
63
|
+
yield* clickServiceBook(page, params.serviceName, params.appointmentTypeId);
|
|
64
|
+
|
|
65
|
+
// Step 3: Navigate to the target month
|
|
66
|
+
const targetDate = new Date(params.date + 'T12:00:00');
|
|
67
|
+
const targetMonth = `${targetDate.getFullYear()}-${String(targetDate.getMonth() + 1).padStart(2, '0')}`;
|
|
68
|
+
yield* navigateToTargetMonth(page, targetMonth);
|
|
69
|
+
|
|
70
|
+
// Step 4: Click the target day
|
|
71
|
+
yield* clickDay(page, targetDate.getDate());
|
|
72
|
+
|
|
73
|
+
// Step 5: Read time slots
|
|
74
|
+
const slots = yield* readSlotButtons(page, params.date);
|
|
75
|
+
|
|
76
|
+
return slots;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// HELPERS
|
|
81
|
+
// =============================================================================
|
|
82
|
+
|
|
83
|
+
const clickServiceBook = (
|
|
84
|
+
page: Page,
|
|
85
|
+
serviceName: string,
|
|
86
|
+
expectedId?: string,
|
|
87
|
+
) =>
|
|
88
|
+
Effect.gen(function* () {
|
|
89
|
+
yield* resolveSelector(page, Selectors.serviceList, 10000).pipe(
|
|
90
|
+
Effect.catchTag('SelectorError', () =>
|
|
91
|
+
Effect.fail(
|
|
92
|
+
new WizardStepError({
|
|
93
|
+
step: 'read-slots',
|
|
94
|
+
message: 'Service list did not load',
|
|
95
|
+
}),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const serviceItem: ElementHandle | null = yield* Effect.tryPromise({
|
|
101
|
+
try: async () => {
|
|
102
|
+
const items = await page.$$(Selectors.serviceList[0]);
|
|
103
|
+
for (const item of items) {
|
|
104
|
+
const nameEl = await item.$(Selectors.serviceName[0]);
|
|
105
|
+
const name = await nameEl?.textContent();
|
|
106
|
+
if (name && name.trim().toLowerCase().includes(serviceName.toLowerCase())) {
|
|
107
|
+
return item;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
},
|
|
112
|
+
catch: (e) =>
|
|
113
|
+
new WizardStepError({
|
|
114
|
+
step: 'read-slots',
|
|
115
|
+
message: `Error searching services: ${e instanceof Error ? e.message : String(e)}`,
|
|
116
|
+
cause: e,
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!serviceItem) {
|
|
121
|
+
return yield* Effect.fail(
|
|
122
|
+
new WizardStepError({
|
|
123
|
+
step: 'read-slots',
|
|
124
|
+
message: `Service "${serviceName}" not found`,
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const bookBtn = yield* Effect.tryPromise({
|
|
130
|
+
try: () => serviceItem.$(Selectors.serviceBookButton[0]),
|
|
131
|
+
catch: (e) =>
|
|
132
|
+
new WizardStepError({
|
|
133
|
+
step: 'read-slots',
|
|
134
|
+
message: `Book button error: ${e instanceof Error ? e.message : String(e)}`,
|
|
135
|
+
cause: e,
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!bookBtn) {
|
|
140
|
+
return yield* Effect.fail(
|
|
141
|
+
new WizardStepError({
|
|
142
|
+
step: 'read-slots',
|
|
143
|
+
message: `"Book" button not found for "${serviceName}"`,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
yield* Effect.tryPromise({
|
|
149
|
+
try: async () => {
|
|
150
|
+
await bookBtn.click();
|
|
151
|
+
await page.waitForURL(/\/appointment\/\d+\/calendar\/\d+/, { timeout: 10000 });
|
|
152
|
+
},
|
|
153
|
+
catch: (e) =>
|
|
154
|
+
new WizardStepError({
|
|
155
|
+
step: 'read-slots',
|
|
156
|
+
message: `Failed to navigate to calendar: ${e instanceof Error ? e.message : String(e)}`,
|
|
157
|
+
cause: e,
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (expectedId) {
|
|
162
|
+
const url = page.url();
|
|
163
|
+
const match = url.match(/\/appointment\/(\d+)/);
|
|
164
|
+
if (match && match[1] !== expectedId) {
|
|
165
|
+
return yield* Effect.fail(
|
|
166
|
+
new WizardStepError({
|
|
167
|
+
step: 'read-slots',
|
|
168
|
+
message: `Expected appointment type ${expectedId} but got ${match[1]}`,
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const MONTH_NAMES = [
|
|
176
|
+
'january', 'february', 'march', 'april', 'may', 'june',
|
|
177
|
+
'july', 'august', 'september', 'october', 'november', 'december',
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
const navigateToTargetMonth = (page: Page, targetMonth: string): Effect.Effect<void, WizardStepError> =>
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
yield* resolveSelector(page, Selectors.calendar, 10000).pipe(
|
|
183
|
+
Effect.catchTag('SelectorError', () =>
|
|
184
|
+
Effect.fail(
|
|
185
|
+
new WizardStepError({
|
|
186
|
+
step: 'read-slots',
|
|
187
|
+
message: 'Calendar did not load',
|
|
188
|
+
}),
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const [yearStr, monthStr] = targetMonth.split('-');
|
|
194
|
+
const targetYear = parseInt(yearStr, 10);
|
|
195
|
+
const targetMonthIdx = parseInt(monthStr, 10) - 1;
|
|
196
|
+
|
|
197
|
+
for (let i = 0; i < 12; i++) {
|
|
198
|
+
const current = yield* getCalendarMonth(page);
|
|
199
|
+
if (!current) {
|
|
200
|
+
return yield* Effect.fail(
|
|
201
|
+
new WizardStepError({
|
|
202
|
+
step: 'read-slots',
|
|
203
|
+
message: 'Could not determine calendar month',
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (current.month === targetMonthIdx && current.year === targetYear) return;
|
|
209
|
+
|
|
210
|
+
const currentFirst = new Date(current.year, current.month, 1);
|
|
211
|
+
const targetFirst = new Date(targetYear, targetMonthIdx, 1);
|
|
212
|
+
const direction = targetFirst > currentFirst ? 'next' : 'prev';
|
|
213
|
+
const selectors = direction === 'prev' ? Selectors.calendarPrev : Selectors.calendarNext;
|
|
214
|
+
|
|
215
|
+
const btn = yield* resolveSelector(page, selectors, 3000).pipe(
|
|
216
|
+
Effect.catchTag('SelectorError', () =>
|
|
217
|
+
Effect.fail(
|
|
218
|
+
new WizardStepError({
|
|
219
|
+
step: 'read-slots',
|
|
220
|
+
message: `Calendar ${direction} button not found`,
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
yield* Effect.tryPromise({
|
|
227
|
+
try: async () => {
|
|
228
|
+
await btn.element.click();
|
|
229
|
+
await page.waitForTimeout(500);
|
|
230
|
+
},
|
|
231
|
+
catch: (e) =>
|
|
232
|
+
new WizardStepError({
|
|
233
|
+
step: 'read-slots',
|
|
234
|
+
message: `Calendar nav failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
235
|
+
cause: e,
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const getCalendarMonth = (
|
|
242
|
+
page: Page,
|
|
243
|
+
): Effect.Effect<{ month: number; year: number } | null, never> =>
|
|
244
|
+
Effect.gen(function* () {
|
|
245
|
+
// Wait for calendar month label to appear
|
|
246
|
+
yield* Effect.tryPromise({
|
|
247
|
+
try: () => page.waitForSelector(Selectors.calendarMonth[0], { timeout: 5000 }),
|
|
248
|
+
catch: () => null,
|
|
249
|
+
}).pipe(Effect.orElseSucceed(() => null));
|
|
250
|
+
|
|
251
|
+
// Retry up to 3 times — React may still be rendering
|
|
252
|
+
for (let retry = 0; retry < 3; retry++) {
|
|
253
|
+
const info = yield* Effect.tryPromise({
|
|
254
|
+
try: async () => {
|
|
255
|
+
for (const selector of Selectors.calendarMonth) {
|
|
256
|
+
const text = await page.$eval(selector, (el) => el.textContent?.trim() ?? null).catch(() => null);
|
|
257
|
+
if (text) {
|
|
258
|
+
const match = text.match(/([A-Za-z]+)\s*(\d{4})/);
|
|
259
|
+
if (match) {
|
|
260
|
+
const monthIndex = MONTH_NAMES.indexOf(match[1].toLowerCase());
|
|
261
|
+
if (monthIndex >= 0) {
|
|
262
|
+
return { month: monthIndex, year: parseInt(match[2], 10) };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// Also try innerText which resolves visibility better than textContent
|
|
268
|
+
for (const selector of Selectors.calendarMonth) {
|
|
269
|
+
const text = await page.$eval(selector, (el) => (el as HTMLElement).innerText?.trim() ?? null).catch(() => null);
|
|
270
|
+
if (text) {
|
|
271
|
+
const match = text.match(/([A-Za-z]+)\s*(\d{4})/);
|
|
272
|
+
if (match) {
|
|
273
|
+
const monthIndex = MONTH_NAMES.indexOf(match[1].toLowerCase());
|
|
274
|
+
if (monthIndex >= 0) {
|
|
275
|
+
return { month: monthIndex, year: parseInt(match[2], 10) };
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
},
|
|
282
|
+
catch: () => null,
|
|
283
|
+
}).pipe(Effect.orElseSucceed(() => null));
|
|
284
|
+
|
|
285
|
+
if (info) return info;
|
|
286
|
+
|
|
287
|
+
// Wait before retrying
|
|
288
|
+
yield* Effect.tryPromise({
|
|
289
|
+
try: () => page.waitForTimeout(1000),
|
|
290
|
+
catch: () => null,
|
|
291
|
+
}).pipe(Effect.orElseSucceed(() => null));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return null;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Click the calendar tile for a specific day number.
|
|
299
|
+
*/
|
|
300
|
+
const clickDay = (page: Page, dayOfMonth: number): Effect.Effect<void, WizardStepError> =>
|
|
301
|
+
Effect.gen(function* () {
|
|
302
|
+
const clicked = yield* Effect.tryPromise({
|
|
303
|
+
try: async () => {
|
|
304
|
+
const tiles = await page.$$(Selectors.calendarDay[0]);
|
|
305
|
+
for (const tile of tiles) {
|
|
306
|
+
const isDisabled = await tile.evaluate((el) => (el as HTMLButtonElement).disabled);
|
|
307
|
+
if (isDisabled) continue;
|
|
308
|
+
|
|
309
|
+
const classes = (await tile.getAttribute('class')) ?? '';
|
|
310
|
+
if (classes.includes('neighboringMonth')) continue;
|
|
311
|
+
|
|
312
|
+
const text = await tile.textContent();
|
|
313
|
+
const num = parseInt(text?.trim() ?? '', 10);
|
|
314
|
+
if (num === dayOfMonth) {
|
|
315
|
+
await tile.click();
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
},
|
|
321
|
+
catch: (e) =>
|
|
322
|
+
new WizardStepError({
|
|
323
|
+
step: 'read-slots',
|
|
324
|
+
message: `Error clicking day ${dayOfMonth}: ${e instanceof Error ? e.message : String(e)}`,
|
|
325
|
+
cause: e,
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
if (!clicked) {
|
|
330
|
+
return yield* Effect.fail(
|
|
331
|
+
new WizardStepError({
|
|
332
|
+
step: 'read-slots',
|
|
333
|
+
message: `Day ${dayOfMonth} not available on calendar`,
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Wait for time slots to appear
|
|
339
|
+
yield* resolveSelector(page, Selectors.timeSlotContainer, 10000).pipe(
|
|
340
|
+
Effect.catchTag('SelectorError', () =>
|
|
341
|
+
Effect.fail(
|
|
342
|
+
new WizardStepError({
|
|
343
|
+
step: 'read-slots',
|
|
344
|
+
message: 'Time slots did not appear after clicking day',
|
|
345
|
+
}),
|
|
346
|
+
),
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Read all time slot buttons and return structured data.
|
|
353
|
+
* Slot text format: "10:00 AM1 spot left" or "2:30 PM"
|
|
354
|
+
*/
|
|
355
|
+
const readSlotButtons = (
|
|
356
|
+
page: Page,
|
|
357
|
+
dateStr: string,
|
|
358
|
+
): Effect.Effect<SlotResult[], WizardStepError> =>
|
|
359
|
+
Effect.tryPromise({
|
|
360
|
+
try: async () => {
|
|
361
|
+
const results: SlotResult[] = [];
|
|
362
|
+
const slots = await page.$$(Selectors.timeSlot[0]);
|
|
363
|
+
|
|
364
|
+
for (const slot of slots) {
|
|
365
|
+
const text = await slot.textContent();
|
|
366
|
+
if (!text) continue;
|
|
367
|
+
|
|
368
|
+
// Extract time from slot text: "10:00 AM1 spot left" → "10:00 AM"
|
|
369
|
+
const timeMatch = text.trim().match(/^(\d{1,2}:\d{2}\s*[AP]M)/i);
|
|
370
|
+
if (!timeMatch) continue;
|
|
371
|
+
|
|
372
|
+
const timeStr = timeMatch[1].trim();
|
|
373
|
+
|
|
374
|
+
// Convert to ISO 8601 datetime
|
|
375
|
+
const datetime = buildIsoDatetime(dateStr, timeStr);
|
|
376
|
+
results.push({ datetime, available: true });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return results;
|
|
380
|
+
},
|
|
381
|
+
catch: (e) =>
|
|
382
|
+
new WizardStepError({
|
|
383
|
+
step: 'read-slots',
|
|
384
|
+
message: `Error reading slots: ${e instanceof Error ? e.message : String(e)}`,
|
|
385
|
+
cause: e,
|
|
386
|
+
}),
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Build ISO datetime from date string and time string.
|
|
391
|
+
* "2026-03-15" + "10:00 AM" → "2026-03-15T10:00:00"
|
|
392
|
+
*/
|
|
393
|
+
const buildIsoDatetime = (dateStr: string, timeStr: string): string => {
|
|
394
|
+
const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
|
395
|
+
if (!match) return `${dateStr}T00:00:00`;
|
|
396
|
+
|
|
397
|
+
let hours = parseInt(match[1], 10);
|
|
398
|
+
const minutes = match[2];
|
|
399
|
+
const ampm = match[3].toUpperCase();
|
|
400
|
+
|
|
401
|
+
if (ampm === 'PM' && hours !== 12) hours += 12;
|
|
402
|
+
if (ampm === 'AM' && hours === 12) hours = 0;
|
|
403
|
+
|
|
404
|
+
return `${dateStr}T${String(hours).padStart(2, '0')}:${minutes}:00`;
|
|
405
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wizard Step: Submit Booking
|
|
3
|
+
*
|
|
4
|
+
* Clicks the submit/complete button and waits for the confirmation page.
|
|
5
|
+
* Handles the race between successful navigation to confirmation
|
|
6
|
+
* and validation error display.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Effect, Schedule } from 'effect';
|
|
10
|
+
import type { Page } from 'playwright-core';
|
|
11
|
+
import { BrowserService } from '../browser-service.js';
|
|
12
|
+
import { WizardStepError } from '../errors.js';
|
|
13
|
+
import { resolveSelector, probe, Selectors } from '../selectors.js';
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// TYPES
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export interface SubmitResult {
|
|
20
|
+
readonly submitted: boolean;
|
|
21
|
+
readonly confirmationPageReached: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// IMPLEMENTATION
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Submit the booking form and wait for confirmation.
|
|
30
|
+
*/
|
|
31
|
+
export const submitBooking = () =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const { acquirePage } = yield* BrowserService;
|
|
34
|
+
const page: Page = yield* acquirePage;
|
|
35
|
+
|
|
36
|
+
// Find submit button
|
|
37
|
+
const submitBtn = yield* resolveSelector(page, Selectors.submitButton, 10000).pipe(
|
|
38
|
+
Effect.catchTag('SelectorError', () =>
|
|
39
|
+
Effect.fail(
|
|
40
|
+
new WizardStepError({
|
|
41
|
+
step: 'submit',
|
|
42
|
+
message: 'Submit button not found. The wizard may not have advanced to the final step.',
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Click submit
|
|
49
|
+
yield* Effect.tryPromise({
|
|
50
|
+
try: () => submitBtn.element.click(),
|
|
51
|
+
catch: (e) =>
|
|
52
|
+
new WizardStepError({
|
|
53
|
+
step: 'submit',
|
|
54
|
+
message: `Failed to click submit: ${e instanceof Error ? e.message : String(e)}`,
|
|
55
|
+
cause: e,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Wait for either confirmation page or error
|
|
60
|
+
const outcome = yield* waitForOutcome(page);
|
|
61
|
+
|
|
62
|
+
if (!outcome.confirmationPageReached) {
|
|
63
|
+
return yield* Effect.fail(
|
|
64
|
+
new WizardStepError({
|
|
65
|
+
step: 'submit',
|
|
66
|
+
message: outcome.errorMessage
|
|
67
|
+
? `Booking submission failed: ${outcome.errorMessage}`
|
|
68
|
+
: 'Booking submission did not reach confirmation page',
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
submitted: true,
|
|
75
|
+
confirmationPageReached: true,
|
|
76
|
+
} satisfies SubmitResult;
|
|
77
|
+
}).pipe(
|
|
78
|
+
// Retry once on transient navigation failure
|
|
79
|
+
Effect.retry({
|
|
80
|
+
times: 1,
|
|
81
|
+
schedule: Schedule.spaced('2 seconds'),
|
|
82
|
+
while: (e) => e._tag === 'WizardStepError' && e.message.includes('did not reach'),
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// HELPERS
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
interface OutcomeCheck {
|
|
91
|
+
confirmationPageReached: boolean;
|
|
92
|
+
errorMessage: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Race between confirmation page appearing and error message appearing.
|
|
97
|
+
* Polls both conditions until one is met or timeout.
|
|
98
|
+
*/
|
|
99
|
+
const waitForOutcome = (page: Page): Effect.Effect<OutcomeCheck, WizardStepError> =>
|
|
100
|
+
Effect.gen(function* () {
|
|
101
|
+
const maxWait = 60000;
|
|
102
|
+
const pollInterval = 1000;
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
|
|
105
|
+
while (Date.now() - start < maxWait) {
|
|
106
|
+
// Check for confirmation page via selectors
|
|
107
|
+
const hasConfirmation = yield* probe(page, 'confirmationPage');
|
|
108
|
+
if (hasConfirmation) {
|
|
109
|
+
return { confirmationPageReached: true, errorMessage: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check for confirmation via URL pattern (Acuity redirects after booking)
|
|
113
|
+
const url = page.url();
|
|
114
|
+
if (/\/(confirmation|confirmed|thank-you|complete)/i.test(url)) {
|
|
115
|
+
return { confirmationPageReached: true, errorMessage: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check for confirmation text anywhere on page
|
|
119
|
+
const hasConfirmText = yield* Effect.tryPromise({
|
|
120
|
+
try: () =>
|
|
121
|
+
page
|
|
122
|
+
.$eval(
|
|
123
|
+
'body',
|
|
124
|
+
(el) => {
|
|
125
|
+
const text = el.textContent?.toLowerCase() ?? '';
|
|
126
|
+
return text.includes('booking confirmed') ||
|
|
127
|
+
text.includes('appointment confirmed') ||
|
|
128
|
+
text.includes('successfully booked') ||
|
|
129
|
+
text.includes('your appointment is scheduled');
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
.catch(() => false),
|
|
133
|
+
catch: () => false,
|
|
134
|
+
}).pipe(Effect.orElseSucceed(() => false));
|
|
135
|
+
|
|
136
|
+
if (hasConfirmText) {
|
|
137
|
+
return { confirmationPageReached: true, errorMessage: null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check for validation/submission errors
|
|
141
|
+
const errorText = yield* Effect.tryPromise({
|
|
142
|
+
try: () =>
|
|
143
|
+
page
|
|
144
|
+
.$eval(
|
|
145
|
+
'.error-message, .validation-error, .form-error, .alert-danger',
|
|
146
|
+
(el) => el.textContent?.trim() ?? null,
|
|
147
|
+
)
|
|
148
|
+
.catch(() => null),
|
|
149
|
+
catch: () => null,
|
|
150
|
+
}).pipe(Effect.orElseSucceed(() => null));
|
|
151
|
+
|
|
152
|
+
if (errorText) {
|
|
153
|
+
return { confirmationPageReached: false, errorMessage: errorText };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Wait before next poll
|
|
157
|
+
yield* Effect.tryPromise({
|
|
158
|
+
try: () => page.waitForTimeout(pollInterval),
|
|
159
|
+
catch: () =>
|
|
160
|
+
new WizardStepError({ step: 'submit', message: 'Poll wait interrupted' }),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
confirmationPageReached: false,
|
|
166
|
+
errorMessage: 'Timed out waiting for confirmation page',
|
|
167
|
+
};
|
|
168
|
+
});
|
package/src/server.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022", "DOM"],
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"declarationMap": true,
|
|
11
|
+
"sourceMap": true,
|
|
12
|
+
"strict": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"forceConsistentCasingInFileNames": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"noUnusedLocals": false,
|
|
19
|
+
"noUnusedParameters": false,
|
|
20
|
+
"noImplicitReturns": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true
|
|
22
|
+
},
|
|
23
|
+
"include": ["src/**/*.ts"],
|
|
24
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
25
|
+
}
|