@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.
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Acuity Wizard Adapter
3
+ *
4
+ * Full SchedulingAdapter implementation that uses Effect TS middleware
5
+ * to puppeteer the Acuity booking wizard.
6
+ *
7
+ * Read operations:
8
+ * - getServices: returns static service catalog (if provided) or falls back to scraper
9
+ * - getAvailableDates: Effect program navigates wizard calendar (verified selectors)
10
+ * - getAvailableSlots: Effect program clicks day tile and reads time buttons
11
+ *
12
+ * Write operations:
13
+ * - createBooking/createBookingWithPaymentRef: Effect TS middleware via Playwright
14
+ *
15
+ * This is the bridge between Effect TS (middleware layer) and
16
+ * fp-ts (pipeline/adapter layer).
17
+ */
18
+
19
+ import { Effect, Scope, Exit, Cause } from 'effect';
20
+ import * as E from 'fp-ts/Either';
21
+ import * as TE from 'fp-ts/TaskEither';
22
+ import { pipe } from 'fp-ts/function';
23
+
24
+ import type { SchedulingAdapter } from '../adapters/types.js';
25
+ import { createScraperAdapter, type ScraperConfig } from '../adapters/acuity-scraper.js';
26
+ import type {
27
+ Booking,
28
+ BookingRequest,
29
+ Service,
30
+ SchedulingResult,
31
+ } from '../core/types.js';
32
+ import { Errors } from '../core/types.js';
33
+ import { BrowserServiceLive, type BrowserConfig, defaultBrowserConfig } from './browser-service.js';
34
+ import { toSchedulingError, type MiddlewareError } from './errors.js';
35
+ import { createRemoteWizardAdapter, type RemoteAdapterConfig } from './remote-adapter.js';
36
+ import {
37
+ navigateToBooking,
38
+ fillFormFields,
39
+ bypassPayment,
40
+ generateCouponCode,
41
+ submitBooking,
42
+ extractConfirmation,
43
+ toBooking,
44
+ readAvailableDates,
45
+ readTimeSlots,
46
+ type NavigateResult,
47
+ type ConfirmationData,
48
+ } from './steps/index.js';
49
+
50
+ // =============================================================================
51
+ // CONFIGURATION
52
+ // =============================================================================
53
+
54
+ export interface WizardAdapterConfig extends Partial<BrowserConfig> {
55
+ /** Base URL for the Acuity scheduling page */
56
+ baseUrl: string;
57
+ /** Pre-configured coupon code for payment bypass (from ACUITY_BYPASS_COUPON env) */
58
+ couponCode?: string;
59
+ /**
60
+ * Execution mode:
61
+ * - 'local': Run Playwright in-process (default, requires Chromium installed)
62
+ * - 'remote': Proxy all operations to a remote middleware server via HTTP
63
+ */
64
+ mode?: 'local' | 'remote';
65
+ /** Remote adapter config (required when mode is 'remote') */
66
+ remote?: RemoteAdapterConfig;
67
+ /**
68
+ * Static service catalog. If provided, getServices() returns this list
69
+ * without launching a browser. Recommended for known service sets to avoid
70
+ * expensive browser launches for read-only operations.
71
+ */
72
+ services?: readonly Service[];
73
+ }
74
+
75
+ // =============================================================================
76
+ // EFFECT PROGRAMS
77
+ // =============================================================================
78
+
79
+ /**
80
+ * Full booking creation via the Acuity wizard.
81
+ *
82
+ * Flow:
83
+ * 1. Click through wizard: service → Book → calendar → time → continue
84
+ * 2. Fill client form (standard + intake fields) → "Continue to Payment" → /payment page
85
+ * 3. Apply 100% gift certificate on payment page (coupon toggle → enter code → Apply)
86
+ * 4. Click "PAY & CONFIRM" at $0 total
87
+ * 5. Extract confirmation data
88
+ */
89
+ const createBookingWithPaymentRefProgram = (
90
+ request: BookingRequest,
91
+ paymentRef: string,
92
+ paymentProcessor: string,
93
+ couponCode: string,
94
+ service?: Service,
95
+ ) =>
96
+ Effect.scoped(
97
+ Effect.gen(function* () {
98
+ // Step 1: Navigate through wizard to client form
99
+ const serviceName = service?.name ?? request.serviceId;
100
+ const nav: NavigateResult = yield* navigateToBooking({
101
+ serviceName,
102
+ datetime: request.datetime,
103
+ client: request.client,
104
+ });
105
+
106
+ if (nav.landingStep !== 'client-form') {
107
+ return yield* Effect.fail({
108
+ _tag: 'WizardStepError' as const,
109
+ step: 'navigate' as const,
110
+ message: `Wizard landed on '${nav.landingStep}' instead of client form. Service or datetime may be unavailable.`,
111
+ });
112
+ }
113
+
114
+ // Step 2: Fill form fields (standard + intake) and advance to /payment page
115
+ yield* fillFormFields({
116
+ client: request.client,
117
+ customFields: request.client.customFields,
118
+ });
119
+
120
+ // Step 3: Apply gift certificate on the payment page (total → $0)
121
+ yield* bypassPayment(couponCode);
122
+
123
+ // Step 4: Click "PAY & CONFIRM" at $0 total
124
+ yield* submitBooking();
125
+
126
+ // Step 5: Extract confirmation
127
+ const confirmation: ConfirmationData = yield* extractConfirmation();
128
+
129
+ return toBooking(
130
+ confirmation,
131
+ request,
132
+ paymentRef,
133
+ paymentProcessor,
134
+ service
135
+ ? {
136
+ name: service.name,
137
+ duration: service.duration,
138
+ price: service.price,
139
+ currency: service.currency,
140
+ }
141
+ : undefined,
142
+ );
143
+ }),
144
+ );
145
+
146
+ /**
147
+ * Simple booking creation (no payment bypass needed).
148
+ * For card payments that go through Acuity's normal flow.
149
+ */
150
+ const createBookingProgram = (request: BookingRequest, serviceName?: string) =>
151
+ Effect.scoped(
152
+ Effect.gen(function* () {
153
+ const nav: NavigateResult = yield* navigateToBooking({
154
+ serviceName: serviceName ?? request.serviceId,
155
+ datetime: request.datetime,
156
+ client: request.client,
157
+ });
158
+
159
+ if (nav.landingStep !== 'client-form') {
160
+ return yield* Effect.fail({
161
+ _tag: 'WizardStepError' as const,
162
+ step: 'navigate' as const,
163
+ message: `Wizard landed on '${nav.landingStep}' instead of client form.`,
164
+ });
165
+ }
166
+
167
+ yield* fillFormFields({
168
+ client: request.client,
169
+ customFields: request.client.customFields,
170
+ });
171
+
172
+ // No payment bypass - form fills and advances to payment page
173
+ // where Acuity handles card payment normally
174
+ yield* submitBooking();
175
+ const confirmation: ConfirmationData = yield* extractConfirmation();
176
+
177
+ return toBooking(confirmation, request, '', 'acuity');
178
+ }),
179
+ );
180
+
181
+ // =============================================================================
182
+ // ADAPTER FACTORY
183
+ // =============================================================================
184
+
185
+ /**
186
+ * Create a full SchedulingAdapter that puppeteers the Acuity wizard.
187
+ *
188
+ * Read operations delegate to the existing scraper.
189
+ * Write operations use Effect TS middleware via Playwright.
190
+ */
191
+ export const createWizardAdapter = (config: WizardAdapterConfig): SchedulingAdapter => {
192
+ // Remote mode: delegate everything to the middleware server
193
+ if (config.mode === 'remote') {
194
+ if (!config.remote) {
195
+ throw new Error('WizardAdapterConfig.remote is required when mode is "remote"');
196
+ }
197
+ return createRemoteWizardAdapter({
198
+ ...config.remote,
199
+ couponCode: config.remote.couponCode ?? config.couponCode,
200
+ });
201
+ }
202
+
203
+ // Local mode: run Playwright in-process
204
+ const browserConfig: BrowserConfig = {
205
+ ...defaultBrowserConfig,
206
+ ...config,
207
+ };
208
+
209
+ const layer = BrowserServiceLive(browserConfig);
210
+
211
+ // Helper: run an Effect program and convert to fp-ts TaskEither.
212
+ // Uses runPromiseExit to properly access the typed error E without
213
+ // dealing with FiberFailure wrappers from runPromise.
214
+ const runEffect = <A>(
215
+ effect: Effect.Effect<A, MiddlewareError>,
216
+ ): SchedulingResult<A> =>
217
+ () =>
218
+ Effect.runPromiseExit(effect.pipe(Effect.provide(layer))).then(
219
+ (exit) => {
220
+ if (Exit.isSuccess(exit)) {
221
+ return E.right(exit.value) as E.Either<never, A>;
222
+ }
223
+ // Extract the first failure from the cause
224
+ const failure = Cause.failureOption(exit.cause);
225
+ if (failure._tag === 'Some') {
226
+ return E.left(toSchedulingError(failure.value));
227
+ }
228
+ // Defect (unexpected error) — convert cause to string
229
+ const pretty = Cause.pretty(exit.cause);
230
+ console.error('[runEffect] Defect:', pretty);
231
+ return E.left(
232
+ Errors.infrastructure(
233
+ 'UNKNOWN',
234
+ `Effect defect: ${pretty}`,
235
+ ),
236
+ );
237
+ },
238
+ );
239
+
240
+ // Static services from config (avoids browser launch for service listing)
241
+ const staticServices: Service[] | null = config.services ? [...config.services] : null;
242
+
243
+ // Scraper fallback for services if no static list provided
244
+ const scraperConfig: ScraperConfig = {
245
+ baseUrl: config.baseUrl,
246
+ headless: browserConfig.headless,
247
+ timeout: browserConfig.timeout,
248
+ userAgent: browserConfig.userAgent,
249
+ executablePath: browserConfig.executablePath,
250
+ launchArgs: browserConfig.launchArgs ? [...browserConfig.launchArgs] : undefined,
251
+ };
252
+ const scraper = createScraperAdapter(scraperConfig);
253
+
254
+ // Cache services for getService lookups
255
+ let cachedServices: Service[] | null = staticServices;
256
+
257
+ // Helper: resolve service name from ID using cached services
258
+ const resolveServiceName = (serviceId: string): string | undefined =>
259
+ cachedServices?.find((s) => s.id === serviceId)?.name;
260
+
261
+ return {
262
+ name: 'acuity-wizard',
263
+
264
+ // -----------------------------------------------------------------------
265
+ // Read operations
266
+ // -----------------------------------------------------------------------
267
+
268
+ getServices: () => {
269
+ if (staticServices) {
270
+ return TE.right(staticServices);
271
+ }
272
+ // Fallback to scraper (may fail with broken selectors)
273
+ return pipe(
274
+ scraper.getServices(),
275
+ TE.tap((services) =>
276
+ TE.fromIO(() => {
277
+ cachedServices = services;
278
+ }),
279
+ ),
280
+ );
281
+ },
282
+
283
+ getService: (serviceId: string) => {
284
+ if (cachedServices) {
285
+ const found = cachedServices.find((s) => s.id === serviceId);
286
+ return found
287
+ ? TE.right(found)
288
+ : TE.left(Errors.acuity('NOT_FOUND', `Service ${serviceId} not found`));
289
+ }
290
+ return pipe(
291
+ scraper.getServices(),
292
+ TE.chain((services) => {
293
+ cachedServices = services;
294
+ const found = services.find((s) => s.id === serviceId);
295
+ return found
296
+ ? TE.right(found)
297
+ : TE.left(Errors.acuity('NOT_FOUND', `Service ${serviceId} not found`));
298
+ }),
299
+ );
300
+ },
301
+
302
+ getProviders: () =>
303
+ TE.right([
304
+ {
305
+ id: '1',
306
+ name: 'Default Provider',
307
+ email: 'provider@example.com',
308
+ description: 'Primary provider',
309
+ timezone: 'America/New_York',
310
+ },
311
+ ]),
312
+
313
+ getProvider: () =>
314
+ TE.right({
315
+ id: '1',
316
+ name: 'Default Provider',
317
+ email: 'provider@example.com',
318
+ description: 'Primary provider',
319
+ timezone: 'America/New_York',
320
+ }),
321
+
322
+ getProvidersForService: () =>
323
+ TE.right([
324
+ {
325
+ id: '1',
326
+ name: 'Default Provider',
327
+ email: 'provider@example.com',
328
+ description: 'Primary provider',
329
+ timezone: 'America/New_York',
330
+ },
331
+ ]),
332
+
333
+ getAvailableDates: (params) => {
334
+ const serviceName = resolveServiceName(params.serviceId);
335
+ if (!serviceName) {
336
+ return TE.left(
337
+ Errors.acuity('NOT_FOUND', `Cannot resolve service name for ID ${params.serviceId}. Provide static services in config.`),
338
+ );
339
+ }
340
+ return runEffect(
341
+ Effect.scoped(
342
+ readAvailableDates({
343
+ serviceName,
344
+ // Don't pass appointmentTypeId — our internal IDs don't match Acuity URL IDs
345
+ targetMonth: params.startDate?.slice(0, 7),
346
+ monthsToScan: 2,
347
+ }),
348
+ ) as Effect.Effect<Array<{ date: string; slots: number }>, MiddlewareError>,
349
+ );
350
+ },
351
+
352
+ getAvailableSlots: (params) => {
353
+ const serviceName = resolveServiceName(params.serviceId);
354
+ if (!serviceName) {
355
+ return TE.left(
356
+ Errors.acuity('NOT_FOUND', `Cannot resolve service name for ID ${params.serviceId}. Provide static services in config.`),
357
+ );
358
+ }
359
+ return runEffect(
360
+ Effect.scoped(
361
+ readTimeSlots({
362
+ serviceName,
363
+ date: params.date,
364
+ }),
365
+ ) as Effect.Effect<Array<{ datetime: string; available: boolean }>, MiddlewareError>,
366
+ );
367
+ },
368
+
369
+ checkSlotAvailability: (params) => {
370
+ const serviceName = resolveServiceName(params.serviceId);
371
+ if (!serviceName) {
372
+ return TE.left(
373
+ Errors.acuity('NOT_FOUND', `Cannot resolve service name for ID ${params.serviceId}`),
374
+ );
375
+ }
376
+ return pipe(
377
+ runEffect(
378
+ Effect.scoped(
379
+ readTimeSlots({
380
+ serviceName,
381
+ date: params.datetime.split('T')[0],
382
+ }),
383
+ ) as Effect.Effect<Array<{ datetime: string; available: boolean }>, MiddlewareError>,
384
+ ),
385
+ TE.map((slots) => {
386
+ // Slots return local time (no TZ suffix: "2026-03-07T14:00:00").
387
+ // Request datetime should also be local, but normalize both by
388
+ // stripping any trailing Z or offset for comparison.
389
+ const normalize = (dt: string) => dt.replace(/([+-]\d{2}:\d{2}|Z)$/, '');
390
+ const requestNorm = normalize(params.datetime);
391
+ return slots.some((s) => s.available && normalize(s.datetime) === requestNorm);
392
+ }),
393
+ );
394
+ },
395
+
396
+ // -----------------------------------------------------------------------
397
+ // Reservation - not supported (pipeline has graceful fallback)
398
+ // -----------------------------------------------------------------------
399
+
400
+ createReservation: () =>
401
+ TE.left(
402
+ Errors.reservation(
403
+ 'BLOCK_FAILED',
404
+ 'Reservations not supported by wizard adapter',
405
+ ),
406
+ ),
407
+
408
+ releaseReservation: () => TE.right(undefined),
409
+
410
+ // -----------------------------------------------------------------------
411
+ // Write operations - Effect TS middleware
412
+ // -----------------------------------------------------------------------
413
+
414
+ createBooking: (request) => {
415
+ const serviceName = cachedServices?.find((s) => s.id === request.serviceId)?.name;
416
+ return runEffect(
417
+ createBookingProgram(request, serviceName) as Effect.Effect<Booking, MiddlewareError>,
418
+ );
419
+ },
420
+
421
+ createBookingWithPaymentRef: (request, paymentRef, paymentProcessor) => {
422
+ const coupon = config.couponCode ?? generateCouponCode(paymentRef, paymentProcessor);
423
+ const service = cachedServices?.find((s) => s.id === request.serviceId);
424
+
425
+ return runEffect(
426
+ createBookingWithPaymentRefProgram(
427
+ request,
428
+ paymentRef,
429
+ paymentProcessor,
430
+ coupon,
431
+ service,
432
+ ) as Effect.Effect<Booking, MiddlewareError>,
433
+ );
434
+ },
435
+
436
+ getBooking: () =>
437
+ TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Get booking not yet supported via wizard')),
438
+
439
+ cancelBooking: () =>
440
+ TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Cancel not yet supported via wizard')),
441
+
442
+ rescheduleBooking: () =>
443
+ TE.left(
444
+ Errors.acuity('NOT_IMPLEMENTED', 'Reschedule not yet supported via wizard'),
445
+ ),
446
+
447
+ // -----------------------------------------------------------------------
448
+ // Client - pass-through (client data comes from our form, not Acuity)
449
+ // -----------------------------------------------------------------------
450
+
451
+ findOrCreateClient: (client) =>
452
+ TE.right({ id: `local-${client.email}`, isNew: true }),
453
+
454
+ getClientByEmail: () => TE.right(null),
455
+ };
456
+ };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * BrowserService Layer
3
+ *
4
+ * Effect TS Layer providing managed Playwright browser lifecycle.
5
+ * The browser and pages are acquired/released via Effect's Scope,
6
+ * ensuring proper cleanup even on errors or interruptions.
7
+ */
8
+
9
+ import { Context, Effect, Layer, Scope } from 'effect';
10
+ import type { Browser, Page } from 'playwright-core';
11
+ import { BrowserError } from './errors.js';
12
+
13
+ // =============================================================================
14
+ // CONFIGURATION
15
+ // =============================================================================
16
+
17
+ export interface BrowserConfig {
18
+ /** Base URL for the Acuity scheduling page */
19
+ readonly baseUrl: string;
20
+ /** Run browser in headless mode (default: true) */
21
+ readonly headless: boolean;
22
+ /** Default timeout for page operations in ms (default: 30000) */
23
+ readonly timeout: number;
24
+ /** User agent string */
25
+ readonly userAgent: string;
26
+ /** Take screenshot on failure (default: true) */
27
+ readonly screenshotOnFailure: boolean;
28
+ /** Directory for failure screenshots */
29
+ readonly screenshotDir: string;
30
+ /** Path to Chromium executable (for Lambda/serverless) */
31
+ readonly executablePath?: string;
32
+ /** Additional chromium.launch() args (e.g., Lambda sandbox flags) */
33
+ readonly launchArgs?: readonly string[];
34
+ }
35
+
36
+ export const defaultBrowserConfig: BrowserConfig = {
37
+ baseUrl: 'https://MassageIthaca.as.me',
38
+ headless: true,
39
+ timeout: 30000,
40
+ userAgent:
41
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
42
+ screenshotOnFailure: true,
43
+ screenshotDir: '/tmp/scheduling-kit-screenshots',
44
+ };
45
+
46
+ // =============================================================================
47
+ // SERVICE DEFINITION
48
+ // =============================================================================
49
+
50
+ export interface BrowserServiceShape {
51
+ /** Get a managed Page (scoped - auto-closed when scope ends) */
52
+ readonly acquirePage: Effect.Effect<Page, BrowserError, Scope.Scope>;
53
+ /** Take a screenshot of the most recently created page */
54
+ readonly screenshot: (label: string) => Effect.Effect<Buffer, BrowserError>;
55
+ /** The browser configuration */
56
+ readonly config: BrowserConfig;
57
+ }
58
+
59
+ export class BrowserService extends Context.Tag('scheduling-kit/BrowserService')<
60
+ BrowserService,
61
+ BrowserServiceShape
62
+ >() {}
63
+
64
+ // =============================================================================
65
+ // LIVE IMPLEMENTATION
66
+ // =============================================================================
67
+
68
+ /**
69
+ * Create a live BrowserService layer backed by Playwright Chromium.
70
+ * The browser is launched once when the layer is created and closed
71
+ * when the layer scope ends.
72
+ */
73
+ export const BrowserServiceLive = (
74
+ config: Partial<BrowserConfig> = {},
75
+ ): Layer.Layer<BrowserService, BrowserError> => {
76
+ const cfg: BrowserConfig = { ...defaultBrowserConfig, ...config };
77
+
78
+ return Layer.scoped(
79
+ BrowserService,
80
+ Effect.gen(function* () {
81
+ // Dynamic import - try playwright-core first (lighter, no bundled browsers),
82
+ // fall back to full playwright which includes its own Chromium
83
+ const chromium = yield* Effect.tryPromise({
84
+ try: async (): Promise<typeof import('playwright-core').chromium> => {
85
+ try {
86
+ const pw = await import('playwright-core');
87
+ return pw.chromium;
88
+ } catch {
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ const pw = await (import('playwright-core') as any);
91
+ return pw.chromium;
92
+ }
93
+ },
94
+ catch: () =>
95
+ new BrowserError({
96
+ reason: 'PLAYWRIGHT_MISSING',
97
+ cause: new Error(
98
+ 'playwright-core or playwright is required for the wizard middleware. ' +
99
+ 'Install with: pnpm add playwright-core',
100
+ ),
101
+ }),
102
+ });
103
+
104
+ // Launch browser (released when scope ends)
105
+ const browser: Browser = yield* Effect.acquireRelease(
106
+ Effect.tryPromise({
107
+ try: () =>
108
+ chromium.launch({
109
+ headless: cfg.headless,
110
+ executablePath: cfg.executablePath,
111
+ args: cfg.launchArgs ? [...cfg.launchArgs] : undefined,
112
+ }),
113
+ catch: (e) => new BrowserError({ reason: 'LAUNCH_FAILED', cause: e }),
114
+ }),
115
+ (browser) =>
116
+ Effect.promise(() => browser.close()).pipe(Effect.ignoreLogged),
117
+ );
118
+
119
+ // Create a single managed page for the layer's lifetime.
120
+ // All step programs share this page within a scope.
121
+ const page: Page = yield* Effect.acquireRelease(
122
+ Effect.tryPromise({
123
+ try: async () => {
124
+ const p = await browser.newPage({ userAgent: cfg.userAgent });
125
+ p.setDefaultTimeout(cfg.timeout);
126
+ return p;
127
+ },
128
+ catch: (e) => new BrowserError({ reason: 'PAGE_FAILED', cause: e }),
129
+ }),
130
+ (p) =>
131
+ Effect.promise(() => p.close()).pipe(Effect.ignoreLogged),
132
+ );
133
+
134
+ // acquirePage returns the singleton page — cleanup is handled by the
135
+ // layer scope above. The no-op release keeps the Scope type requirement
136
+ // so callers still need Effect.scoped.
137
+ const acquirePage = Effect.acquireRelease(
138
+ Effect.succeed(page),
139
+ () => Effect.void,
140
+ );
141
+
142
+ const screenshot = (label: string) =>
143
+ Effect.tryPromise({
144
+ try: async () => {
145
+ if (page.isClosed()) {
146
+ throw new Error('No active page for screenshot');
147
+ }
148
+ const buffer = await page.screenshot({
149
+ path: `${cfg.screenshotDir}/${label}-${Date.now()}.png`,
150
+ fullPage: true,
151
+ });
152
+ return buffer;
153
+ },
154
+ catch: (e) => new BrowserError({ reason: 'SCREENSHOT_FAILED', cause: e }),
155
+ });
156
+
157
+ return { acquirePage, screenshot, config: cfg };
158
+ }),
159
+ );
160
+ };
161
+
162
+ // =============================================================================
163
+ // TEST IMPLEMENTATION
164
+ // =============================================================================
165
+
166
+ /**
167
+ * A mock BrowserService for unit tests.
168
+ * Does not launch a real browser.
169
+ */
170
+ export const BrowserServiceTest = (
171
+ config: Partial<BrowserConfig> = {},
172
+ ): Layer.Layer<BrowserService> => {
173
+ const cfg: BrowserConfig = { ...defaultBrowserConfig, ...config };
174
+
175
+ return Layer.succeed(BrowserService, {
176
+ acquirePage: Effect.die(
177
+ new Error('BrowserServiceTest: acquirePage called - use a real browser for integration tests'),
178
+ ) as unknown as Effect.Effect<Page, BrowserError, Scope.Scope>,
179
+ screenshot: () =>
180
+ Effect.succeed(Buffer.from('mock-screenshot')),
181
+ config: cfg,
182
+ });
183
+ };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Middleware Error Types
3
+ *
4
+ * Effect TS error types for the Acuity wizard middleware.
5
+ * Bridges to fp-ts SchedulingError at the adapter boundary.
6
+ */
7
+
8
+ import { Data } from 'effect';
9
+ import { Errors, type SchedulingError } from '../core/types.js';
10
+
11
+ // =============================================================================
12
+ // ERROR CLASSES
13
+ // =============================================================================
14
+
15
+ export class BrowserError extends Data.TaggedError('BrowserError')<{
16
+ readonly reason:
17
+ | 'PLAYWRIGHT_MISSING'
18
+ | 'LAUNCH_FAILED'
19
+ | 'PAGE_FAILED'
20
+ | 'SCREENSHOT_FAILED'
21
+ | 'NAVIGATION_FAILED';
22
+ readonly cause?: unknown;
23
+ }> {}
24
+
25
+ export class SelectorError extends Data.TaggedError('SelectorError')<{
26
+ readonly candidates: readonly string[];
27
+ readonly message: string;
28
+ }> {}
29
+
30
+ export class WizardStepError extends Data.TaggedError('WizardStepError')<{
31
+ readonly step: 'navigate' | 'fill-form' | 'bypass-payment' | 'submit' | 'extract' | 'read-availability' | 'read-slots';
32
+ readonly message: string;
33
+ readonly screenshot?: Buffer;
34
+ readonly cause?: unknown;
35
+ }> {}
36
+
37
+ export class CouponError extends Data.TaggedError('CouponError')<{
38
+ readonly code: string;
39
+ readonly message: string;
40
+ }> {}
41
+
42
+ export type MiddlewareError = BrowserError | SelectorError | WizardStepError | CouponError;
43
+
44
+ // =============================================================================
45
+ // BRIDGE: Effect errors -> fp-ts SchedulingError
46
+ // =============================================================================
47
+
48
+ /**
49
+ * Convert Effect middleware errors to fp-ts SchedulingError
50
+ * for compatibility with the existing booking pipeline.
51
+ */
52
+ export const toSchedulingError = (error: MiddlewareError): SchedulingError => {
53
+ switch (error._tag) {
54
+ case 'BrowserError':
55
+ return Errors.infrastructure(
56
+ error.reason === 'PLAYWRIGHT_MISSING' ? 'UNKNOWN' : 'NETWORK',
57
+ `Browser error: ${error.reason}`,
58
+ error.cause instanceof Error ? error.cause : undefined,
59
+ );
60
+ case 'SelectorError':
61
+ return Errors.acuity('SCRAPE_FAILED', error.message);
62
+ case 'WizardStepError':
63
+ return Errors.acuity(
64
+ 'SCRAPE_FAILED',
65
+ `Wizard step '${error.step}' failed: ${error.message}`,
66
+ );
67
+ case 'CouponError':
68
+ return Errors.acuity('BOOKING_FAILED', `Coupon error: ${error.message}`);
69
+ }
70
+ };