@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,80 @@
1
+ /**
2
+ * Middleware Module - Server-only Acuity wizard automation
3
+ *
4
+ * This module provides the Effect TS-based browser middleware for
5
+ * puppeteering the Acuity scheduling wizard. It is a SEPARATE subpath
6
+ * export (`@tummycrypt/scheduling-kit/middleware`) and should NOT be
7
+ * imported in client-side code (it depends on Playwright).
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { createWizardAdapter } from '@tummycrypt/scheduling-kit/middleware';
12
+ * import { createSchedulingKit } from '@tummycrypt/scheduling-kit';
13
+ * import { createVenmoAdapter } from '@tummycrypt/scheduling-kit/payments';
14
+ *
15
+ * const scheduler = createWizardAdapter({
16
+ * baseUrl: process.env.ACUITY_BASE_URL,
17
+ * couponCode: process.env.ACUITY_BYPASS_COUPON,
18
+ * });
19
+ *
20
+ * const venmo = createVenmoAdapter({ ... });
21
+ * const kit = createSchedulingKit(scheduler, [venmo]);
22
+ *
23
+ * // Full booking with Venmo payment
24
+ * const result = await kit.completeBooking(request, 'venmo')();
25
+ * ```
26
+ */
27
+
28
+ // Adapter factories
29
+ export { createWizardAdapter, type WizardAdapterConfig } from './acuity-wizard.js';
30
+ export { createRemoteWizardAdapter, type RemoteAdapterConfig } from './remote-adapter.js';
31
+
32
+ // Browser service (for custom Layer composition)
33
+ export {
34
+ BrowserService,
35
+ BrowserServiceLive,
36
+ BrowserServiceTest,
37
+ defaultBrowserConfig,
38
+ type BrowserConfig,
39
+ type BrowserServiceShape,
40
+ } from './browser-service.js';
41
+
42
+ // Error types and bridge
43
+ export {
44
+ BrowserError,
45
+ SelectorError,
46
+ WizardStepError,
47
+ CouponError,
48
+ toSchedulingError,
49
+ type MiddlewareError,
50
+ } from './errors.js';
51
+
52
+ // Selector registry
53
+ export {
54
+ Selectors,
55
+ resolveSelector,
56
+ resolve,
57
+ probeSelector,
58
+ probe,
59
+ healthCheck,
60
+ type SelectorKey,
61
+ type ResolvedSelector,
62
+ } from './selectors.js';
63
+
64
+ // Individual wizard steps (for advanced composition)
65
+ export {
66
+ navigateToBooking,
67
+ fillFormFields,
68
+ bypassPayment,
69
+ generateCouponCode,
70
+ submitBooking,
71
+ extractConfirmation,
72
+ toBooking,
73
+ type NavigateParams,
74
+ type NavigateResult,
75
+ type FillFormParams,
76
+ type FillFormResult,
77
+ type BypassPaymentResult,
78
+ type SubmitResult,
79
+ type ConfirmationData,
80
+ } from './steps/index.js';
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Remote Wizard Adapter
3
+ *
4
+ * SchedulingAdapter implementation backed by HTTP calls to a remote
5
+ * middleware server running Playwright + Chromium (e.g., Modal Labs,
6
+ * Fly.io, or any Docker host).
7
+ *
8
+ * This adapter is the client-side counterpart to `middleware/server.ts`.
9
+ * It serializes requests, sends them over HTTP, and deserializes
10
+ * responses back into fp-ts TaskEither types.
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const adapter = createRemoteWizardAdapter({
15
+ * baseUrl: process.env.MODAL_MIDDLEWARE_URL,
16
+ * authToken: process.env.MODAL_AUTH_TOKEN,
17
+ * });
18
+ * const kit = createSchedulingKit(adapter, [venmoAdapter]);
19
+ * ```
20
+ */
21
+
22
+ import * as TE from 'fp-ts/TaskEither';
23
+ import { pipe } from 'fp-ts/function';
24
+ import type { SchedulingAdapter } from '../adapters/types.js';
25
+ import type {
26
+ Booking,
27
+ BookingRequest,
28
+ Service,
29
+ Provider,
30
+ TimeSlot,
31
+ AvailableDate,
32
+ SlotReservation,
33
+ ClientInfo,
34
+ SchedulingError,
35
+ SchedulingResult,
36
+ } from '../core/types.js';
37
+ import { Errors } from '../core/types.js';
38
+
39
+ // =============================================================================
40
+ // CONFIGURATION
41
+ // =============================================================================
42
+
43
+ export interface RemoteAdapterConfig {
44
+ /** Base URL of the middleware server (e.g., https://scheduling-middleware--org.modal.run) */
45
+ readonly baseUrl: string;
46
+ /** Auth token for the middleware server */
47
+ readonly authToken?: string;
48
+ /** Request timeout in ms (default: 60000 - wizard flow can take 30s+) */
49
+ readonly timeout?: number;
50
+ /** Coupon code for payment bypass */
51
+ readonly couponCode?: string;
52
+ }
53
+
54
+ // =============================================================================
55
+ // HTTP HELPERS
56
+ // =============================================================================
57
+
58
+ interface RemoteResponse<T> {
59
+ readonly success: boolean;
60
+ readonly data?: T;
61
+ readonly error?: {
62
+ readonly tag: string;
63
+ readonly code: string;
64
+ readonly message: string;
65
+ };
66
+ }
67
+
68
+ const makeRequest = <T>(
69
+ config: RemoteAdapterConfig,
70
+ path: string,
71
+ method: 'GET' | 'POST',
72
+ body?: unknown,
73
+ ): SchedulingResult<T> =>
74
+ TE.tryCatch(
75
+ async () => {
76
+ const url = `${config.baseUrl}${path}`;
77
+ const headers: Record<string, string> = {
78
+ 'Content-Type': 'application/json',
79
+ };
80
+ if (config.authToken) {
81
+ headers['Authorization'] = `Bearer ${config.authToken}`;
82
+ }
83
+
84
+ const response = await fetch(url, {
85
+ method,
86
+ headers,
87
+ body: body ? JSON.stringify(body) : undefined,
88
+ signal: AbortSignal.timeout(config.timeout ?? 60000),
89
+ });
90
+
91
+ if (!response.ok) {
92
+ const errorBody = await response.json().catch(() => ({})) as RemoteResponse<never>;
93
+ throw Object.assign(new Error(errorBody.error?.message ?? `HTTP ${response.status}`), {
94
+ tag: errorBody.error?.tag ?? 'InfrastructureError',
95
+ code: errorBody.error?.code ?? 'NETWORK',
96
+ });
97
+ }
98
+
99
+ const json = (await response.json()) as RemoteResponse<T>;
100
+
101
+ if (!json.success && json.error) {
102
+ throw Object.assign(new Error(json.error.message), {
103
+ tag: json.error.tag,
104
+ code: json.error.code,
105
+ });
106
+ }
107
+
108
+ return json.data as T;
109
+ },
110
+ (e): SchedulingError => {
111
+ if (e instanceof Error && 'tag' in e) {
112
+ const tagged = e as Error & { tag: string; code: string };
113
+ return mapRemoteError(tagged.tag, tagged.code, tagged.message);
114
+ }
115
+ if (e instanceof DOMException && e.name === 'TimeoutError') {
116
+ return Errors.infrastructure('TIMEOUT', 'Middleware server request timed out');
117
+ }
118
+ return Errors.infrastructure(
119
+ 'NETWORK',
120
+ `Middleware server error: ${e instanceof Error ? e.message : String(e)}`,
121
+ );
122
+ },
123
+ );
124
+
125
+ const mapRemoteError = (tag: string, code: string, message: string): SchedulingError => {
126
+ switch (tag) {
127
+ case 'AcuityError':
128
+ return Errors.acuity(code, message);
129
+ case 'PaymentError':
130
+ return Errors.payment(code, message, 'remote');
131
+ case 'ValidationError':
132
+ return Errors.validation(code, message);
133
+ case 'ReservationError':
134
+ return Errors.reservation(code as 'SLOT_TAKEN' | 'BLOCK_FAILED' | 'TIMEOUT', message);
135
+ case 'InfrastructureError':
136
+ default:
137
+ return Errors.infrastructure(
138
+ (code as 'NETWORK' | 'TIMEOUT' | 'REDIS' | 'UNKNOWN') ?? 'UNKNOWN',
139
+ message,
140
+ );
141
+ }
142
+ };
143
+
144
+ // =============================================================================
145
+ // ADAPTER FACTORY
146
+ // =============================================================================
147
+
148
+ /**
149
+ * Create a SchedulingAdapter that proxies all operations to a remote
150
+ * middleware server via HTTP. The remote server runs Playwright + Chromium
151
+ * and executes the actual wizard automation.
152
+ */
153
+ export const createRemoteWizardAdapter = (config: RemoteAdapterConfig): SchedulingAdapter => ({
154
+ name: 'acuity-wizard-remote',
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Read operations - proxied to remote scraper
158
+ // ---------------------------------------------------------------------------
159
+
160
+ getServices: () =>
161
+ makeRequest<Service[]>(config, '/services', 'GET'),
162
+
163
+ getService: (serviceId) =>
164
+ makeRequest<Service>(config, `/services/${encodeURIComponent(serviceId)}`, 'GET'),
165
+
166
+ getProviders: () =>
167
+ TE.right([{
168
+ id: '1',
169
+ name: 'Default Provider',
170
+ email: 'provider@example.com',
171
+ description: 'Primary provider',
172
+ timezone: 'America/New_York',
173
+ }]),
174
+
175
+ getProvider: () =>
176
+ TE.right({
177
+ id: '1',
178
+ name: 'Default Provider',
179
+ email: 'provider@example.com',
180
+ description: 'Primary provider',
181
+ timezone: 'America/New_York',
182
+ }),
183
+
184
+ getProvidersForService: () =>
185
+ TE.right([{
186
+ id: '1',
187
+ name: 'Default Provider',
188
+ email: 'provider@example.com',
189
+ description: 'Primary provider',
190
+ timezone: 'America/New_York',
191
+ }]),
192
+
193
+ getAvailableDates: (params) =>
194
+ makeRequest<AvailableDate[]>(config, '/availability/dates', 'POST', params),
195
+
196
+ getAvailableSlots: (params) =>
197
+ makeRequest<TimeSlot[]>(config, '/availability/slots', 'POST', params),
198
+
199
+ checkSlotAvailability: (params) =>
200
+ makeRequest<boolean>(config, '/availability/check', 'POST', params),
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Reservation - not supported (pipeline has graceful fallback)
204
+ // ---------------------------------------------------------------------------
205
+
206
+ createReservation: () =>
207
+ TE.left(Errors.reservation('BLOCK_FAILED', 'Reservations not supported by remote wizard adapter')),
208
+
209
+ releaseReservation: () => TE.right(undefined),
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Write operations - proxied to remote wizard
213
+ // ---------------------------------------------------------------------------
214
+
215
+ createBooking: (request) =>
216
+ makeRequest<Booking>(config, '/booking/create', 'POST', {
217
+ request,
218
+ couponCode: config.couponCode,
219
+ }),
220
+
221
+ createBookingWithPaymentRef: (request, paymentRef, paymentProcessor) =>
222
+ makeRequest<Booking>(config, '/booking/create-with-payment', 'POST', {
223
+ request,
224
+ paymentRef,
225
+ paymentProcessor,
226
+ couponCode: config.couponCode,
227
+ }),
228
+
229
+ getBooking: () =>
230
+ TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Get booking not yet supported via wizard')),
231
+
232
+ cancelBooking: () =>
233
+ TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Cancel not yet supported via wizard')),
234
+
235
+ rescheduleBooking: () =>
236
+ TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Reschedule not yet supported via wizard')),
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Client - pass-through
240
+ // ---------------------------------------------------------------------------
241
+
242
+ findOrCreateClient: (client) =>
243
+ TE.right({ id: `local-${client.email}`, isNew: true }),
244
+
245
+ getClientByEmail: () => TE.right(null),
246
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Acuity CSS Selector Registry
3
+ *
4
+ * Single source of truth for all CSS selectors used by the wizard middleware.
5
+ * Each selector has a primary pattern and fallback chain.
6
+ * When Acuity changes their DOM, fix this ONE file.
7
+ */
8
+
9
+ import { Effect } from 'effect';
10
+ import type { Page, ElementHandle } from 'playwright-core';
11
+ import { SelectorError } from './errors.js';
12
+
13
+ // =============================================================================
14
+ // SELECTOR DEFINITIONS
15
+ // =============================================================================
16
+
17
+ /**
18
+ * Acuity Scheduling (2026 React SPA) CSS Selector Registry
19
+ *
20
+ * Verified against live DOM: 2026-02-25
21
+ * Uses Emotion CSS-in-JS (css-* hashes are UNSTABLE — prefer semantic classes)
22
+ *
23
+ * Wizard flow:
24
+ * 1. Service page: massageithaca.as.me → <li.select-item> list with "Book" buttons
25
+ * 2. Calendar page: /schedule/<hash>/appointment/<aptId>/calendar/<calId>
26
+ * - react-calendar month grid + available-times-container
27
+ * 3. Client form: (after selecting time slot) → input fields
28
+ * 4. Payment/coupon: certificate input → Apply → verify $0
29
+ * 5. Submit → confirmation
30
+ *
31
+ * URL pattern (no query params):
32
+ * /schedule/<hash>/appointment/<appointmentTypeId>/calendar/<calendarId>
33
+ */
34
+ export const Selectors = {
35
+ // -- Service selection page --
36
+ // Services are <li class="select-item select-item-box"> with NO <a> links
37
+ // Categories use <div class="select-type"> with <div class="select-label">
38
+ serviceList: ['.select-item', '.select-item-box', '.appointment-type-item'],
39
+ serviceName: ['.appointment-type-name', '.type-name', 'h3'],
40
+ serviceLink: ['.select-item', '.select-item-box'],
41
+ // Price & duration are combined: <span>30 minutes @ $150.00</span> inside .duration-container
42
+ servicePrice: ['.duration-container', '.duration-container span', '.price', '.cost'],
43
+ serviceDuration: ['.duration-container', '.duration-container span', '.duration', '.time-duration'],
44
+ serviceDescription: ['.type-description', '.description', 'p.type-description'],
45
+ // "Book" button inside each service item
46
+ serviceBookButton: ['button.btn', '.select-item button.btn'],
47
+ // Category labels
48
+ serviceCategory: ['.select-label', '.select-label p', '.select-type .select-label'],
49
+
50
+ // -- Calendar page (react-calendar component) --
51
+ // Wrapper: .monthly-calendar-v2 > .react-calendar.monthly-calendar-react-calendar
52
+ calendar: ['.monthly-calendar-v2', '.react-calendar', '.monthly-calendar-react-calendar'],
53
+ calendarMonth: ['.react-calendar__navigation__label', '.react-calendar__navigation__label__labelText'],
54
+ calendarPrev: ['.react-calendar__navigation__prev-button'],
55
+ calendarNext: ['.react-calendar__navigation__next-button'],
56
+ // Day tiles are buttons: <button class="react-calendar__tile react-calendar__month-view__days__day">1</button>
57
+ calendarDay: [
58
+ '.react-calendar__tile',
59
+ '.react-calendar__month-view__days__day',
60
+ 'button.react-calendar__tile',
61
+ ],
62
+ // Active/selected day: react-calendar__tile--active + custom "activeday" class
63
+ activeDay: [
64
+ '.react-calendar__tile--active',
65
+ '.activeday',
66
+ '.react-calendar__tile:not(:disabled)',
67
+ ],
68
+
69
+ // -- Time slot selection --
70
+ // Container: .available-times-container
71
+ // Slots: <button class="time-selection">10:00 AM1 spot left</button>
72
+ // Selected: <button class="time-selection selected-time">
73
+ timeSlotContainer: ['.available-times-container'],
74
+ timeSlot: ['button.time-selection', '.time-selection', '.time-slot', '[data-time]'],
75
+ timeSlotSelected: ['button.time-selection.selected-time', '.selected-time'],
76
+ // "Select and continue" is an <li role="menuitem"> NOT a button
77
+ selectAndContinue: [
78
+ 'li[role="menuitem"]',
79
+ '[data-keyboard-navigable="keyboard-navigable-list-item"]',
80
+ 'text=Select and continue',
81
+ ],
82
+
83
+ // -- Client form --
84
+ // Field names use "client." prefix: client.firstName, client.lastName, etc.
85
+ firstNameInput: ['input[name="client.firstName"]', '#client\\.firstName', 'input[name="firstName"]'],
86
+ lastNameInput: ['input[name="client.lastName"]', '#client\\.lastName', 'input[name="lastName"]'],
87
+ emailInput: ['input[name="client.email"]', '#client\\.email', 'input[name="email"]'],
88
+ phoneInput: ['input[name="client.phone"]', '#client\\.phone', 'input[name="phone"]'],
89
+ // "Continue to Payment" button on the form page
90
+ continueToPayment: [
91
+ 'button.btn:has-text("Continue to Payment")',
92
+ 'button:has-text("Continue to Payment")',
93
+ 'button.btn[type="submit"]',
94
+ ],
95
+ // "Check Code Balance" button for entering coupon codes
96
+ checkCodeBalance: [
97
+ 'button:has-text("Check Code Balance")',
98
+ 'button.css-9zfkvr',
99
+ ],
100
+ // Terms agreement checkbox (custom field)
101
+ termsCheckbox: [
102
+ 'input[type="checkbox"][name*="field-13933959"]',
103
+ 'input[id*="13933959"]',
104
+ ],
105
+
106
+ // -- Client form intake fields --
107
+ // Radio buttons have NO name or id attrs; are purely React-controlled.
108
+ // Strategy: click <label> wrapping the radio via locator().nth().
109
+ // 3 yes/no question groups, each with aria-required="true".
110
+ radioNoLabel: ['label:has(input[type="radio"][value="no"])'],
111
+ radioYesLabel: ['label:has(input[type="radio"][value="yes"])'],
112
+ // "How did you hear" multi-checkbox (REQUIRED — at least 1 must be checked)
113
+ // Names: "Internet search", "google maps", "referral from Noha Acupuncture",
114
+ // "referral from dentist", "referral from PT or other practitioner"
115
+ howDidYouHearCheckbox: [
116
+ 'input[type="checkbox"][name="Internet search"]',
117
+ 'label:has(input[type="checkbox"][name="Internet search"])',
118
+ ],
119
+ // Medication textarea
120
+ medicationField: [
121
+ 'textarea[name="fields[field-16606770]"]',
122
+ '#fields\\[field-16606770\\]',
123
+ ],
124
+
125
+ // -- Payment / coupon --
126
+ // PAYMENT IS A SEPARATE PAGE at URL .../datetime/<ISO>/payment
127
+ // Verified 2026-02-26: Square-powered (NOT Stripe).
128
+ //
129
+ // "Check Code Balance" modal on client form page is INFORMATIONAL ONLY.
130
+ // The REAL coupon entry is on the PAYMENT page:
131
+ // "Package, gift, or coupon code" expandable section
132
+ //
133
+ // Client form modal selectors (kept for reference):
134
+ couponField: ['#code', 'input#code', 'input[id="code"]'],
135
+ couponTabByCode: [
136
+ 'button:has-text("Check by code")',
137
+ 'button.css-1jjp8vb:has-text("Check by code")',
138
+ ],
139
+ couponConfirmButton: [
140
+ '[role="dialog"] button:has-text("Confirm")',
141
+ 'button.css-qgmcoe',
142
+ 'button:has-text("Confirm")',
143
+ ],
144
+ couponCloseButton: ['button:has-text("Close")', 'button.css-ve50y1'],
145
+ couponError: [
146
+ '[role="dialog"] p:has-text("weren\'t able to recognize")',
147
+ '[role="dialog"] p:has-text("try entering it again")',
148
+ 'p.css-7bwtx1',
149
+ ],
150
+ couponSuccess: [
151
+ '[role="dialog"] p:has-text("balance")',
152
+ '[role="dialog"] [class*="success"]',
153
+ '.coupon-applied',
154
+ '.certificate-success',
155
+ ],
156
+
157
+ // -- Payment page (Square checkout) --
158
+ // URL pattern: .../datetime/<ISO>/payment
159
+ // "Package, gift, or coupon code" expandable section is the coupon entry point.
160
+ paymentCouponToggle: [
161
+ 'button:has-text("Package, gift, or coupon code")',
162
+ 'text=Package, gift, or coupon code',
163
+ ],
164
+ // After expanding: input placeholder="Enter code" (React id unstable like :r9:)
165
+ // and an "Apply" button. Verified 2026-02-26.
166
+ paymentCouponInput: ['input[placeholder="Enter code"]', 'input[placeholder*="code" i]', 'input[name*="coupon"]'],
167
+ paymentCouponApply: ['button:has-text("Apply")', 'button:has-text("Redeem")'],
168
+ // After applying, the certificate shows with a "REMOVE" link
169
+ paymentCouponRemove: ['text=REMOVE', 'a:has-text("REMOVE")', 'button:has-text("REMOVE")'],
170
+ // Order summary on payment page
171
+ paymentTotal: ['.order-total', '.payment-total', '.total-amount', 'text=$0.00'],
172
+ paymentSubtotal: ['text=Subtotal'],
173
+ // Pay & Confirm button (the final submit on payment page)
174
+ payAndConfirm: [
175
+ 'button:has-text("Pay & Confirm")',
176
+ 'button:has-text("PAY & CONFIRM")',
177
+ 'button:has-text("Confirm Appointment")',
178
+ ],
179
+
180
+ // -- Checkout / submit (legacy — use payAndConfirm for payment page) --
181
+ submitButton: [
182
+ 'button:has-text("Pay & Confirm")',
183
+ 'button:has-text("PAY & CONFIRM")',
184
+ 'button[type="submit"].confirm',
185
+ '.complete-booking',
186
+ '#submit-booking',
187
+ 'button:has-text("Complete Appointment")',
188
+ 'button:has-text("Book Now")',
189
+ 'button:has-text("Schedule")',
190
+ ],
191
+
192
+ // -- Confirmation page --
193
+ confirmationPage: ['.confirmation', '.booking-confirmed', '.thank-you', '#confirmation'],
194
+ confirmationId: ['.confirmation-number', '.appointment-id', '[data-confirmation]'],
195
+ confirmationService: ['.appointment-type', '.service-name', '.booked-service'],
196
+ confirmationDatetime: ['.appointment-datetime', '.booked-time', '.booking-date'],
197
+ } as const;
198
+
199
+ // =============================================================================
200
+ // SELECTOR TYPE
201
+ // =============================================================================
202
+
203
+ export type SelectorKey = keyof typeof Selectors;
204
+
205
+ export interface ResolvedSelector {
206
+ readonly selector: string;
207
+ readonly element: ElementHandle;
208
+ }
209
+
210
+ // =============================================================================
211
+ // RESOLUTION UTILITIES
212
+ // =============================================================================
213
+
214
+ /**
215
+ * Try selectors in order, return the first match.
216
+ * Fails with SelectorError if none match.
217
+ */
218
+ export const resolveSelector = (
219
+ page: Page,
220
+ candidates: readonly string[],
221
+ timeout = 3000,
222
+ ): Effect.Effect<ResolvedSelector, SelectorError> =>
223
+ Effect.gen(function* () {
224
+ for (const selector of candidates) {
225
+ const el = yield* Effect.tryPromise({
226
+ try: () =>
227
+ page.waitForSelector(selector, { timeout, state: 'attached' }).then(
228
+ (handle) => handle,
229
+ () => null,
230
+ ),
231
+ catch: () => null,
232
+ }).pipe(Effect.orElseSucceed(() => null));
233
+
234
+ if (el) {
235
+ return { selector, element: el } as ResolvedSelector;
236
+ }
237
+ }
238
+
239
+ return yield* Effect.fail(
240
+ new SelectorError({
241
+ candidates,
242
+ message: `None of [${candidates.join(', ')}] found within ${timeout}ms`,
243
+ }),
244
+ );
245
+ });
246
+
247
+ /**
248
+ * Resolve a selector from the registry by key name.
249
+ */
250
+ export const resolve = (
251
+ page: Page,
252
+ key: SelectorKey,
253
+ timeout?: number,
254
+ ): Effect.Effect<ResolvedSelector, SelectorError> =>
255
+ resolveSelector(page, Selectors[key], timeout);
256
+
257
+ /**
258
+ * Check if any selector in the candidates list exists on the page (non-blocking).
259
+ * Returns the matching selector string or null.
260
+ */
261
+ export const probeSelector = (
262
+ page: Page,
263
+ candidates: readonly string[],
264
+ ): Effect.Effect<string | null, never> =>
265
+ Effect.gen(function* () {
266
+ for (const selector of candidates) {
267
+ const exists = yield* Effect.tryPromise({
268
+ try: () => page.$(selector).then((el) => el !== null),
269
+ catch: () => false,
270
+ }).pipe(Effect.orElseSucceed(() => false));
271
+
272
+ if (exists) return selector;
273
+ }
274
+ return null;
275
+ });
276
+
277
+ /**
278
+ * Probe a selector from the registry by key name.
279
+ */
280
+ export const probe = (page: Page, key: SelectorKey): Effect.Effect<string | null, never> =>
281
+ probeSelector(page, Selectors[key]);
282
+
283
+ /**
284
+ * Validate that all critical selectors can be resolved on the current page.
285
+ * Returns a report of which selectors passed/failed.
286
+ */
287
+ export const healthCheck = (
288
+ page: Page,
289
+ keys: readonly SelectorKey[],
290
+ ): Effect.Effect<
291
+ { passed: SelectorKey[]; failed: SelectorKey[] },
292
+ never
293
+ > =>
294
+ Effect.gen(function* () {
295
+ const passed: SelectorKey[] = [];
296
+ const failed: SelectorKey[] = [];
297
+
298
+ for (const key of keys) {
299
+ const found = yield* probe(page, key);
300
+ if (found) {
301
+ passed.push(key);
302
+ } else {
303
+ failed.push(key);
304
+ }
305
+ }
306
+
307
+ return { passed, failed };
308
+ });